agent-working-memory 0.14.0 → 0.14.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +244 -653
- package/dist/core/whoami.d.ts +9 -1
- package/dist/core/whoami.d.ts.map +1 -1
- package/dist/core/whoami.js +12 -2
- package/dist/core/whoami.js.map +1 -1
- package/dist/engine/activation.d.ts +2 -0
- package/dist/engine/activation.d.ts.map +1 -1
- package/dist/engine/activation.js +29 -3
- package/dist/engine/activation.js.map +1 -1
- package/dist/engine/eval.d.ts +15 -0
- package/dist/engine/eval.d.ts.map +1 -1
- package/dist/engine/eval.js +23 -0
- package/dist/engine/eval.js.map +1 -1
- package/dist/hooks/sidecar.d.ts +18 -2
- package/dist/hooks/sidecar.d.ts.map +1 -1
- package/dist/hooks/sidecar.js +30 -4
- package/dist/hooks/sidecar.js.map +1 -1
- package/dist/mcp.js +71 -14
- package/dist/mcp.js.map +1 -1
- package/dist/storage/pglite.d.ts +7 -0
- package/dist/storage/pglite.d.ts.map +1 -1
- package/dist/storage/pglite.js +17 -3
- package/dist/storage/pglite.js.map +1 -1
- package/dist/storage/postgres.d.ts +7 -0
- package/dist/storage/postgres.d.ts.map +1 -1
- package/dist/storage/postgres.js +17 -3
- package/dist/storage/postgres.js.map +1 -1
- package/dist/storage/sqlite.d.ts +12 -0
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +24 -3
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/types/engram.d.ts +40 -0
- package/dist/types/engram.d.ts.map +1 -1
- package/dist/types/eval.d.ts +2 -0
- package/dist/types/eval.d.ts.map +1 -1
- package/package.json +20 -2
- package/src/core/whoami.ts +11 -1
- package/src/engine/activation.ts +29 -3
- package/src/engine/eval.ts +34 -0
- package/src/hooks/sidecar.ts +50 -6
- package/src/mcp.ts +74 -15
- package/src/storage/pglite.ts +22 -4
- package/src/storage/postgres.ts +22 -4
- package/src/storage/sqlite.ts +26 -4
- package/src/types/engram.ts +41 -0
- package/src/types/eval.ts +3 -1
package/src/hooks/sidecar.ts
CHANGED
|
@@ -27,7 +27,20 @@ export interface SidecarDeps {
|
|
|
27
27
|
store: EngramStore;
|
|
28
28
|
agentId: string;
|
|
29
29
|
secret: string | null;
|
|
30
|
+
/** Preferred port. If busy, the sidecar walks upward (see `portRange`). */
|
|
30
31
|
port: number;
|
|
32
|
+
/**
|
|
33
|
+
* 0.14.2: how many consecutive ports to try starting at `port` (default 10).
|
|
34
|
+
* Every Claude Code session spawns its own MCP process, and each one used to
|
|
35
|
+
* ask for exactly 8401; on EADDRINUSE the sidecar logged "hooks disabled" and
|
|
36
|
+
* gave up, so with N concurrent sessions only the first had working hooks —
|
|
37
|
+
* and `memory_whoami` still reported the configured port as if it were bound.
|
|
38
|
+
* Observed 2026-09-11: 4 of 5 live sessions had no sidecar at all.
|
|
39
|
+
* Hook clients probe the same range and match on `/health.agentId`.
|
|
40
|
+
*/
|
|
41
|
+
portRange?: number;
|
|
42
|
+
/** Reported by /health so hook clients can prefer the newest build. */
|
|
43
|
+
version?: string;
|
|
31
44
|
onConsolidate?: (agentId: string, reason: string) => void;
|
|
32
45
|
/**
|
|
33
46
|
* 0.12.2: warm recall for hooks. The sidecar runs in the same process as
|
|
@@ -157,8 +170,16 @@ function json(res: ServerResponse, status: number, body: Record<string, unknown>
|
|
|
157
170
|
|
|
158
171
|
const AUTO_CHECKPOINT_INTERVAL_MS = 15 * 60_000; // 15 minutes
|
|
159
172
|
|
|
160
|
-
export
|
|
173
|
+
export interface SidecarHandle {
|
|
174
|
+
close: () => void;
|
|
175
|
+
/** The port actually bound, or null while binding / if every port in range was busy. */
|
|
176
|
+
boundPort: () => number | null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function startSidecar(deps: SidecarDeps): SidecarHandle {
|
|
161
180
|
const { store, agentId, secret, port, onConsolidate } = deps;
|
|
181
|
+
const portRange = Math.max(1, deps.portRange ?? 10);
|
|
182
|
+
let bound: number | null = null;
|
|
162
183
|
|
|
163
184
|
const server = createServer(async (req, res) => {
|
|
164
185
|
// CORS preflight
|
|
@@ -170,7 +191,12 @@ export function startSidecar(deps: SidecarDeps): { close: () => void } {
|
|
|
170
191
|
|
|
171
192
|
// Health check — no auth required
|
|
172
193
|
if (req.url === '/health' && req.method === 'GET') {
|
|
173
|
-
|
|
194
|
+
// port/pid/version let a hook that probes 8401..841x pick the right
|
|
195
|
+
// process: same agentId as its session, newest version on a tie.
|
|
196
|
+
json(res, 200, {
|
|
197
|
+
status: 'ok', sidecar: true, agentId,
|
|
198
|
+
port: bound, pid: process.pid, version: deps.version ?? null,
|
|
199
|
+
});
|
|
174
200
|
return;
|
|
175
201
|
}
|
|
176
202
|
|
|
@@ -340,17 +366,34 @@ export function startSidecar(deps: SidecarDeps): { close: () => void } {
|
|
|
340
366
|
json(res, 404, { error: 'Not found' });
|
|
341
367
|
});
|
|
342
368
|
|
|
343
|
-
|
|
344
|
-
|
|
369
|
+
// Bind to the first free port in [port, port + portRange). A listen error
|
|
370
|
+
// fires 'error' before 'listening', so we re-listen on the next candidate
|
|
371
|
+
// from the error handler; any other error, or exhausting the range, leaves
|
|
372
|
+
// the sidecar unbound (hooks disabled) but never takes the MCP server down.
|
|
373
|
+
let attempt = 0;
|
|
374
|
+
const tryListen = () => {
|
|
375
|
+
const candidate = port + attempt;
|
|
376
|
+
server.listen(candidate, '127.0.0.1');
|
|
377
|
+
};
|
|
378
|
+
server.on('listening', () => {
|
|
379
|
+
const addr = server.address();
|
|
380
|
+
bound = addr && typeof addr === 'object' ? addr.port : port + attempt;
|
|
381
|
+
const note = bound === port ? '' : ` (preferred ${port} was busy)`;
|
|
382
|
+
console.error(`AWM hook sidecar listening on 127.0.0.1:${bound}${note}`);
|
|
345
383
|
});
|
|
346
|
-
|
|
347
384
|
server.on('error', (err: NodeJS.ErrnoException) => {
|
|
385
|
+
if (err.code === 'EADDRINUSE' && attempt + 1 < portRange) {
|
|
386
|
+
attempt++;
|
|
387
|
+
tryListen();
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
348
390
|
if (err.code === 'EADDRINUSE') {
|
|
349
|
-
console.error(`AWM hook sidecar:
|
|
391
|
+
console.error(`AWM hook sidecar: ports ${port}-${port + portRange - 1} all in use, hooks disabled`);
|
|
350
392
|
} else {
|
|
351
393
|
console.error('AWM hook sidecar error:', err.message);
|
|
352
394
|
}
|
|
353
395
|
});
|
|
396
|
+
tryListen();
|
|
354
397
|
|
|
355
398
|
// --- Silent auto-checkpoint every 15 minutes ---
|
|
356
399
|
const autoCheckpointTimer = setInterval(async () => {
|
|
@@ -383,5 +426,6 @@ export function startSidecar(deps: SidecarDeps): { close: () => void } {
|
|
|
383
426
|
clearInterval(autoCheckpointTimer);
|
|
384
427
|
server.close();
|
|
385
428
|
},
|
|
429
|
+
boundPort: () => bound,
|
|
386
430
|
};
|
|
387
431
|
}
|
package/src/mcp.ts
CHANGED
|
@@ -71,7 +71,7 @@ import { evaluateSalience, computeNovelty, computeNoveltyWithMatch } from './cor
|
|
|
71
71
|
import { performWrite } from './core/write-pipeline.js';
|
|
72
72
|
import type { ConsciousState } from './types/checkpoint.js';
|
|
73
73
|
import type { SalienceEventType } from './core/salience.js';
|
|
74
|
-
import type { TaskStatus, TaskPriority } from './types/engram.js';
|
|
74
|
+
import type { TaskStatus, TaskPriority, AbstentionInfo } from './types/engram.js';
|
|
75
75
|
import { DEFAULT_AGENT_CONFIG } from './types/agent.js';
|
|
76
76
|
import { embed, getEmbedder } from './core/embeddings.js';
|
|
77
77
|
import { getReranker } from './core/reranker.js';
|
|
@@ -124,6 +124,15 @@ function deriveAgentFromDir(): string {
|
|
|
124
124
|
}
|
|
125
125
|
const AGENT_ID = process.env.AWM_AGENT_ID ?? process.env.WORKER_NAME ?? deriveAgentFromDir();
|
|
126
126
|
const HOOK_PORT = parseInt(process.env.AWM_HOOK_PORT ?? '8401', 10);
|
|
127
|
+
// 0.14.2: ports to try upward from HOOK_PORT when it is busy (see sidecar.ts).
|
|
128
|
+
const HOOK_PORT_RANGE = Math.max(1, parseInt(process.env.AWM_HOOK_PORT_RANGE ?? '10', 10) || 10);
|
|
129
|
+
// Set once the sidecar starts; whoami reads the bound port from it.
|
|
130
|
+
let sidecarHandle: { boundPort: () => number | null } | null = null;
|
|
131
|
+
function sidecarPortLabel(): string {
|
|
132
|
+
const p = sidecarHandle?.boundPort() ?? null;
|
|
133
|
+
if (p === null) return `not bound (preferred ${HOOK_PORT}; hooks disabled)`;
|
|
134
|
+
return p === HOOK_PORT ? `127.0.0.1:${p}` : `127.0.0.1:${p} (preferred ${HOOK_PORT} was busy)`;
|
|
135
|
+
}
|
|
127
136
|
const HOOK_SECRET = process.env.AWM_HOOK_SECRET ?? null;
|
|
128
137
|
|
|
129
138
|
initLogger(DB_PATH);
|
|
@@ -414,7 +423,7 @@ Returns the most relevant memories ranked by text relevance, temporal recency, a
|
|
|
414
423
|
use_expansion: z.boolean().optional().default(true).describe('Expand query with synonyms for better recall (default true)'),
|
|
415
424
|
memory_type: z.enum(['episodic', 'semantic', 'procedural']).optional().describe('Filter by memory type (omit to search all types)'),
|
|
416
425
|
workspace: z.string().optional().describe('Search across all agents in this workspace (hive mode). Omit for agent-scoped recall only.'),
|
|
417
|
-
require_confidence: z.number().optional().default(0.05).describe('Abstain (return nothing) when recall confidence is below this threshold. Defaults to 0.05 — a LIGHT filter, chosen from measurement: it halves the rate of answering off-topic queries at zero cost to hit rate. Raise it only for push-style use where nobody asked (0.25 is what the prime hook uses). Do NOT raise it for ordinary recall: a miss is expensive, because the agent then reads the codebase instead (~2,106 tokens), so aggressive thresholds measurably destroy value — 0.20+ cut net tokens saved by 25% in tests/abstention-eval. Pass 0 to disable.'),
|
|
426
|
+
require_confidence: z.number().optional().default(0.05).describe('Abstain (return nothing) when recall confidence is below this threshold. Defaults to 0.05 — a LIGHT filter, chosen from measurement: it halves the rate of answering off-topic queries at zero cost to hit rate. Raise it only for push-style use where nobody asked (0.25 is what the prime hook uses). Do NOT raise it for ordinary recall: a miss is expensive, because the agent then reads the codebase instead (~2,106 tokens), so aggressive thresholds measurably destroy value — 0.20+ cut net tokens saved by 25% in tests/abstention-eval. Pass 0 to disable. NOTE: this is NOT min_score — it gates on the SHAPE of the score distribution across the whole result set, not per-result relevance, so memories that pass min_score can still be withheld. When that happens the reply says RECALL ABSTAINED and reports how many were withheld; an empty result is only absence when it does not.'),
|
|
418
427
|
granularity: z.enum(['full', 'compact', 'auto']).optional().describe('Output granularity (Paper 3: cognitive teaming). "full" (default): no change. "compact": every result carries a short summary field. "auto": confidence-adaptive — top result gets a longer summary when there is a clear winner, otherwise everything is compact for scanning.'),
|
|
419
428
|
max_tokens: z.number().optional().describe('Token budget for the response. `limit` is a COUNT and is token-blind — 5 results may cost 400 tokens or 4,000. Use this when context is tight: results are packed by value-per-token until the budget is reached, the top-scored match always gets first refusal, and the reply reports what it cost and what was withheld. Omit for no budget (everything is returned, still with accounting).'),
|
|
420
429
|
},
|
|
@@ -430,6 +439,9 @@ Returns the most relevant memories ranked by text relevance, temporal recency, a
|
|
|
430
439
|
}
|
|
431
440
|
// Use workspace from param, env var, or omit for agent-scoped
|
|
432
441
|
const workspace = params.workspace ?? process.env.AWM_WORKSPACE ?? undefined;
|
|
442
|
+
// Set only when a gate WITHHELD results. Distinguishes abstention from absence,
|
|
443
|
+
// which an empty array cannot.
|
|
444
|
+
let abstained: AbstentionInfo | undefined;
|
|
433
445
|
const results = await activationEngine.activate({
|
|
434
446
|
agentId: AGENT_ID,
|
|
435
447
|
context: queryText,
|
|
@@ -442,6 +454,7 @@ Returns the most relevant memories ranked by text relevance, temporal recency, a
|
|
|
442
454
|
workspace,
|
|
443
455
|
requireConfidence: params.require_confidence,
|
|
444
456
|
granularity: params.granularity,
|
|
457
|
+
onAbstain: (info) => { abstained = info; },
|
|
445
458
|
});
|
|
446
459
|
|
|
447
460
|
// Auto-checkpoint: track recall
|
|
@@ -458,12 +471,29 @@ Returns the most relevant memories ranked by text relevance, temporal recency, a
|
|
|
458
471
|
: '';
|
|
459
472
|
|
|
460
473
|
if (results.length === 0) {
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
474
|
+
// Two very different conditions used to render identically as
|
|
475
|
+
// "No relevant memories found." — a claim of absence the system cannot make
|
|
476
|
+
// when a gate withheld matches. Callers read it as absence and stopped
|
|
477
|
+
// looking, for memories scoring well above minScore.
|
|
478
|
+
const text = abstained
|
|
479
|
+
? [
|
|
480
|
+
`RECALL ABSTAINED — this is NOT "no memories exist".`,
|
|
481
|
+
``,
|
|
482
|
+
`${abstained.candidates} candidate${abstained.candidates === 1 ? '' : 's'} matched ` +
|
|
483
|
+
`(best score ${abstained.topScore.toFixed(3)}) and ${abstained.candidates === 1 ? 'was' : 'were'} ` +
|
|
484
|
+
`withheld because recall confidence ` +
|
|
485
|
+
`${abstained.confidence !== undefined ? abstained.confidence.toFixed(3) + ' ' : ''}` +
|
|
486
|
+
`fell below your require_confidence of ${abstained.threshold ?? '?'}.`,
|
|
487
|
+
``,
|
|
488
|
+
`require_confidence is NOT min_score. It gates on the SHAPE of the score` +
|
|
489
|
+
` distribution across the whole result set, not on how relevant any single` +
|
|
490
|
+
` memory is — so results that comfortably pass min_score can still be withheld here.`,
|
|
491
|
+
``,
|
|
492
|
+
`To see them: re-run this query with require_confidence: 0.`,
|
|
493
|
+
`Do not conclude the memories are absent without doing that.`,
|
|
494
|
+
].join('\n') + peerSuffix
|
|
495
|
+
: 'No relevant memories found.' + peerSuffix;
|
|
496
|
+
return { content: [{ type: 'text' as const, text }] };
|
|
467
497
|
}
|
|
468
498
|
|
|
469
499
|
// Confidence-adaptive output (Paper 3: cognitive teaming) and D8
|
|
@@ -500,10 +530,15 @@ Returns the most relevant memories ranked by text relevance, temporal recency, a
|
|
|
500
530
|
};
|
|
501
531
|
}
|
|
502
532
|
|
|
533
|
+
// 0.14.3: surface the activation event id once, so memory_feedback can join
|
|
534
|
+
// to this recall. Rendered after the token footer; cheap (one short line).
|
|
535
|
+
const evId = results[0]?.activationEventId;
|
|
536
|
+
const evFooter = evId ? `\n[recall_id: ${evId}]` : '';
|
|
537
|
+
|
|
503
538
|
return {
|
|
504
539
|
content: [{
|
|
505
540
|
type: 'text' as const,
|
|
506
|
-
text: packed.lines.join('\n') + peerSuffix + formatTokenFooter(packed, params.max_tokens),
|
|
541
|
+
text: packed.lines.join('\n') + peerSuffix + formatTokenFooter(packed, params.max_tokens) + evFooter,
|
|
507
542
|
}],
|
|
508
543
|
};
|
|
509
544
|
}
|
|
@@ -518,9 +553,19 @@ Always call this after using a recalled memory so the system learns what's valua
|
|
|
518
553
|
engram_id: z.string().describe('ID of the memory (from memory_recall results)'),
|
|
519
554
|
useful: z.boolean().describe('Was this memory actually helpful?'),
|
|
520
555
|
context: z.string().optional().describe('Brief note on why it was/wasn\'t useful'),
|
|
556
|
+
recall_id: z.string().optional().describe(
|
|
557
|
+
'The [recall_id: …] printed at the end of the memory_recall output that returned this memory. '
|
|
558
|
+
+ 'Pass it so the feedback is joined to that recall; omitted, it defaults to the most recent recall in this session.',
|
|
559
|
+
),
|
|
521
560
|
},
|
|
522
561
|
async (params) => {
|
|
523
|
-
|
|
562
|
+
// 0.14.3: join feedback to the recall that produced it. Explicit recall_id
|
|
563
|
+
// wins; otherwise fall back to the engine's most recent logged activation —
|
|
564
|
+
// the common case is "recall, use, feedback" in one turn. Before this the
|
|
565
|
+
// MCP path hardcoded null (the HTTP route already accepted the id), which
|
|
566
|
+
// is why every retrieval_feedback row in the live store was orphaned.
|
|
567
|
+
const eventId = params.recall_id ?? activationEngine.lastActivationEventId ?? null;
|
|
568
|
+
await store.logRetrievalFeedback(eventId, params.engram_id, params.useful, params.context ?? '');
|
|
524
569
|
|
|
525
570
|
const engram = await store.getEngram(params.engram_id);
|
|
526
571
|
if (engram) {
|
|
@@ -624,7 +669,7 @@ server.tool(
|
|
|
624
669
|
`Identify THIS AWM instance — agent id, mode (standalone/hive), backend, store path, code provenance, ports, and the sibling agent spaces present in the same store. Call when unsure which AWM instance or memory space you are talking to.`,
|
|
625
670
|
{},
|
|
626
671
|
async () => {
|
|
627
|
-
const info = await buildWhoami(store, AGENT_ID, 'mcp');
|
|
672
|
+
const info = await buildWhoami(store, AGENT_ID, 'mcp', sidecarHandle ? sidecarHandle.boundPort() : undefined);
|
|
628
673
|
return { content: [{ type: 'text', text: formatWhoami(info) }] };
|
|
629
674
|
},
|
|
630
675
|
);
|
|
@@ -637,6 +682,11 @@ Also shows the activity log path so the user can tail it to see what's happening
|
|
|
637
682
|
async () => {
|
|
638
683
|
const metrics = await evalEngine.computeMetrics(AGENT_ID);
|
|
639
684
|
const checkpoint = await store.getCheckpoint(AGENT_ID);
|
|
685
|
+
// 0.14.3: outcome numbers, not activity counters. "Edge utility" (share of
|
|
686
|
+
// edges ever activated) was monotone — it could only rise — and read as
|
|
687
|
+
// health when it was not. Latency was a mean over a column that mixes cold
|
|
688
|
+
// loads and stalls (live store: mean 20 s, median 1.6 s); medians only now.
|
|
689
|
+
const usage = await evalEngine.computeUsage(AGENT_ID);
|
|
640
690
|
const lines = [
|
|
641
691
|
`Agent: ${AGENT_ID}`,
|
|
642
692
|
`Active memories: ${metrics.activeEngramCount}`,
|
|
@@ -644,9 +694,12 @@ Also shows the activity log path so the user can tail it to see what's happening
|
|
|
644
694
|
`Retracted: ${metrics.retractedCount}`,
|
|
645
695
|
`Avg confidence: ${metrics.avgConfidence.toFixed(3)}`,
|
|
646
696
|
`Total edges: ${metrics.totalEdges}`,
|
|
647
|
-
|
|
697
|
+
``,
|
|
698
|
+
`Write:recall (30d): 1 : ${usage.recallsPerWrite30d.toFixed(2)} (${usage.writes30d} writes, ${usage.recalls30d} recalls)`,
|
|
699
|
+
`Never recalled: ${(usage.neverRecalledShare * 100).toFixed(0)}% of active memories`,
|
|
700
|
+
`Recall→use (7d): ${usage.feedbackLinked7d > 0 ? (usage.usefulShare7d * 100).toFixed(0) + '% useful of ' + usage.feedbackLinked7d + ' judged' : 'no linked feedback yet'}`,
|
|
648
701
|
`Activations (24h): ${metrics.activationCount}`,
|
|
649
|
-
`
|
|
702
|
+
`Recall latency (24h): p50 ${metrics.p50LatencyMs.toFixed(0)}ms p90 ${metrics.p90LatencyMs.toFixed(0)}ms`,
|
|
650
703
|
``,
|
|
651
704
|
`Session writes: ${checkpoint?.auto.writeCountSinceConsolidation ?? 0}`,
|
|
652
705
|
`Session recalls: ${checkpoint?.auto.recallCountSinceConsolidation ?? 0}`,
|
|
@@ -654,7 +707,10 @@ Also shows the activity log path so the user can tail it to see what's happening
|
|
|
654
707
|
`Checkpoint: ${checkpoint?.executionState ? checkpoint.executionState.currentTask : 'none'}`,
|
|
655
708
|
``,
|
|
656
709
|
`Activity log: ${getLogPath() ?? 'not configured'}`,
|
|
657
|
-
|
|
710
|
+
// Report the port actually BOUND, not the one configured. Before 0.14.2
|
|
711
|
+
// this line said 8401 in every session, including the ones whose sidecar
|
|
712
|
+
// had lost the port and silently disabled hooks.
|
|
713
|
+
`Hook sidecar: ${sidecarPortLabel()}`,
|
|
658
714
|
];
|
|
659
715
|
|
|
660
716
|
return {
|
|
@@ -1315,6 +1371,8 @@ async function main() {
|
|
|
1315
1371
|
agentId: AGENT_ID,
|
|
1316
1372
|
secret: HOOK_SECRET,
|
|
1317
1373
|
port: HOOK_PORT,
|
|
1374
|
+
portRange: HOOK_PORT_RANGE,
|
|
1375
|
+
version: VERSION,
|
|
1318
1376
|
// 0.12.2: warm recall for hooks — the sidecar shares this process's
|
|
1319
1377
|
// activation engine and loaded models, so a UserPromptSubmit hook can get
|
|
1320
1378
|
// warm-latency recall without any standing server. Trimmed result shape
|
|
@@ -1398,7 +1456,8 @@ async function main() {
|
|
|
1398
1456
|
|
|
1399
1457
|
// Log to stderr (stdout is reserved for MCP protocol)
|
|
1400
1458
|
console.error(`AgentWorkingMemory MCP server started (agent: ${AGENT_ID}, db: ${DB_PATH})`);
|
|
1401
|
-
|
|
1459
|
+
sidecarHandle = sidecar;
|
|
1460
|
+
console.error(`Hook sidecar preferred 127.0.0.1:${HOOK_PORT} (range ${HOOK_PORT_RANGE})${HOOK_SECRET ? ' (auth enabled)' : ' (no auth — set AWM_HOOK_SECRET)'}`);
|
|
1402
1461
|
|
|
1403
1462
|
// Clean shutdown
|
|
1404
1463
|
const cleanup = async () => {
|
package/src/storage/pglite.ts
CHANGED
|
@@ -1020,7 +1020,7 @@ export class PGliteEngramStore {
|
|
|
1020
1020
|
};
|
|
1021
1021
|
}
|
|
1022
1022
|
|
|
1023
|
-
async getActivationStats(agentId: string, windowHours: number = 24): Promise<{ count: number; avgLatencyMs: number; p95LatencyMs: number }> {
|
|
1023
|
+
async getActivationStats(agentId: string, windowHours: number = 24): Promise<{ count: number; avgLatencyMs: number; p50LatencyMs: number; p90LatencyMs: number; p95LatencyMs: number }> {
|
|
1024
1024
|
await this.readyPromise;
|
|
1025
1025
|
// Flush any buffered activation events so stats reflect the latest writes.
|
|
1026
1026
|
await this.flushActivationEvents();
|
|
@@ -1031,17 +1031,35 @@ export class PGliteEngramStore {
|
|
|
1031
1031
|
ORDER BY latency_ms ASC`,
|
|
1032
1032
|
[agentId, since],
|
|
1033
1033
|
);
|
|
1034
|
-
if (result.rows.length === 0) return { count: 0, avgLatencyMs: 0, p95LatencyMs: 0 };
|
|
1034
|
+
if (result.rows.length === 0) return { count: 0, avgLatencyMs: 0, p50LatencyMs: 0, p90LatencyMs: 0, p95LatencyMs: 0 };
|
|
1035
1035
|
const latencies = result.rows.map((r) => Number(r.latency_ms));
|
|
1036
1036
|
const total = latencies.reduce((s, l) => s + l, 0);
|
|
1037
|
-
|
|
1037
|
+
// 0.14.3: p50/p90 — see sqlite.ts for why the mean is not usable here.
|
|
1038
|
+
const pct = (q: number) => latencies[Math.min(Math.floor(latencies.length * q), latencies.length - 1)];
|
|
1038
1039
|
return {
|
|
1039
1040
|
count: latencies.length,
|
|
1040
1041
|
avgLatencyMs: total / latencies.length,
|
|
1041
|
-
|
|
1042
|
+
p50LatencyMs: pct(0.5),
|
|
1043
|
+
p90LatencyMs: pct(0.9),
|
|
1044
|
+
p95LatencyMs: pct(0.95),
|
|
1042
1045
|
};
|
|
1043
1046
|
}
|
|
1044
1047
|
|
|
1048
|
+
/** 0.14.3: feedback rows joined to an activation event — see sqlite.ts. */
|
|
1049
|
+
async getLinkedFeedbackStats(agentId: string, windowHours: number = 24 * 7): Promise<{ total: number; useful: number }> {
|
|
1050
|
+
await this.readyPromise;
|
|
1051
|
+
const since = new Date(Date.now() - windowHours * 3600_000).toISOString();
|
|
1052
|
+
const result = await this.db.query<any>(
|
|
1053
|
+
`SELECT COUNT(*) AS total, COUNT(CASE WHEN rf.useful = TRUE THEN 1 END) AS useful
|
|
1054
|
+
FROM retrieval_feedback rf
|
|
1055
|
+
JOIN activation_events ae ON ae.id = rf.activation_event_id
|
|
1056
|
+
WHERE ae.agent_id = $1 AND rf.timestamp > $2`,
|
|
1057
|
+
[agentId, since],
|
|
1058
|
+
);
|
|
1059
|
+
const row = result.rows[0] ?? { total: 0, useful: 0 };
|
|
1060
|
+
return { total: Number(row.total), useful: Number(row.useful) };
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1045
1063
|
async getConsolidatedCount(agentId: string): Promise<number> {
|
|
1046
1064
|
await this.readyPromise;
|
|
1047
1065
|
const result = await this.db.query<any>(
|
package/src/storage/postgres.ts
CHANGED
|
@@ -1119,7 +1119,7 @@ export class PostgresEngramStore {
|
|
|
1119
1119
|
};
|
|
1120
1120
|
}
|
|
1121
1121
|
|
|
1122
|
-
async getActivationStats(agentId: string, windowHours: number = 24): Promise<{ count: number; avgLatencyMs: number; p95LatencyMs: number }> {
|
|
1122
|
+
async getActivationStats(agentId: string, windowHours: number = 24): Promise<{ count: number; avgLatencyMs: number; p50LatencyMs: number; p90LatencyMs: number; p95LatencyMs: number }> {
|
|
1123
1123
|
await this.readyPromise;
|
|
1124
1124
|
// Flush any buffered activation events so stats reflect the latest writes.
|
|
1125
1125
|
await this.flushActivationEvents();
|
|
@@ -1130,17 +1130,35 @@ export class PostgresEngramStore {
|
|
|
1130
1130
|
ORDER BY latency_ms ASC`,
|
|
1131
1131
|
[agentId, since],
|
|
1132
1132
|
);
|
|
1133
|
-
if (result.rows.length === 0) return { count: 0, avgLatencyMs: 0, p95LatencyMs: 0 };
|
|
1133
|
+
if (result.rows.length === 0) return { count: 0, avgLatencyMs: 0, p50LatencyMs: 0, p90LatencyMs: 0, p95LatencyMs: 0 };
|
|
1134
1134
|
const latencies = result.rows.map((r) => Number(r.latency_ms));
|
|
1135
1135
|
const total = latencies.reduce((s, l) => s + l, 0);
|
|
1136
|
-
|
|
1136
|
+
// 0.14.3: p50/p90 — see sqlite.ts for why the mean is not usable here.
|
|
1137
|
+
const pct = (q: number) => latencies[Math.min(Math.floor(latencies.length * q), latencies.length - 1)];
|
|
1137
1138
|
return {
|
|
1138
1139
|
count: latencies.length,
|
|
1139
1140
|
avgLatencyMs: total / latencies.length,
|
|
1140
|
-
|
|
1141
|
+
p50LatencyMs: pct(0.5),
|
|
1142
|
+
p90LatencyMs: pct(0.9),
|
|
1143
|
+
p95LatencyMs: pct(0.95),
|
|
1141
1144
|
};
|
|
1142
1145
|
}
|
|
1143
1146
|
|
|
1147
|
+
/** 0.14.3: feedback rows joined to an activation event — see sqlite.ts. */
|
|
1148
|
+
async getLinkedFeedbackStats(agentId: string, windowHours: number = 24 * 7): Promise<{ total: number; useful: number }> {
|
|
1149
|
+
await this.readyPromise;
|
|
1150
|
+
const since = new Date(Date.now() - windowHours * 3600_000).toISOString();
|
|
1151
|
+
const result = await this.q<any>(
|
|
1152
|
+
`SELECT COUNT(*) AS total, COUNT(CASE WHEN rf.useful = TRUE THEN 1 END) AS useful
|
|
1153
|
+
FROM retrieval_feedback rf
|
|
1154
|
+
JOIN activation_events ae ON ae.id = rf.activation_event_id
|
|
1155
|
+
WHERE ae.agent_id = $1 AND rf.timestamp > $2`,
|
|
1156
|
+
[agentId, since],
|
|
1157
|
+
);
|
|
1158
|
+
const row = result.rows[0] ?? { total: 0, useful: 0 };
|
|
1159
|
+
return { total: Number(row.total), useful: Number(row.useful) };
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1144
1162
|
async getConsolidatedCount(agentId: string): Promise<number> {
|
|
1145
1163
|
await this.readyPromise;
|
|
1146
1164
|
const result = await this.q<any>(
|
package/src/storage/sqlite.ts
CHANGED
|
@@ -1348,7 +1348,7 @@ export class EngramStore {
|
|
|
1348
1348
|
}
|
|
1349
1349
|
|
|
1350
1350
|
getActivationStats(agentId: string, windowHours: number = 24): {
|
|
1351
|
-
count: number; avgLatencyMs: number; p95LatencyMs: number;
|
|
1351
|
+
count: number; avgLatencyMs: number; p50LatencyMs: number; p90LatencyMs: number; p95LatencyMs: number;
|
|
1352
1352
|
} {
|
|
1353
1353
|
const since = new Date(Date.now() - windowHours * 3600_000).toISOString();
|
|
1354
1354
|
const rows = this.db.prepare(`
|
|
@@ -1357,17 +1357,39 @@ export class EngramStore {
|
|
|
1357
1357
|
ORDER BY latency_ms ASC
|
|
1358
1358
|
`).all(agentId, since) as { latency_ms: number }[];
|
|
1359
1359
|
|
|
1360
|
-
if (rows.length === 0) return { count: 0, avgLatencyMs: 0, p95LatencyMs: 0 };
|
|
1360
|
+
if (rows.length === 0) return { count: 0, avgLatencyMs: 0, p50LatencyMs: 0, p90LatencyMs: 0, p95LatencyMs: 0 };
|
|
1361
1361
|
|
|
1362
|
+
// 0.14.3: p50/p90 added. The column mixes warm recalls with cold model loads
|
|
1363
|
+
// and stalls (live store: mean 20 s, median 1.6 s, max 38 min), so the mean
|
|
1364
|
+
// is not a usable number; percentiles are. Rows are already sorted ASC.
|
|
1365
|
+
const pct = (q: number) => rows[Math.min(Math.floor(rows.length * q), rows.length - 1)].latency_ms;
|
|
1362
1366
|
const total = rows.reduce((s, r) => s + r.latency_ms, 0);
|
|
1363
|
-
const p95Index = Math.min(Math.floor(rows.length * 0.95), rows.length - 1);
|
|
1364
1367
|
return {
|
|
1365
1368
|
count: rows.length,
|
|
1366
1369
|
avgLatencyMs: total / rows.length,
|
|
1367
|
-
|
|
1370
|
+
p50LatencyMs: pct(0.5),
|
|
1371
|
+
p90LatencyMs: pct(0.9),
|
|
1372
|
+
p95LatencyMs: pct(0.95),
|
|
1368
1373
|
};
|
|
1369
1374
|
}
|
|
1370
1375
|
|
|
1376
|
+
/**
|
|
1377
|
+
* 0.14.3: feedback rows in the window that are JOINED to an activation event.
|
|
1378
|
+
* Unlinked rows (activation_event_id IS NULL — all 872 rows written before
|
|
1379
|
+
* 0.14.3) are excluded on purpose: this is the recall→use signal, and a row
|
|
1380
|
+
* that cannot be traced to a recall does not measure it.
|
|
1381
|
+
*/
|
|
1382
|
+
getLinkedFeedbackStats(agentId: string, windowHours: number = 24 * 7): { total: number; useful: number } {
|
|
1383
|
+
const since = new Date(Date.now() - windowHours * 3600_000).toISOString();
|
|
1384
|
+
const row = this.db.prepare(`
|
|
1385
|
+
SELECT COUNT(*) AS total, COUNT(CASE WHEN rf.useful = 1 THEN 1 END) AS useful
|
|
1386
|
+
FROM retrieval_feedback rf
|
|
1387
|
+
JOIN activation_events ae ON ae.id = rf.activation_event_id
|
|
1388
|
+
WHERE ae.agent_id = ? AND rf.timestamp > ?
|
|
1389
|
+
`).get(agentId, since) as { total: number; useful: number };
|
|
1390
|
+
return { total: row.total, useful: row.useful };
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1371
1393
|
getConsolidatedCount(agentId: string): number {
|
|
1372
1394
|
const row = this.db.prepare(
|
|
1373
1395
|
`SELECT COUNT(*) as cnt FROM engrams WHERE agent_id = ? AND stage = 'consolidated'`
|
package/src/types/engram.ts
CHANGED
|
@@ -219,6 +219,13 @@ export interface ActivationResult {
|
|
|
219
219
|
* See `src/engine/confidence.ts` for the formula.
|
|
220
220
|
*/
|
|
221
221
|
confidence?: number;
|
|
222
|
+
/**
|
|
223
|
+
* Id of the activation_events row this recall logged (0.14.3). Same value on
|
|
224
|
+
* every result in the same recall. Hand it back on memory_feedback so the
|
|
225
|
+
* feedback joins to the recall that produced it — before this, the id was
|
|
226
|
+
* generated and dropped, and every retrieval_feedback row was orphaned.
|
|
227
|
+
*/
|
|
228
|
+
activationEventId?: string;
|
|
222
229
|
/**
|
|
223
230
|
* Confidence-adaptive content preview (Paper 3: cognitive teaming).
|
|
224
231
|
* Set when the query opts in via `granularity: 'compact' | 'auto'`.
|
|
@@ -254,6 +261,22 @@ export interface PhaseScores {
|
|
|
254
261
|
*/
|
|
255
262
|
export type QueryMode = 'targeted' | 'exploratory' | 'balanced' | 'auto';
|
|
256
263
|
|
|
264
|
+
/** Why a recall returned nothing, when the cause was a gate rather than an empty store. */
|
|
265
|
+
export interface AbstentionInfo {
|
|
266
|
+
/** `confidence` = score-distribution gate (`requireConfidence`).
|
|
267
|
+
* `agreement` = cross-channel agreement gate (`abstentionThreshold`). */
|
|
268
|
+
reason: 'confidence' | 'agreement';
|
|
269
|
+
/** Candidates that survived scoring and were then withheld. Never 0 — a genuinely
|
|
270
|
+
* empty result does not produce an AbstentionInfo at all. */
|
|
271
|
+
candidates: number;
|
|
272
|
+
/** Score of the best withheld candidate. Compare against `minScore`, not against
|
|
273
|
+
* the confidence threshold — they measure different things. */
|
|
274
|
+
topScore: number;
|
|
275
|
+
/** Computed recall confidence, and the threshold it failed. */
|
|
276
|
+
confidence?: number;
|
|
277
|
+
threshold?: number;
|
|
278
|
+
}
|
|
279
|
+
|
|
257
280
|
export interface ActivationQuery {
|
|
258
281
|
agentId: string;
|
|
259
282
|
context: string;
|
|
@@ -273,6 +296,15 @@ export interface ActivationQuery {
|
|
|
273
296
|
* 0.25 (balanced), 0.40 (aggressive — only return high-confidence recall).
|
|
274
297
|
*/
|
|
275
298
|
requireConfidence?: number;
|
|
299
|
+
/**
|
|
300
|
+
* Called when a gate withholds results, INSTEAD of silently returning [].
|
|
301
|
+
*
|
|
302
|
+
* An empty array cannot distinguish "nothing matched" from "matches were found
|
|
303
|
+
* and withheld", and callers reliably read the second as the first — including
|
|
304
|
+
* when the withheld results scored well above `minScore`. If you act on an empty
|
|
305
|
+
* recall, wire this up.
|
|
306
|
+
*/
|
|
307
|
+
onAbstain?: (info: AbstentionInfo) => void;
|
|
276
308
|
internal?: boolean; // Skip access count increment, Hebbian update, and event logging (for system calls)
|
|
277
309
|
spread?: boolean; // R2: when AWM_SPREAD=1, set false to skip iterative spreading activation (connection-discovery uses this so edge-building doesn't recurse)
|
|
278
310
|
memoryType?: MemoryType; // Filter by memory type (episodic, semantic, procedural)
|
|
@@ -285,6 +317,15 @@ export interface ActivationQuery {
|
|
|
285
317
|
* something different on every run.
|
|
286
318
|
*/
|
|
287
319
|
asOf?: number;
|
|
320
|
+
/**
|
|
321
|
+
* 0.14.4: clock for ACT-R decay. Defaults to Date.now(). `asOf` pinned the
|
|
322
|
+
* temporal PARSER but decay still read the wall clock, so a "frozen" eval
|
|
323
|
+
* snapshot scored differently every day — a 300-query benchmark read 70.0%
|
|
324
|
+
* s@1 one evening and 67.0% the next afternoon on byte-identical data and
|
|
325
|
+
* code. Evals must pass the snapshot's own timestamp here. Production leaves
|
|
326
|
+
* it unset.
|
|
327
|
+
*/
|
|
328
|
+
now?: number;
|
|
288
329
|
bm25Only?: boolean; // Skip embedding — fast text-only retrieval for bulk/benchmark scenarios
|
|
289
330
|
/**
|
|
290
331
|
* Output granularity (Paper 3: cognitive teaming, Brill 2018 ACT-R collaboration).
|
package/src/types/eval.ts
CHANGED
|
@@ -54,7 +54,9 @@ export interface EvalMetrics {
|
|
|
54
54
|
// Retrieval quality
|
|
55
55
|
activationCount: number;
|
|
56
56
|
avgPrecisionAtK: number; // Of returned results, % judged useful
|
|
57
|
-
avgLatencyMs: number;
|
|
57
|
+
avgLatencyMs: number; // kept for callers; misleading on the live store (mixes cold loads) — prefer p50/p90
|
|
58
|
+
p50LatencyMs: number; // 0.14.3
|
|
59
|
+
p90LatencyMs: number; // 0.14.3
|
|
58
60
|
p95LatencyMs: number;
|
|
59
61
|
|
|
60
62
|
// Connection quality
|