@shadowforge0/aquifer-memory 1.0.3 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -29
- package/consumers/claude-code.js +117 -0
- package/consumers/cli.js +28 -1
- package/consumers/default/daily-entries.js +196 -0
- package/consumers/default/index.js +282 -0
- package/consumers/default/prompts/summary.js +153 -0
- package/consumers/mcp.js +3 -23
- package/consumers/miranda/context-inject.js +119 -0
- package/consumers/miranda/daily-entries.js +224 -0
- package/consumers/miranda/index.js +353 -0
- package/consumers/miranda/instance.js +55 -0
- package/consumers/miranda/llm.js +99 -0
- package/consumers/miranda/profile.json +145 -0
- package/consumers/miranda/prompts/summary.js +303 -0
- package/consumers/miranda/recall-format.js +74 -0
- package/consumers/miranda/render-daily-md.js +186 -0
- package/consumers/miranda/workspace-files.js +91 -0
- package/consumers/openclaw-ext/index.js +38 -0
- package/consumers/openclaw-ext/openclaw.plugin.json +9 -0
- package/consumers/openclaw-ext/package.json +10 -0
- package/consumers/openclaw-plugin.js +66 -74
- package/consumers/opencode.js +21 -24
- package/consumers/shared/autodetect.js +64 -0
- package/consumers/shared/entity-parser.js +119 -0
- package/consumers/shared/ingest.js +148 -0
- package/consumers/shared/llm-autodetect.js +137 -0
- package/consumers/shared/normalize.js +129 -0
- package/consumers/shared/recall-format.js +110 -0
- package/core/aquifer.js +209 -71
- 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.js +1 -3
- package/core/errors.js +97 -0
- package/core/handoff.js +153 -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 +86 -28
- package/core/timeline.js +152 -0
- package/docs/postprocess-contract.md +132 -0
- package/index.js +23 -1
- package/package.json +23 -2
- package/pipeline/_http.js +1 -1
- package/pipeline/consolidation/apply.js +176 -0
- package/pipeline/consolidation/index.js +21 -0
- package/pipeline/extract-entities.js +2 -2
- package/pipeline/rerank.js +1 -1
- package/pipeline/summarize.js +4 -1
- package/schema/001-base.sql +61 -24
- package/schema/002-entities.sql +17 -3
- package/schema/004-completion.sql +375 -0
- package/schema/004-facts.sql +67 -0
- package/scripts/diagnose-fts-zh.js +168 -134
- package/scripts/diagnose-vector.js +188 -0
- package/scripts/install-openclaw.sh +59 -0
- package/scripts/smoke.mjs +2 -2
package/core/state.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// aq.state.* — latest-snapshot-per-scope session state capability.
|
|
4
|
+
//
|
|
5
|
+
// Spec: aquifer-completion §7 sessionState. Strict core table is
|
|
6
|
+
// ${schema}.session_states. Default shape
|
|
7
|
+
// { goal, active_work, blockers, affect } is projected to explicit
|
|
8
|
+
// columns for cheap filtering; full payload also stored as JSONB so
|
|
9
|
+
// consumer-specific fields are lossless.
|
|
10
|
+
//
|
|
11
|
+
// write() supersedes the prior is_latest=true row for the same
|
|
12
|
+
// (tenant, agent, scope_key) atomically. idempotencyKey replay returns
|
|
13
|
+
// the existing row unchanged. Partial unique index enforces at-most-one
|
|
14
|
+
// latest per scope.
|
|
15
|
+
|
|
16
|
+
const crypto = require('crypto');
|
|
17
|
+
const { AqError, ok, err } = require('./errors');
|
|
18
|
+
|
|
19
|
+
const DEFAULT_PROFILE = Object.freeze({
|
|
20
|
+
id: 'anon',
|
|
21
|
+
version: 0,
|
|
22
|
+
schemaHash: 'pending',
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function resolveProfile(profile) {
|
|
26
|
+
if (!profile) return DEFAULT_PROFILE;
|
|
27
|
+
return {
|
|
28
|
+
id: profile.id || DEFAULT_PROFILE.id,
|
|
29
|
+
version: Number.isInteger(profile.version) ? profile.version : DEFAULT_PROFILE.version,
|
|
30
|
+
schemaHash: profile.schemaHash || DEFAULT_PROFILE.schemaHash,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function toNumber(v) {
|
|
35
|
+
if (v === null || v === undefined) return null;
|
|
36
|
+
const n = Number(v);
|
|
37
|
+
return Number.isFinite(n) ? n : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function defaultIdempotencyKey({ tenantId, agentId, scopeKey, payload }) {
|
|
41
|
+
return crypto.createHash('sha256')
|
|
42
|
+
.update(`${tenantId}:${agentId}:${scopeKey}:${JSON.stringify(payload)}`)
|
|
43
|
+
.digest('hex');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function mapRow(row) {
|
|
47
|
+
if (!row) return null;
|
|
48
|
+
return {
|
|
49
|
+
stateId: toNumber(row.id),
|
|
50
|
+
agentId: row.agent_id,
|
|
51
|
+
scopeKey: row.scope_key,
|
|
52
|
+
payload: row.payload || {},
|
|
53
|
+
isLatest: row.is_latest,
|
|
54
|
+
supersedesStateId: toNumber(row.supersedes_state_id),
|
|
55
|
+
createdAt: row.created_at,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function createState({ pool, schema, defaultTenantId }) {
|
|
60
|
+
async function write(input) {
|
|
61
|
+
try {
|
|
62
|
+
if (!input || typeof input !== 'object') {
|
|
63
|
+
return err('AQ_INVALID_INPUT', 'write requires an input object');
|
|
64
|
+
}
|
|
65
|
+
if (!input.agentId) return err('AQ_INVALID_INPUT', 'agentId is required');
|
|
66
|
+
if (!input.payload || typeof input.payload !== 'object') {
|
|
67
|
+
return err('AQ_INVALID_INPUT', 'payload is required');
|
|
68
|
+
}
|
|
69
|
+
const tenantId = input.tenantId || defaultTenantId || 'default';
|
|
70
|
+
const agentId = input.agentId;
|
|
71
|
+
const scopeKey = input.scopeKey || agentId;
|
|
72
|
+
const payload = input.payload;
|
|
73
|
+
const profile = resolveProfile(input.profile);
|
|
74
|
+
const idempotencyKey = input.idempotencyKey
|
|
75
|
+
|| defaultIdempotencyKey({ tenantId, agentId, scopeKey, payload });
|
|
76
|
+
|
|
77
|
+
// Projected columns for cheap filtering/indexes. Fall back cleanly
|
|
78
|
+
// when payload uses consumer-specific shape.
|
|
79
|
+
const goal = typeof payload.goal === 'string' ? payload.goal : null;
|
|
80
|
+
const activeWork = Array.isArray(payload.active_work) ? payload.active_work : [];
|
|
81
|
+
const blockers = Array.isArray(payload.blockers) ? payload.blockers : [];
|
|
82
|
+
const affect = payload.affect && typeof payload.affect === 'object' ? payload.affect : {};
|
|
83
|
+
|
|
84
|
+
const client = await pool.connect();
|
|
85
|
+
try {
|
|
86
|
+
await client.query('BEGIN');
|
|
87
|
+
|
|
88
|
+
const existing = await client.query(
|
|
89
|
+
`SELECT * FROM ${schema}.session_states WHERE idempotency_key = $1`,
|
|
90
|
+
[idempotencyKey],
|
|
91
|
+
);
|
|
92
|
+
if (existing.rowCount > 0) {
|
|
93
|
+
await client.query('COMMIT');
|
|
94
|
+
return ok(mapRow(existing.rows[0]));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const prev = await client.query(
|
|
98
|
+
`UPDATE ${schema}.session_states
|
|
99
|
+
SET is_latest = false
|
|
100
|
+
WHERE tenant_id = $1 AND agent_id = $2 AND scope_key = $3
|
|
101
|
+
AND is_latest = true
|
|
102
|
+
RETURNING id`,
|
|
103
|
+
[tenantId, agentId, scopeKey],
|
|
104
|
+
);
|
|
105
|
+
const supersedesStateId = prev.rowCount > 0 ? toNumber(prev.rows[0].id) : null;
|
|
106
|
+
|
|
107
|
+
const inserted = await client.query(
|
|
108
|
+
`INSERT INTO ${schema}.session_states (
|
|
109
|
+
tenant_id, agent_id, scope_key, source_session_id,
|
|
110
|
+
consumer_profile_id, consumer_profile_version, consumer_schema_hash,
|
|
111
|
+
idempotency_key, goal, active_work, blockers, affect, payload,
|
|
112
|
+
is_latest, supersedes_state_id
|
|
113
|
+
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, true, $14)
|
|
114
|
+
RETURNING *`,
|
|
115
|
+
[
|
|
116
|
+
tenantId, agentId, scopeKey, input.sessionId || null,
|
|
117
|
+
profile.id, profile.version, profile.schemaHash,
|
|
118
|
+
idempotencyKey, goal,
|
|
119
|
+
JSON.stringify(activeWork), JSON.stringify(blockers), JSON.stringify(affect),
|
|
120
|
+
JSON.stringify(payload), supersedesStateId,
|
|
121
|
+
],
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
await client.query('COMMIT');
|
|
125
|
+
return ok(mapRow(inserted.rows[0]));
|
|
126
|
+
} catch (e) {
|
|
127
|
+
await client.query('ROLLBACK').catch(() => {});
|
|
128
|
+
throw e;
|
|
129
|
+
} finally {
|
|
130
|
+
client.release();
|
|
131
|
+
}
|
|
132
|
+
} catch (e) {
|
|
133
|
+
if (e instanceof AqError) return err(e);
|
|
134
|
+
return err('AQ_INTERNAL', e.message, { cause: e });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function getLatest(input = {}) {
|
|
139
|
+
try {
|
|
140
|
+
if (!input.agentId) return err('AQ_INVALID_INPUT', 'agentId is required');
|
|
141
|
+
const tenantId = input.tenantId || defaultTenantId || 'default';
|
|
142
|
+
const scopeKey = input.scopeKey || input.agentId;
|
|
143
|
+
const { rows } = await pool.query(
|
|
144
|
+
`SELECT * FROM ${schema}.session_states
|
|
145
|
+
WHERE tenant_id = $1 AND agent_id = $2
|
|
146
|
+
AND scope_key = $3 AND is_latest = true
|
|
147
|
+
LIMIT 1`,
|
|
148
|
+
[tenantId, input.agentId, scopeKey],
|
|
149
|
+
);
|
|
150
|
+
const mapped = mapRow(rows[0]);
|
|
151
|
+
return ok({
|
|
152
|
+
state: mapped ? mapped.payload : null,
|
|
153
|
+
stateId: mapped ? mapped.stateId : null,
|
|
154
|
+
});
|
|
155
|
+
} catch (e) {
|
|
156
|
+
return err('AQ_INTERNAL', e.message, { cause: e });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return { write, getLatest };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = { createState };
|
package/core/storage.js
CHANGED
|
@@ -281,7 +281,10 @@ async function searchSessions(pool, query, {
|
|
|
281
281
|
FROM ${qi(schema)}.sessions s
|
|
282
282
|
LEFT JOIN ${qi(schema)}.session_summaries ss ON ss.session_row_id = s.id
|
|
283
283
|
WHERE ${where.join(' AND ')}
|
|
284
|
-
ORDER BY
|
|
284
|
+
ORDER BY
|
|
285
|
+
COALESCE(ss.search_text ILIKE '%' || $1 || '%', FALSE) DESC,
|
|
286
|
+
fts_rank DESC,
|
|
287
|
+
s.last_message_at DESC NULLS LAST
|
|
285
288
|
LIMIT $${params.length}`,
|
|
286
289
|
params
|
|
287
290
|
);
|
|
@@ -361,7 +364,6 @@ async function upsertTurnEmbeddings(pool, sessionRowId, {
|
|
|
361
364
|
}
|
|
362
365
|
|
|
363
366
|
// Batch insert: build multi-row VALUES clause
|
|
364
|
-
const COLS_PER_ROW = 10;
|
|
365
367
|
const valueClauses = [];
|
|
366
368
|
const params = [];
|
|
367
369
|
|
|
@@ -416,6 +418,16 @@ async function searchTurnEmbeddings(pool, {
|
|
|
416
418
|
source,
|
|
417
419
|
limit = 15,
|
|
418
420
|
}) {
|
|
421
|
+
// HNSW index fires only on `ORDER BY embedding <=> $vec LIMIT N` without
|
|
422
|
+
// additional predicates in the same query level. So the CTE does a plain
|
|
423
|
+
// nearest-neighbor scan (uses idx_turn_emb_embedding_hnsw at scale), then
|
|
424
|
+
// the outer SELECT applies tenant/agent/date/source filters and dedups.
|
|
425
|
+
//
|
|
426
|
+
// Filter narrowness may leave fewer than `limit` rows after post-filter;
|
|
427
|
+
// NN_OVERFETCH trades extra vector work for filter survival headroom.
|
|
428
|
+
const NN_OVERFETCH = 10;
|
|
429
|
+
const nnLimit = Math.max(50, limit * NN_OVERFETCH);
|
|
430
|
+
|
|
419
431
|
const where = ['s.tenant_id = $1'];
|
|
420
432
|
const params = [tenantId];
|
|
421
433
|
|
|
@@ -434,40 +446,70 @@ async function searchTurnEmbeddings(pool, {
|
|
|
434
446
|
}
|
|
435
447
|
if (agentIds) {
|
|
436
448
|
params.push(agentIds);
|
|
437
|
-
where.push(`
|
|
449
|
+
where.push(`s.agent_id = ANY($${params.length})`);
|
|
438
450
|
}
|
|
439
451
|
if (source) {
|
|
440
452
|
params.push(source);
|
|
441
|
-
where.push(`
|
|
453
|
+
where.push(`s.source = $${params.length}`);
|
|
442
454
|
}
|
|
443
455
|
|
|
444
456
|
params.push(`[${queryVec.join(',')}]`);
|
|
445
457
|
const vecPos = params.length;
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
params.push(limit * 3); // fetch more than needed for DISTINCT ON dedup
|
|
449
|
-
const innerLimitPos = params.length;
|
|
458
|
+
params.push(nnLimit);
|
|
459
|
+
const nnLimitPos = params.length;
|
|
450
460
|
|
|
451
461
|
const result = await pool.query(
|
|
452
|
-
`
|
|
453
|
-
SELECT
|
|
462
|
+
`WITH nn AS (
|
|
463
|
+
SELECT t.session_row_id, t.content_text, t.turn_index,
|
|
464
|
+
(t.embedding <=> $${vecPos}::vector) AS turn_distance
|
|
465
|
+
FROM ${qi(schema)}.turn_embeddings t
|
|
466
|
+
ORDER BY t.embedding <=> $${vecPos}::vector ASC
|
|
467
|
+
LIMIT $${nnLimitPos}
|
|
468
|
+
)
|
|
469
|
+
SELECT * FROM (
|
|
470
|
+
SELECT DISTINCT ON (nn.session_row_id)
|
|
454
471
|
s.session_id, s.id AS session_row_id, s.agent_id, s.source, s.started_at,
|
|
455
472
|
ss.summary_text, ss.structured_summary, ss.access_count, ss.last_accessed_at,
|
|
456
473
|
COALESCE(ss.trust_score, 0.5) AS trust_score,
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
FROM
|
|
460
|
-
JOIN ${qi(schema)}.sessions s ON s.id =
|
|
474
|
+
nn.content_text AS matched_turn_text, nn.turn_index AS matched_turn_index,
|
|
475
|
+
nn.turn_distance
|
|
476
|
+
FROM nn
|
|
477
|
+
JOIN ${qi(schema)}.sessions s ON s.id = nn.session_row_id
|
|
461
478
|
LEFT JOIN ${qi(schema)}.session_summaries ss ON ss.session_row_id = s.id
|
|
462
479
|
WHERE ${where.join(' AND ')}
|
|
463
|
-
ORDER BY
|
|
464
|
-
)
|
|
465
|
-
ORDER BY turn_distance ASC
|
|
466
|
-
LIMIT $${innerLimitPos}`,
|
|
480
|
+
ORDER BY nn.session_row_id, nn.turn_distance ASC
|
|
481
|
+
) dedup
|
|
482
|
+
ORDER BY turn_distance ASC`,
|
|
467
483
|
params
|
|
468
484
|
);
|
|
469
485
|
|
|
470
|
-
|
|
486
|
+
if (result.rows.length > 0) {
|
|
487
|
+
return { rows: result.rows.slice(0, limit) };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Fallback: HNSW-first path filtered out to nothing. This can happen when
|
|
491
|
+
// tenant/agent filters are narrow enough to eliminate every NN candidate.
|
|
492
|
+
// Pay the cost of a filter-first scan to guarantee we don't silently return
|
|
493
|
+
// empty when qualifying rows exist. No HNSW on this path — slower, correct.
|
|
494
|
+
const fallbackParams = params.slice(0, params.length - 1); // drop nnLimit
|
|
495
|
+
fallbackParams.push(limit);
|
|
496
|
+
const fallbackLimitPos = fallbackParams.length;
|
|
497
|
+
const fallback = await pool.query(
|
|
498
|
+
`SELECT DISTINCT ON (t.session_row_id)
|
|
499
|
+
s.session_id, s.id AS session_row_id, s.agent_id, s.source, s.started_at,
|
|
500
|
+
ss.summary_text, ss.structured_summary, ss.access_count, ss.last_accessed_at,
|
|
501
|
+
COALESCE(ss.trust_score, 0.5) AS trust_score,
|
|
502
|
+
t.content_text AS matched_turn_text, t.turn_index AS matched_turn_index,
|
|
503
|
+
(t.embedding <=> $${vecPos}::vector) AS turn_distance
|
|
504
|
+
FROM ${qi(schema)}.turn_embeddings t
|
|
505
|
+
JOIN ${qi(schema)}.sessions s ON s.id = t.session_row_id
|
|
506
|
+
LEFT JOIN ${qi(schema)}.session_summaries ss ON ss.session_row_id = s.id
|
|
507
|
+
WHERE ${where.join(' AND ')}
|
|
508
|
+
ORDER BY t.session_row_id, t.embedding <=> $${vecPos}::vector ASC
|
|
509
|
+
LIMIT $${fallbackLimitPos}`,
|
|
510
|
+
fallbackParams
|
|
511
|
+
);
|
|
512
|
+
return { rows: fallback.rows };
|
|
471
513
|
}
|
|
472
514
|
|
|
473
515
|
// ---------------------------------------------------------------------------
|
|
@@ -504,16 +546,32 @@ async function recordFeedback(pool, {
|
|
|
504
546
|
}
|
|
505
547
|
|
|
506
548
|
const trustBefore = parseFloat(current.rows[0].trust_score);
|
|
507
|
-
const trustAfter = verdict === 'helpful'
|
|
508
|
-
? Math.min(1.0, trustBefore + TRUST_UP)
|
|
509
|
-
: Math.max(0.0, trustBefore - TRUST_DOWN);
|
|
510
549
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
550
|
+
// Dedupe: the same (agent, verdict) applied more than once must not stack.
|
|
551
|
+
// Audit row is still inserted so the sequence of feedback events is
|
|
552
|
+
// preserved; only the trust_score delta is skipped.
|
|
553
|
+
const prior = await client.query(
|
|
554
|
+
`SELECT 1 FROM ${qi(schema)}.session_feedback
|
|
555
|
+
WHERE session_row_id = $1 AND agent_id = $2 AND verdict = $3
|
|
556
|
+
LIMIT 1`,
|
|
557
|
+
[sessionRowId, agentId, verdict]
|
|
516
558
|
);
|
|
559
|
+
const isDup = prior.rows.length > 0;
|
|
560
|
+
|
|
561
|
+
const trustAfter = isDup
|
|
562
|
+
? trustBefore
|
|
563
|
+
: (verdict === 'helpful'
|
|
564
|
+
? Math.min(1.0, trustBefore + TRUST_UP)
|
|
565
|
+
: Math.max(0.0, trustBefore - TRUST_DOWN));
|
|
566
|
+
|
|
567
|
+
if (!isDup) {
|
|
568
|
+
await client.query(
|
|
569
|
+
`UPDATE ${qi(schema)}.session_summaries
|
|
570
|
+
SET trust_score = $1, updated_at = now()
|
|
571
|
+
WHERE session_row_id = $2`,
|
|
572
|
+
[trustAfter, sessionRowId]
|
|
573
|
+
);
|
|
574
|
+
}
|
|
517
575
|
|
|
518
576
|
await client.query(
|
|
519
577
|
`INSERT INTO ${qi(schema)}.session_feedback
|
|
@@ -523,7 +581,7 @@ async function recordFeedback(pool, {
|
|
|
523
581
|
);
|
|
524
582
|
|
|
525
583
|
await client.query('COMMIT');
|
|
526
|
-
return { trustBefore, trustAfter, verdict };
|
|
584
|
+
return { trustBefore, trustAfter, verdict, duplicate: isDup };
|
|
527
585
|
} catch (err) {
|
|
528
586
|
await client.query('ROLLBACK').catch(() => {});
|
|
529
587
|
throw err;
|
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 };
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# `enrich({ postProcess })` Contract
|
|
2
|
+
|
|
3
|
+
`aquifer.enrich(sessionId, opts)` runs commit → summarize → embed → entity-extract → mark-status inside a single DB transaction. After the transaction commits and the client is released, if `opts.postProcess` was supplied, Aquifer invokes it once with a context object. This is how consumers hook persona-specific side-effects (daily logs, workspace files, consolidation, narrative regen, metrics) without mutating core.
|
|
4
|
+
|
|
5
|
+
**Stability**: stable in 1.x. Additive changes only (new ctx fields). No removals or breaking renames without a major bump.
|
|
6
|
+
|
|
7
|
+
## Signature
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
postProcess?: (ctx: PostProcessContext) => Promise<void>
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## When it runs
|
|
14
|
+
|
|
15
|
+
- **After** transaction commit and client release. The session row is already at its final status (`succeeded` or `partial`); nothing in postProcess can affect that.
|
|
16
|
+
- **At most once per enrich call**. No retry. If `postProcess` throws, the error is captured on the returned result as `postProcessError` (not re-thrown).
|
|
17
|
+
- Best-effort. The enrich call's return value resolves regardless of postProcess outcome.
|
|
18
|
+
|
|
19
|
+
## `ctx` shape
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
interface PostProcessContext {
|
|
23
|
+
session: {
|
|
24
|
+
id: number; // DB primary key (miranda.sessions.id)
|
|
25
|
+
sessionId: string; // caller-provided session key
|
|
26
|
+
agentId: string;
|
|
27
|
+
model: string | null;
|
|
28
|
+
source: string | null;
|
|
29
|
+
startedAt: string | null; // ISO-8601
|
|
30
|
+
endedAt: string | null; // ISO-8601
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// opts.model override, falling back to session.model. Handy for consumers
|
|
34
|
+
// that want to pass the runtime model into downstream consolidation prompts.
|
|
35
|
+
effectiveModel: string | null;
|
|
36
|
+
|
|
37
|
+
// Summary result, if summarize ran. Null when skipSummary or summary failed.
|
|
38
|
+
summary: {
|
|
39
|
+
summaryText: string;
|
|
40
|
+
structuredSummary: object | null; // custom summaryFn payload
|
|
41
|
+
} | null;
|
|
42
|
+
|
|
43
|
+
// Summary-level embedding vector (size = embed.dim). Null if embed skipped/failed.
|
|
44
|
+
embedding: number[] | null;
|
|
45
|
+
|
|
46
|
+
// Per-turn embedding vectors (one per user turn). Null if skipped/failed.
|
|
47
|
+
turnVectors: number[][] | null;
|
|
48
|
+
|
|
49
|
+
// Passthrough from customSummaryFn return { extra }. Consumers use this to
|
|
50
|
+
// smuggle intermediate results (recap/sections/workingFacts) from summaryFn
|
|
51
|
+
// into postProcess without recomputing.
|
|
52
|
+
extra: any;
|
|
53
|
+
|
|
54
|
+
// Messages used for embedding/entity extraction. Same array commit() saw.
|
|
55
|
+
normalized: Array<{ role: string; content: string; timestamp?: string }>;
|
|
56
|
+
|
|
57
|
+
// Parsed entities from entityParseFn (or built-in parser).
|
|
58
|
+
parsedEntities: Array<{ name: string; normalizedName: string; aliases: string[]; type: string }>;
|
|
59
|
+
|
|
60
|
+
// Which pipeline steps ran.
|
|
61
|
+
skipped: { summary: boolean; entities: boolean; turns: boolean };
|
|
62
|
+
|
|
63
|
+
// Counts from the tx.
|
|
64
|
+
turnsEmbedded: number;
|
|
65
|
+
entitiesFound: number;
|
|
66
|
+
|
|
67
|
+
// Non-fatal failures collected inside enrich. Defensive copy — mutating this
|
|
68
|
+
// array does NOT affect enrich's own warnings list.
|
|
69
|
+
warnings: string[];
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Typical usage
|
|
74
|
+
|
|
75
|
+
```js
|
|
76
|
+
const result = await aquifer.enrich(sessionId, {
|
|
77
|
+
agentId: 'main',
|
|
78
|
+
summaryFn: async (msgs) => {
|
|
79
|
+
const output = await callLlm(buildPrompt({ msgs }));
|
|
80
|
+
const sections = parseSummaryOutput(output);
|
|
81
|
+
const recap = parseRecapLines(sections.recap);
|
|
82
|
+
return {
|
|
83
|
+
summaryText: recap.overview || '',
|
|
84
|
+
structuredSummary: recap,
|
|
85
|
+
entityRaw: sections.entities || null,
|
|
86
|
+
extra: { sections, recap, workingFacts: parseWorkingFacts(sections.working_facts) },
|
|
87
|
+
};
|
|
88
|
+
},
|
|
89
|
+
entityParseFn: (text) => parseEntitySection(text).entities,
|
|
90
|
+
postProcess: async (ctx) => {
|
|
91
|
+
const recap = ctx.extra?.recap;
|
|
92
|
+
const sections = ctx.extra?.sections;
|
|
93
|
+
const workingFacts = ctx.extra?.workingFacts || [];
|
|
94
|
+
|
|
95
|
+
// Daily log
|
|
96
|
+
if (recap || sections) {
|
|
97
|
+
await writeDailyEntries({ recap, sections, sessionId: ctx.session.sessionId, agentId: ctx.session.agentId });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Write fact candidates (consumer-specific table, not in Aquifer schema)
|
|
101
|
+
if (workingFacts.length > 0) {
|
|
102
|
+
await writeFactCandidates({ facts: workingFacts, sessionId: ctx.session.sessionId });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Consolidation (optional — requires enableFacts())
|
|
106
|
+
if (recap) {
|
|
107
|
+
const prompt = buildConsolidationPrompt({ recap, activeFacts, candidates, currentNarrative });
|
|
108
|
+
const output = await callLlm(prompt);
|
|
109
|
+
const { actions } = parseConsolidationOutput(output);
|
|
110
|
+
if (actions.length > 0) {
|
|
111
|
+
await aquifer.consolidate(ctx.session.sessionId, { actions, agentId: ctx.session.agentId });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
if (result.postProcessError) {
|
|
118
|
+
logger.warn(`postProcess failed: ${result.postProcessError.message}`);
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## What NOT to do in postProcess
|
|
123
|
+
|
|
124
|
+
- Don't throw as a signal of "enrich should have failed" — enrich is already committed. Use warnings or a separate audit table.
|
|
125
|
+
- Don't mutate `ctx.normalized`, `ctx.parsedEntities`, or `ctx.warnings`. They're shared-reference with the enrich return; defensive copy if you need to modify.
|
|
126
|
+
- Don't rely on postProcess running quickly — it's outside the tx. Long-running work should be fire-and-forget (see Miranda's `setImmediate` consolidation) or queued.
|
|
127
|
+
|
|
128
|
+
## What Aquifer guarantees
|
|
129
|
+
|
|
130
|
+
- `postProcess` receives the same `session` row the tx wrote. No stale reads.
|
|
131
|
+
- If enrich's tx rolls back, postProcess is NOT called.
|
|
132
|
+
- If postProcess throws, the error is on `result.postProcessError`. The session status is unaffected.
|
package/index.js
CHANGED
|
@@ -3,5 +3,27 @@
|
|
|
3
3
|
const { createAquifer } = require('./core/aquifer');
|
|
4
4
|
const { createEmbedder } = require('./pipeline/embed');
|
|
5
5
|
const { createReranker } = require('./pipeline/rerank');
|
|
6
|
+
const { normalizeEntityName } = require('./core/entity');
|
|
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');
|
|
6
10
|
|
|
7
|
-
module.exports = {
|
|
11
|
+
module.exports = {
|
|
12
|
+
createAquifer,
|
|
13
|
+
createEmbedder,
|
|
14
|
+
createReranker,
|
|
15
|
+
normalizeEntityName,
|
|
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,
|
|
29
|
+
};
|