@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.
- package/README.md +8 -9
- package/consumers/cli.js +11 -1
- package/consumers/default/index.js +17 -4
- package/consumers/mcp.js +21 -0
- package/consumers/miranda/index.js +15 -4
- package/consumers/miranda/profile.json +145 -0
- package/consumers/miranda/recall-format.js +5 -3
- package/consumers/miranda/render-daily-md.js +186 -0
- package/consumers/shared/config.js +8 -0
- package/consumers/shared/factory.js +2 -1
- package/consumers/shared/llm.js +1 -1
- package/consumers/shared/recall-format.js +21 -1
- package/core/aquifer.js +693 -87
- package/core/artifacts.js +174 -0
- package/core/bundles.js +400 -0
- package/core/consolidation.js +340 -0
- package/core/decisions.js +164 -0
- package/core/entity-state.js +483 -0
- package/core/errors.js +97 -0
- package/core/handoff.js +153 -0
- package/core/insights.js +499 -0
- package/core/mcp-manifest.js +131 -0
- package/core/narratives.js +212 -0
- package/core/profiles.js +171 -0
- package/core/state.js +163 -0
- package/core/storage.js +82 -5
- package/core/timeline.js +152 -0
- package/index.js +14 -0
- package/package.json +1 -1
- package/pipeline/extract-state-changes.js +205 -0
- package/schema/001-base.sql +186 -16
- package/schema/002-entities.sql +35 -1
- package/schema/004-completion.sql +391 -0
- package/schema/005-entity-state-history.sql +87 -0
- package/schema/006-insights.sql +138 -0
- package/scripts/diagnose-fts-zh.js +37 -4
- package/scripts/drop-entity-state-history.sql +17 -0
- package/scripts/drop-insights.sql +12 -0
- package/scripts/extract-insights-from-recent-sessions.js +315 -0
- package/scripts/find-dburl-hints.js +29 -0
- package/scripts/queries.json +45 -0
- package/scripts/retro-recall-bench.js +409 -0
- 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
|
|
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 =
|
|
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('
|
|
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('
|
|
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
|
-
|
|
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
|
};
|
package/core/timeline.js
ADDED
|
@@ -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.
|
|
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
|
+
};
|