Il gateway irraggiungibile costava ~30s x4 tentativi (fino a ~2 minuti) per ogni
chiamata: ora si distinguono i due casi e le chiamate successive sono immediate.
- due timeout separati: `connectTimeoutMs` (default 2500, connect+headers) e
`timeoutMs` (default 30000, budget per il body). Nessuna risposta entro il
primo = "gateway non raggiungibile"; body lento = "elaborazione lunga"
- circuit breaker persistente in ~/.local/share/pi-qmem/breaker.json
(env QMEM_BREAKER_FILE): fallimento definitivo (connessione rifiutata/DNS/
connect timeout) → nessun retry e apertura immediata per `breakerBaseMs`
(default 120000 = 2 min) con escalation fino a `breakerMaxMs` (10 min);
5xx/body lento sono ambigui → retry con Retry-After e apertura dopo
`breakerTripAfter` (default 2). Un successo lo richiude; cambiando `url` lo
stato riparte chiuso (endpoint-aware)
- con breaker aperto gatewayRequest ritorna in ~0 ms senza rete
(`gateway_unreachable`, `breaker_open`, `retry_in_ms`): i tool passano subito
al fallback locale e l'outbox accoda
- fix di due bug scoperti durante i test:
* `res.json().catch(() => ({}))` trasformava un body non completato in
"successo con dati vuoti" → l'agente vedeva "nessun risultato" invece del
fallback locale. Ora è `timeout_body` (fallimento, ambiguo)
* `submitOrQueue` passava un AbortSignal esterno, che con la nuova semantica
sarebbe stato letto come annullamento utente (eccezione invece di coda)
- messaggi dei tool con lo stato del breaker e come forzare un tentativo;
`details.breaker` per l'osservabilità
- comandi: `/qmem:local breaker [reset]` e `qmem-sqlite breaker [--reset]`;
lo stato compare in `/qmem:local status` e nella CLI
- budget interni per enrich/pull/flush (niente AbortSignal esterni)
Misure: connessione rifiutata → 4-8 ms (prima: 4 x 30 s); front che risponde
503 dopo ~40 s → 3,5 s alla prima chiamata, poi 0 ms di rete a breaker aperto;
server che accetta e non risponde → 708 ms (connect timeout); body lento →
1,2 s senza aprire il breaker; persistenza verificata fra processi distinti.
Test: scripts/test-local.mjs 38/38 (nuova fase dedicata al breaker).
159 lines
6.2 KiB
TypeScript
159 lines
6.2 KiB
TypeScript
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
import { Type } from "typebox";
|
|
import { gatewayRequest, loadConfig } from "../shared";
|
|
import { localDbPath, submitOrQueue } from "../local-db.ts";
|
|
import { breakerInfo } from "../shared.ts";
|
|
|
|
export function registerQmemStore(pi: ExtensionAPI) {
|
|
pi.registerTool({
|
|
name: "qmem_store",
|
|
label: "Qmem memory store",
|
|
description:
|
|
"Store one compact, high-signal memory record. project_id is required; do not store raw transcripts.",
|
|
parameters: Type.Object({
|
|
text: Type.String({ description: "Compact memory content." }),
|
|
kind: Type.Optional(
|
|
Type.Union(
|
|
[Type.Literal("decision"), Type.Literal("fact"), Type.Literal("episode"), Type.Literal("preference")],
|
|
{ description: "Record kind; default fact." },
|
|
),
|
|
),
|
|
agent_id: Type.Optional(Type.String({ description: "Writer provenance." })),
|
|
project_id: Type.String({
|
|
description: "Required project ID, kebab-case.",
|
|
}),
|
|
scope: Type.Optional(
|
|
Type.Union([Type.Literal("agent"), Type.Literal("project"), Type.Literal("org")], {
|
|
description: "Scope; default agent.",
|
|
}),
|
|
),
|
|
confidence: Type.Optional(
|
|
Type.Union([Type.Literal("high"), Type.Literal("medium"), Type.Literal("low")], {
|
|
description: "Confidence; default medium.",
|
|
}),
|
|
),
|
|
source: Type.Optional(Type.String({ description: "Source label." })),
|
|
expires_at: Type.Optional(
|
|
Type.String({
|
|
description: "ISO 8601 expiry; omit for permanent records.",
|
|
}),
|
|
),
|
|
supersedes_id: Type.Optional(Type.String({ description: "UUID replaced by this record." })),
|
|
supersede_reason: Type.Optional(Type.String({ description: "Reason for replacement." })),
|
|
parent_id: Type.Optional(Type.String({ description: "Parent UUID." })),
|
|
level: Type.Optional(
|
|
Type.Union([Type.Literal("L1_ROOT"), Type.Literal("L2_SUBTOPIC"), Type.Literal("L3_DETAIL")], {
|
|
description: "Hierarchy level.",
|
|
}),
|
|
),
|
|
topic: Type.Optional(Type.String({ description: "Hierarchy topic ID." })),
|
|
private: Type.Optional(
|
|
Type.Boolean({
|
|
description: "Hide from standard search; use only for sensitive data.",
|
|
}),
|
|
),
|
|
links: Type.Optional(
|
|
Type.Array(
|
|
Type.Object({
|
|
target_id: Type.String({ description: "Target UUID." }),
|
|
predicate: Type.Optional(Type.String({ description: "Relation type." })),
|
|
weight: Type.Optional(Type.Number({ description: "Relation weight; default 1.0." })),
|
|
}),
|
|
{ description: "Related records." },
|
|
),
|
|
),
|
|
}),
|
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
const cfg = loadConfig();
|
|
if (!cfg.apiKey) {
|
|
return {
|
|
content: [{ type: "text", text: "Config mancante: esegui /qmem:config per impostare url e apiKey." }],
|
|
details: { error: "missing_config" },
|
|
};
|
|
}
|
|
const p = params as any;
|
|
onUpdate?.({ content: [{ type: "text", text: "qmem: salvataggio record..." }] });
|
|
// Rilevamento duplicati: cerca prima di salvare (soglia alta, non blocca)
|
|
let dupes: any[] = [];
|
|
const dupCheck = await gatewayRequest(
|
|
cfg,
|
|
"POST",
|
|
"/v1/memories:search",
|
|
{ query: p.text, project_id: p.project_id, top_k: 3, min_score: 0.92, include_superseded: false },
|
|
signal,
|
|
);
|
|
if (dupCheck.ok) dupes = dupCheck.data.results ?? [];
|
|
// Idempotency: la chiave è generata da submitOrQueue (Idempotency-Key)
|
|
const payload = {
|
|
text: p.text,
|
|
kind: p.kind ?? "fact",
|
|
agent_id: p.agent_id,
|
|
project_id: p.project_id,
|
|
scope: p.scope ?? "agent",
|
|
source: p.source,
|
|
confidence: p.confidence ?? "medium",
|
|
expires_at: p.expires_at,
|
|
supersedes_id: p.supersedes_id,
|
|
supersede_reason: p.supersede_reason,
|
|
parent_id: p.parent_id,
|
|
private: p.private ?? false,
|
|
level: p.level,
|
|
topic: p.topic,
|
|
links: p.links,
|
|
};
|
|
// Online → gateway (con Idempotency-Key); offline → coda locale (outbox)
|
|
const submitted = await submitOrQueue(cfg, payload, { dbFile: localDbPath(cfg) });
|
|
if (submitted.queued) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text:
|
|
`⚠️ Gateway non raggiungibile (HTTP ${submitted.status}): record ACCODATO in locale (outbox).\n` +
|
|
`id locale: ${submitted.local_id}\n` +
|
|
`in coda: ${submitted.queue_size} record\n` +
|
|
(() => {
|
|
const br = breakerInfo();
|
|
return br.open
|
|
? `circuit breaker aperto: nessun nuovo tentativo verso il gateway per altri ${Math.ceil(br.remainingMs / 1000)}s ` +
|
|
`(ultimo errore: ${br.lastError ?? "?"}; per forzare: /qmem:local breaker reset)\n`
|
|
: "";
|
|
})() +
|
|
`Il contenuto è già ricercabile offline (indice locale, marcato ⏳) e verrà caricato automaticamente al ritorno della connessione ` +
|
|
`(/qmem:local flush per forzare, /qmem:local queue per lo stato).`,
|
|
},
|
|
],
|
|
details: {
|
|
queued: true,
|
|
local_id: submitted.local_id,
|
|
queue_size: submitted.queue_size,
|
|
gateway_status: submitted.status,
|
|
breaker: submitted.breaker
|
|
? { open: submitted.breaker.open, remaining_ms: Math.round(submitted.breaker.remainingMs), failures: submitted.breaker.failures }
|
|
: undefined,
|
|
},
|
|
};
|
|
}
|
|
const { ok, status, data } = submitted;
|
|
if (!ok) {
|
|
return {
|
|
content: [{ type: "text", text: `Errore ${status}: ${JSON.stringify(data)}` }],
|
|
details: { error: "gateway_error", status },
|
|
};
|
|
}
|
|
const dupWarning =
|
|
dupes.length > 0
|
|
? "\n⚠️ Possibili duplicati (score >= 0.92, stesso project_id):\n" +
|
|
dupes.map((d: any) => ` - ${d.memory_id} (score ${d.score}): ${String(d.text).slice(0, 100)}`).join("\n") +
|
|
"\nValuta se il nuovo record è davvero necessario o se conviene qmem_correct sul duplicato."
|
|
: "";
|
|
const extra = `${p.level ? `, level ${p.level}` : ""}${p.topic ? `, topic ${p.topic}` : ""}${p.parent_id ? ` — parent ${p.parent_id}` : ""}`;
|
|
return {
|
|
content: [{ type: "text", text: `Memoria salvata: ${data.memory_id} (${p.kind ?? "fact"}, scope ${p.scope ?? "agent"}${extra})${p.supersedes_id ? ` — supersede ${p.supersedes_id}` : ""}${dupWarning}` }],
|
|
details: { memory_id: data.memory_id, created_at: data.created_at, duplicates: dupes.length, parent_id: p.parent_id, level: p.level, topic: p.topic },
|
|
};
|
|
},
|
|
});
|
|
|
|
}
|