agent-working-memory 0.7.3 → 0.7.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-working-memory",
3
- "version": "0.7.3",
3
+ "version": "0.7.4",
4
4
  "description": "Cognitive memory layer for AI agents — activation-based retrieval, salience filtering, associative connections",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/src/api/routes.ts CHANGED
@@ -705,7 +705,7 @@ export function registerRoutes(app: FastifyInstance, deps: MemoryDeps): void {
705
705
  const base: Record<string, unknown> = {
706
706
  status: 'ok',
707
707
  timestamp: new Date().toISOString(),
708
- version: '0.7.2',
708
+ version: '0.7.4',
709
709
  coordination: coordEnabled,
710
710
  };
711
711
  if (coordEnabled) {
package/src/cli.ts CHANGED
@@ -334,7 +334,7 @@ async function exportMemories() {
334
334
  const agents = [...new Set(memories.map((m: any) => m.agent_id))];
335
335
 
336
336
  const exportData = {
337
- version: '0.7.2',
337
+ version: '0.7.4',
338
338
  exported_at: new Date().toISOString(),
339
339
  source_db: dbPath,
340
340
  agent_filter: agentFilter,
@@ -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,33 @@
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
+ }
17
43
 
18
44
  export interface SalienceInput {
19
45
  content: string;
@@ -56,15 +82,28 @@ export function evaluateSalience(
56
82
  activeThreshold: number = 0.4,
57
83
  stagingThreshold: number = 0.2
58
84
  ): SalienceResult {
85
+ // Auto-detect user feedback before scoring. If content matches the pattern,
86
+ // force eventType='user_feedback' and memoryClass='canonical'. This bypasses
87
+ // the BM25 novelty floor that was discarding pivotal user decisions at 0.14.
88
+ let resolvedEventType: SalienceEventType = input.eventType ?? 'observation';
89
+ let resolvedMemoryClass: MemoryClass = input.memoryClass ?? 'working';
90
+ let autoPromoted = false;
91
+ if (detectUserFeedback(input.content)) {
92
+ resolvedEventType = 'user_feedback';
93
+ resolvedMemoryClass = 'canonical';
94
+ autoPromoted = true;
95
+ }
96
+
59
97
  const features: SalienceFeatures = {
60
98
  surprise: input.surprise ?? 0,
61
99
  decisionMade: input.decisionMade ?? false,
62
100
  causalDepth: input.causalDepth ?? 0,
63
101
  resolutionEffort: input.resolutionEffort ?? 0,
64
- eventType: input.eventType ?? 'observation',
102
+ eventType: resolvedEventType,
65
103
  };
66
104
 
67
105
  const reasonCodes: string[] = [];
106
+ if (autoPromoted) reasonCodes.push('auto:user_feedback');
68
107
 
69
108
  // Novelty: 1.0 = completely new info, 0 = exact duplicate exists
70
109
  // Default to 0.8 (assume mostly novel) when caller doesn't check
@@ -91,13 +130,14 @@ export function evaluateSalience(
91
130
  case 'friction': typeBonus = 0.2; reasonCodes.push('event:friction'); break;
92
131
  case 'surprise': typeBonus = 0.25; reasonCodes.push('event:surprise'); break;
93
132
  case 'causal': typeBonus = 0.2; reasonCodes.push('event:causal'); break;
133
+ case 'user_feedback': typeBonus = 0.3; reasonCodes.push('event:user_feedback'); break;
94
134
  case 'observation': break;
95
135
  }
96
136
 
97
137
  let score = Math.min(surpriseScore + decisionScore + causalScore + effortScore + noveltyScore + typeBonus, 1.0);
98
138
 
99
139
  // Memory class overrides
100
- const memoryClass = input.memoryClass ?? 'working';
140
+ const memoryClass = resolvedMemoryClass;
101
141
 
102
142
  if (memoryClass === 'canonical') {
103
143
  // Canonical memories: salience floor of 0.7, never go to staging
package/src/index.ts CHANGED
@@ -177,7 +177,7 @@ async function main() {
177
177
 
178
178
  // Start server
179
179
  await app.listen({ port: PORT, host: '0.0.0.0' });
180
- console.log(`AgentWorkingMemory v0.7.2 listening on port ${PORT}`);
180
+ console.log(`AgentWorkingMemory v0.7.4 listening on port ${PORT}`);
181
181
 
182
182
  // Graceful shutdown
183
183
  const shutdown = async () => {
package/src/mcp.ts CHANGED
@@ -78,7 +78,7 @@ const INCOGNITO = process.env.AWM_INCOGNITO === '1' || process.env.AWM_INCOGNITO
78
78
 
79
79
  if (INCOGNITO) {
80
80
  console.error('AWM: incognito mode — all memory tools disabled, nothing will be recorded');
81
- const server = new McpServer({ name: 'agent-working-memory', version: '0.7.2' });
81
+ const server = new McpServer({ name: 'agent-working-memory', version: '0.7.4' });
82
82
  const transport = new StdioServerTransport();
83
83
  server.connect(transport).catch(err => {
84
84
  console.error('MCP server failed:', err);
@@ -115,7 +115,7 @@ let coordDb: import('better-sqlite3').Database | null = null;
115
115
 
116
116
  const server = new McpServer({
117
117
  name: 'agent-working-memory',
118
- version: '0.7.2',
118
+ version: '0.7.4',
119
119
  });
120
120
 
121
121
  server.registerResource(