@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.
Files changed (59) hide show
  1. package/README.md +37 -29
  2. package/consumers/claude-code.js +117 -0
  3. package/consumers/cli.js +28 -1
  4. package/consumers/default/daily-entries.js +196 -0
  5. package/consumers/default/index.js +282 -0
  6. package/consumers/default/prompts/summary.js +153 -0
  7. package/consumers/mcp.js +3 -23
  8. package/consumers/miranda/context-inject.js +119 -0
  9. package/consumers/miranda/daily-entries.js +224 -0
  10. package/consumers/miranda/index.js +353 -0
  11. package/consumers/miranda/instance.js +55 -0
  12. package/consumers/miranda/llm.js +99 -0
  13. package/consumers/miranda/profile.json +145 -0
  14. package/consumers/miranda/prompts/summary.js +303 -0
  15. package/consumers/miranda/recall-format.js +74 -0
  16. package/consumers/miranda/render-daily-md.js +186 -0
  17. package/consumers/miranda/workspace-files.js +91 -0
  18. package/consumers/openclaw-ext/index.js +38 -0
  19. package/consumers/openclaw-ext/openclaw.plugin.json +9 -0
  20. package/consumers/openclaw-ext/package.json +10 -0
  21. package/consumers/openclaw-plugin.js +66 -74
  22. package/consumers/opencode.js +21 -24
  23. package/consumers/shared/autodetect.js +64 -0
  24. package/consumers/shared/entity-parser.js +119 -0
  25. package/consumers/shared/ingest.js +148 -0
  26. package/consumers/shared/llm-autodetect.js +137 -0
  27. package/consumers/shared/normalize.js +129 -0
  28. package/consumers/shared/recall-format.js +110 -0
  29. package/core/aquifer.js +209 -71
  30. package/core/artifacts.js +174 -0
  31. package/core/bundles.js +400 -0
  32. package/core/consolidation.js +340 -0
  33. package/core/decisions.js +164 -0
  34. package/core/entity.js +1 -3
  35. package/core/errors.js +97 -0
  36. package/core/handoff.js +153 -0
  37. package/core/mcp-manifest.js +131 -0
  38. package/core/narratives.js +212 -0
  39. package/core/profiles.js +171 -0
  40. package/core/state.js +163 -0
  41. package/core/storage.js +86 -28
  42. package/core/timeline.js +152 -0
  43. package/docs/postprocess-contract.md +132 -0
  44. package/index.js +23 -1
  45. package/package.json +23 -2
  46. package/pipeline/_http.js +1 -1
  47. package/pipeline/consolidation/apply.js +176 -0
  48. package/pipeline/consolidation/index.js +21 -0
  49. package/pipeline/extract-entities.js +2 -2
  50. package/pipeline/rerank.js +1 -1
  51. package/pipeline/summarize.js +4 -1
  52. package/schema/001-base.sql +61 -24
  53. package/schema/002-entities.sql +17 -3
  54. package/schema/004-completion.sql +375 -0
  55. package/schema/004-facts.sql +67 -0
  56. package/scripts/diagnose-fts-zh.js +168 -134
  57. package/scripts/diagnose-vector.js +188 -0
  58. package/scripts/install-openclaw.sh +59 -0
  59. package/scripts/smoke.mjs +2 -2
@@ -0,0 +1,153 @@
1
+ 'use strict';
2
+
3
+ // aq.handoff.* — append-only session handoff capability.
4
+ //
5
+ // Spec: aquifer-completion §8 sessionHandoff. Every write is an append.
6
+ // getLatest retrieves by created_at DESC, optionally narrowed to a single
7
+ // sessionId.
8
+
9
+ const crypto = require('crypto');
10
+ const { AqError, ok, err } = require('./errors');
11
+
12
+ const DEFAULT_PROFILE = Object.freeze({
13
+ id: 'anon',
14
+ version: 0,
15
+ schemaHash: 'pending',
16
+ });
17
+
18
+ const VALID_STATUSES = new Set(['in_progress', 'completed', 'blocked']);
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 toNumber(v) {
30
+ if (v === null || v === undefined) return null;
31
+ const n = Number(v);
32
+ return Number.isFinite(n) ? n : null;
33
+ }
34
+
35
+ function defaultIdempotencyKey({ tenantId, agentId, sessionId, payload }) {
36
+ return crypto.createHash('sha256')
37
+ .update(`${tenantId}:${agentId}:${sessionId}:${JSON.stringify(payload)}`)
38
+ .digest('hex');
39
+ }
40
+
41
+ function mapRow(row) {
42
+ if (!row) return null;
43
+ return {
44
+ handoffId: toNumber(row.id),
45
+ sessionId: row.source_session_id,
46
+ agentId: row.agent_id,
47
+ status: row.status,
48
+ lastStep: row.last_step,
49
+ nextStep: row.next_step,
50
+ blockers: row.blockers || [],
51
+ decided: row.decided || [],
52
+ openLoops: row.open_loops || [],
53
+ payload: row.payload || {},
54
+ createdAt: row.created_at,
55
+ };
56
+ }
57
+
58
+ function createHandoff({ pool, schema, defaultTenantId }) {
59
+ async function write(input) {
60
+ try {
61
+ if (!input || typeof input !== 'object') {
62
+ return err('AQ_INVALID_INPUT', 'write requires an input object');
63
+ }
64
+ if (!input.agentId) return err('AQ_INVALID_INPUT', 'agentId is required');
65
+ if (!input.sessionId) return err('AQ_INVALID_INPUT', 'sessionId 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 sessionId = input.sessionId;
72
+ const payload = input.payload;
73
+ const profile = resolveProfile(input.profile);
74
+
75
+ const status = payload.status || 'in_progress';
76
+ if (!VALID_STATUSES.has(status)) {
77
+ return err('AQ_INVALID_INPUT', `status must be one of ${Array.from(VALID_STATUSES).join(', ')}`);
78
+ }
79
+ const lastStep = typeof payload.last_step === 'string' ? payload.last_step : null;
80
+ const nextStep = typeof payload.next === 'string' ? payload.next : null;
81
+ const blockers = Array.isArray(payload.blockers) ? payload.blockers : [];
82
+ const decided = Array.isArray(payload.decided) ? payload.decided : [];
83
+ const openLoops = Array.isArray(payload.open_loops) ? payload.open_loops : [];
84
+ const idempotencyKey = input.idempotencyKey
85
+ || defaultIdempotencyKey({ tenantId, agentId, sessionId, payload });
86
+
87
+ // ON CONFLICT DO NOTHING + fallback SELECT for the canonical row.
88
+ const insertResult = await pool.query(
89
+ `INSERT INTO ${schema}.session_handoffs (
90
+ tenant_id, agent_id, source_session_id,
91
+ consumer_profile_id, consumer_profile_version, consumer_schema_hash,
92
+ idempotency_key, status, last_step, next_step,
93
+ blockers, decided, open_loops, payload
94
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
95
+ ON CONFLICT (idempotency_key) DO NOTHING
96
+ RETURNING *`,
97
+ [
98
+ tenantId, agentId, sessionId,
99
+ profile.id, profile.version, profile.schemaHash,
100
+ idempotencyKey, status, lastStep, nextStep,
101
+ JSON.stringify(blockers), JSON.stringify(decided),
102
+ JSON.stringify(openLoops), JSON.stringify(payload),
103
+ ],
104
+ );
105
+ let row = insertResult.rows[0];
106
+ if (!row) {
107
+ const existing = await pool.query(
108
+ `SELECT * FROM ${schema}.session_handoffs WHERE idempotency_key = $1`,
109
+ [idempotencyKey],
110
+ );
111
+ row = existing.rows[0];
112
+ }
113
+ const mapped = mapRow(row);
114
+ return ok({ handoffId: mapped.handoffId, payload: mapped.payload });
115
+ } catch (e) {
116
+ if (e instanceof AqError) return err(e);
117
+ return err('AQ_INTERNAL', e.message, { cause: e });
118
+ }
119
+ }
120
+
121
+ async function getLatest(input = {}) {
122
+ try {
123
+ if (!input.agentId) return err('AQ_INVALID_INPUT', 'agentId is required');
124
+ const tenantId = input.tenantId || defaultTenantId || 'default';
125
+
126
+ const params = [tenantId, input.agentId];
127
+ let where = 'tenant_id = $1 AND agent_id = $2';
128
+ if (input.sessionId) {
129
+ params.push(input.sessionId);
130
+ where += ` AND source_session_id = $${params.length}`;
131
+ }
132
+
133
+ const { rows } = await pool.query(
134
+ `SELECT * FROM ${schema}.session_handoffs
135
+ WHERE ${where}
136
+ ORDER BY created_at DESC, id DESC
137
+ LIMIT 1`,
138
+ params,
139
+ );
140
+ const mapped = mapRow(rows[0]);
141
+ return ok({
142
+ handoff: mapped ? mapped.payload : null,
143
+ handoffId: mapped ? mapped.handoffId : null,
144
+ });
145
+ } catch (e) {
146
+ return err('AQ_INTERNAL', e.message, { cause: e });
147
+ }
148
+ }
149
+
150
+ return { write, getLatest };
151
+ }
152
+
153
+ module.exports = { createHandoff };
@@ -0,0 +1,131 @@
1
+ 'use strict';
2
+
3
+ // MCP tool manifest — single source of truth for Aquifer's MCP surface.
4
+ //
5
+ // Spec: aquifer-completion §G1 (bi-directional registration). Gateway hosts
6
+ // `require('@shadowforge0/aquifer-memory').MCP_TOOL_MANIFEST` in-process;
7
+ // cross-process hosts (CC MCP server) consume the JSON file written by
8
+ // `writeManifestFile()` / the `aquifer mcp-contract` CLI. Both paths read
9
+ // from this module, so the two surfaces can never drift.
10
+ //
11
+ // Tool definitions are expressed as JSON Schema (standard, language-agnostic,
12
+ // serialisable). consumers/mcp.js builds Zod schemas from these descriptors
13
+ // at server start-up.
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+
18
+ const MCP_SERVER_NAME = 'aquifer-memory';
19
+
20
+ const MCP_TOOL_MANIFEST = Object.freeze([
21
+ {
22
+ name: 'session_recall',
23
+ description: 'Search stored sessions by keyword. Supports entity intersection for precise multi-entity queries.',
24
+ inputSchema: {
25
+ type: 'object',
26
+ additionalProperties: false,
27
+ properties: {
28
+ query: { type: 'string', minLength: 1, description: 'Search query (keyword or natural language)' },
29
+ limit: { type: 'integer', minimum: 1, maximum: 20, description: 'Max results (default 5)' },
30
+ agentId: { type: 'string', description: 'Filter by agent ID' },
31
+ source: { type: 'string', description: 'Filter by source (e.g., gateway, cc)' },
32
+ dateFrom: { type: 'string', description: 'Start date YYYY-MM-DD' },
33
+ dateTo: { type: 'string', description: 'End date YYYY-MM-DD' },
34
+ entities: {
35
+ type: 'array',
36
+ items: { type: 'string' },
37
+ description: 'Entity names to match',
38
+ },
39
+ entityMode: {
40
+ type: 'string',
41
+ enum: ['any', 'all'],
42
+ description: '"any" (default, boost) or "all" (only sessions with every entity)',
43
+ },
44
+ mode: {
45
+ type: 'string',
46
+ enum: ['fts', 'hybrid', 'vector'],
47
+ description: 'Recall mode: "fts" (keyword only, no embed needed), "hybrid" (default, FTS + vector), "vector" (vector only)',
48
+ },
49
+ },
50
+ required: ['query'],
51
+ },
52
+ },
53
+ {
54
+ name: 'session_feedback',
55
+ description: 'Record trust feedback on a recalled session. Helpful sessions rank higher in future recalls.',
56
+ inputSchema: {
57
+ type: 'object',
58
+ additionalProperties: false,
59
+ properties: {
60
+ sessionId: { type: 'string', minLength: 1, description: 'Session ID to give feedback on' },
61
+ verdict: { type: 'string', enum: ['helpful', 'unhelpful'], description: 'Was the recalled session useful?' },
62
+ note: { type: 'string', description: 'Optional reason' },
63
+ agentId: { type: 'string', description: 'Agent ID the session was stored under (e.g. "main"). Defaults to "agent" if omitted.' },
64
+ },
65
+ required: ['sessionId', 'verdict'],
66
+ },
67
+ },
68
+ {
69
+ name: 'memory_stats',
70
+ description: 'Return storage statistics for the Aquifer memory store (session counts by status, summaries, turn embeddings, entities, date range).',
71
+ inputSchema: {
72
+ type: 'object',
73
+ additionalProperties: false,
74
+ properties: {},
75
+ },
76
+ },
77
+ {
78
+ name: 'memory_pending',
79
+ description: 'List sessions with pending or failed processing status.',
80
+ inputSchema: {
81
+ type: 'object',
82
+ additionalProperties: false,
83
+ properties: {
84
+ limit: { type: 'integer', minimum: 1, maximum: 200, description: 'Max results (default 20)' },
85
+ },
86
+ },
87
+ },
88
+ {
89
+ name: 'session_bootstrap',
90
+ description: 'Load recent session context for a new conversation. Returns summaries, open items, and decisions from recent sessions. Call this at the start of a conversation for continuity; use session_recall for keyword search.',
91
+ inputSchema: {
92
+ type: 'object',
93
+ additionalProperties: false,
94
+ properties: {
95
+ agentId: { type: 'string', description: 'Filter by agent ID' },
96
+ limit: { type: 'integer', minimum: 1, maximum: 20, description: 'Max sessions (default 5)' },
97
+ lookbackDays: { type: 'integer', minimum: 1, maximum: 90, description: 'How far back in days (default 14)' },
98
+ maxChars: { type: 'integer', minimum: 500, maximum: 12000, description: 'Max output characters (default 4000)' },
99
+ },
100
+ },
101
+ },
102
+ ]);
103
+
104
+ function getManifest() {
105
+ return {
106
+ manifestVersion: 1,
107
+ serverName: MCP_SERVER_NAME,
108
+ generatedAt: new Date().toISOString(),
109
+ tools: MCP_TOOL_MANIFEST.map(t => ({
110
+ name: t.name,
111
+ description: t.description,
112
+ inputSchema: JSON.parse(JSON.stringify(t.inputSchema)),
113
+ })),
114
+ };
115
+ }
116
+
117
+ function writeManifestFile(outPath) {
118
+ if (!outPath) throw new Error('outPath is required');
119
+ const resolved = path.resolve(outPath);
120
+ const dir = path.dirname(resolved);
121
+ fs.mkdirSync(dir, { recursive: true });
122
+ fs.writeFileSync(resolved, JSON.stringify(getManifest(), null, 2) + '\n', 'utf8');
123
+ return resolved;
124
+ }
125
+
126
+ module.exports = {
127
+ MCP_SERVER_NAME,
128
+ MCP_TOOL_MANIFEST,
129
+ getManifest,
130
+ writeManifestFile,
131
+ };
@@ -0,0 +1,212 @@
1
+ 'use strict';
2
+
3
+ // aq.narratives.* — cross-session state snapshot capability.
4
+ //
5
+ // Spec: aquifer-completion §2 narrative. Strict core table is
6
+ // ${schema}.narratives, with at-most-one 'active' row per
7
+ // (tenant_id, agent_id, scope, scope_key) enforced by partial UNIQUE index
8
+ // idx_narratives_active_scope.
9
+ //
10
+ // upsertSnapshot atomically supersedes the prior active row and inserts a
11
+ // new active row. If the same idempotencyKey has already been recorded, the
12
+ // existing narrative is returned unchanged (safe replay).
13
+
14
+ const crypto = require('crypto');
15
+ const { AqError, ok, err } = require('./errors');
16
+
17
+ // Placeholder profile stamp — real stamps land via aq.schema registry
18
+ // (capability 4, P2-2b). Until then consumers may pass a partial profile
19
+ // or rely on this default so narratives/timeline can ship independently.
20
+ const DEFAULT_PROFILE = Object.freeze({
21
+ id: 'anon',
22
+ version: 0,
23
+ schemaHash: 'pending',
24
+ });
25
+
26
+ function resolveProfile(profile) {
27
+ if (!profile) return DEFAULT_PROFILE;
28
+ return {
29
+ id: profile.id || DEFAULT_PROFILE.id,
30
+ version: Number.isInteger(profile.version) ? profile.version : DEFAULT_PROFILE.version,
31
+ schemaHash: profile.schemaHash || DEFAULT_PROFILE.schemaHash,
32
+ };
33
+ }
34
+
35
+ function defaultIdempotencyKey({ tenantId, agentId, scope, scopeKey, text }) {
36
+ return crypto.createHash('sha256')
37
+ .update(`${tenantId}:${agentId}:${scope}:${scopeKey}:${text}`)
38
+ .digest('hex');
39
+ }
40
+
41
+ function toNumber(v) {
42
+ if (v === null || v === undefined) return null;
43
+ const n = Number(v);
44
+ return Number.isFinite(n) ? n : null;
45
+ }
46
+
47
+ function mapRow(row) {
48
+ if (!row) return null;
49
+ return {
50
+ id: toNumber(row.id),
51
+ tenantId: row.tenant_id,
52
+ agentId: row.agent_id,
53
+ scope: row.scope,
54
+ scopeKey: row.scope_key,
55
+ sourceSessionId: row.source_session_id,
56
+ text: row.text,
57
+ status: row.status,
58
+ basedOnFactIds: (row.based_on_fact_ids || []).map(toNumber),
59
+ metadata: row.metadata || {},
60
+ supersededByNarrativeId: toNumber(row.superseded_by_narrative_id),
61
+ effectiveAt: row.effective_at,
62
+ createdAt: row.created_at,
63
+ updatedAt: row.updated_at,
64
+ consumerProfileId: row.consumer_profile_id,
65
+ consumerProfileVersion: row.consumer_profile_version,
66
+ consumerSchemaHash: row.consumer_schema_hash,
67
+ };
68
+ }
69
+
70
+ function createNarratives({ pool, schema, defaultTenantId }) {
71
+ async function upsertSnapshot(input) {
72
+ try {
73
+ if (!input || typeof input !== 'object') {
74
+ return err('AQ_INVALID_INPUT', 'upsertSnapshot requires an input object');
75
+ }
76
+ if (!input.agentId) {
77
+ return err('AQ_INVALID_INPUT', 'agentId is required');
78
+ }
79
+ if (!input.text || typeof input.text !== 'string') {
80
+ return err('AQ_INVALID_INPUT', 'text is required');
81
+ }
82
+ const tenantId = input.tenantId || defaultTenantId || 'default';
83
+ const agentId = input.agentId;
84
+ const scope = input.scope || 'agent';
85
+ const scopeKey = input.scopeKey || agentId;
86
+ const text = input.text;
87
+ const basedOnFactIds = Array.isArray(input.basedOnFactIds) ? input.basedOnFactIds : [];
88
+ const metadata = input.metadata || {};
89
+ const profile = resolveProfile(input.profile);
90
+ const idempotencyKey = input.idempotencyKey
91
+ || defaultIdempotencyKey({ tenantId, agentId, scope, scopeKey, text });
92
+
93
+ const client = await pool.connect();
94
+ try {
95
+ await client.query('BEGIN');
96
+
97
+ // Idempotent replay: if this idempotency_key already exists, return it.
98
+ const existing = await client.query(
99
+ `SELECT * FROM ${schema}.narratives WHERE idempotency_key = $1`,
100
+ [idempotencyKey],
101
+ );
102
+ if (existing.rowCount > 0) {
103
+ await client.query('COMMIT');
104
+ return ok({
105
+ narrative: mapRow(existing.rows[0]),
106
+ supersededNarrativeId: null,
107
+ });
108
+ }
109
+
110
+ // Mark the prior active row (if any) as superseded; capture its id
111
+ // so the caller can link supersede chain for observability.
112
+ const prev = await client.query(
113
+ `UPDATE ${schema}.narratives
114
+ SET status = 'superseded'
115
+ WHERE tenant_id = $1 AND agent_id = $2 AND scope = $3
116
+ AND scope_key = $4 AND status = 'active'
117
+ RETURNING id`,
118
+ [tenantId, agentId, scope, scopeKey],
119
+ );
120
+ const supersededNarrativeId = prev.rowCount > 0 ? toNumber(prev.rows[0].id) : null;
121
+
122
+ const inserted = await client.query(
123
+ `INSERT INTO ${schema}.narratives (
124
+ tenant_id, agent_id, scope, scope_key, text, status,
125
+ based_on_fact_ids, metadata, source_session_id,
126
+ consumer_profile_id, consumer_profile_version, consumer_schema_hash,
127
+ idempotency_key
128
+ ) VALUES ($1, $2, $3, $4, $5, 'active', $6, $7, $8, $9, $10, $11, $12)
129
+ RETURNING *`,
130
+ [
131
+ tenantId, agentId, scope, scopeKey, text,
132
+ basedOnFactIds, metadata, input.sourceSessionId || null,
133
+ profile.id, profile.version, profile.schemaHash,
134
+ idempotencyKey,
135
+ ],
136
+ );
137
+
138
+ if (supersededNarrativeId) {
139
+ await client.query(
140
+ `UPDATE ${schema}.narratives
141
+ SET superseded_by_narrative_id = $1
142
+ WHERE id = $2`,
143
+ [inserted.rows[0].id, supersededNarrativeId],
144
+ );
145
+ }
146
+
147
+ await client.query('COMMIT');
148
+ return ok({
149
+ narrative: mapRow(inserted.rows[0]),
150
+ supersededNarrativeId,
151
+ });
152
+ } catch (e) {
153
+ await client.query('ROLLBACK').catch(() => {});
154
+ throw e;
155
+ } finally {
156
+ client.release();
157
+ }
158
+ } catch (e) {
159
+ if (e instanceof AqError) return err(e);
160
+ return err('AQ_INTERNAL', e.message, { cause: e });
161
+ }
162
+ }
163
+
164
+ async function getLatest(input = {}) {
165
+ try {
166
+ if (!input.agentId) {
167
+ return err('AQ_INVALID_INPUT', 'agentId is required');
168
+ }
169
+ const tenantId = input.tenantId || defaultTenantId || 'default';
170
+ const scope = input.scope || 'agent';
171
+ const scopeKey = input.scopeKey || input.agentId;
172
+ const { rows } = await pool.query(
173
+ `SELECT * FROM ${schema}.narratives
174
+ WHERE tenant_id = $1 AND agent_id = $2
175
+ AND scope = $3 AND scope_key = $4
176
+ AND status = 'active'
177
+ LIMIT 1`,
178
+ [tenantId, input.agentId, scope, scopeKey],
179
+ );
180
+ return ok({ narrative: mapRow(rows[0]) });
181
+ } catch (e) {
182
+ return err('AQ_INTERNAL', e.message, { cause: e });
183
+ }
184
+ }
185
+
186
+ async function listHistory(input = {}) {
187
+ try {
188
+ if (!input.agentId) {
189
+ return err('AQ_INVALID_INPUT', 'agentId is required');
190
+ }
191
+ const tenantId = input.tenantId || defaultTenantId || 'default';
192
+ const scope = input.scope || 'agent';
193
+ const scopeKey = input.scopeKey || input.agentId;
194
+ const limit = Math.min(Math.max(input.limit || 20, 1), 200);
195
+ const { rows } = await pool.query(
196
+ `SELECT * FROM ${schema}.narratives
197
+ WHERE tenant_id = $1 AND agent_id = $2
198
+ AND scope = $3 AND scope_key = $4
199
+ ORDER BY effective_at DESC, id DESC
200
+ LIMIT $5`,
201
+ [tenantId, input.agentId, scope, scopeKey, limit],
202
+ );
203
+ return ok({ rows: rows.map(mapRow) });
204
+ } catch (e) {
205
+ return err('AQ_INTERNAL', e.message, { cause: e });
206
+ }
207
+ }
208
+
209
+ return { upsertSnapshot, getLatest, listHistory };
210
+ }
211
+
212
+ module.exports = { createNarratives };
@@ -0,0 +1,171 @@
1
+ 'use strict';
2
+
3
+ // aq.profiles.* — consumer profile registry (capability #4 schemaRegistry).
4
+ //
5
+ // Spec: aquifer-completion §4 schemaRegistry, §D2 (consumer_profiles DDL).
6
+ // The P2-2b surface only covers register/load; diff + resolveForWrite +
7
+ // compiled template/schema generation land in P3 alongside prompt template
8
+ // engine and output parser work.
9
+ //
10
+ // register(): insert (tenant_id, consumer_id, version, profile_hash,
11
+ // profile_json). If the same (consumer_id, version) with a different
12
+ // profile_hash is submitted, the UNIQUE (consumer_id, version,
13
+ // profile_hash) constraint makes this a conflict, returning
14
+ // AQ_CONFLICT so callers must bump version rather than silently drift.
15
+ //
16
+ // load(): fetch latest non-deprecated version (or a specific version).
17
+
18
+ const crypto = require('crypto');
19
+ const { AqError, ok, err } = require('./errors');
20
+
21
+ function toNumber(v) {
22
+ if (v === null || v === undefined) return null;
23
+ const n = Number(v);
24
+ return Number.isFinite(n) ? n : null;
25
+ }
26
+
27
+ function canonicaliseJson(value) {
28
+ if (value === null || typeof value !== 'object') return value;
29
+ if (Array.isArray(value)) return value.map(canonicaliseJson);
30
+ const sorted = {};
31
+ for (const key of Object.keys(value).sort()) {
32
+ sorted[key] = canonicaliseJson(value[key]);
33
+ }
34
+ return sorted;
35
+ }
36
+
37
+ function computeProfileHash(profileJson) {
38
+ // Stable hash over deeply-canonicalised JSON so semantically identical
39
+ // profiles always produce the same hash regardless of key ordering.
40
+ const canonical = JSON.stringify(canonicaliseJson(profileJson));
41
+ return crypto.createHash('sha256').update(canonical).digest('hex');
42
+ }
43
+
44
+ function mapRow(row) {
45
+ if (!row) return null;
46
+ return {
47
+ tenantId: row.tenant_id,
48
+ consumerId: row.consumer_id,
49
+ version: toNumber(row.version),
50
+ profileHash: row.profile_hash,
51
+ profile: row.profile_json,
52
+ loadedAt: row.loaded_at,
53
+ deprecatedAt: row.deprecated_at,
54
+ };
55
+ }
56
+
57
+ function createProfiles({ pool, schema, defaultTenantId }) {
58
+ async function register(input) {
59
+ try {
60
+ if (!input || typeof input !== 'object') {
61
+ return err('AQ_INVALID_INPUT', 'register requires an input object');
62
+ }
63
+ if (!input.consumerId) return err('AQ_INVALID_INPUT', 'consumerId is required');
64
+ if (!Number.isInteger(input.version) || input.version < 1) {
65
+ return err('AQ_INVALID_INPUT', 'version must be a positive integer');
66
+ }
67
+ if (!input.profile || typeof input.profile !== 'object') {
68
+ return err('AQ_INVALID_INPUT', 'profile (object) is required');
69
+ }
70
+
71
+ const tenantId = input.tenantId || defaultTenantId || 'default';
72
+ const profileHash = input.profileHash || computeProfileHash(input.profile);
73
+
74
+ // Try insert; if (tenant, consumer, version) already exists with a
75
+ // different hash, map to AQ_CONFLICT — the caller must bump version.
76
+ try {
77
+ const { rows } = await pool.query(
78
+ `INSERT INTO ${schema}.consumer_profiles
79
+ (tenant_id, consumer_id, version, profile_hash, profile_json)
80
+ VALUES ($1, $2, $3, $4, $5)
81
+ ON CONFLICT (tenant_id, consumer_id, version) DO NOTHING
82
+ RETURNING *`,
83
+ [tenantId, input.consumerId, input.version, profileHash, input.profile],
84
+ );
85
+ if (rows.length > 0) {
86
+ return ok({
87
+ consumerProfileId: input.consumerId,
88
+ version: input.version,
89
+ schemaHash: profileHash,
90
+ inserted: true,
91
+ });
92
+ }
93
+ } catch (e) {
94
+ // UNIQUE (consumer_id, version, profile_hash) may still violate if
95
+ // same version registered with different hash under a different
96
+ // tenant — surface as conflict.
97
+ if (e.code === '23505') {
98
+ return err('AQ_CONFLICT', 'profile hash collision on (consumer_id, version)');
99
+ }
100
+ throw e;
101
+ }
102
+
103
+ // Row already exists — verify hash matches for idempotent replay.
104
+ const existing = await pool.query(
105
+ `SELECT profile_hash FROM ${schema}.consumer_profiles
106
+ WHERE tenant_id = $1 AND consumer_id = $2 AND version = $3`,
107
+ [tenantId, input.consumerId, input.version],
108
+ );
109
+ if (existing.rows[0].profile_hash !== profileHash) {
110
+ return err('AQ_CONFLICT',
111
+ `profile (${input.consumerId} v${input.version}) already registered with a different hash — bump version`);
112
+ }
113
+ return ok({
114
+ consumerProfileId: input.consumerId,
115
+ version: input.version,
116
+ schemaHash: profileHash,
117
+ inserted: false,
118
+ });
119
+ } catch (e) {
120
+ if (e instanceof AqError) return err(e);
121
+ return err('AQ_INTERNAL', e.message, { cause: e });
122
+ }
123
+ }
124
+
125
+ async function load(input = {}) {
126
+ try {
127
+ if (!input.consumerId) return err('AQ_INVALID_INPUT', 'consumerId is required');
128
+ const tenantId = input.tenantId || defaultTenantId || 'default';
129
+
130
+ let rows;
131
+ if (input.version === 'latest' || input.version === undefined || input.version === null) {
132
+ const result = await pool.query(
133
+ `SELECT * FROM ${schema}.consumer_profiles
134
+ WHERE tenant_id = $1 AND consumer_id = $2 AND deprecated_at IS NULL
135
+ ORDER BY version DESC
136
+ LIMIT 1`,
137
+ [tenantId, input.consumerId],
138
+ );
139
+ rows = result.rows;
140
+ } else if (Number.isInteger(input.version) && input.version >= 1) {
141
+ const result = await pool.query(
142
+ `SELECT * FROM ${schema}.consumer_profiles
143
+ WHERE tenant_id = $1 AND consumer_id = $2 AND version = $3`,
144
+ [tenantId, input.consumerId, input.version],
145
+ );
146
+ rows = result.rows;
147
+ } else {
148
+ return err('AQ_INVALID_INPUT', 'version must be a positive integer or "latest"');
149
+ }
150
+
151
+ if (rows.length === 0) {
152
+ return err('AQ_PROFILE_NOT_FOUND',
153
+ `no profile for consumer=${input.consumerId} version=${input.version || 'latest'}`);
154
+ }
155
+ const mapped = mapRow(rows[0]);
156
+ return ok({
157
+ profile: mapped.profile,
158
+ consumerProfileId: mapped.consumerId,
159
+ version: mapped.version,
160
+ schemaHash: mapped.profileHash,
161
+ loadedAt: mapped.loadedAt,
162
+ });
163
+ } catch (e) {
164
+ return err('AQ_INTERNAL', e.message, { cause: e });
165
+ }
166
+ }
167
+
168
+ return { register, load };
169
+ }
170
+
171
+ module.exports = { createProfiles };