agent-working-memory 0.16.0 → 0.17.0

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.
@@ -16,42 +16,69 @@ import type { IEngramStore as EngramStore } from '../storage/store.js';
16
16
  export type SalienceEventType = 'decision' | 'friction' | 'surprise' | 'causal' | 'observation' | 'user_feedback';
17
17
 
18
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"
19
+ * Auto-detect user-feedback memories: content that begins with a configured
20
+ * stakeholder's name + a feedback verb. These memories represent direct human
21
+ * decisions and must never be discarded. With AWM_FEEDBACK_NAMES="Ada,Grace":
22
+ * "Ada verbatim: 'programs-first, like the CRM'"
23
+ * "Grace said the certification cycle resets on promotion"
24
+ * "Ada directed that tier 1 has no grace period"
25
25
  *
26
26
  * Why this exists: the BM25 novelty check collapses near-duplicates regardless
27
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.
28
+ * feedback often shares terminology with prior memories (the same handful of
29
+ * domain nouns recur in every one) and gets discarded at salience 0.14 (verified
30
+ * in activity log 2026-05-06T19:08:47). Detecting "<name> said X" → canonical
31
+ * class bypasses the salience filter entirely.
32
32
  *
33
- * Tune via AWM_FEEDBACK_NAMES (comma list). Pattern requires word boundary at
34
- * start so "Roberta" or "Hannahs" don't match.
33
+ * Set via AWM_FEEDBACK_NAMES (comma list). A name must be followed by whitespace
34
+ * and then a verb, so a longer name sharing a configured prefix ("Adaline" for a
35
+ * configured "Ada") does not match.
35
36
  */
36
- // D4 (2026-07-30): names/verbs are configurable so the library is not coupled
37
- // to one organization's staff list. AWM_FEEDBACK_NAMES / AWM_FEEDBACK_VERBS
38
- // take comma-separated lists. The built-in default preserves existing installs;
39
- // a future 1.0 flips the default to empty with the installer seeding values.
40
- const DEFAULT_FEEDBACK_NAMES = 'Robert,Katherine,Catherine,Nancy,Brandy,Brandi,Hannah,Marilyn,Kaylee,Pete,Abby,Tom,Wendy,Sita,Nick,Rob,Joan,Jennifer,Cindy,Jason,Alex,Molly';
37
+ // D4 (2026-07-30): names/verbs are configurable so the library is not coupled to one
38
+ // organization's staff list. AWM_FEEDBACK_NAMES / AWM_FEEDBACK_VERBS take comma lists.
39
+ //
40
+ // 2026-10-11: the default is now EMPTY, which disables the detector until an operator
41
+ // configures it. It previously shipped 22 real first names — a deployment's own staff
42
+ // list, published in the npm tarball (`src/` is in package.json `files`) and readable by
43
+ // anyone who installed the package. That is other people's data, and a general-purpose
44
+ // library has no business carrying it. The flip was the decision of record from the start
45
+ // (docs/archive/eval-decisions-2026-07-30.md:37, "installer seeds Robert's current values
46
+ // so behavior is unchanged for him"); D4 shipped only the configurability half and
47
+ // deferred the flip to 1.0. It is brought forward here because the exposure is live.
48
+ //
49
+ // Operators who relied on the built-in list must now set AWM_FEEDBACK_NAMES explicitly —
50
+ // in the MCP server's `env` block, since that is the only place guaranteed to be read
51
+ // (the .env loader in cli.ts/mcp.ts resolves against process.cwd(), which an MCP server
52
+ // does not control). Without it, "<name> said X" no longer auto-promotes to canonical;
53
+ // an explicit memory_class: 'canonical' on the write still does.
54
+ const DEFAULT_FEEDBACK_NAMES = '';
41
55
  const DEFAULT_FEEDBACK_VERBS = 'said,verbatim,feedback,asked,wants,prefers,requested,directed,decided,confirmed,clarified,chose,specified,explained';
42
56
  function csvToAlternation(env: string | undefined, fallback: string): string {
43
57
  return (env ?? fallback).split(',').map(x => x.trim()).filter(Boolean)
44
58
  .map(x => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
45
59
  .join('|');
46
60
  }
61
+ const FEEDBACK_NAMES_ALTERNATION = csvToAlternation(process.env.AWM_FEEDBACK_NAMES, DEFAULT_FEEDBACK_NAMES);
47
62
  const USER_FEEDBACK_PATTERN = new RegExp(
48
- '^(' + csvToAlternation(process.env.AWM_FEEDBACK_NAMES, DEFAULT_FEEDBACK_NAMES) + ')\\s+(' +
63
+ '^(' + FEEDBACK_NAMES_ALTERNATION + ')\\s+(' +
49
64
  csvToAlternation(process.env.AWM_FEEDBACK_VERBS, DEFAULT_FEEDBACK_VERBS) + ')\\b',
50
65
  'i',
51
66
  );
52
67
 
68
+ /** True when at least one feedback name is configured; otherwise the detector is off. */
69
+ export function userFeedbackDetectionEnabled(): boolean {
70
+ return FEEDBACK_NAMES_ALTERNATION.length > 0;
71
+ }
72
+
53
73
  /** Returns true if the content looks like direct user feedback that should auto-promote to canonical. */
54
74
  export function detectUserFeedback(content: string): boolean {
75
+ // No configured names means the detector is OFF, as a decision rather than as an
76
+ // accident of the regex. An empty name list compiles to `^()\s+(said|asked|...)`, whose
77
+ // empty first group matches the empty string — so the pattern degrades to "starts with
78
+ // whitespace then a feedback verb". That is unreachable today only because the content
79
+ // is trimmed one line below, which makes correctness depend on the order of two
80
+ // unrelated statements. Check the configuration instead.
81
+ if (!userFeedbackDetectionEnabled()) return false;
55
82
  if (typeof content !== 'string' || content.length === 0) return false;
56
83
  return USER_FEEDBACK_PATTERN.test(content.trim());
57
84
  }
@@ -169,8 +169,16 @@ export async function performWrite(
169
169
  input: WriteInput,
170
170
  ): Promise<WriteResult> {
171
171
  const { store, connectionEngine } = engines;
172
+ // An explicit `supersedes` argument outranks every novelty signal: the caller has
173
+ // NAMED the engram this write replaces. Gating reinforcement off it routes the write
174
+ // to createNewEngram, which is the only path that honors meta.supersedesId (see the
175
+ // supersession side-effects below). Without this gate the argument was silently
176
+ // discarded by every branch in the block below — R1 (healthy match reinforced),
177
+ // R2 (the superseder reinforced instead), R1b (an archived match revived), and R3,
178
+ // which supersedes `matched.id` rather than the id the caller actually asked for.
172
179
  const enableReinforcement = input.enableReinforcement !== false
173
- && process.env.AWM_WRITE_PIPELINE !== 'off';
180
+ && process.env.AWM_WRITE_PIPELINE !== 'off'
181
+ && !input.supersedes;
174
182
 
175
183
  // Profiling: AWM_PROFILE_WRITE=1 logs ms-per-phase to stderr (v0.8.2).
176
184
  // Off by default; zero cost when unset.
@@ -314,7 +322,22 @@ export async function performWrite(
314
322
 
315
323
  // -- Default: create new engram (if no reinforce/supersede branch fired) --
316
324
  if (!result) {
317
- result = await createNewEngram(engines, input, salience, noveltyResult, {
325
+ // A correction of a near-duplicate has low novelty BY CONSTRUCTION: the replacement
326
+ // says almost what the engram it replaces said. Left on its own salience it lands at
327
+ // `discard` (confidence 0.25, tagged `low-salience`, below HEALTHY_CONFIDENCE_FLOOR)
328
+ // or at `staging` — while the supersession side-effect fires regardless. The old
329
+ // engram would be retired and its replacement too weak to stand in for it, so the
330
+ // fact leaves recall altogether: strictly worse than dropping the argument. R3 above
331
+ // already forces `active` for precisely this reason; an explicit `supersedes` is the
332
+ // same correction intent, stated by the caller instead of inferred from eventType.
333
+ const supersedeSalience: SalienceResult = input.supersedes
334
+ ? {
335
+ ...salience,
336
+ disposition: 'active' as const,
337
+ reasonCodes: [...salience.reasonCodes, 'supersede:override-active'],
338
+ }
339
+ : salience;
340
+ result = await createNewEngram(engines, input, supersedeSalience, noveltyResult, {
318
341
  effectiveEventType,
319
342
  effectiveMemoryClass,
320
343
  supersedesId: input.supersedes,
@@ -541,13 +541,25 @@ export class ConsolidationEngine {
541
541
  //
542
542
  // Fade applies only to accessed engrams (the never-accessed ones go straight
543
543
  // to archive via Phase 6). Heavily-used (accessCount >= FADE_PROTECTED_ACCESS_COUNT),
544
- // canonical, structural, and short-content engrams are excluded.
544
+ // canonical, structural, short-content, and UNFINISHED TASK engrams are excluded.
545
545
  let fadeCount = 0;
546
546
  for (const engram of engrams) {
547
547
  if (fadeCount >= FADE_MAX_PER_CYCLE) break;
548
548
  if (engram.accessCount === 0) continue;
549
549
  if (engram.accessCount >= FADE_PROTECTED_ACCESS_COUNT) continue;
550
550
  if (FADE_PROTECTED_CLASSES.has(engram.memoryClass)) continue;
551
+ // An unfinished task is a COMMITMENT, not a stale observation, and its body is
552
+ // where the acceptance criteria, source meeting, file paths and blocking reason
553
+ // live — the concept is 3-10 words by schema, so fading a task leaves a title
554
+ // nobody can action. Measured 2026-10-10 on the live store before this guard
555
+ // existed: 19 task bodies had already been cut to 150 chars, and `blocked` tasks
556
+ // were hit at 29% (5/17) against 1.2% (3/243) for `open` — ~24x — precisely
557
+ // BECAUSE a blocked task accrues idle days by definition while it waits on
558
+ // someone else, which is also when its body matters most. Only 1 of the 19 was
559
+ // recoverable from any backup. `done` tasks still fade: the work is finished and
560
+ // the gist suffices. memory_task_add sets salience 0.9 but never memoryClass, so
561
+ // FADE_PROTECTED_CLASSES above never covers a task.
562
+ if (engram.taskStatus && engram.taskStatus !== 'done') continue;
551
563
  if (engram.retracted) continue;
552
564
  if (engram.content.length < FADE_MIN_CONTENT_LEN) continue;
553
565
  if (engram.content.endsWith(FADE_MARKER)) continue; // Already faded