@shadowforge0/aquifer-memory 1.2.1 → 1.5.8

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.
Files changed (43) hide show
  1. package/README.md +8 -9
  2. package/consumers/cli.js +11 -1
  3. package/consumers/default/index.js +17 -4
  4. package/consumers/mcp.js +21 -0
  5. package/consumers/miranda/index.js +15 -4
  6. package/consumers/miranda/profile.json +145 -0
  7. package/consumers/miranda/recall-format.js +5 -3
  8. package/consumers/miranda/render-daily-md.js +186 -0
  9. package/consumers/shared/config.js +8 -0
  10. package/consumers/shared/factory.js +2 -1
  11. package/consumers/shared/llm.js +1 -1
  12. package/consumers/shared/recall-format.js +21 -1
  13. package/core/aquifer.js +693 -87
  14. package/core/artifacts.js +174 -0
  15. package/core/bundles.js +400 -0
  16. package/core/consolidation.js +340 -0
  17. package/core/decisions.js +164 -0
  18. package/core/entity-state.js +483 -0
  19. package/core/errors.js +97 -0
  20. package/core/handoff.js +153 -0
  21. package/core/insights.js +499 -0
  22. package/core/mcp-manifest.js +131 -0
  23. package/core/narratives.js +212 -0
  24. package/core/profiles.js +171 -0
  25. package/core/state.js +163 -0
  26. package/core/storage.js +82 -5
  27. package/core/timeline.js +152 -0
  28. package/index.js +14 -0
  29. package/package.json +1 -1
  30. package/pipeline/extract-state-changes.js +205 -0
  31. package/schema/001-base.sql +186 -16
  32. package/schema/002-entities.sql +35 -1
  33. package/schema/004-completion.sql +391 -0
  34. package/schema/005-entity-state-history.sql +87 -0
  35. package/schema/006-insights.sql +138 -0
  36. package/scripts/diagnose-fts-zh.js +37 -4
  37. package/scripts/drop-entity-state-history.sql +17 -0
  38. package/scripts/drop-insights.sql +12 -0
  39. package/scripts/extract-insights-from-recent-sessions.js +315 -0
  40. package/scripts/find-dburl-hints.js +29 -0
  41. package/scripts/queries.json +45 -0
  42. package/scripts/retro-recall-bench.js +409 -0
  43. package/scripts/sample-bench-queries.sql +75 -0
package/core/storage.js CHANGED
@@ -59,7 +59,7 @@ async function upsertSession(pool, {
59
59
  (tenant_id, session_id, session_key, agent_id, source, messages,
60
60
  msg_count, user_count, assistant_count, model, tokens_in, tokens_out,
61
61
  started_at, ended_at, last_message_at, processing_status)
62
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,now(),$14,'pending')
62
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,COALESCE($13,now()),COALESCE($14,now()),$14,'pending')
63
63
  ON CONFLICT (tenant_id, agent_id, session_id) DO UPDATE SET
64
64
  session_key = EXCLUDED.session_key,
65
65
  source = COALESCE(EXCLUDED.source, ${qi(schema)}.sessions.source),
@@ -71,7 +71,7 @@ async function upsertSession(pool, {
71
71
  tokens_in = EXCLUDED.tokens_in,
72
72
  tokens_out = EXCLUDED.tokens_out,
73
73
  started_at = COALESCE(EXCLUDED.started_at, ${qi(schema)}.sessions.started_at),
74
- ended_at = now(),
74
+ ended_at = COALESCE(EXCLUDED.last_message_at, ${qi(schema)}.sessions.ended_at),
75
75
  last_message_at = COALESCE(EXCLUDED.last_message_at, ${qi(schema)}.sessions.last_message_at),
76
76
  processing_status = 'pending',
77
77
  processing_error = NULL
@@ -223,9 +223,13 @@ async function searchSessions(pool, query, {
223
223
  dateFrom,
224
224
  dateTo,
225
225
  limit = 20,
226
+ ftsConfig = 'simple',
226
227
  } = {}) {
227
228
  const clampedLimit = Math.max(1, Math.min(100, limit));
228
229
 
230
+ // Whitelist tsconfig to prevent injection
231
+ const cfg = (ftsConfig === 'zhcfg' || ftsConfig === 'simple') ? ftsConfig : 'simple';
232
+
229
233
  // Normalize agentId/agentIds
230
234
  const agentIds = rawAgentIds && rawAgentIds.length > 0
231
235
  ? rawAgentIds
@@ -237,7 +241,7 @@ async function searchSessions(pool, query, {
237
241
  // Primary: trigram ILIKE on search_text (works for CJK + Latin)
238
242
  // Fallback: tsvector FTS (for installations without search_text populated)
239
243
  const where = [
240
- `(ss.search_text ILIKE '%' || $1 || '%' OR ss.search_tsv @@ plainto_tsquery('simple', $2))`,
244
+ `(ss.search_text ILIKE '%' || $1 || '%' OR ss.search_tsv @@ plainto_tsquery('${cfg}', $2))`,
241
245
  `s.tenant_id = $3`,
242
246
  ];
243
247
  const params = [likeQuery, query, tenantId];
@@ -276,10 +280,15 @@ async function searchSessions(pool, query, {
276
280
  ss.trust_score,
277
281
  CASE WHEN ss.search_text IS NOT NULL
278
282
  THEN similarity(ss.search_text, $2)
279
- ELSE ts_rank(ss.search_tsv, plainto_tsquery('simple', $2))
283
+ ELSE ts_rank(ss.search_tsv, plainto_tsquery('${cfg}', $2))
280
284
  END AS fts_rank
281
285
  FROM ${qi(schema)}.sessions s
282
- LEFT JOIN ${qi(schema)}.session_summaries ss ON ss.session_row_id = s.id
286
+ -- INNER JOIN: the WHERE clause references ss.search_text / ss.search_tsv,
287
+ -- which a LEFT JOIN would leave NULL for unenriched sessions — filtering
288
+ -- them out. Be explicit: FTS recall is a SUMMARIZED-sessions search. Raw
289
+ -- unenriched sessions don't participate. Named searchSessions for historic
290
+ -- reasons; semantically it is search-over-enriched-sessions.
291
+ JOIN ${qi(schema)}.session_summaries ss ON ss.session_row_id = s.id
283
292
  WHERE ${where.join(' AND ')}
284
293
  ORDER BY
285
294
  COALESCE(ss.search_text ILIKE '%' || $1 || '%', FALSE) DESC,
@@ -512,6 +521,73 @@ async function searchTurnEmbeddings(pool, {
512
521
  return { rows: fallback.rows };
513
522
  }
514
523
 
524
+ // ---------------------------------------------------------------------------
525
+ // searchSummaryEmbeddings — pgvector cosine search on session_summaries.embedding
526
+ // ---------------------------------------------------------------------------
527
+
528
+ async function searchSummaryEmbeddings(pool, {
529
+ schema,
530
+ tenantId,
531
+ queryVec,
532
+ agentId,
533
+ agentIds: rawAgentIds,
534
+ source,
535
+ dateFrom,
536
+ dateTo,
537
+ candidateSessionIds,
538
+ limit = 15,
539
+ } = {}) {
540
+ const where = ['s.tenant_id = $1'];
541
+ const params = [tenantId];
542
+
543
+ params.push(`[${queryVec.join(',')}]`);
544
+ const vecPos = params.length;
545
+
546
+ const agentIds = rawAgentIds && rawAgentIds.length > 0
547
+ ? rawAgentIds
548
+ : (agentId ? [agentId] : null);
549
+
550
+ if (dateFrom) {
551
+ params.push(dateFrom);
552
+ where.push(`s.started_at::date >= $${params.length}::date`);
553
+ }
554
+ if (dateTo) {
555
+ params.push(dateTo);
556
+ where.push(`s.started_at::date <= $${params.length}::date`);
557
+ }
558
+ if (agentIds) {
559
+ params.push(agentIds);
560
+ where.push(`s.agent_id = ANY($${params.length})`);
561
+ }
562
+ if (source) {
563
+ params.push(source);
564
+ where.push(`s.source = $${params.length}`);
565
+ }
566
+ if (candidateSessionIds && candidateSessionIds.length > 0) {
567
+ params.push(candidateSessionIds);
568
+ where.push(`s.session_id = ANY($${params.length})`);
569
+ }
570
+
571
+ params.push(limit);
572
+
573
+ const result = await pool.query(
574
+ `SELECT
575
+ s.id, s.session_id, s.agent_id, s.source, s.started_at, s.last_message_at,
576
+ ss.summary_text, ss.structured_summary, ss.access_count, ss.last_accessed_at,
577
+ ss.trust_score,
578
+ (ss.embedding <=> $${vecPos}::vector) AS distance
579
+ FROM ${qi(schema)}.session_summaries ss
580
+ JOIN ${qi(schema)}.sessions s ON s.id = ss.session_row_id
581
+ WHERE ss.embedding IS NOT NULL
582
+ AND ${where.join(' AND ')}
583
+ ORDER BY distance ASC
584
+ LIMIT $${params.length}`,
585
+ params
586
+ );
587
+
588
+ return { rows: result.rows };
589
+ }
590
+
515
591
  // ---------------------------------------------------------------------------
516
592
  // recordFeedback — explicit trust feedback with audit trail
517
593
  // ---------------------------------------------------------------------------
@@ -605,5 +681,6 @@ module.exports = {
605
681
  extractUserTurns,
606
682
  upsertTurnEmbeddings,
607
683
  searchTurnEmbeddings,
684
+ searchSummaryEmbeddings,
608
685
  recordFeedback,
609
686
  };
@@ -0,0 +1,152 @@
1
+ 'use strict';
2
+
3
+ // aq.timeline.* — append-only event log capability.
4
+ //
5
+ // Spec: aquifer-completion §10 timeline. Fixed event shape
6
+ // (occurred_at / source / session_ref / category / text / metadata),
7
+ // consumer-owned category vocabulary. idempotency_key UNIQUE across the
8
+ // table; appends with a duplicate key are a safe no-op and return the
9
+ // existing row.
10
+
11
+ const crypto = require('crypto');
12
+ const { AqError, ok, err } = require('./errors');
13
+
14
+ const DEFAULT_PROFILE = Object.freeze({
15
+ id: 'anon',
16
+ version: 0,
17
+ schemaHash: 'pending',
18
+ });
19
+
20
+ function resolveProfile(profile) {
21
+ if (!profile) return DEFAULT_PROFILE;
22
+ return {
23
+ id: profile.id || DEFAULT_PROFILE.id,
24
+ version: Number.isInteger(profile.version) ? profile.version : DEFAULT_PROFILE.version,
25
+ schemaHash: profile.schemaHash || DEFAULT_PROFILE.schemaHash,
26
+ };
27
+ }
28
+
29
+ function defaultIdempotencyKey({ tenantId, agentId, occurredAt, category, text }) {
30
+ return crypto.createHash('sha256')
31
+ .update(`${tenantId}:${agentId}:${occurredAt}:${category}:${text}`)
32
+ .digest('hex');
33
+ }
34
+
35
+ function toNumber(v) {
36
+ if (v === null || v === undefined) return null;
37
+ const n = Number(v);
38
+ return Number.isFinite(n) ? n : null;
39
+ }
40
+
41
+ function mapRow(row) {
42
+ if (!row) return null;
43
+ return {
44
+ eventId: toNumber(row.id),
45
+ occurredAt: row.occurred_at,
46
+ source: row.source,
47
+ sessionRef: row.session_ref,
48
+ category: row.category,
49
+ text: row.text,
50
+ metadata: row.metadata || {},
51
+ };
52
+ }
53
+
54
+ function createTimeline({ pool, schema, defaultTenantId }) {
55
+ async function append(input) {
56
+ try {
57
+ if (!input || typeof input !== 'object') {
58
+ return err('AQ_INVALID_INPUT', 'append requires an input object');
59
+ }
60
+ if (!input.agentId) return err('AQ_INVALID_INPUT', 'agentId is required');
61
+ if (!input.occurredAt) return err('AQ_INVALID_INPUT', 'occurredAt is required');
62
+ if (!input.source) return err('AQ_INVALID_INPUT', 'source is required');
63
+ if (!input.category) return err('AQ_INVALID_INPUT', 'category is required');
64
+ if (!input.text || typeof input.text !== 'string') {
65
+ return err('AQ_INVALID_INPUT', 'text is required');
66
+ }
67
+
68
+ const tenantId = input.tenantId || defaultTenantId || 'default';
69
+ const agentId = input.agentId;
70
+ const profile = resolveProfile(input.profile);
71
+ const idempotencyKey = input.idempotencyKey
72
+ || defaultIdempotencyKey({
73
+ tenantId, agentId,
74
+ occurredAt: input.occurredAt,
75
+ category: input.category,
76
+ text: input.text,
77
+ });
78
+
79
+ // Idempotent append: on conflict, fall back to SELECT so the caller
80
+ // always gets the canonical row (the row that *won* the insert).
81
+ const insertResult = await pool.query(
82
+ `INSERT INTO ${schema}.timeline_events (
83
+ tenant_id, agent_id, occurred_at, source, session_ref,
84
+ category, text, metadata, source_session_id,
85
+ consumer_profile_id, consumer_profile_version, consumer_schema_hash,
86
+ idempotency_key
87
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
88
+ ON CONFLICT (idempotency_key) DO NOTHING
89
+ RETURNING *`,
90
+ [
91
+ tenantId, agentId, input.occurredAt, input.source,
92
+ input.sessionRef || null, input.category, input.text,
93
+ input.metadata || {}, input.sessionId || null,
94
+ profile.id, profile.version, profile.schemaHash,
95
+ idempotencyKey,
96
+ ],
97
+ );
98
+ let row = insertResult.rows[0];
99
+ if (!row) {
100
+ const existing = await pool.query(
101
+ `SELECT * FROM ${schema}.timeline_events WHERE idempotency_key = $1`,
102
+ [idempotencyKey],
103
+ );
104
+ row = existing.rows[0];
105
+ }
106
+ const mapped = mapRow(row);
107
+ return ok({ eventId: mapped.eventId, event: mapped });
108
+ } catch (e) {
109
+ if (e instanceof AqError) return err(e);
110
+ return err('AQ_INTERNAL', e.message, { cause: e });
111
+ }
112
+ }
113
+
114
+ async function list(input = {}) {
115
+ try {
116
+ if (!input.agentId) return err('AQ_INVALID_INPUT', 'agentId is required');
117
+ const tenantId = input.tenantId || defaultTenantId || 'default';
118
+ const limit = Math.min(Math.max(input.limit || 50, 1), 500);
119
+
120
+ const params = [tenantId, input.agentId];
121
+ let where = 'tenant_id = $1 AND agent_id = $2';
122
+ if (Array.isArray(input.categories) && input.categories.length > 0) {
123
+ params.push(input.categories);
124
+ where += ` AND category = ANY($${params.length})`;
125
+ }
126
+ if (input.since) {
127
+ params.push(input.since);
128
+ where += ` AND occurred_at >= $${params.length}`;
129
+ }
130
+ if (input.until) {
131
+ params.push(input.until);
132
+ where += ` AND occurred_at <= $${params.length}`;
133
+ }
134
+ params.push(limit);
135
+
136
+ const { rows } = await pool.query(
137
+ `SELECT * FROM ${schema}.timeline_events
138
+ WHERE ${where}
139
+ ORDER BY occurred_at DESC, id DESC
140
+ LIMIT $${params.length}`,
141
+ params,
142
+ );
143
+ return ok({ rows: rows.map(mapRow) });
144
+ } catch (e) {
145
+ return err('AQ_INTERNAL', e.message, { cause: e });
146
+ }
147
+ }
148
+
149
+ return { append, list };
150
+ }
151
+
152
+ module.exports = { createTimeline };
package/index.js CHANGED
@@ -5,6 +5,8 @@ const { createEmbedder } = require('./pipeline/embed');
5
5
  const { createReranker } = require('./pipeline/rerank');
6
6
  const { normalizeEntityName } = require('./core/entity');
7
7
  const { parseEntitySection } = require('./consumers/shared/entity-parser');
8
+ const { AqError, ok, err, asResult, isKnownCode, KNOWN_CODES } = require('./core/errors');
9
+ const { MCP_SERVER_NAME, MCP_TOOL_MANIFEST, getManifest, writeManifestFile } = require('./core/mcp-manifest');
8
10
 
9
11
  module.exports = {
10
12
  createAquifer,
@@ -12,4 +14,16 @@ module.exports = {
12
14
  createReranker,
13
15
  normalizeEntityName,
14
16
  parseEntitySection,
17
+ // Completion-capability error envelope (P1 foundation).
18
+ AqError,
19
+ ok,
20
+ err,
21
+ asResult,
22
+ isKnownCode,
23
+ KNOWN_CODES,
24
+ // MCP tool manifest — canonical for gateway in-process + CC cross-process.
25
+ MCP_SERVER_NAME,
26
+ MCP_TOOL_MANIFEST,
27
+ getMcpManifest: getManifest,
28
+ writeMcpManifestFile: writeManifestFile,
15
29
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shadowforge0/aquifer-memory",
3
- "version": "1.2.1",
3
+ "version": "1.5.8",
4
4
  "description": "PG-native long-term memory for AI agents. Turn-level embedding, hybrid RRF ranking, optional knowledge graph. MCP server, CLI, and library API.",
5
5
  "main": "index.js",
6
6
  "files": [
@@ -0,0 +1,205 @@
1
+ 'use strict';
2
+
3
+ // Extract temporal state-change facts from session content.
4
+ //
5
+ // Input: message array + entity context (name→id map) + LLM.
6
+ // Output: array of change objects ready to feed entity-state.applyChanges().
7
+ //
8
+ // Strict rules baked into the prompt:
9
+ // - Only "已發生 / past-tense / 完成式" transitions — reject tentative
10
+ // ("I might / I was thinking about / let's consider").
11
+ // - Must have explicit time anchor ("on 2026-04-18", "as of today",
12
+ // "this morning") — tag to session started_at if only "now".
13
+ // - attribute must be stable snake_case path (version.stable,
14
+ // editor.preference, runtime.node.version).
15
+ // - value must be JSON-serialisable (strings, numbers, bools, nested OK).
16
+ // - confidence ∈ [0,1]; default 0.7, any < threshold is dropped by caller.
17
+
18
+ const ATTRIBUTE_RE = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$/;
19
+
20
+ function defaultStateChangePrompt(messages, ctx = {}) {
21
+ const conversation = messages
22
+ .map(m => `[${m.role}] ${typeof m.content === 'string' ? m.content : JSON.stringify(m.content)}`)
23
+ .join('\n');
24
+ const entityList = ctx.entities && ctx.entities.length
25
+ ? ctx.entities.map(e => ` - "${e.name}" (id=${e.id})`).join('\n')
26
+ : ' (no entities resolved yet)';
27
+ const sessionTime = ctx.sessionStartedAt || new Date().toISOString();
28
+
29
+ return `You extract TEMPORAL STATE-CHANGE FACTS from a conversation.
30
+ A state change means "this specific attribute of this specific entity CHANGED its value at a specific moment."
31
+
32
+ ## Strict rules
33
+
34
+ 1. Only extract CHANGES — not first-observations, not opinions, not preferences merely mentioned.
35
+ 2. Only PAST-TENSE / COMPLETED transitions. Reject tentative language:
36
+ - REJECT: "I might try", "I was thinking about", "let's consider", "maybe", "probably", "planning to"
37
+ - ACCEPT: "I upgraded", "switched to", "changed to", "升到", "改成", "換成", "現在用", "已經改"
38
+ 3. Must have explicit TIME ANCHOR — exact date, "today", "this morning", "as of", "自 X 起"
39
+ If only implicit "now", use ctx.sessionStartedAt as valid_from.
40
+ 4. attribute MUST be a STABLE snake_case path (lowercase, dots as separators):
41
+ - GOOD: version.stable, editor.preference, runtime.node.version, indexing.pgvector.strategy
42
+ - BAD: "Version Stable", "My Editor", "editor-pref"
43
+ 5. Each change MUST match an entity in the list below by entity_name (exact match preferred, alias OK).
44
+ If no matching entity exists, DROP the change silently.
45
+ 6. value must be JSON-serialisable. Wrap scalars plain (e.g. "1.3.0"), objects as {key: v}.
46
+
47
+ ## Output
48
+
49
+ Emit ONE JSON object, no prose, no code fence, no commentary:
50
+
51
+ {
52
+ "state_changes": [
53
+ {
54
+ "entity_name": "<must match list>",
55
+ "attribute": "<snake_case.dotted.path>",
56
+ "value": <any JSON>,
57
+ "valid_from": "<ISO8601 timestamp>",
58
+ "time_anchor_text": "<the phrase that anchors the time>",
59
+ "evidence_text": "<the sentence that states the change, <= 240 chars>",
60
+ "confidence": <0..1>
61
+ }
62
+ ]
63
+ }
64
+
65
+ If no changes, output: {"state_changes": []}
66
+
67
+ ## Entities in scope
68
+
69
+ ${entityList}
70
+
71
+ ## Session started at: ${sessionTime}
72
+
73
+ ## Conversation
74
+
75
+ ${conversation}
76
+ `;
77
+ }
78
+
79
+ // Attempt to recover a JSON object from LLM output — some models wrap in
80
+ // code fences, some prepend "Here is the JSON:" etc. Tolerant but strict
81
+ // about the resulting shape.
82
+ function extractJsonBlock(text) {
83
+ if (!text || typeof text !== 'string') return null;
84
+ // Strip triple-backtick fences if present.
85
+ let s = text.trim();
86
+ const fenceMatch = s.match(/```(?:json)?\s*([\s\S]*?)```/);
87
+ if (fenceMatch) s = fenceMatch[1].trim();
88
+ // Take the substring from the first { to the last }.
89
+ const first = s.indexOf('{');
90
+ const last = s.lastIndexOf('}');
91
+ if (first < 0 || last < first) return null;
92
+ const candidate = s.slice(first, last + 1);
93
+ try {
94
+ return JSON.parse(candidate);
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ // Normalize one raw LLM-emitted change. Returns { entityName, ... } with the
101
+ // human-facing name intact — resolution to entity_id happens in the caller
102
+ // (enrich) after entity upsert, so state extraction itself doesn't need
103
+ // a populated id lookup.
104
+ function normalizeChange(raw, ctx) {
105
+ if (!raw || typeof raw !== 'object') return null;
106
+ const name = typeof raw.entity_name === 'string' ? raw.entity_name.trim() : null;
107
+ if (!name) return null;
108
+
109
+ // If a scope whitelist is passed, reject names not on it (case-insensitive).
110
+ if (ctx.scopeNames && !ctx.scopeNames.has(name.toLowerCase())) return null;
111
+
112
+ const attribute = typeof raw.attribute === 'string' ? raw.attribute.trim() : '';
113
+ if (!ATTRIBUTE_RE.test(attribute)) return null;
114
+
115
+ if (raw.value === undefined) return null; // explicit null is OK
116
+
117
+ const validFromDate = new Date(raw.valid_from || raw.validFrom || ctx.sessionStartedAt);
118
+ if (!Number.isFinite(validFromDate.getTime())) return null;
119
+
120
+ let confidence = raw.confidence;
121
+ if (typeof confidence !== 'number' || !Number.isFinite(confidence)) confidence = 0.7;
122
+ if (confidence < 0) confidence = 0;
123
+ if (confidence > 1) confidence = 1;
124
+
125
+ const evidenceText = typeof raw.evidence_text === 'string' ? raw.evidence_text.slice(0, 240) : '';
126
+
127
+ return {
128
+ entityName: name,
129
+ attribute,
130
+ value: raw.value,
131
+ validFrom: validFromDate.toISOString(),
132
+ evidenceText,
133
+ confidence,
134
+ source: 'llm',
135
+ evidenceSessionId: ctx.evidenceSessionId || null,
136
+ sessionRowId: ctx.sessionRowId ?? null,
137
+ };
138
+ }
139
+
140
+ async function extractStateChanges(messages, {
141
+ llmFn,
142
+ promptFn,
143
+ entities = [], // [{id, name, aliases?: []}]
144
+ sessionStartedAt,
145
+ evidenceSessionId,
146
+ sessionRowId,
147
+ confidenceThreshold = 0.7,
148
+ timeoutMs = 10000,
149
+ maxOutputTokens = 600,
150
+ logger,
151
+ } = {}) {
152
+ if (!llmFn) return { changes: [], warnings: ['no_llm'] };
153
+ if (!entities.length) return { changes: [], warnings: ['no_entities_in_scope'] };
154
+
155
+ // Build case-insensitive name whitelist (entity name + aliases).
156
+ const scopeNames = new Set();
157
+ for (const e of entities) {
158
+ if (!e || !e.name) continue;
159
+ scopeNames.add(String(e.name).toLowerCase());
160
+ for (const a of (e.aliases || [])) {
161
+ if (typeof a === 'string') scopeNames.add(a.toLowerCase());
162
+ }
163
+ }
164
+
165
+ const buildPrompt = promptFn || defaultStateChangePrompt;
166
+ const prompt = buildPrompt(messages, { entities, sessionStartedAt });
167
+
168
+ const warnings = [];
169
+ let rawResponse;
170
+ try {
171
+ // Simple timeout wrapper — llmFn signature in this repo is (prompt) => string.
172
+ rawResponse = await Promise.race([
173
+ llmFn(prompt, { maxTokens: maxOutputTokens }),
174
+ new Promise((_, rej) => setTimeout(() => rej(new Error('llm_timeout')), timeoutMs)),
175
+ ]);
176
+ } catch (e) {
177
+ if (logger && logger.warn) logger.warn(`[extract-state-changes] llm call failed: ${e.message}`);
178
+ return { changes: [], warnings: [`llm_error: ${e.message}`] };
179
+ }
180
+
181
+ const parsed = extractJsonBlock(rawResponse);
182
+ if (!parsed || !Array.isArray(parsed.state_changes)) {
183
+ if (logger && logger.warn) logger.warn(`[extract-state-changes] malformed output, dropping batch`);
184
+ return { changes: [], warnings: ['malformed_json'] };
185
+ }
186
+
187
+ const ctx = { scopeNames, sessionStartedAt, evidenceSessionId, sessionRowId };
188
+ const changes = [];
189
+ let dropped = 0;
190
+ for (const raw of parsed.state_changes) {
191
+ const n = normalizeChange(raw, ctx);
192
+ if (!n) { dropped++; continue; }
193
+ if (n.confidence < confidenceThreshold) { dropped++; continue; }
194
+ changes.push(n);
195
+ }
196
+ if (dropped > 0) warnings.push(`dropped_${dropped}_invalid_or_low_confidence`);
197
+ return { changes, warnings };
198
+ }
199
+
200
+ module.exports = {
201
+ defaultStateChangePrompt,
202
+ extractJsonBlock,
203
+ normalizeChange,
204
+ extractStateChanges,
205
+ };