@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
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// aq.consolidation.* — session-level multi-phase orchestration.
|
|
4
|
+
//
|
|
5
|
+
// Spec: aquifer-completion §3 consolidationOrchestration. State lives in
|
|
6
|
+
// sessions.consolidation_phases JSONB keyed by phase name. 10 phases cover
|
|
7
|
+
// the post-session pipeline: summary_extract, entity_extract, fact_extract,
|
|
8
|
+
// fact_consolidation, narrative_refresh, decision_write, handoff_write,
|
|
9
|
+
// session_state_write, timeline_write, artifact_dispatch.
|
|
10
|
+
//
|
|
11
|
+
// Status vocabulary: pending|claimed|running|succeeded|failed|skipped.
|
|
12
|
+
// State transitions (enforced by transitionPhase):
|
|
13
|
+
// pending → claimed
|
|
14
|
+
// claimed → running|failed|skipped|claimed (stale reclaim only)
|
|
15
|
+
// running → succeeded|failed|claimed (stale reclaim only)
|
|
16
|
+
// failed → claimed (retry)
|
|
17
|
+
// succeeded|skipped → non-terminal requires forceReplay=true
|
|
18
|
+
//
|
|
19
|
+
// Advisory lock (pg_advisory_xact_lock on session_row_id) wraps the
|
|
20
|
+
// read-modify-write in a transaction so two workers can't claim the same
|
|
21
|
+
// session phase simultaneously.
|
|
22
|
+
|
|
23
|
+
const crypto = require('crypto');
|
|
24
|
+
const { AqError, ok, err } = require('./errors');
|
|
25
|
+
|
|
26
|
+
const PHASES = Object.freeze([
|
|
27
|
+
'summary_extract',
|
|
28
|
+
'entity_extract',
|
|
29
|
+
'fact_extract',
|
|
30
|
+
'fact_consolidation',
|
|
31
|
+
'narrative_refresh',
|
|
32
|
+
'decision_write',
|
|
33
|
+
'handoff_write',
|
|
34
|
+
'session_state_write',
|
|
35
|
+
'timeline_write',
|
|
36
|
+
'artifact_dispatch',
|
|
37
|
+
]);
|
|
38
|
+
const PHASE_SET = new Set(PHASES);
|
|
39
|
+
|
|
40
|
+
const STATUSES = Object.freeze([
|
|
41
|
+
'pending', 'claimed', 'running', 'succeeded', 'failed', 'skipped',
|
|
42
|
+
]);
|
|
43
|
+
const STATUS_SET = new Set(STATUSES);
|
|
44
|
+
|
|
45
|
+
const TERMINAL = new Set(['succeeded', 'skipped']);
|
|
46
|
+
|
|
47
|
+
// Valid transitions. Caller must specify fromStatus to guard against races.
|
|
48
|
+
const VALID_TRANSITIONS = {
|
|
49
|
+
pending: new Set(['claimed']),
|
|
50
|
+
claimed: new Set(['running', 'failed', 'skipped', 'claimed']),
|
|
51
|
+
running: new Set(['succeeded', 'failed', 'claimed']),
|
|
52
|
+
failed: new Set(['claimed']),
|
|
53
|
+
succeeded: new Set([]),
|
|
54
|
+
skipped: new Set([]),
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function toNumber(v) {
|
|
58
|
+
if (v === null || v === undefined) return null;
|
|
59
|
+
const n = Number(v);
|
|
60
|
+
return Number.isFinite(n) ? n : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function emptyPhaseState() {
|
|
64
|
+
return { status: 'pending', attempts: 0 };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function fillDefaults(phasesJson) {
|
|
68
|
+
const out = { ...(phasesJson || {}) };
|
|
69
|
+
for (const phase of PHASES) {
|
|
70
|
+
if (!out[phase]) out[phase] = emptyPhaseState();
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isStale(phaseState, staleAfterSeconds) {
|
|
76
|
+
if (phaseState.status !== 'claimed' && phaseState.status !== 'running') return false;
|
|
77
|
+
const startedAt = phaseState.startedAt;
|
|
78
|
+
if (!startedAt) return true;
|
|
79
|
+
const ageMs = Date.now() - new Date(startedAt).getTime();
|
|
80
|
+
return ageMs > staleAfterSeconds * 1000;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function newClaimToken() {
|
|
84
|
+
return crypto.randomBytes(12).toString('hex');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function advisoryLockKey(sessionRowId) {
|
|
88
|
+
// Map bigint-ish id to signed int4 range for pg_advisory_xact_lock.
|
|
89
|
+
const id = Number(sessionRowId);
|
|
90
|
+
return (id ^ 0x9e3779b9) & 0x7fffffff;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function createConsolidation({ pool, schema, defaultTenantId }) {
|
|
94
|
+
|
|
95
|
+
async function claimNext(input = {}) {
|
|
96
|
+
try {
|
|
97
|
+
if (!input.workerId) return err('AQ_INVALID_INPUT', 'workerId is required');
|
|
98
|
+
const tenantId = input.tenantId || defaultTenantId || 'default';
|
|
99
|
+
const phases = Array.isArray(input.phases) && input.phases.length > 0
|
|
100
|
+
? input.phases.filter(p => PHASE_SET.has(p))
|
|
101
|
+
: PHASES;
|
|
102
|
+
if (phases.length === 0) {
|
|
103
|
+
return err('AQ_INVALID_INPUT', 'phases filter produced empty list');
|
|
104
|
+
}
|
|
105
|
+
const staleAfterSeconds = Number.isFinite(input.staleAfterSeconds)
|
|
106
|
+
? Math.max(10, input.staleAfterSeconds)
|
|
107
|
+
: 600;
|
|
108
|
+
|
|
109
|
+
// Look at candidate sessions with at least one non-terminal phase,
|
|
110
|
+
// then iterate under advisory lock to claim atomically.
|
|
111
|
+
const candidates = await pool.query(
|
|
112
|
+
`SELECT id AS session_row_id, session_id, agent_id, processing_status,
|
|
113
|
+
consolidation_phases
|
|
114
|
+
FROM ${schema}.sessions
|
|
115
|
+
WHERE tenant_id = $1
|
|
116
|
+
ORDER BY id ASC
|
|
117
|
+
LIMIT 200`,
|
|
118
|
+
[tenantId],
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
for (const row of candidates.rows) {
|
|
122
|
+
const current = fillDefaults(row.consolidation_phases);
|
|
123
|
+
let targetPhase = null;
|
|
124
|
+
for (const p of phases) {
|
|
125
|
+
const st = current[p];
|
|
126
|
+
if (st.status === 'pending' || st.status === 'failed') {
|
|
127
|
+
targetPhase = p; break;
|
|
128
|
+
}
|
|
129
|
+
if ((st.status === 'claimed' || st.status === 'running')
|
|
130
|
+
&& isStale(st, staleAfterSeconds)) {
|
|
131
|
+
targetPhase = p; break;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (!targetPhase) continue;
|
|
135
|
+
|
|
136
|
+
const client = await pool.connect();
|
|
137
|
+
try {
|
|
138
|
+
await client.query('BEGIN');
|
|
139
|
+
await client.query('SELECT pg_advisory_xact_lock($1)',
|
|
140
|
+
[advisoryLockKey(row.session_row_id)]);
|
|
141
|
+
|
|
142
|
+
// Re-read under lock — another worker may have claimed in between.
|
|
143
|
+
const { rows: freshRows } = await client.query(
|
|
144
|
+
`SELECT consolidation_phases FROM ${schema}.sessions WHERE id = $1`,
|
|
145
|
+
[row.session_row_id],
|
|
146
|
+
);
|
|
147
|
+
const fresh = fillDefaults(freshRows[0] && freshRows[0].consolidation_phases);
|
|
148
|
+
const freshState = fresh[targetPhase];
|
|
149
|
+
const eligible =
|
|
150
|
+
freshState.status === 'pending'
|
|
151
|
+
|| freshState.status === 'failed'
|
|
152
|
+
|| ((freshState.status === 'claimed' || freshState.status === 'running')
|
|
153
|
+
&& isStale(freshState, staleAfterSeconds));
|
|
154
|
+
if (!eligible) {
|
|
155
|
+
await client.query('ROLLBACK');
|
|
156
|
+
client.release();
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const claimToken = newClaimToken();
|
|
161
|
+
const attempts = (freshState.attempts || 0) + 1;
|
|
162
|
+
fresh[targetPhase] = {
|
|
163
|
+
...freshState,
|
|
164
|
+
status: 'claimed',
|
|
165
|
+
claimToken,
|
|
166
|
+
workerId: input.workerId,
|
|
167
|
+
startedAt: new Date().toISOString(),
|
|
168
|
+
finishedAt: null,
|
|
169
|
+
attempts,
|
|
170
|
+
errorCode: null,
|
|
171
|
+
errorMessage: null,
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
await client.query(
|
|
175
|
+
`UPDATE ${schema}.sessions SET consolidation_phases = $1
|
|
176
|
+
WHERE id = $2`,
|
|
177
|
+
[JSON.stringify(fresh), row.session_row_id],
|
|
178
|
+
);
|
|
179
|
+
await client.query('COMMIT');
|
|
180
|
+
client.release();
|
|
181
|
+
|
|
182
|
+
return ok({
|
|
183
|
+
session: {
|
|
184
|
+
sessionRowId: toNumber(row.session_row_id),
|
|
185
|
+
sessionId: row.session_id,
|
|
186
|
+
agentId: row.agent_id,
|
|
187
|
+
processingStatus: row.processing_status,
|
|
188
|
+
phases: fresh,
|
|
189
|
+
},
|
|
190
|
+
claimToken,
|
|
191
|
+
claimedPhase: targetPhase,
|
|
192
|
+
});
|
|
193
|
+
} catch (e) {
|
|
194
|
+
await client.query('ROLLBACK').catch(() => {});
|
|
195
|
+
client.release();
|
|
196
|
+
throw e;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return ok({ session: null, claimToken: null, claimedPhase: null });
|
|
201
|
+
} catch (e) {
|
|
202
|
+
if (e instanceof AqError) return err(e);
|
|
203
|
+
return err('AQ_INTERNAL', e.message, { cause: e });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function transitionPhase(input = {}) {
|
|
208
|
+
try {
|
|
209
|
+
if (!input.sessionId) return err('AQ_INVALID_INPUT', 'sessionId is required');
|
|
210
|
+
if (!input.phase || !PHASE_SET.has(input.phase)) {
|
|
211
|
+
return err('AQ_INVALID_INPUT', `phase must be one of ${PHASES.join(', ')}`);
|
|
212
|
+
}
|
|
213
|
+
if (!input.fromStatus || !STATUS_SET.has(input.fromStatus)) {
|
|
214
|
+
return err('AQ_INVALID_INPUT', 'valid fromStatus is required');
|
|
215
|
+
}
|
|
216
|
+
if (!input.toStatus || !STATUS_SET.has(input.toStatus)) {
|
|
217
|
+
return err('AQ_INVALID_INPUT', 'valid toStatus is required');
|
|
218
|
+
}
|
|
219
|
+
const tenantId = input.tenantId || defaultTenantId || 'default';
|
|
220
|
+
|
|
221
|
+
const sessionRow = await pool.query(
|
|
222
|
+
`SELECT id FROM ${schema}.sessions WHERE tenant_id = $1 AND session_id = $2`,
|
|
223
|
+
[tenantId, input.sessionId],
|
|
224
|
+
);
|
|
225
|
+
if (sessionRow.rowCount === 0) {
|
|
226
|
+
return err('AQ_NOT_FOUND', `session ${input.sessionId} not found`);
|
|
227
|
+
}
|
|
228
|
+
const sessionRowId = sessionRow.rows[0].id;
|
|
229
|
+
|
|
230
|
+
const client = await pool.connect();
|
|
231
|
+
try {
|
|
232
|
+
await client.query('BEGIN');
|
|
233
|
+
await client.query('SELECT pg_advisory_xact_lock($1)',
|
|
234
|
+
[advisoryLockKey(sessionRowId)]);
|
|
235
|
+
|
|
236
|
+
const { rows } = await client.query(
|
|
237
|
+
`SELECT consolidation_phases FROM ${schema}.sessions WHERE id = $1`,
|
|
238
|
+
[sessionRowId],
|
|
239
|
+
);
|
|
240
|
+
const phases = fillDefaults(rows[0] && rows[0].consolidation_phases);
|
|
241
|
+
const current = phases[input.phase];
|
|
242
|
+
|
|
243
|
+
// Guard: fromStatus must match current.
|
|
244
|
+
if (current.status !== input.fromStatus) {
|
|
245
|
+
await client.query('ROLLBACK');
|
|
246
|
+
return err('AQ_PHASE_CLAIM_CONFLICT',
|
|
247
|
+
`phase ${input.phase} currently ${current.status}, not ${input.fromStatus}`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Guard: claimToken must match when transitioning from claimed/running.
|
|
251
|
+
if ((input.fromStatus === 'claimed' || input.fromStatus === 'running')
|
|
252
|
+
&& input.claimToken && current.claimToken !== input.claimToken) {
|
|
253
|
+
await client.query('ROLLBACK');
|
|
254
|
+
return err('AQ_PHASE_CLAIM_CONFLICT',
|
|
255
|
+
`claimToken mismatch for phase ${input.phase}`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Validate transition.
|
|
259
|
+
const allowed = VALID_TRANSITIONS[input.fromStatus] || new Set();
|
|
260
|
+
if (!allowed.has(input.toStatus)) {
|
|
261
|
+
// Terminal → non-terminal requires forceReplay.
|
|
262
|
+
const leavingTerminal = TERMINAL.has(input.fromStatus);
|
|
263
|
+
if (!(leavingTerminal && input.forceReplay === true)) {
|
|
264
|
+
await client.query('ROLLBACK');
|
|
265
|
+
return err('AQ_PHASE_TRANSITION_INVALID',
|
|
266
|
+
`cannot transition ${input.phase} from ${input.fromStatus} to ${input.toStatus}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const next = { ...current, status: input.toStatus };
|
|
271
|
+
if (input.toStatus === 'running') {
|
|
272
|
+
next.startedAt = current.startedAt || new Date().toISOString();
|
|
273
|
+
}
|
|
274
|
+
if (input.toStatus === 'succeeded' || input.toStatus === 'failed'
|
|
275
|
+
|| input.toStatus === 'skipped') {
|
|
276
|
+
next.finishedAt = new Date().toISOString();
|
|
277
|
+
if (input.toStatus === 'succeeded' || input.toStatus === 'skipped') {
|
|
278
|
+
next.errorCode = null;
|
|
279
|
+
next.errorMessage = null;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (input.toStatus === 'failed' && input.error) {
|
|
283
|
+
next.errorCode = input.error.code || 'AQ_INTERNAL';
|
|
284
|
+
next.errorMessage = input.error.message || '';
|
|
285
|
+
}
|
|
286
|
+
if (input.retryAfter) next.retryAfter = input.retryAfter;
|
|
287
|
+
if (input.idempotencyKey) next.idempotencyKey = input.idempotencyKey;
|
|
288
|
+
if (input.outputRef) next.outputRef = { ...(current.outputRef || {}), ...input.outputRef };
|
|
289
|
+
|
|
290
|
+
phases[input.phase] = next;
|
|
291
|
+
|
|
292
|
+
await client.query(
|
|
293
|
+
`UPDATE ${schema}.sessions SET consolidation_phases = $1 WHERE id = $2`,
|
|
294
|
+
[JSON.stringify(phases), sessionRowId],
|
|
295
|
+
);
|
|
296
|
+
await client.query('COMMIT');
|
|
297
|
+
|
|
298
|
+
return ok({
|
|
299
|
+
sessionId: input.sessionId,
|
|
300
|
+
phase: input.phase,
|
|
301
|
+
state: next,
|
|
302
|
+
});
|
|
303
|
+
} catch (e) {
|
|
304
|
+
await client.query('ROLLBACK').catch(() => {});
|
|
305
|
+
throw e;
|
|
306
|
+
} finally {
|
|
307
|
+
client.release();
|
|
308
|
+
}
|
|
309
|
+
} catch (e) {
|
|
310
|
+
if (e instanceof AqError) return err(e);
|
|
311
|
+
return err('AQ_INTERNAL', e.message, { cause: e });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function getState(input = {}) {
|
|
316
|
+
try {
|
|
317
|
+
if (!input.sessionId) return err('AQ_INVALID_INPUT', 'sessionId is required');
|
|
318
|
+
const tenantId = input.tenantId || defaultTenantId || 'default';
|
|
319
|
+
const { rows } = await pool.query(
|
|
320
|
+
`SELECT processing_status, consolidation_phases
|
|
321
|
+
FROM ${schema}.sessions
|
|
322
|
+
WHERE tenant_id = $1 AND session_id = $2`,
|
|
323
|
+
[tenantId, input.sessionId],
|
|
324
|
+
);
|
|
325
|
+
if (rows.length === 0) {
|
|
326
|
+
return err('AQ_NOT_FOUND', `session ${input.sessionId} not found`);
|
|
327
|
+
}
|
|
328
|
+
return ok({
|
|
329
|
+
processingStatus: rows[0].processing_status,
|
|
330
|
+
phases: fillDefaults(rows[0].consolidation_phases),
|
|
331
|
+
});
|
|
332
|
+
} catch (e) {
|
|
333
|
+
return err('AQ_INTERNAL', e.message, { cause: e });
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return { claimNext, transitionPhase, getState, PHASES, STATUSES };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
module.exports = { createConsolidation, PHASES, STATUSES };
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// aq.decisions.* — append-only decision log capability.
|
|
4
|
+
//
|
|
5
|
+
// Spec: aquifer-completion §9 decisionLog. status vocabulary
|
|
6
|
+
// (proposed/committed/reversed) enforced both at API layer (fast reject)
|
|
7
|
+
// and by DB CHECK constraint (defense in depth). reversal is implemented
|
|
8
|
+
// by appending a new 'reversed' decision and optionally pointing
|
|
9
|
+
// reversed_by_decision_id; Aquifer doesn't auto-compute the chain.
|
|
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
|
+
const VALID_STATUSES = new Set(['proposed', 'committed', 'reversed']);
|
|
21
|
+
|
|
22
|
+
function resolveProfile(profile) {
|
|
23
|
+
if (!profile) return DEFAULT_PROFILE;
|
|
24
|
+
return {
|
|
25
|
+
id: profile.id || DEFAULT_PROFILE.id,
|
|
26
|
+
version: Number.isInteger(profile.version) ? profile.version : DEFAULT_PROFILE.version,
|
|
27
|
+
schemaHash: profile.schemaHash || DEFAULT_PROFILE.schemaHash,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function toNumber(v) {
|
|
32
|
+
if (v === null || v === undefined) return null;
|
|
33
|
+
const n = Number(v);
|
|
34
|
+
return Number.isFinite(n) ? n : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function defaultIdempotencyKey({ tenantId, agentId, sessionId, payload }) {
|
|
38
|
+
return crypto.createHash('sha256')
|
|
39
|
+
.update(`${tenantId}:${agentId}:${sessionId}:${JSON.stringify(payload)}`)
|
|
40
|
+
.digest('hex');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function mapRow(row) {
|
|
44
|
+
if (!row) return null;
|
|
45
|
+
return {
|
|
46
|
+
decisionId: toNumber(row.id),
|
|
47
|
+
sessionId: row.source_session_id,
|
|
48
|
+
agentId: row.agent_id,
|
|
49
|
+
status: row.status,
|
|
50
|
+
decisionText: row.decision_text,
|
|
51
|
+
reasonText: row.reason_text,
|
|
52
|
+
payload: row.payload || {},
|
|
53
|
+
metadata: row.metadata || {},
|
|
54
|
+
decidedAt: row.decided_at,
|
|
55
|
+
reversedByDecisionId: toNumber(row.reversed_by_decision_id),
|
|
56
|
+
createdAt: row.created_at,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function createDecisions({ pool, schema, defaultTenantId }) {
|
|
61
|
+
async function append(input) {
|
|
62
|
+
try {
|
|
63
|
+
if (!input || typeof input !== 'object') {
|
|
64
|
+
return err('AQ_INVALID_INPUT', 'append requires an input object');
|
|
65
|
+
}
|
|
66
|
+
if (!input.agentId) return err('AQ_INVALID_INPUT', 'agentId is required');
|
|
67
|
+
if (!input.sessionId) return err('AQ_INVALID_INPUT', 'sessionId is required');
|
|
68
|
+
if (!input.payload || typeof input.payload !== 'object') {
|
|
69
|
+
return err('AQ_INVALID_INPUT', 'payload is required');
|
|
70
|
+
}
|
|
71
|
+
const tenantId = input.tenantId || defaultTenantId || 'default';
|
|
72
|
+
const agentId = input.agentId;
|
|
73
|
+
const sessionId = input.sessionId;
|
|
74
|
+
const payload = input.payload;
|
|
75
|
+
const profile = resolveProfile(input.profile);
|
|
76
|
+
|
|
77
|
+
const status = payload.status || 'committed';
|
|
78
|
+
if (!VALID_STATUSES.has(status)) {
|
|
79
|
+
return err('AQ_INVALID_INPUT',
|
|
80
|
+
`status must be one of ${Array.from(VALID_STATUSES).join(', ')}`);
|
|
81
|
+
}
|
|
82
|
+
const decisionText = typeof payload.decision === 'string'
|
|
83
|
+
? payload.decision
|
|
84
|
+
: (typeof payload.decision_text === 'string' ? payload.decision_text : null);
|
|
85
|
+
if (!decisionText) {
|
|
86
|
+
return err('AQ_INVALID_INPUT', 'payload.decision (or decision_text) is required');
|
|
87
|
+
}
|
|
88
|
+
const reasonText = typeof payload.reason === 'string'
|
|
89
|
+
? payload.reason
|
|
90
|
+
: (typeof payload.reason_text === 'string' ? payload.reason_text : null);
|
|
91
|
+
|
|
92
|
+
const idempotencyKey = input.idempotencyKey
|
|
93
|
+
|| defaultIdempotencyKey({ tenantId, agentId, sessionId, payload });
|
|
94
|
+
|
|
95
|
+
const insertResult = await pool.query(
|
|
96
|
+
`INSERT INTO ${schema}.decisions (
|
|
97
|
+
tenant_id, agent_id, source_session_id,
|
|
98
|
+
consumer_profile_id, consumer_profile_version, consumer_schema_hash,
|
|
99
|
+
idempotency_key, payload, status, decision_text, reason_text,
|
|
100
|
+
decided_at, metadata
|
|
101
|
+
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11,
|
|
102
|
+
COALESCE($12::timestamptz, now()), $13)
|
|
103
|
+
ON CONFLICT (idempotency_key) DO NOTHING
|
|
104
|
+
RETURNING *`,
|
|
105
|
+
[
|
|
106
|
+
tenantId, agentId, sessionId,
|
|
107
|
+
profile.id, profile.version, profile.schemaHash,
|
|
108
|
+
idempotencyKey, JSON.stringify(payload), status,
|
|
109
|
+
decisionText, reasonText,
|
|
110
|
+
input.decidedAt || null,
|
|
111
|
+
JSON.stringify(payload.metadata || {}),
|
|
112
|
+
],
|
|
113
|
+
);
|
|
114
|
+
let row = insertResult.rows[0];
|
|
115
|
+
if (!row) {
|
|
116
|
+
const existing = await pool.query(
|
|
117
|
+
`SELECT * FROM ${schema}.decisions WHERE idempotency_key = $1`,
|
|
118
|
+
[idempotencyKey],
|
|
119
|
+
);
|
|
120
|
+
row = existing.rows[0];
|
|
121
|
+
}
|
|
122
|
+
const mapped = mapRow(row);
|
|
123
|
+
return ok({ decisionId: mapped.decisionId, payload: mapped.payload });
|
|
124
|
+
} catch (e) {
|
|
125
|
+
if (e instanceof AqError) return err(e);
|
|
126
|
+
return err('AQ_INTERNAL', e.message, { cause: e });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function list(input = {}) {
|
|
131
|
+
try {
|
|
132
|
+
if (!input.agentId) return err('AQ_INVALID_INPUT', 'agentId is required');
|
|
133
|
+
const tenantId = input.tenantId || defaultTenantId || 'default';
|
|
134
|
+
const limit = Math.min(Math.max(input.limit || 50, 1), 500);
|
|
135
|
+
|
|
136
|
+
const params = [tenantId, input.agentId];
|
|
137
|
+
let where = 'tenant_id = $1 AND agent_id = $2';
|
|
138
|
+
if (Array.isArray(input.statuses) && input.statuses.length > 0) {
|
|
139
|
+
params.push(input.statuses);
|
|
140
|
+
where += ` AND status = ANY($${params.length})`;
|
|
141
|
+
}
|
|
142
|
+
if (input.sessionId) {
|
|
143
|
+
params.push(input.sessionId);
|
|
144
|
+
where += ` AND source_session_id = $${params.length}`;
|
|
145
|
+
}
|
|
146
|
+
params.push(limit);
|
|
147
|
+
|
|
148
|
+
const { rows } = await pool.query(
|
|
149
|
+
`SELECT * FROM ${schema}.decisions
|
|
150
|
+
WHERE ${where}
|
|
151
|
+
ORDER BY decided_at DESC, id DESC
|
|
152
|
+
LIMIT $${params.length}`,
|
|
153
|
+
params,
|
|
154
|
+
);
|
|
155
|
+
return ok({ rows: rows.map(mapRow) });
|
|
156
|
+
} catch (e) {
|
|
157
|
+
return err('AQ_INTERNAL', e.message, { cause: e });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return { append, list };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
module.exports = { createDecisions };
|
package/core/entity.js
CHANGED
|
@@ -236,7 +236,6 @@ async function upsertEntityRelations(pool, {
|
|
|
236
236
|
if (validPairs.length === 0) return { upserted: 0 };
|
|
237
237
|
|
|
238
238
|
// Batch insert: multi-row VALUES
|
|
239
|
-
const COLS_PER_ROW = 3;
|
|
240
239
|
const valueClauses = [];
|
|
241
240
|
const params = [];
|
|
242
241
|
|
|
@@ -387,8 +386,7 @@ async function resolveEntities(pool, {
|
|
|
387
386
|
if (!normQ || seen.has(normQ)) continue;
|
|
388
387
|
seen.set(normQ, true);
|
|
389
388
|
|
|
390
|
-
|
|
391
|
-
const result = await pool.query(
|
|
389
|
+
const result = await pool.query(
|
|
392
390
|
`SELECT id, name, normalized_name
|
|
393
391
|
FROM ${qi(schema)}.entities
|
|
394
392
|
WHERE status = 'active'
|
package/core/errors.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// AqError / AqResult — canonical error and result envelope for the
|
|
4
|
+
// completion-capability API surface (P1 foundation).
|
|
5
|
+
//
|
|
6
|
+
// Scope: NEW capability methods only (aq.narratives.*, aq.facts.*,
|
|
7
|
+
// aq.consolidation.*, aq.profiles.*, aq.timeline.*, etc.).
|
|
8
|
+
// Legacy APIs (commit/enrich/recall/migrate) keep throw semantics
|
|
9
|
+
// until a 2.0 major. See aquifer-completion define §audit.
|
|
10
|
+
//
|
|
11
|
+
// Shape mirrors the spec:
|
|
12
|
+
// type AqResult<T> = { ok: true, data: T } | { ok: false, error: AqError };
|
|
13
|
+
//
|
|
14
|
+
// AqError is a plain subclass of Error that carries a stable `code`,
|
|
15
|
+
// an optional `details` bag, and a `retryable` flag so transport-layer
|
|
16
|
+
// retries (cc-afterburn, gateway afterburn) can make routing decisions
|
|
17
|
+
// without string-matching messages.
|
|
18
|
+
|
|
19
|
+
const KNOWN_CODES = new Set([
|
|
20
|
+
// Generic
|
|
21
|
+
'AQ_INVALID_INPUT',
|
|
22
|
+
'AQ_NOT_FOUND',
|
|
23
|
+
'AQ_CONFLICT',
|
|
24
|
+
'AQ_INTERNAL',
|
|
25
|
+
'AQ_DEPENDENCY',
|
|
26
|
+
// Consolidation orchestration
|
|
27
|
+
'AQ_PHASE_CLAIM_CONFLICT',
|
|
28
|
+
'AQ_PHASE_TRANSITION_INVALID',
|
|
29
|
+
// Schema registry / profile
|
|
30
|
+
'AQ_PROFILE_NOT_FOUND',
|
|
31
|
+
'AQ_PROFILE_MARKER_MISMATCH',
|
|
32
|
+
// Bundle
|
|
33
|
+
'AQ_IMPORT_CONFLICT',
|
|
34
|
+
// Facts / narratives lifecycle
|
|
35
|
+
'AQ_FACT_SUPERSEDED',
|
|
36
|
+
'AQ_NARRATIVE_SUPERSEDED',
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
class AqError extends Error {
|
|
40
|
+
constructor(code, message, opts = {}) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.name = 'AqError';
|
|
43
|
+
this.code = code;
|
|
44
|
+
this.details = opts.details || null;
|
|
45
|
+
this.retryable = opts.retryable === true;
|
|
46
|
+
if (opts.cause) this.cause = opts.cause;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
toJSON() {
|
|
50
|
+
return {
|
|
51
|
+
name: this.name,
|
|
52
|
+
code: this.code,
|
|
53
|
+
message: this.message,
|
|
54
|
+
details: this.details,
|
|
55
|
+
retryable: this.retryable,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function ok(data) {
|
|
61
|
+
return { ok: true, data };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function err(code, message, opts = {}) {
|
|
65
|
+
const error = code instanceof AqError
|
|
66
|
+
? code
|
|
67
|
+
: new AqError(code, message, opts);
|
|
68
|
+
return { ok: false, error };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Wraps an async function so any thrown error becomes an AQ_INTERNAL AqError.
|
|
72
|
+
// Use at capability method boundaries; inside, code should prefer explicit
|
|
73
|
+
// ok()/err() returns for known failure modes.
|
|
74
|
+
function asResult(asyncFn) {
|
|
75
|
+
return async (...args) => {
|
|
76
|
+
try {
|
|
77
|
+
const data = await asyncFn(...args);
|
|
78
|
+
return ok(data);
|
|
79
|
+
} catch (e) {
|
|
80
|
+
if (e instanceof AqError) return err(e);
|
|
81
|
+
return err('AQ_INTERNAL', e.message, { cause: e });
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isKnownCode(code) {
|
|
87
|
+
return KNOWN_CODES.has(code);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
module.exports = {
|
|
91
|
+
AqError,
|
|
92
|
+
ok,
|
|
93
|
+
err,
|
|
94
|
+
asResult,
|
|
95
|
+
isKnownCode,
|
|
96
|
+
KNOWN_CODES,
|
|
97
|
+
};
|