agent-working-memory 0.7.2 → 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.
@@ -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
@@ -150,17 +190,35 @@ export function computeNovelty(store: EngramStore, agentId: string, concept: str
150
190
  // Higher score = stronger match = less novel.
151
191
  const topScore = results[0].bm25Score;
152
192
 
153
- // Penalize exact concept string duplicates if any result has the same concept,
154
- // heavily reduce novelty to prevent hub toxicity from repeated task_end summaries
193
+ // Quadratic dampening (1 - topScore²) so mid-range matches don't kill novelty.
194
+ // Old curve was linear (1 - topScore) which floored at 0.1 for almost any match
195
+ // in a populated DB, killing the salience signal.
196
+ // Curve comparison (topScore → novelty):
197
+ // 0.30 → 0.91 (different topic — strong novelty)
198
+ // 0.60 → 0.64 (loosely related — partial credit)
199
+ // 0.80 → 0.36 (related but distinct — meaningful signal)
200
+ // 0.95 → 0.10 (near-dupe — still suppress)
201
+ const baseNovelty = 1.0 - topScore * topScore;
202
+
203
+ // Concept penalty scoped to recent matches only — re-using the same concept
204
+ // string for a NEW topic months later shouldn't be punished. Penalty was 0.4
205
+ // (too harsh); now 0.3 and only applies if any matched result is < 30 days old.
155
206
  const conceptLower = conceptStr.toLowerCase().trim();
156
- const exactConceptMatch = results.some(r => r.engram?.concept?.toLowerCase().trim() === conceptLower);
157
- const conceptPenalty = exactConceptMatch ? 0.4 : 0;
158
-
159
- // Continuous novelty: inversely proportional to BM25 similarity
160
- // Maps topScore (0..1) novelty (0.1..0.95) using a smooth curve
161
- // Floor at 0.1 (never zero — even duplicates might have new context)
162
- // Ceiling at 0.95 (never 1.0 — always a tiny chance of overlap)
163
- return Math.max(0.1, Math.min(0.95, 1.0 - topScore - conceptPenalty));
207
+ const cutoffMs = Date.now() - 30 * 24 * 60 * 60 * 1000;
208
+ const exactConceptRecent = results.some(r => {
209
+ if (r.engram?.concept?.toLowerCase().trim() !== conceptLower) return false;
210
+ const created = r.engram?.createdAt as Date | string | number | undefined;
211
+ if (!created) return true; // No timestamp treat as recent (conservative)
212
+ const createdMs = created instanceof Date
213
+ ? created.getTime()
214
+ : typeof created === 'number' ? created : Date.parse(created);
215
+ return Number.isFinite(createdMs) && createdMs >= cutoffMs;
216
+ });
217
+ const conceptPenalty = exactConceptRecent ? 0.3 : 0;
218
+
219
+ // Floor lowered to 0.05 (was 0.10) so true duplicates can score near-zero
220
+ // and clearly stay below stagingThreshold (0.2). Ceiling unchanged.
221
+ return Math.max(0.05, Math.min(0.95, baseNovelty - conceptPenalty));
164
222
  } catch {
165
223
  // If BM25 search fails (e.g., FTS not ready), assume novel
166
224
  return 0.8;
@@ -206,12 +264,25 @@ export function computeNoveltyWithMatch(
206
264
  const top = allResults[0];
207
265
  const topScore = top.bm25Score;
208
266
 
209
- // Concept penalty for exact duplicates
267
+ // Quadratic dampening — see computeNovelty for curve rationale
268
+ const baseNovelty = 1.0 - topScore * topScore;
269
+
270
+ // Recent-only concept penalty (30d window)
210
271
  const conceptLower = conceptStr.toLowerCase().trim();
211
- const exactMatch = allResults.some(r => (r.engram as any)?.concept?.toLowerCase().trim() === conceptLower);
212
- const conceptPenalty = exactMatch ? 0.4 : 0;
272
+ const cutoffMs = Date.now() - 30 * 24 * 60 * 60 * 1000;
273
+ const exactConceptRecent = allResults.some(r => {
274
+ const eng = r.engram as { concept?: string; createdAt?: Date | string | number };
275
+ if (eng?.concept?.toLowerCase().trim() !== conceptLower) return false;
276
+ const created = eng?.createdAt;
277
+ if (!created) return true;
278
+ const createdMs = created instanceof Date
279
+ ? created.getTime()
280
+ : typeof created === 'number' ? created : Date.parse(created);
281
+ return Number.isFinite(createdMs) && createdMs >= cutoffMs;
282
+ });
283
+ const conceptPenalty = exactConceptRecent ? 0.3 : 0;
213
284
 
214
- const novelty = Math.max(0.1, Math.min(0.95, 1.0 - topScore - conceptPenalty));
285
+ const novelty = Math.max(0.05, Math.min(0.95, baseNovelty - conceptPenalty));
215
286
  return { novelty, matchedEngramId: top.engram.id, matchScore: topScore };
216
287
  } catch {
217
288
  return { novelty: 0.8, matchedEngramId: null, matchScore: 0 };
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(