agent-working-memory 0.7.3 → 0.7.6

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.
@@ -11,7 +11,7 @@ import type { EngramStore } from '../storage/sqlite.js';
11
11
  import { ZodError } from 'zod';
12
12
  import { initCoordinationTables } from './schema.js';
13
13
  import { registerCoordinationRoutes } from './routes.js';
14
- import { cleanSlate, pruneOldHeartbeats, purgeDeadAgents } from './stale.js';
14
+ import { cleanSlate, pruneOldHeartbeats, purgeDeadAgents, cleanupStale } from './stale.js';
15
15
  import { createWriteMutex, needsWriteLock } from './write-mutex.js';
16
16
  import { createEventBus, type CoordinationEventBus } from './events.js';
17
17
  import { loadPlugins, teardownPlugins } from './plugin-loader.js';
@@ -106,6 +106,25 @@ export function initCoordination(app: FastifyInstance, db: Database.Database, st
106
106
  }, 60 * 60 * 1000),
107
107
  );
108
108
 
109
+ // Periodic stale-agent cleanup every 5 min with 600s threshold (10 min idle).
110
+ // Forgiving for long-running edits — workers should pulse every 60s during active
111
+ // work, so 10 min without a pulse is genuinely dead. This catches the
112
+ // "alive but not seeing each other" pattern where workers' processes persist
113
+ // but their heartbeats stop. Without this scheduled, only an explicit
114
+ // POST /stale/cleanup call (made by the coordinator agent on startup) ever
115
+ // fires cleanupStale, leaving zombie agents accumulating between coordinator
116
+ // sessions.
117
+ cleanupIntervals.push(
118
+ setInterval(() => {
119
+ try {
120
+ const result = cleanupStale(db, 600);
121
+ if (result.cleaned > 0) {
122
+ console.log(` [stale-cleanup] auto-cleaned ${result.stale.length} stale agent(s), ${result.cleaned} resource(s) released`);
123
+ }
124
+ } catch { /* db may be closed */ }
125
+ }, 5 * 60 * 1000),
126
+ );
127
+
109
128
  // Periodic channel liveness probe every 60s — mark unreachable sessions as disconnected
110
129
  cleanupIntervals.push(
111
130
  setInterval(async () => {
@@ -33,6 +33,45 @@ function coordLog(msg: string): void {
33
33
  console.log(`${ts()} [coord] ${msg}`);
34
34
  }
35
35
 
36
+ /**
37
+ * In-process counters for channel push telemetry.
38
+ * Reset on coordinator restart — intended for short-window observability
39
+ * ("ship it, watch numbers for a day"). Persistent counters would need a
40
+ * coord_metrics table; deferred until we know what's worth keeping.
41
+ *
42
+ * Fields:
43
+ * attempts — every call to deliverToChannel (HTTP push to worker)
44
+ * delivered — fetch returned 2xx
45
+ * failed_http — fetch returned non-2xx (worker reachable but rejected)
46
+ * failed_unreachable — fetch threw (timeout, ECONNREFUSED, etc.)
47
+ * no_session — push intent existed but no connected session
48
+ * fallback_mailbox — push failed, message queued to mailbox instead
49
+ * session_disconnects — session marked 'disconnected' after delivery failure
50
+ */
51
+ interface ChannelMetrics {
52
+ attempts: number;
53
+ delivered: number;
54
+ failed_http: number;
55
+ failed_unreachable: number;
56
+ no_session: number;
57
+ fallback_mailbox: number;
58
+ session_disconnects: number;
59
+ started_at: number;
60
+ }
61
+
62
+ function createChannelMetrics(): ChannelMetrics {
63
+ return {
64
+ attempts: 0,
65
+ delivered: 0,
66
+ failed_http: 0,
67
+ failed_unreachable: 0,
68
+ no_session: 0,
69
+ fallback_mailbox: 0,
70
+ session_disconnects: 0,
71
+ started_at: Date.now(),
72
+ };
73
+ }
74
+
36
75
  /**
37
76
  * Optional session-token check.
38
77
  * If X-Session-Token header is present and doesn't match the stored token → returns false (caller should 403).
@@ -47,6 +86,9 @@ function sessionTokenOk(db: Database.Database, agentId: string, req: import('fas
47
86
  }
48
87
 
49
88
  export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Database, store?: EngramStore, eventBus?: import('./events.js').CoordinationEventBus): void {
89
+ // Channel push telemetry — process-scoped counters. See ChannelMetrics docs above.
90
+ const channelMetrics = createChannelMetrics();
91
+
50
92
 
51
93
  // Request logging — one line per request with method, url, status, response time
52
94
  app.addHook('onRequest', async (request) => {
@@ -132,9 +174,14 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
132
174
  const sessionToken = wasDead ? randomUUID() : (
133
175
  (db.prepare(`SELECT session_token FROM coord_agents WHERE id = ?`).get(existing.id) as { session_token: string | null }).session_token ?? randomUUID()
134
176
  );
177
+ // role IS updated on every checkin — agents know their own role and
178
+ // re-registrations may correct stale role values (e.g., when an old
179
+ // coord_agents row was inserted with role='orchestrator' before the
180
+ // 'coordinator' role was canonical, or when the channel-server's
181
+ // hardcoded role='worker' overwrote a real role).
135
182
  db.prepare(
136
- `UPDATE coord_agents SET last_seen = datetime('now'), status = CASE WHEN status = 'dead' THEN 'idle' ELSE status END, pid = COALESCE(?, pid), capabilities = COALESCE(?, capabilities), workspace = COALESCE(?, workspace), session_token = ? WHERE id = ?`
137
- ).run(pid ?? null, capsJson, workspace ?? null, sessionToken, existing.id);
183
+ `UPDATE coord_agents SET last_seen = datetime('now'), status = CASE WHEN status = 'dead' THEN 'idle' ELSE status END, role = ?, pid = COALESCE(?, pid), capabilities = COALESCE(?, capabilities), workspace = COALESCE(?, workspace), session_token = ? WHERE id = ?`
184
+ ).run(role, pid ?? null, capsJson, workspace ?? null, sessionToken, existing.id);
138
185
 
139
186
  const eventType = wasDead ? 'reconnected' : 'heartbeat';
140
187
  const detail = wasDead ? `${name} reconnected (was dead)` : `heartbeat from ${name}`;
@@ -544,6 +591,9 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
544
591
  `UPDATE coord_channel_sessions SET last_push_at = datetime('now'), push_count = push_count + 1 WHERE agent_id = ?`
545
592
  ).run(agentId);
546
593
  }
594
+ } else {
595
+ // Session disappeared between intent record and delivery — race or rapid disconnect
596
+ channelMetrics.no_session++;
547
597
  }
548
598
  }
549
599
 
@@ -1359,28 +1409,41 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
1359
1409
  const q = workersQuerySchema.safeParse(req.query);
1360
1410
  const { capability, status: filterStatus, workspace } = q.success ? q.data : { capability: undefined, status: undefined, workspace: undefined };
1361
1411
 
1412
+ // Join with coord_channel_sessions so the coordinator agent can compute
1413
+ // alive=true for workers that have a connected channel session even when
1414
+ // their /pulse is stale. Without this, /workers under-reports liveness
1415
+ // during long tool-call sequences where the worker is processing but
1416
+ // hasn't called /pulse for >5min — leading to false-positive duplicate
1417
+ // spawns. Channel sessions get probed every 60s (coordination/index.ts:111),
1418
+ // so a stale channel-server.js gets status='disconnected' within 60-120s.
1362
1419
  let workers = workspace
1363
1420
  ? db.prepare(
1364
- `SELECT id, name, role, status, current_task, capabilities, workspace, last_seen,
1365
- ROUND((julianday('now') - julianday(last_seen)) * 86400) AS seconds_since_seen
1366
- FROM coord_agents
1367
- WHERE status != 'dead' AND role NOT IN ('orchestrator', 'coordinator') AND workspace = ?
1368
- ORDER BY name LIMIT 200`
1421
+ `SELECT a.id, a.name, a.role, a.status, a.current_task, a.capabilities, a.workspace, a.last_seen,
1422
+ ROUND((julianday('now') - julianday(a.last_seen)) * 86400) AS seconds_since_seen,
1423
+ cs.status AS channel_status, cs.last_push_at AS channel_last_push
1424
+ FROM coord_agents a
1425
+ LEFT JOIN coord_channel_sessions cs ON cs.agent_id = a.id
1426
+ WHERE a.status != 'dead' AND a.role NOT IN ('orchestrator', 'coordinator') AND a.workspace = ?
1427
+ ORDER BY a.name LIMIT 200`
1369
1428
  ).all(workspace) as Array<{
1370
1429
  id: string; name: string; role: string; status: string;
1371
1430
  current_task: string | null; capabilities: string | null;
1372
1431
  workspace: string | null; last_seen: string; seconds_since_seen: number;
1432
+ channel_status: string | null; channel_last_push: string | null;
1373
1433
  }>
1374
1434
  : db.prepare(
1375
- `SELECT id, name, role, status, current_task, capabilities, workspace, last_seen,
1376
- ROUND((julianday('now') - julianday(last_seen)) * 86400) AS seconds_since_seen
1377
- FROM coord_agents
1378
- WHERE status != 'dead' AND role NOT IN ('orchestrator', 'coordinator')
1379
- ORDER BY name LIMIT 200`
1435
+ `SELECT a.id, a.name, a.role, a.status, a.current_task, a.capabilities, a.workspace, a.last_seen,
1436
+ ROUND((julianday('now') - julianday(a.last_seen)) * 86400) AS seconds_since_seen,
1437
+ cs.status AS channel_status, cs.last_push_at AS channel_last_push
1438
+ FROM coord_agents a
1439
+ LEFT JOIN coord_channel_sessions cs ON cs.agent_id = a.id
1440
+ WHERE a.status != 'dead' AND a.role NOT IN ('orchestrator', 'coordinator')
1441
+ ORDER BY a.name LIMIT 200`
1380
1442
  ).all() as Array<{
1381
1443
  id: string; name: string; role: string; status: string;
1382
1444
  current_task: string | null; capabilities: string | null;
1383
1445
  workspace: string | null; last_seen: string; seconds_since_seen: number;
1446
+ channel_status: string | null; channel_last_push: string | null;
1384
1447
  }>;
1385
1448
 
1386
1449
  if (capability) {
@@ -1409,7 +1472,14 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
1409
1472
  workspace: w.workspace,
1410
1473
  lastSeen: w.last_seen,
1411
1474
  secondsSinceSeen: w.seconds_since_seen,
1412
- alive: w.seconds_since_seen < 300,
1475
+ // alive = recent /pulse OR connected channel session.
1476
+ // Channel sessions get probed every 60s and marked 'disconnected'
1477
+ // when unreachable, so a connected session is reliable proof of life
1478
+ // even during long tool sequences where the worker hasn't pulsed.
1479
+ // Prevents duplicate worker spawns when /pulse is stale but worker is busy.
1480
+ alive: w.seconds_since_seen < 300 || w.channel_status === 'connected',
1481
+ channelStatus: w.channel_status,
1482
+ channelLastPush: w.channel_last_push,
1413
1483
  }));
1414
1484
 
1415
1485
  return reply.send({
@@ -1691,6 +1761,32 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
1691
1761
  lines.push('# TYPE coord_uptime_seconds gauge');
1692
1762
  lines.push(`coord_uptime_seconds ${uptime}`);
1693
1763
 
1764
+ // ─── Channel push telemetry (process-scoped, reset on restart) ───
1765
+ lines.push('# HELP coord_channel_push_attempts_total Total channel push attempts since coordinator startup');
1766
+ lines.push('# TYPE coord_channel_push_attempts_total counter');
1767
+ lines.push(`coord_channel_push_attempts_total ${channelMetrics.attempts}`);
1768
+
1769
+ lines.push('# HELP coord_channel_push_delivered_total Successful channel deliveries');
1770
+ lines.push('# TYPE coord_channel_push_delivered_total counter');
1771
+ lines.push(`coord_channel_push_delivered_total ${channelMetrics.delivered}`);
1772
+
1773
+ lines.push('# HELP coord_channel_push_failed_total Failed channel deliveries by reason');
1774
+ lines.push('# TYPE coord_channel_push_failed_total counter');
1775
+ lines.push(`coord_channel_push_failed_total{reason="http"} ${channelMetrics.failed_http}`);
1776
+ lines.push(`coord_channel_push_failed_total{reason="unreachable"} ${channelMetrics.failed_unreachable}`);
1777
+
1778
+ lines.push('# HELP coord_channel_no_session_total Push attempts where agent had no connected session');
1779
+ lines.push('# TYPE coord_channel_no_session_total counter');
1780
+ lines.push(`coord_channel_no_session_total ${channelMetrics.no_session}`);
1781
+
1782
+ lines.push('# HELP coord_channel_fallback_mailbox_total Pushes that fell back to mailbox after delivery failure');
1783
+ lines.push('# TYPE coord_channel_fallback_mailbox_total counter');
1784
+ lines.push(`coord_channel_fallback_mailbox_total ${channelMetrics.fallback_mailbox}`);
1785
+
1786
+ lines.push('# HELP coord_channel_session_disconnects_total Sessions marked disconnected after delivery failure');
1787
+ lines.push('# TYPE coord_channel_session_disconnects_total counter');
1788
+ lines.push(`coord_channel_session_disconnects_total ${channelMetrics.session_disconnects}`);
1789
+
1694
1790
  return reply.type('text/plain; version=0.0.4; charset=utf-8').send(lines.join('\n') + '\n');
1695
1791
  });
1696
1792
 
@@ -1800,6 +1896,7 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
1800
1896
  async function deliverToChannel(
1801
1897
  agentId: string, channelUrl: string, content: string, meta?: Record<string, string>
1802
1898
  ): Promise<{ delivered: boolean; error?: string }> {
1899
+ channelMetrics.attempts++;
1803
1900
  try {
1804
1901
  const res = await fetch(`${channelUrl}/push`, {
1805
1902
  method: 'POST',
@@ -1808,11 +1905,15 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
1808
1905
  signal: AbortSignal.timeout(5000),
1809
1906
  });
1810
1907
  if (!res.ok) {
1908
+ channelMetrics.failed_http++;
1811
1909
  return { delivered: false, error: `channel returned ${res.status}` };
1812
1910
  }
1911
+ channelMetrics.delivered++;
1813
1912
  return { delivered: true };
1814
1913
  } catch (err) {
1815
1914
  // Connection refused / timeout → worker process is dead, mark session disconnected
1915
+ channelMetrics.failed_unreachable++;
1916
+ channelMetrics.session_disconnects++;
1816
1917
  db.prepare(
1817
1918
  `UPDATE coord_channel_sessions SET status = 'disconnected' WHERE agent_id = ?`
1818
1919
  ).run(agentId);
@@ -1822,11 +1923,40 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
1822
1923
  }
1823
1924
  }
1824
1925
 
1825
- /** POST /channel/push — Push a message to an agent. Tries live delivery first, falls back to mailbox queue. */
1926
+ /** POST /channel/push — Push a message to an agent. Tries live delivery first, falls back to mailbox queue.
1927
+ *
1928
+ * Two addressing modes:
1929
+ * - {agentId, message} — direct UUID
1930
+ * - {role, workspace, message} — server resolves to most-recently-seen alive agent
1931
+ * matching role+workspace. Used by workers to notify
1932
+ * coordinator (whose UUID changes across restarts).
1933
+ */
1826
1934
  app.post('/channel/push', async (request, reply) => {
1827
1935
  const parsed = channelPushSchema.safeParse(request.body);
1828
1936
  if (!parsed.success) return reply.status(400).send({ error: parsed.error.flatten() });
1829
- const { agentId, message } = parsed.data;
1937
+ const { message } = parsed.data;
1938
+ let { agentId } = parsed.data;
1939
+
1940
+ // Role-based addressing — resolve to a concrete agentId
1941
+ if (!agentId && parsed.data.role && parsed.data.workspace) {
1942
+ const resolved = db.prepare(
1943
+ `SELECT id FROM coord_agents
1944
+ WHERE role = ? AND workspace = ? AND status != 'dead'
1945
+ ORDER BY last_seen DESC
1946
+ LIMIT 1`
1947
+ ).get(parsed.data.role, parsed.data.workspace) as { id: string } | undefined;
1948
+ if (!resolved) {
1949
+ return reply.status(404).send({
1950
+ error: `No alive agent found for role='${parsed.data.role}' workspace='${parsed.data.workspace}'`,
1951
+ });
1952
+ }
1953
+ agentId = resolved.id;
1954
+ }
1955
+
1956
+ // Type narrowing — Zod refine guarantees agentId is set by this point,
1957
+ // but TypeScript can't see through the refine. This guard is unreachable
1958
+ // in practice (would have 400'd earlier).
1959
+ if (!agentId) return reply.status(400).send({ error: 'Internal: agentId resolution failed' });
1830
1960
 
1831
1961
  const agent = db.prepare(`SELECT name, workspace FROM coord_agents WHERE id = ?`).get(agentId) as { name: string; workspace: string | null } | undefined;
1832
1962
  if (!agent) return reply.status(404).send({ error: 'Agent not found' });
@@ -1853,6 +1983,10 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
1853
1983
  return reply.send({ ok: true, delivered: true, channelId: session.channel_id });
1854
1984
  }
1855
1985
  // Live delivery failed — fall through to mailbox
1986
+ channelMetrics.fallback_mailbox++;
1987
+ } else {
1988
+ // No connected session — push went straight to mailbox
1989
+ channelMetrics.no_session++;
1856
1990
  }
1857
1991
 
1858
1992
  // Queue to mailbox (delivered on next /next poll)
@@ -1914,4 +2048,48 @@ export function registerCoordinationRoutes(app: FastifyInstance, db: Database.Da
1914
2048
 
1915
2049
  return reply.send({ probed: results.length, alive, dead, results });
1916
2050
  });
2051
+
2052
+ /**
2053
+ * GET /telemetry/channels — Channel push delivery telemetry.
2054
+ *
2055
+ * Counters reset on coordinator restart (in-process). Use this to answer:
2056
+ * "Are channels reliable enough to depend on, or do we need a polling fallback?"
2057
+ *
2058
+ * Response shape:
2059
+ * {
2060
+ * since: ISO timestamp of when counters started,
2061
+ * uptime_seconds: number,
2062
+ * attempts, delivered, failed_http, failed_unreachable,
2063
+ * no_session, fallback_mailbox, session_disconnects: number,
2064
+ * delivery_rate: 0..1 (delivered / attempts) or null if zero attempts,
2065
+ * per_agent: [{ agent_name, push_count, last_push_at, status }]
2066
+ * }
2067
+ */
2068
+ app.get('/telemetry/channels', async (_request, reply) => {
2069
+ const perAgent = db.prepare(`
2070
+ SELECT a.name AS agent_name, cs.push_count, cs.last_push_at, cs.status,
2071
+ cs.connected_at
2072
+ FROM coord_channel_sessions cs
2073
+ JOIN coord_agents a ON a.id = cs.agent_id
2074
+ ORDER BY cs.push_count DESC, cs.connected_at DESC
2075
+ `).all();
2076
+
2077
+ const deliveryRate = channelMetrics.attempts > 0
2078
+ ? channelMetrics.delivered / channelMetrics.attempts
2079
+ : null;
2080
+
2081
+ return reply.send({
2082
+ since: new Date(channelMetrics.started_at).toISOString(),
2083
+ uptime_seconds: Math.round((Date.now() - channelMetrics.started_at) / 1000),
2084
+ attempts: channelMetrics.attempts,
2085
+ delivered: channelMetrics.delivered,
2086
+ failed_http: channelMetrics.failed_http,
2087
+ failed_unreachable: channelMetrics.failed_unreachable,
2088
+ no_session: channelMetrics.no_session,
2089
+ fallback_mailbox: channelMetrics.fallback_mailbox,
2090
+ session_disconnects: channelMetrics.session_disconnects,
2091
+ delivery_rate: deliveryRate,
2092
+ per_agent: perAgent,
2093
+ });
2094
+ });
1917
2095
  }
@@ -210,10 +210,25 @@ export const channelDeregisterSchema = z.object({
210
210
  agentId: z.string().uuid(),
211
211
  });
212
212
 
213
+ /**
214
+ * Push to an agent's channel session. Accepts either:
215
+ * - agentId (direct addressing — caller knows the UUID)
216
+ * - role + workspace (role-based addressing — server resolves to the most
217
+ * recently-seen alive agent matching that role and workspace)
218
+ *
219
+ * Role-based addressing is the right choice when a worker wants to notify the
220
+ * coordinator: workers don't know the coordinator's UUID (it changes across
221
+ * coordinator restarts) but they do know the role and their own workspace.
222
+ */
213
223
  export const channelPushSchema = z.object({
214
- agentId: z.string().uuid(),
224
+ agentId: z.string().uuid().optional(),
225
+ role: agentRoleEnum.optional(),
226
+ workspace: z.string().min(1).max(50).optional(),
215
227
  message: z.string().min(1).max(10000),
216
- });
228
+ }).refine(
229
+ (d) => d.agentId !== undefined || (d.role !== undefined && d.workspace !== undefined),
230
+ { message: 'Must provide either agentId, or both role and workspace' }
231
+ );
217
232
 
218
233
  // ─── Stats ─────────────────────────────────────────────────────
219
234
 
@@ -13,7 +13,68 @@
13
13
  import type { SalienceFeatures, MemoryClass } from '../types/index.js';
14
14
  import type { EngramStore } from '../storage/sqlite.js';
15
15
 
16
- export type SalienceEventType = 'decision' | 'friction' | 'surprise' | 'causal' | 'observation';
16
+ export type SalienceEventType = 'decision' | 'friction' | 'surprise' | 'causal' | 'observation' | 'user_feedback';
17
+
18
+ /**
19
+ * Auto-detect user-feedback memories: content that begins with a known user's
20
+ * name + a feedback verb. These memories represent direct human decisions and
21
+ * must never be discarded. Examples:
22
+ * "Robert verbatim: 'LMS programs-first like CRM'"
23
+ * "Katherine said the CEC cycle resets on promotion"
24
+ * "Nancy directed Tier 1 has no grace period"
25
+ *
26
+ * Why this exists: the BM25 novelty check collapses near-duplicates regardless
27
+ * of whether the content is a NEW decision or a repeat observation. User
28
+ * feedback often shares terminology with prior memories ("LMS", "ECP",
29
+ * "officials") and gets discarded at salience 0.14 (verified in activity log
30
+ * 2026-05-06T19:08:47). Detecting "Robert said X" → canonical class bypasses
31
+ * the salience filter entirely.
32
+ *
33
+ * Tune the name list as new staff join. Pattern requires word boundary at
34
+ * start so "Roberta" or "Hannahs" don't match.
35
+ */
36
+ const USER_FEEDBACK_PATTERN = /^(Robert|Katherine|Catherine|Nancy|Brandy|Brandi|Hannah|Marilyn|Kaylee|Pete|Abby|Tom|Wendy|Sita|Nick|Rob|Joan|Jennifer|Cindy|Jason|Alex|Molly)\s+(said|verbatim|feedback|asked|wants|prefers|requested|requested|directed|decided|confirmed|clarified|chose|specified|explained)\b/i;
37
+
38
+ /** Returns true if the content looks like direct user feedback that should auto-promote to canonical. */
39
+ export function detectUserFeedback(content: string): boolean {
40
+ if (typeof content !== 'string' || content.length === 0) return false;
41
+ return USER_FEEDBACK_PATTERN.test(content.trim());
42
+ }
43
+
44
+ /**
45
+ * Auto-detect verified operational findings: batch records, completion summaries,
46
+ * incident reconciliations. These have low BM25 novelty (terminology repeats across
47
+ * runs — "USEF results submission", "Freshdesk triage batch") but the SPECIFIC
48
+ * event/ticket IDs, dates, and counts make each one uniquely valuable for future
49
+ * recall.
50
+ *
51
+ * Why this exists: the salience filter discarded a 6-event USEF batch summary at
52
+ * 0.14 (verified in activity log 2026-05-07T18:44:14) because the topic words
53
+ * collided with the long-running USEF history. The procedural memory beside it
54
+ * scored 0.70 — same topic, different content shape. The novelty signal alone
55
+ * can't distinguish a useful operational record from a duplicate observation.
56
+ *
57
+ * Pattern requires BOTH:
58
+ * 1. An action-verb header (Submitted/Finalized/Completed/Reconciled/Triaged/Posted/Resolved/Stamped)
59
+ * 2. At least 2 concrete identifiers — absolute dates (YYYY-MM-DD) OR numeric IDs
60
+ * with context (event \d+, ticket #\d+, USEF \d+, USEA \d+).
61
+ *
62
+ * Matched memories get a salience floor of 0.45 (active, but below canonical
63
+ * 0.7) — preserves the record without claiming source-of-truth status.
64
+ */
65
+ const OPERATIONAL_VERB_PATTERN = /\b(Submitted|Finalized|Completed|Reconciled|Triaged|Posted|Resolved|Stamped|Pushed|Deployed|Migrated|Imported|Exported|Backfilled)\b/i;
66
+ const ISO_DATE_PATTERN = /\b\d{4}-\d{2}-\d{2}\b/g;
67
+ const CONCRETE_ID_PATTERN = /\b(?:events?|tickets?|comps?|comp_id|usef|usea|classes|class|cases?|orders?|payments?|member_id|horse_id|user_id|orgs?|#)\s*[#:]?\s*\d{3,}/gi;
68
+
69
+ /** Returns true if the content looks like a verified operational/batch record that should auto-bump salience. */
70
+ export function detectVerifiedFinding(content: string): boolean {
71
+ if (typeof content !== 'string' || content.length === 0) return false;
72
+ const text = content.trim();
73
+ if (!OPERATIONAL_VERB_PATTERN.test(text)) return false;
74
+ const dateCount = (text.match(ISO_DATE_PATTERN) || []).length;
75
+ const idCount = (text.match(CONCRETE_ID_PATTERN) || []).length;
76
+ return dateCount + idCount >= 2;
77
+ }
17
78
 
18
79
  export interface SalienceInput {
19
80
  content: string;
@@ -56,15 +117,38 @@ export function evaluateSalience(
56
117
  activeThreshold: number = 0.4,
57
118
  stagingThreshold: number = 0.2
58
119
  ): SalienceResult {
120
+ // Auto-detect user feedback before scoring. If content matches the pattern,
121
+ // force eventType='user_feedback' and memoryClass='canonical'. This bypasses
122
+ // the BM25 novelty floor that was discarding pivotal user decisions at 0.14.
123
+ let resolvedEventType: SalienceEventType = input.eventType ?? 'observation';
124
+ let resolvedMemoryClass: MemoryClass = input.memoryClass ?? 'working';
125
+ let autoPromoted = false;
126
+ let verifiedFindingFloor = false;
127
+ if (detectUserFeedback(input.content)) {
128
+ resolvedEventType = 'user_feedback';
129
+ resolvedMemoryClass = 'canonical';
130
+ autoPromoted = true;
131
+ } else if (detectVerifiedFinding(input.content)) {
132
+ // Operational record: bump eventType to 'decision' (typeBonus +0.15) and
133
+ // remember to apply a 0.45 salience floor below. Do NOT promote to canonical
134
+ // — these records are verified, not source-of-truth.
135
+ if (resolvedEventType === 'observation') {
136
+ resolvedEventType = 'decision';
137
+ }
138
+ verifiedFindingFloor = true;
139
+ }
140
+
59
141
  const features: SalienceFeatures = {
60
142
  surprise: input.surprise ?? 0,
61
143
  decisionMade: input.decisionMade ?? false,
62
144
  causalDepth: input.causalDepth ?? 0,
63
145
  resolutionEffort: input.resolutionEffort ?? 0,
64
- eventType: input.eventType ?? 'observation',
146
+ eventType: resolvedEventType,
65
147
  };
66
148
 
67
149
  const reasonCodes: string[] = [];
150
+ if (autoPromoted) reasonCodes.push('auto:user_feedback');
151
+ if (verifiedFindingFloor) reasonCodes.push('auto:verified_finding');
68
152
 
69
153
  // Novelty: 1.0 = completely new info, 0 = exact duplicate exists
70
154
  // Default to 0.8 (assume mostly novel) when caller doesn't check
@@ -91,13 +175,14 @@ export function evaluateSalience(
91
175
  case 'friction': typeBonus = 0.2; reasonCodes.push('event:friction'); break;
92
176
  case 'surprise': typeBonus = 0.25; reasonCodes.push('event:surprise'); break;
93
177
  case 'causal': typeBonus = 0.2; reasonCodes.push('event:causal'); break;
178
+ case 'user_feedback': typeBonus = 0.3; reasonCodes.push('event:user_feedback'); break;
94
179
  case 'observation': break;
95
180
  }
96
181
 
97
182
  let score = Math.min(surpriseScore + decisionScore + causalScore + effortScore + noveltyScore + typeBonus, 1.0);
98
183
 
99
184
  // Memory class overrides
100
- const memoryClass = input.memoryClass ?? 'working';
185
+ const memoryClass = resolvedMemoryClass;
101
186
 
102
187
  if (memoryClass === 'canonical') {
103
188
  // Canonical memories: salience floor of 0.7, never go to staging
@@ -105,6 +190,9 @@ export function evaluateSalience(
105
190
  reasonCodes.push('class:canonical');
106
191
  } else if (memoryClass === 'ephemeral') {
107
192
  reasonCodes.push('class:ephemeral');
193
+ } else if (verifiedFindingFloor) {
194
+ // Verified operational record: 0.45 floor — keeps it active without canonical promotion
195
+ score = Math.max(score, 0.45);
108
196
  }
109
197
 
110
198
  let disposition: 'active' | 'staging' | 'discard';
@@ -294,9 +294,11 @@ export class ActivationEngine {
294
294
  const simStdDev = Math.max(rawStdDev, 0.10);
295
295
 
296
296
  // Phase 3b: Score each candidate with per-phase breakdown
297
+ // Batch-fetch associations for all candidates at once (was N+1, now 1 query)
298
+ const associationsByEngram = this.store.getAssociationsForBatch(candidates.map(e => e.id));
297
299
  const scored = candidates.map(engram => {
298
300
  const ageDays = (Date.now() - engram.createdAt.getTime()) / (1000 * 60 * 60 * 24);
299
- const associations = this.store.getAssociationsFor(engram.id);
301
+ const associations = associationsByEngram.get(engram.id) ?? [];
300
302
 
301
303
  // --- Text relevance (keyword signals) ---
302
304