@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
|
@@ -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 or natural language. Use entities when the user names specific people, projects, files, tools, or concepts; entityMode="all" hard-filters to sessions containing every entity (default "any" boosts). Use mode to force fts/vector/hybrid (default hybrid). Use dateFrom/dateTo for time-bounded recall.',
|
|
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 };
|
package/core/profiles.js
ADDED
|
@@ -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 };
|
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 };
|