@remnic/core 9.25.2 → 9.25.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/src/extraction.ts CHANGED
@@ -40,6 +40,7 @@ import { normalizeProcedureSteps } from "./procedural/procedure-types.js";
40
40
  import { normalizeReasoningTrace } from "./reasoning-trace-types.js";
41
41
  import { looksLikeMechanicalTelemetryTranscript } from "./telemetry-transcript.js";
42
42
  import { buildFactProvenance, type ProvenanceTurnInput } from "./provenance.js";
43
+ import { isMemoryCategory } from "./write-envelope.js";
43
44
  import { classifyExtractionThrownError, classifyFallbackParseFailure } from "./extraction-error-classification.js";
44
45
  export { classifyExtractionThrownError, classifyFallbackParseFailure } from "./extraction-error-classification.js";
45
46
  import { resolvePipelineProcessingCapabilities } from "./capabilities.js";
@@ -52,6 +53,41 @@ type ExtractedEntityResult = ExtractionResult["entities"][number];
52
53
  type ExtractedRelationshipResult = NonNullable<ExtractionResult["relationships"]>[number];
53
54
 
54
55
  const PROACTIVE_MIN_CONFIDENCE = 0.8;
56
+ const EXTRACTION_RESPONSE_SHAPE = `{
57
+ "facts": [{
58
+ "category": "<category>",
59
+ "content": "<source-grounded statement>",
60
+ "confidence": 0.0,
61
+ "tags": ["<tag>"],
62
+ "entityRef": "<optional normalized-name>",
63
+ "promptedByQuestion": "<optional source-grounded question>",
64
+ "quote": "<optional exact contiguous source span>",
65
+ "scope": "<optional project-or-global>",
66
+ "structuredAttributes": {"<key>": "<value>"},
67
+ "procedureSteps": [{"order": 1, "intent": "<step>"}, {"order": 2, "intent": "<step>"}],
68
+ "reasoningTrace": {
69
+ "steps": [{"order": 1, "description": "<step>"}, {"order": 2, "description": "<step>"}],
70
+ "finalAnswer": "<answer>",
71
+ "observedOutcome": "<optional outcome>"
72
+ },
73
+ "eventTime": "<optional source temporal expression>"
74
+ }],
75
+ "entities": [{
76
+ "name": "<normalized-name>",
77
+ "type": "<entity-type>",
78
+ "facts": ["<source-grounded statement>"],
79
+ "promptedByQuestion": "<optional source-grounded question>",
80
+ "structuredSections": [{"key": "<section-key>", "title": "<section-title>", "facts": ["<source-grounded statement>"]}]
81
+ }],
82
+ "profileUpdates": ["<source-grounded profile update>"],
83
+ "questions": [{"question": "<source-grounded unresolved question>", "context": "<source-grounded context>", "priority": 0.0}],
84
+ "identityReflection": "<conversation-grounded agent reflection>",
85
+ "relationships": [{"source": "<normalized-name>", "target": "<normalized-name>", "label": "<source-grounded relationship>"}]
86
+ }`;
87
+ const EXTRACTION_RESPONSE_PLACEHOLDERS: Record<string, true> = {};
88
+ for (const placeholder of EXTRACTION_RESPONSE_SHAPE.match(/<[^<>\r\n]+>/g) ?? []) {
89
+ EXTRACTION_RESPONSE_PLACEHOLDERS[placeholder] = true;
90
+ }
55
91
  const CONSOLIDATION_RESPONSE_SCHEMA = `{
56
92
  "items": [
57
93
  {
@@ -70,6 +106,46 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
70
106
  return typeof value === "object" && value !== null && !Array.isArray(value);
71
107
  }
72
108
 
109
+ function containsExtractionPlaceholder(value: unknown): boolean {
110
+ if (typeof value === "string") return EXTRACTION_RESPONSE_PLACEHOLDERS[value.trim()] === true;
111
+ if (Array.isArray(value)) return value.some(containsExtractionPlaceholder);
112
+ return isPlainRecord(value) && Object.values(value).some(containsExtractionPlaceholder);
113
+ }
114
+
115
+ function extractionText(value: unknown): string | undefined {
116
+ if (typeof value !== "string") return undefined;
117
+ const text = value.trim();
118
+ return text.length > 0 && !containsExtractionPlaceholder(text) ? text : undefined;
119
+ }
120
+
121
+ function extractionAttributes(value: unknown): Record<string, string> | undefined {
122
+ if (!isPlainRecord(value)) return undefined;
123
+ const attributes: Record<string, string> = {};
124
+ for (const [key, candidate] of Object.entries(value)) {
125
+ const normalizedKey = extractionText(key);
126
+ const normalizedValue = extractionText(candidate);
127
+ if (normalizedKey !== undefined && normalizedValue !== undefined) {
128
+ attributes[normalizedKey] = normalizedValue;
129
+ }
130
+ }
131
+ return Object.keys(attributes).length > 0 ? attributes : undefined;
132
+ }
133
+
134
+ function extractionEntityType(value: unknown): ExtractedEntityResult["type"] | undefined {
135
+ const type = extractionText(value);
136
+ if (
137
+ type === "person" ||
138
+ type === "project" ||
139
+ type === "tool" ||
140
+ type === "company" ||
141
+ type === "place" ||
142
+ type === "other"
143
+ ) {
144
+ return type;
145
+ }
146
+ return undefined;
147
+ }
148
+
73
149
  function normalizeQuestion(question: ExtractionQuestion): ExtractionQuestion {
74
150
  const priority = Number.isFinite(question.priority)
75
151
  ? Math.max(0, Math.min(1, question.priority))
@@ -245,182 +321,168 @@ export class ExtractionEngine {
245
321
  }
246
322
 
247
323
  private normalizeExtractionResultPayload(parsed: any): ExtractionResult {
248
- const entities = Array.isArray(parsed?.entities)
324
+ const entities: ExtractedEntityResult[] = Array.isArray(parsed?.entities)
249
325
  ? parsed.entities
250
- .map((e: any) => this.normalizeEntityUpdate(e))
251
- .filter((e: any) => e.name.length > 0)
326
+ .map((candidate: unknown): ExtractedEntityResult | undefined => this.normalizeEntityUpdate(candidate))
327
+ .filter((entity: ExtractedEntityResult | undefined): entity is ExtractedEntityResult => (
328
+ entity !== undefined && entity.name.length > 0
329
+ ))
252
330
  : [];
253
331
 
254
332
  const facts = Array.isArray(parsed?.facts)
255
333
  ? parsed.facts
256
- .map((f: any) => ({
257
- category: typeof f?.category === "string" ? f.category : "fact",
258
- content: typeof f?.content === "string" ? f.content : typeof f?.text === "string" ? f.text : "",
259
- confidence: typeof f?.confidence === "number" ? f.confidence : 0.7,
260
- tags: Array.isArray(f?.tags) ? f.tags.filter((t: any) => typeof t === "string") : [],
261
- entityRef: typeof f?.entityRef === "string" ? f.entityRef : undefined,
262
- promptedByQuestion:
263
- typeof f?.promptedByQuestion === "string" ? f.promptedByQuestion : undefined,
264
- scope:
265
- f?.scope === "global" || f?.scope === "project" ? f.scope : undefined,
266
- structuredAttributes:
267
- f?.structuredAttributes && typeof f.structuredAttributes === "object" && !Array.isArray(f.structuredAttributes)
268
- ? Object.fromEntries(
269
- Object.entries(f.structuredAttributes)
270
- .filter(([k, v]) => typeof k === "string" && typeof v === "string")
271
- ) as Record<string, string>
272
- : undefined,
273
- procedureSteps: Array.isArray(f?.procedureSteps)
334
+ .map((candidate: unknown) => {
335
+ const f = isPlainRecord(candidate) ? candidate : {};
336
+ const category = typeof f.category === "string" ? f.category.trim() : "fact";
337
+ const reasoningTraceInput = isPlainRecord(f.reasoningTrace)
338
+ ? f.reasoningTrace
339
+ : isPlainRecord(f?.reasoning_trace)
340
+ ? f.reasoning_trace
341
+ : undefined;
342
+ if (!isMemoryCategory(category)) return undefined;
343
+ const procedureSteps = Array.isArray(f.procedureSteps)
274
344
  ? normalizeProcedureSteps(f.procedureSteps)
275
- : undefined,
276
- reasoningTrace: (() => {
277
- // Accept both camelCase and snake_case payload keys. The
278
- // category itself is snake_case and we already tolerate
279
- // snake_case nested fields in normalizeReasoningTrace, so a
280
- // loose local/direct LLM that outputs `reasoning_trace` on the
281
- // fact should not silently drop the structured chain.
282
- const candidate =
283
- f?.reasoningTrace && typeof f.reasoningTrace === "object" && !Array.isArray(f.reasoningTrace)
284
- ? f.reasoningTrace
285
- : f?.reasoning_trace && typeof f.reasoning_trace === "object" && !Array.isArray(f.reasoning_trace)
286
- ? f.reasoning_trace
287
- : null;
288
- return candidate ? normalizeReasoningTrace(candidate) ?? undefined : undefined;
289
- })(),
290
- // Issue #1575 PR 2: the LLM provides a verbatim supporting quote
291
- // per fact. Optional/nullable per repo gotcha 6 (OpenAI Responses
292
- // API emits null for absent optional fields). The post-parse
293
- // validator (buildFactProvenance) locates this quote in the
294
- // buffered turns and builds verified ProvenanceSource[] entries.
295
- quote:
296
- typeof f?.quote === "string" && f.quote.trim().length > 0
297
- ? f.quote
298
- : undefined,
299
- eventTime:
300
- typeof f?.eventTime === "string" && f.eventTime.trim().length > 0
301
- ? f.eventTime.trim()
302
- : typeof f?.event_time === "string" && f.event_time.trim().length > 0
303
- ? f.event_time.trim()
304
- : undefined,
305
- }))
306
- .filter((f: any) => f.content.length > 0)
307
- : [];
308
-
309
- const questions = Array.isArray(parsed?.questions)
310
- ? parsed.questions
311
- .map((q: any) => {
312
- if (typeof q === "string") return { question: q, context: "", priority: 0.5 };
345
+ : undefined;
346
+ const reasoningTrace = reasoningTraceInput
347
+ ? normalizeReasoningTrace(reasoningTraceInput) ?? undefined
348
+ : undefined;
349
+ if (
350
+ containsExtractionPlaceholder(procedureSteps) ||
351
+ containsExtractionPlaceholder(reasoningTrace)
352
+ ) {
353
+ return undefined;
354
+ }
313
355
  return {
314
- question: typeof q?.question === "string" ? q.question : typeof q?.text === "string" ? q.text : "",
315
- context: typeof q?.context === "string" ? q.context : "",
316
- priority: typeof q?.priority === "number" ? q.priority : 0.5,
356
+ category,
357
+ content: extractionText(f.content) ?? extractionText(f.text) ?? "",
358
+ confidence: typeof f.confidence === "number" ? f.confidence : 0.7,
359
+ tags: Array.isArray(f.tags)
360
+ ? f.tags.flatMap((tag: unknown) => {
361
+ const text = extractionText(tag);
362
+ return text === undefined ? [] : [text];
363
+ })
364
+ : [],
365
+ entityRef: extractionText(f.entityRef),
366
+ promptedByQuestion: extractionText(f.promptedByQuestion),
367
+ scope:
368
+ f.scope === "global" || f.scope === "project" ? f.scope : undefined,
369
+ structuredAttributes: extractionAttributes(f.structuredAttributes),
370
+ procedureSteps,
371
+ reasoningTrace,
372
+ quote: extractionText(f.quote),
373
+ eventTime: extractionText(f.eventTime) ?? extractionText(f.event_time),
317
374
  };
318
375
  })
319
- .filter((q: any) => q.question.length > 0)
376
+ .filter((fact: ExtractedFactResult | undefined): fact is ExtractedFactResult => (
377
+ fact !== undefined && fact.content.length > 0
378
+ ))
379
+ : [];
380
+
381
+ const questions: ExtractionQuestion[] = Array.isArray(parsed?.questions)
382
+ ? parsed.questions.flatMap((candidate: unknown) => {
383
+ const record = isPlainRecord(candidate) ? candidate : undefined;
384
+ const question =
385
+ extractionText(record?.question) ??
386
+ extractionText(record?.text) ??
387
+ extractionText(candidate);
388
+ if (question === undefined) return [];
389
+ return [{
390
+ question,
391
+ context: extractionText(record?.context) ?? "",
392
+ priority: typeof record?.priority === "number" ? record.priority : 0.5,
393
+ }];
394
+ })
320
395
  : [];
321
396
 
397
+ const profileUpdates = Array.isArray(parsed?.profileUpdates)
398
+ ? parsed.profileUpdates.flatMap((candidate: unknown) => {
399
+ const update = extractionText(candidate);
400
+ return update === undefined ? [] : [update];
401
+ })
402
+ : [];
403
+
404
+ const relationships: ExtractedRelationshipResult[] | undefined = Array.isArray(parsed?.relationships)
405
+ ? parsed.relationships.flatMap((candidate: unknown) => {
406
+ const relationship = isPlainRecord(candidate) ? candidate : undefined;
407
+ const source = extractionText(relationship?.source);
408
+ const target = extractionText(relationship?.target);
409
+ const label = extractionText(relationship?.label);
410
+ if (source === undefined || target === undefined || label === undefined) return [];
411
+ return [{
412
+ source,
413
+ target,
414
+ label,
415
+ promptedByQuestion: extractionText(relationship?.promptedByQuestion),
416
+ }];
417
+ })
418
+ : undefined;
419
+
322
420
  return {
323
421
  facts,
324
422
  entities,
325
- profileUpdates: Array.isArray(parsed?.profileUpdates)
326
- ? parsed.profileUpdates.filter((u: any) => typeof u === "string" && u.trim().length > 0)
327
- : [],
423
+ profileUpdates,
328
424
  questions,
329
- identityReflection: parsed?.identityReflection ?? undefined,
330
- relationships: Array.isArray(parsed?.relationships)
331
- ? parsed.relationships.filter(
332
- (r: any) =>
333
- typeof r?.source === "string" &&
334
- typeof r?.target === "string" &&
335
- typeof r?.label === "string",
336
- )
337
- .map((r: any) => ({
338
- source: r.source,
339
- target: r.target,
340
- label: r.label,
341
- promptedByQuestion:
342
- typeof r?.promptedByQuestion === "string" ? r.promptedByQuestion : undefined,
343
- }))
344
- : undefined,
425
+ identityReflection: extractionText(parsed?.identityReflection),
426
+ relationships,
345
427
  };
346
428
  }
347
429
 
348
- private normalizeEntityUpdate(entity: any): ExtractedEntityResult {
349
- const rawUpdates = isPlainRecord(entity?.updates) ? entity.updates : null;
350
- const directFacts = Array.isArray(entity?.facts)
351
- ? entity.facts
352
- .filter((fact: any) => typeof fact === "string")
353
- .map((fact: string) => fact.trim())
354
- .filter((fact: string) => fact.length > 0)
355
- : [];
356
- const updateFacts = rawUpdates && Array.isArray(rawUpdates.facts)
357
- ? rawUpdates.facts
358
- .filter((fact: unknown) => typeof fact === "string")
359
- .map((fact: string) => fact.trim())
360
- .filter((fact: string) => fact.length > 0)
361
- : [];
430
+ private normalizeEntityUpdate(entity: unknown): ExtractedEntityResult | undefined {
431
+ const record = isPlainRecord(entity) ? entity : {};
432
+ const rawUpdates = isPlainRecord(record.updates) ? record.updates : undefined;
433
+ const normalizedTexts = (value: unknown): string[] =>
434
+ Array.isArray(value)
435
+ ? value.flatMap((candidate: unknown) => {
436
+ const text = extractionText(candidate);
437
+ return text === undefined ? [] : [text];
438
+ })
439
+ : [];
440
+ const directFacts = normalizedTexts(record.facts);
441
+ const updateFacts = normalizedTexts(rawUpdates?.facts);
362
442
  const scalarUpdateFacts = rawUpdates
363
- ? Object.keys(rawUpdates)
364
- .sort((a, b) => a.localeCompare(b))
365
- .filter((key) => !["facts", "name", "promptedByQuestion", "structuredSections", "type"].includes(key))
366
- .flatMap((key) => {
367
- const value = rawUpdates[key];
368
- if (typeof value === "string" && value.trim().length > 0) {
369
- return [`${key}: ${value.trim()}`];
443
+ ? Object.entries(rawUpdates)
444
+ .sort(([left], [right]) => left.localeCompare(right))
445
+ .flatMap(([key, value]) => {
446
+ if (["facts", "name", "promptedByQuestion", "structuredSections", "type"].includes(key)) {
447
+ return [];
370
448
  }
449
+ const normalizedKey = extractionText(key);
450
+ if (normalizedKey === undefined) return [];
451
+ const normalizedValue = extractionText(value);
452
+ if (normalizedValue !== undefined) return [`${normalizedKey}: ${normalizedValue}`];
371
453
  if (typeof value === "number" || typeof value === "boolean") {
372
- return [`${key}: ${String(value)}`];
454
+ return [`${normalizedKey}: ${String(value)}`];
373
455
  }
374
456
  return [];
375
457
  })
376
458
  : [];
377
- const structuredSectionsSource = Array.isArray(entity?.structuredSections)
378
- ? entity.structuredSections
459
+ const structuredSectionsSource = Array.isArray(record.structuredSections)
460
+ ? record.structuredSections
379
461
  : Array.isArray(rawUpdates?.structuredSections)
380
462
  ? rawUpdates.structuredSections
381
463
  : [];
382
- const name =
383
- typeof entity?.name === "string"
384
- ? entity.name.trim()
385
- : typeof entity?.entityId === "string"
386
- ? entity.entityId.trim()
387
- : typeof rawUpdates?.name === "string"
388
- ? rawUpdates.name.trim()
389
- : "";
390
- const type =
391
- typeof entity?.type === "string" && entity.type.trim().length > 0
392
- ? entity.type.trim()
393
- : typeof rawUpdates?.type === "string" && rawUpdates.type.trim().length > 0
394
- ? rawUpdates.type.trim()
395
- : "other";
464
+ const structuredSections = structuredSectionsSource.flatMap((candidate: unknown) => {
465
+ const section = isPlainRecord(candidate) ? candidate : {};
466
+ const key = extractionText(section.key);
467
+ const title = extractionText(section.title);
468
+ const facts = normalizedTexts(section.facts);
469
+ if (key === undefined || title === undefined || facts.length === 0) return [];
470
+ return [{ key, title, facts }];
471
+ });
396
472
 
473
+ const rawType = record.type ?? rawUpdates?.type;
474
+ const type = rawType === undefined ? "other" : extractionEntityType(rawType);
475
+ if (type === undefined) return undefined;
397
476
  return {
398
- name,
477
+ name:
478
+ extractionText(record.name) ??
479
+ extractionText(record.entityId) ??
480
+ extractionText(rawUpdates?.name) ??
481
+ "",
399
482
  type,
400
483
  facts: [...directFacts, ...updateFacts, ...scalarUpdateFacts],
401
- structuredSections: structuredSectionsSource.length > 0
402
- ? structuredSectionsSource
403
- .map((section: any) => ({
404
- key: typeof section?.key === "string" ? section.key.trim() : "",
405
- title: typeof section?.title === "string" ? section.title.trim() : "",
406
- facts: Array.isArray(section?.facts)
407
- ? section.facts.filter((fact: any) => typeof fact === "string")
408
- .map((fact: string) => fact.trim())
409
- .filter((fact: string) => fact.length > 0)
410
- : [],
411
- }))
412
- .filter((section: any) => (
413
- section.key.length > 0 &&
414
- section.title.length > 0 &&
415
- section.facts.length > 0
416
- ))
417
- : undefined,
418
- promptedByQuestion:
419
- typeof entity?.promptedByQuestion === "string"
420
- ? entity.promptedByQuestion
421
- : typeof rawUpdates?.promptedByQuestion === "string"
422
- ? rawUpdates.promptedByQuestion
423
- : undefined,
484
+ structuredSections: structuredSections.length > 0 ? structuredSections : undefined,
485
+ promptedByQuestion: extractionText(record.promptedByQuestion) ?? extractionText(rawUpdates?.promptedByQuestion),
424
486
  };
425
487
  }
426
488
 
@@ -584,8 +646,10 @@ export class ExtractionEngine {
584
646
  )
585
647
  .filter((update) => update.length > 0);
586
648
  const entityUpdates = (Array.isArray(result.entityUpdates) ? result.entityUpdates : [])
587
- .map((entity: any) => this.normalizeEntityUpdate(entity))
588
- .filter((entity: ExtractedEntityResult) => entity.name.length > 0);
649
+ .map((entity: unknown): ExtractedEntityResult | undefined => this.normalizeEntityUpdate(entity))
650
+ .filter((entity: ExtractedEntityResult | undefined): entity is ExtractedEntityResult => (
651
+ entity !== undefined && entity.name.length > 0
652
+ ));
589
653
  return { items, profileUpdates, entityUpdates };
590
654
  }
591
655
 
@@ -1279,37 +1343,8 @@ export class ExtractionEngine {
1279
1343
  log.debug(
1280
1344
  `extracted ${result.facts.length} facts, ${result.entities.length} entities, ${(result.questions ?? []).length} questions via fallback (${detailed.modelUsed})`,
1281
1345
  );
1282
- // Zod schema accepts snake_case aliases (final_answer / observed_outcome)
1283
- // alongside camelCase for gateway-tolerance, but the downstream
1284
- // ExtractedFact contract only exposes camelCase. Collapse each fact's
1285
- // reasoningTrace through normalizeReasoningTrace before passing it on so
1286
- // gateway output matches the shape local/direct-client paths produce.
1287
- const normalizedFacts = result.facts.map((f: any) => {
1288
- if (!f) return f;
1289
- // Gateway tolerance: collapse snake_case event_time → camelCase
1290
- // eventTime so the gateway path matches the local/direct/proactive
1291
- // normalization (#1578 r3 — cursor bugbot).
1292
- const eventTime =
1293
- typeof f.eventTime === "string" && f.eventTime.trim().length > 0
1294
- ? f.eventTime.trim()
1295
- : typeof f.event_time === "string" && f.event_time.trim().length > 0
1296
- ? f.event_time.trim()
1297
- : undefined;
1298
- if (!f.reasoningTrace && eventTime === undefined) return f;
1299
- return {
1300
- ...f,
1301
- ...(f.reasoningTrace
1302
- ? { reasoningTrace: normalizeReasoningTrace(f.reasoningTrace) ?? undefined }
1303
- : {}),
1304
- ...(eventTime !== undefined ? { eventTime } : {}),
1305
- };
1306
- });
1307
- const sanitized = this.sanitizeExtractionResult({
1308
- ...result,
1309
- facts: normalizedFacts,
1310
- questions: result.questions ?? [],
1311
- identityReflection: result.identityReflection ?? undefined,
1312
- } as ExtractionResult, messageTimestamp);
1346
+ const normalized = this.normalizeExtractionResultPayload(result);
1347
+ const sanitized = this.sanitizeExtractionResult(normalized, messageTimestamp);
1313
1348
  const finalResult = await this.applyProactiveQuestionPass(conversation, sanitized);
1314
1349
  return this.attachProvenanceToResult(finalResult, boundedTurns);
1315
1350
  }
@@ -1384,90 +1419,36 @@ export class ExtractionEngine {
1384
1419
 
1385
1420
  const localPrompt = `You are a memory extraction system. Extract durable, reusable memories from this conversation.
1386
1421
 
1387
- Memory categories — use the MOST SPECIFIC category that fits:
1388
- - fact: Objective information about the world
1389
- - preference: User likes, dislikes, or stylistic choices
1390
- - correction: User correcting a mistake (highest priority)
1391
- - entity: People, projects, tools, companies (use canonical hyphenated names like "my-project")
1392
- - decision: Choices made with rationale
1393
- - relationship: How entities relate (e.g., "Alice manages Bob")
1394
- - principle: Durable rules or operating beliefs (e.g., "never use X API")
1395
- - commitment: Promises, obligations, deadlines
1396
- - moment: Emotionally significant events
1397
- - skill: Demonstrated capabilities
1398
- - rule: Explicit operational rules or constraints
1399
- - procedure: Repeatable workflowsuse when the user describes a multi-step play (≥2 ordered steps). Put the human-readable trigger/context in "content" (e.g. "When you deploy…") and list steps in "procedureSteps" as [{"order":1,"intent":""}, …] mirroring the gateway extraction schema.
1400
- - reasoning_trace: Stored solution chains — use when the user narrates HOW they solved a specific problem step-by-step ("here's how I figured out…", "the debugging went like this…"). Put a short title in "content" (e.g. "How I debugged the staging latency spike") and the chain in "reasoningTrace": {"steps":[{"order":1,"description":"…"}, …], "finalAnswer":"…", "observedOutcome":"…" (optional)}. Require ≥2 ordered steps and a finalAnswer. Do NOT use for ordinary decisions (prefer "decision") or reusable workflows (prefer "procedure").
1401
-
1402
- IMPORTANT: Do NOT label everything as "fact". Use "decision" for architectural choices, "commitment" for deadlines/promises, "principle" for reusable rules, "correction" for when the user rejects a suggestion, etc.
1403
-
1404
- === DO NOT EXTRACT (negative examples) ===
1405
- These are operational noise - skip them:
1406
- - "The user has a cron job that runs every 30 minutes" (scheduled task descriptions)
1407
- - "The user encountered error XYZ at 3:45 PM" (temporary error states)
1408
- - "The file is located at /path/to/project/file" (transient file paths)
1409
- - "The system is using 4GB of memory" (current resource usage)
1410
- - "The user ran the 'git status' command" (individual command executions)
1411
- - "The conversation took place on Tuesday" (session metadata)
1412
- - "The agent read the file at /path/to/file.txt" (agent's own actions)
1413
- - "The user's OpenClaw automation posts to #channel on failures" (automation behavior descriptions)
1414
- - "The user stores state in /path/to/state.json" (implementation details)
1415
- - "The X-watch automation has been stalled for 58 hours" (system status updates)
1416
- - "The user processed 5 batch files and extracted insights" (processing summaries)
1417
- - "The user has a cron job that runs a Checkpoint Loop every 2 hours" (automation schedules)
1418
- - "The user runs a Morning Surprise cron job daily at 7:30 AM" (automation schedules)
1419
- - "The user runs an X Bookmarks → Insights pipeline hourly at :13" (automation schedules)
1420
- - "The user's system mines X/Twitter mentions for ideas every 10a/2p/6p" (automation schedules)
1421
- - "The user runs a Health Insights cron job weekday mornings" (automation schedules)
1422
- - "The system monitors the showcase page every 12 hours" (system monitoring configurations)
1423
-
1424
- === DO EXTRACT (positive examples) ===
1425
- These are durable insights - capture them:
1426
- - "The user prefers dark mode interfaces and finds light mode uncomfortable" (preference)
1427
- - "The user works primarily with TypeScript and avoids Python for frontend code" (long-term fact)
1428
- - "The user's side project 'alpha-trader' uses a custom algorithm for arbitrage" (entity + detail)
1429
- - "The user corrected that PostgreSQL 15 is required, not version 14" (correction)
1430
- - "The user never commits code without running tests first" (principle)
1431
- - "The user has a meeting with the design team every Friday at 2pm" (commitment)
1432
-
1433
- === Rules ===
1434
- - Extract only NEW information worth remembering across sessions
1435
- - Skip transient details (file paths, current errors, temporary states, agent actions)
1436
- - Confidence: Explicit (0.95-1.0), Implied (0.70-0.94), Inferred (0.40-0.69), Speculative (0.00-0.39)${this.config.provenance?.enabled ? `
1437
- - Source quotes: For each fact, include a "quote" field with the EXACT verbatim words from the conversation that support the fact (copy a contiguous span from a single turn, not a paraphrase). Cap at ~300 chars.` : ""}
1438
- - Corrections get highest confidence (0.95+)
1439
- - Each fact should be standalone and self-contained
1440
- - Lines labelled [context user] or [context assistant] are reference context only. Use them to resolve pronouns and adjacent question/answer pairs, but do not extract a memory stated only in context lines unless a normal [user] or [assistant] line confirms or completes it.
1441
- - CRITICAL: Use canonical hyphenated entity names (e.g., "jane-doe" not "janedoe")
1442
- - CRITICAL: NEVER extract the same fact twice - check for duplicates before adding to facts array
1443
- - CRITICAL: NEVER extract cron job schedules, automation configurations, or system monitoring details (these are operational noise)
1444
- - If uncertain about relevance, prefer NOT extracting${lifecycleCaps.extractionScopeClassification ? `
1445
- - For each fact, set "scope" to "global" (cross-project knowledge: framework bugs, library behavior, user preferences, tool configs, general patterns) or "project" (codebase-specific: file paths, env configs, deployment details, project workarounds). When in doubt, prefer "project".` : ""}
1446
-
1447
- === Structured Attributes ===
1448
- When a fact contains measurable, categorical, or precisely valued data, add a "structuredAttributes" object with key-value string pairs. This captures exact values for precise retrieval later.
1449
- Examples of when to add structuredAttributes:
1450
- - Product details: {"price": "29.99", "brand": "Sony", "color": "black", "rating": "4.5"}
1451
- - Person details: {"age": "32", "occupation": "engineer", "city": "Austin"}
1452
- - Events with dates: {"date": "2024-03-15", "location": "San Francisco"}
1453
- - Decisions: {"chosen": "PostgreSQL", "rejected": "MongoDB", "reason": "ACID compliance"}
1454
- - Quantities/measurements: {"budget": "50000", "team_size": "5", "deadline": "2024-06-01"}
1455
- Only add structuredAttributes when there are concrete values. Skip for abstract or narrative facts.
1456
- ${this.eventTimePromptInstruction()}
1457
- Also generate:
1458
- 1. 1-3 genuine questions you're curious about from this conversation
1459
- 2. Profile updates about user patterns/behaviors (if any)
1460
- 3. Relationships between entities (max 5). Use normalized names like "person-jane-doe", "company-acme-corp".
1461
- 4. For entity facts that fit a durable named heading, include entity.structuredSections with {key, title, facts}.
1422
+ Use the most specific category:
1423
+ - fact: objective information
1424
+ - preference: a durable preference or style
1425
+ - correction: a correction of a prior mistake
1426
+ - entity: a durable person, project, tool, company, or place
1427
+ - decision: a choice with rationale
1428
+ - relationship: a durable link between two entities
1429
+ - principle: a reusable rule or operating belief
1430
+ ${resolveRecallAuxiliaryCapabilities(this.config).causalRuleExtraction ? "- rule: an explicit causal rule or constraint\n" : ""}- commitment: a promise, obligation, or deadline
1431
+ - moment: a significant milestone
1432
+ - skill: a demonstrated capability
1433
+ - procedure: an explicit reusable workflow with ordered procedureSteps
1434
+ - reasoning_trace: Stored solution chains an explicitly narrated solution path with reasoningTrace. Use {"category": "reasoning_trace", "reasoningTrace": {"steps": [...], "finalAnswer": "..."}} only when the conversation provides the chain.
1462
1435
 
1463
- Output JSON:
1464
- {
1465
- "facts": [{"category": "decision", "content": "Chose PostgreSQL over MongoDB for the user service", "importance": 8, "confidence": 0.9, "scope": "project", "structuredAttributes": {"chosen": "PostgreSQL", "rejected": "MongoDB"}}, {"category": "procedure", "content": "When you cut a hotfix release, follow the checklist", "importance": 8, "confidence": 0.9, "scope": "project", "procedureSteps": [{"order": 1, "intent": "Branch from main and cherry-pick the fix"}, {"order": 2, "intent": "Run CI and tag the release"}]}, {"category": "reasoning_trace", "content": "How I debugged the staging latency spike", "importance": 7, "confidence": 0.9, "scope": "project", "reasoningTrace": {"steps": [{"order": 1, "description": "Checked CPU/memory dashboards — both were flat"}, {"order": 2, "description": "Ran a traceroute and saw retries against the cache tier"}, {"order": 3, "description": "Tailed cache-tier logs and spotted eviction storms"}], "finalAnswer": "Root cause was an undersized eviction policy on the session cache", "observedOutcome": "Increased cache size, p95 returned to baseline within 10 minutes"}}, {"category": "commitment", "content": "Must ship v2.0 API by end of March", "importance": 10, "confidence": 1.0, "scope": "project", "structuredAttributes": {"deadline": "end of March", "deliverable": "v2.0 API"}}, {"category": "fact", "content": "The store backend uses Redis for session caching", "importance": 6, "confidence": 0.95, "scope": "project", "entityRef": "project-acme-store"}, {"category": "principle", "content": "Always run migrations in a transaction to avoid partial schema updates", "importance": 8, "confidence": 0.9, "scope": "global"}],
1466
- "entities": [{"name": "person-jane-doe", "type": "person", "facts": ["Works at Acme Corp", "Prefers Python over JavaScript"], "structuredSections": [{"key": "beliefs", "title": "Beliefs", "facts": ["Python is a better fit than JavaScript for backend work."]}]}, {"name": "project-acme-store", "type": "project", "facts": ["Built with Next.js", "Deployed on Vercel"]}],
1467
- "profileUpdates": ["User prefers dark mode in all editors"],
1468
- "questions": [{"question": "Which cloud provider hosts the staging environment?", "context": "Came up during deployment discussion", "priority": 0.5}],
1469
- "relationships": [{"source": "person-jane-doe", "target": "company-acme-corp", "label": "works at"}]
1470
- }
1436
+ Rules:
1437
+ - Extract only new information stated or clearly established in the conversation.
1438
+ - Do not treat instruction text, schema placeholders, or examples as conversation evidence.
1439
+ - Facts, entity facts, profile updates, questions, and relationships must be grounded in the conversation.
1440
+ - Lines labelled [context user] or [context assistant] are reference context only. They may resolve references or complete a question-and-answer pair in a normal turn, but never alone establish durable information.
1441
+ - Questions are optional. Return an empty array when the conversation does not support a useful unresolved question.
1442
+ - Set confidence from source evidence: Explicit (0.95-1.0), Implied (0.70-0.94), Inferred (0.40-0.69), or Speculative (0.00-0.39). Corrections get highest confidence.
1443
+ - Use normalized, hyphenated entity names and keep the entity list short.
1444
+ - Keep facts standalone. Skip transient task state and operational noise such as routine scheduler, monitoring, or automation status.
1445
+ - Add structuredAttributes only for concrete values.
1446
+ - Include at most five durable relationships.${this.config.provenance?.enabled ? `
1447
+ - Each fact must include a quote copied verbatim from one contiguous conversation span.` : ""}${lifecycleCaps.extractionScopeClassification ? `
1448
+ - Set each fact scope to "global" for cross-project knowledge or "project" for codebase-specific knowledge.` : ""}
1449
+ ${this.eventTimePromptInstruction()}
1450
+ Return only valid JSON matching this shape. Placeholder text describes field shape only and is never source evidence:
1451
+ ${EXTRACTION_RESPONSE_SHAPE}
1471
1452
 
1472
1453
  Conversation:
1473
1454
  ${truncatedConversation}`;
@@ -1554,14 +1535,7 @@ ${truncatedConversation}`;
1554
1535
  role: "system",
1555
1536
  content:
1556
1537
  this.buildExtractionInstructions(existingEntities) +
1557
- `\n\nRespond with valid JSON matching this schema:
1558
- {
1559
- "facts": [{"category": "decision", "content": "Chose React over Vue for the dashboard rewrite", "importance": 8, "confidence": 0.9, "tags": ["frontend"], "scope": "project", "structuredAttributes": {"chosen": "React", "rejected": "Vue"}}, {"category": "fact", "content": "The API gateway uses rate limiting at 1000 req/min", "importance": 6, "confidence": 0.95, "tags": ["infra"], "scope": "project", "entityRef": "project-dashboard", "structuredAttributes": {"rate_limit": "1000 req/min"}}, {"category": "reasoning_trace", "content": "How I chose the dashboard rewrite framework", "confidence": 0.9, "tags": ["frontend"], "scope": "project", "reasoningTrace": {"steps": [{"order": 1, "description": "Listed constraints: SSR needed, team mostly JS"}, {"order": 2, "description": "Ran a spike in Vue 3 — worked, but ecosystem felt thin for our needs"}, {"order": 3, "description": "Ran the same spike in React — integrated faster with Next.js"}], "finalAnswer": "Picked React with Next.js for SSR + ecosystem fit"}}],
1560
- "entities": [{"name": "person-sarah-chen", "type": "person", "facts": ["Leads the backend team", "Joined from Google in 2024"], "structuredSections": [{"key": "beliefs", "title": "Beliefs", "facts": ["Small teams should own whole systems."]}]}, {"name": "project-dashboard", "type": "project", "facts": ["React-based admin panel", "Deployed on AWS ECS"]}],
1561
- "profileUpdates": ["User prefers TypeScript over plain JavaScript"],
1562
- "questions": [{"question": "What database does the analytics service use?", "context": "Came up during discussion of migration plan", "priority": 0.5}],
1563
- "relationships": [{"source": "person-sarah-chen", "target": "project-dashboard", "label": "leads development of"}]
1564
- }`,
1538
+ `\n\nReturn only valid JSON matching this shape. Placeholder text describes field shape only and is never source evidence:\n${EXTRACTION_RESPONSE_SHAPE}`,
1565
1539
  },
1566
1540
  { role: "user", content: conversation },
1567
1541
  ],
@@ -1597,29 +1571,6 @@ ${truncatedConversation}`;
1597
1571
  * Local LLMs sometimes hit token limits mid-JSON. This tries to salvage valid facts.
1598
1572
  */
1599
1573
  private extractPartialFacts(jsonStr: string): ExtractionResult {
1600
- const allowedCategories = new Set([
1601
- "fact",
1602
- "preference",
1603
- "correction",
1604
- "entity",
1605
- "decision",
1606
- "relationship",
1607
- "principle",
1608
- "commitment",
1609
- "moment",
1610
- "skill",
1611
- "rule",
1612
- "procedure",
1613
- "reasoning_trace",
1614
- ]);
1615
- const allowedEntityTypes = new Set([
1616
- "person",
1617
- "project",
1618
- "tool",
1619
- "company",
1620
- "place",
1621
- "other",
1622
- ]);
1623
1574
 
1624
1575
  const facts: ExtractionResult["facts"] = [];
1625
1576
  const entities: ExtractionResult["entities"] = [];
@@ -1629,8 +1580,8 @@ ${truncatedConversation}`;
1629
1580
  const factRegex = /\{\s*"category"\s*:\s*"([^"]+)"\s*,\s*"content"\s*:\s*"([^"]+)"\s*,\s*"confidence"\s*:\s*([0-9.]+)/g;
1630
1581
  let match;
1631
1582
  while ((match = factRegex.exec(jsonStr)) !== null) {
1632
- const rawCat = match[1];
1633
- const category = allowedCategories.has(rawCat) ? (rawCat as ExtractionResult["facts"][number]["category"]) : "fact";
1583
+ const category = match[1]?.trim() ?? "";
1584
+ if (!isMemoryCategory(category)) continue;
1634
1585
  facts.push({
1635
1586
  category,
1636
1587
  content: match[2].replace(/\\n/g, '\n').replace(/\\"/g, '"'),
@@ -1642,8 +1593,8 @@ ${truncatedConversation}`;
1642
1593
  // Find all complete entity objects
1643
1594
  const entityRegex = /\{\s*"name"\s*:\s*"([^"]+)"\s*,\s*"type"\s*:\s*"([^"]+)"/g;
1644
1595
  while ((match = entityRegex.exec(jsonStr)) !== null) {
1645
- const rawType = match[2];
1646
- const type = allowedEntityTypes.has(rawType) ? (rawType as ExtractionResult["entities"][number]["type"]) : "other";
1596
+ const type = extractionEntityType(match[2]);
1597
+ if (type === undefined) continue;
1647
1598
  entities.push({
1648
1599
  name: match[1],
1649
1600
  type,
@@ -1654,7 +1605,7 @@ ${truncatedConversation}`;
1654
1605
  // Ignore regex errors
1655
1606
  }
1656
1607
 
1657
- return { facts, entities, profileUpdates: [], questions: [] };
1608
+ return this.normalizeExtractionResultPayload({ facts, entities, profileUpdates: [], questions: [] });
1658
1609
  }
1659
1610
 
1660
1611
  /**
@@ -1668,15 +1619,7 @@ ${truncatedConversation}`;
1668
1619
  private eventTimePromptInstruction(): string {
1669
1620
  if (!this.config.temporalBiTemporal) return "";
1670
1621
  return `
1671
- === Event Time (bi-temporal) ===
1672
- When a fact has an explicit temporal anchor — a date, month, season, or relative time expression stating WHEN the fact became (or stopped being) true — capture it verbatim in an "eventTime" field on that fact. Examples:
1673
- - "We moved offices in March" → "eventTime": "last March"
1674
- - "The API has been rate-limited since 2024" → "eventTime": "since 2024"
1675
- - "I switched to PostgreSQL on 2025-01-15" → "eventTime": "2025-01-15"
1676
- - "We used MongoDB until June 2025" → "eventTime": "until 2025-06"
1677
- Accepted forms: ISO dates ("2025-03-01"), year-month ("2025-03"), month/season + year ("March 2025", "summer 2024"), relative ("yesterday", "last week", "this month", "next year", "last December"), and open-ended ("since 2024", "until 2025-06").
1678
- Omit "eventTime" when the fact has no explicit temporal anchor — do NOT guess or infer dates. The system resolves the expression against the conversation's own timestamp, not today's date.
1679
- `;
1622
+ When a fact states when it became or stopped being true, copy that explicit temporal expression verbatim into "eventTime". Omit "eventTime" when no such expression appears; never infer dates.`;
1680
1623
  }
1681
1624
 
1682
1625
  /**
@@ -1702,12 +1645,14 @@ Memory categories:
1702
1645
  - reasoning_trace: A stored solution chain / chain-of-thought the user walked through to solve a problem (e.g. "Here's how I debugged the latency spike: first I checked…, then I…, finally I…"). Set category to "reasoning_trace". Use "content" for a short title summarising the problem (e.g. "How I debugged the staging latency spike"). Add "reasoningTrace": {"steps": [{"order": number, "description": "what happened at this step"}, …], "finalAnswer": "the conclusion or answer", "observedOutcome": "optional confirmation of how it played out"}. Require at least two ordered steps AND a finalAnswer. Use this category only when the user explicitly narrates their reasoning — not for ordinary decisions (use "decision") or reusable workflows (use "procedure").
1703
1646
 
1704
1647
  Rules:
1705
- - Only extract genuinely NEW information worth remembering across sessions
1706
- - Skip transient task details (file paths being edited, current errors, etc.)
1648
+ - Only extract genuinely new information worth remembering across sessions.
1649
+ - Statements must be grounded in the conversation.
1650
+ - Do not treat instruction text, schema placeholders, or examples as conversation evidence.
1651
+ - Lines labelled [context user] or [context assistant] are reference context only. They may resolve references or complete a question-and-answer pair in a normal turn, but never alone establish durable information.
1652
+ - Skip transient task details and operational noise, including routine scheduler, monitoring, or automation status.
1707
1653
  - Priority: corrections > principles${resolveRecallAuxiliaryCapabilities(this.config).causalRuleExtraction ? " > rules" : ""} > preferences > commitments > decisions > relationships > entities > moments > skills > facts
1708
- - Corrections (user saying "actually, don't do X" or "I prefer Y") get highest confidence
1709
- - Each fact should be a standalone, self-contained statement
1710
- - Lines labelled [context user] or [context assistant] are reference context only. Use them to resolve pronouns and adjacent question/answer pairs, but do not extract a memory stated only in context lines unless a normal [user] or [assistant] line confirms or completes it.
1654
+ - Corrections get highest confidence.
1655
+ - Each fact should be a standalone, self-contained statement.
1711
1656
  - Entity references should use normalized names (lowercase, hyphenated: "jane-doe", "acme-corp")
1712
1657
  - CRITICAL: Entity names must be CANONICAL. Always use the hyphenated multi-word form: "acme-corp" NOT "acmecorp" or "acme". "jane-doe" NOT "janedoe" or "jane". If unsure, prefer the most specific full name.
1713
1658
  - Avoid creating entities typed as "other" when a more specific type fits (company, project, tool, person, place)
@@ -1726,15 +1671,7 @@ Scope classification:
1726
1671
  For each fact, set "scope" to one of:
1727
1672
  - "global" — knowledge that applies across projects: core framework/library bugs, API behavior patterns, user preferences (editor, language, style), tool configurations, general coding patterns, infrastructure knowledge, technology facts not tied to one codebase
1728
1673
  - "project" — knowledge specific to one codebase: file paths, environment configs, deployment details, project-specific workarounds, team/stakeholder info tied to one project, repo-specific conventions
1729
- When in doubt, prefer "project" — it is safer to keep knowledge scoped narrowly.
1730
- Examples:
1731
- "Magento 2.4.8 has a race condition in checkout" → "global"
1732
- "User prefers dark mode in all editors" → "global"
1733
- "The staging server is at staging.acme.com" → "project"
1734
- "The deploy script lives at scripts/deploy.sh" → "project"
1735
- "PostgreSQL 15 requires the uuid-ossp extension for gen_random_uuid()" → "global"
1736
- "The acme-store repo uses a custom Webpack config for SSR" → "project"` : ""}
1737
-
1674
+ When in doubt, prefer "project" — it is safer to keep knowledge scoped narrowly.` : ""}
1738
1675
  Entity creation rules (STRICT):
1739
1676
  - Only create entities for DURABLE things: real people, companies, products, tools, ongoing projects
1740
1677
  - NEVER create entities for transient items: individual PRs, branches, Jira tickets, meetings, agent task IDs, log files, database tables, cron job runs, sessions
@@ -1755,14 +1692,9 @@ Also extract relationships between entities mentioned in the conversation.
1755
1692
  - Only include clear, durable relationships (e.g., "works at", "created", "manages", "uses")
1756
1693
  - Use normalized entity names (e.g., "person-jane-doe", "company-acme-corp")
1757
1694
 
1758
- Also generate 1-3 genuine questions you're curious about based on this conversation. These should be things you'd actually want answers to in future sessions not prompts, but real curiosity.
1695
+ Questions are optional. Include only source-grounded unresolved questions that would be useful in future sessions; otherwise return an empty array.
1759
1696
 
1760
- Finally, write a brief identity reflection about the AGENT who had this conversation (not about you, the extraction system). Based on what the agent said and did in the conversation:
1761
- - What communication patterns did the agent show? (e.g., proactive vs reactive, verbose vs concise)
1762
- - Did the agent handle the user's needs well or miss something?
1763
- - What behavioral tendencies are visible? (e.g., cautious, creative, thorough, impatient)
1764
- - What could the agent improve next time?
1765
- Do NOT write about the extraction process itself. Do NOT say things like "I extracted durable facts" — that's about YOUR job, not the agent's behavior.`;
1697
+ Finally, write a brief identity reflection about the agent who had this conversation, based only on the conversation. Do not write about the extraction process.`;
1766
1698
  }
1767
1699
 
1768
1700
  async consolidate(