@shadowforge0/aquifer-memory 1.2.1 → 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 +8 -9
- package/consumers/cli.js +11 -1
- package/consumers/miranda/profile.json +145 -0
- package/consumers/miranda/render-daily-md.js +186 -0
- package/core/aquifer.js +29 -0
- 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/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/timeline.js +152 -0
- package/index.js +14 -0
- package/package.json +1 -1
- package/schema/004-completion.sql +375 -0
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/timeline.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// aq.timeline.* — append-only event log capability.
|
|
4
|
+
//
|
|
5
|
+
// Spec: aquifer-completion §10 timeline. Fixed event shape
|
|
6
|
+
// (occurred_at / source / session_ref / category / text / metadata),
|
|
7
|
+
// consumer-owned category vocabulary. idempotency_key UNIQUE across the
|
|
8
|
+
// table; appends with a duplicate key are a safe no-op and return the
|
|
9
|
+
// existing row.
|
|
10
|
+
|
|
11
|
+
const crypto = require('crypto');
|
|
12
|
+
const { AqError, ok, err } = require('./errors');
|
|
13
|
+
|
|
14
|
+
const DEFAULT_PROFILE = Object.freeze({
|
|
15
|
+
id: 'anon',
|
|
16
|
+
version: 0,
|
|
17
|
+
schemaHash: 'pending',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function resolveProfile(profile) {
|
|
21
|
+
if (!profile) return DEFAULT_PROFILE;
|
|
22
|
+
return {
|
|
23
|
+
id: profile.id || DEFAULT_PROFILE.id,
|
|
24
|
+
version: Number.isInteger(profile.version) ? profile.version : DEFAULT_PROFILE.version,
|
|
25
|
+
schemaHash: profile.schemaHash || DEFAULT_PROFILE.schemaHash,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function defaultIdempotencyKey({ tenantId, agentId, occurredAt, category, text }) {
|
|
30
|
+
return crypto.createHash('sha256')
|
|
31
|
+
.update(`${tenantId}:${agentId}:${occurredAt}:${category}:${text}`)
|
|
32
|
+
.digest('hex');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function toNumber(v) {
|
|
36
|
+
if (v === null || v === undefined) return null;
|
|
37
|
+
const n = Number(v);
|
|
38
|
+
return Number.isFinite(n) ? n : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function mapRow(row) {
|
|
42
|
+
if (!row) return null;
|
|
43
|
+
return {
|
|
44
|
+
eventId: toNumber(row.id),
|
|
45
|
+
occurredAt: row.occurred_at,
|
|
46
|
+
source: row.source,
|
|
47
|
+
sessionRef: row.session_ref,
|
|
48
|
+
category: row.category,
|
|
49
|
+
text: row.text,
|
|
50
|
+
metadata: row.metadata || {},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function createTimeline({ pool, schema, defaultTenantId }) {
|
|
55
|
+
async function append(input) {
|
|
56
|
+
try {
|
|
57
|
+
if (!input || typeof input !== 'object') {
|
|
58
|
+
return err('AQ_INVALID_INPUT', 'append requires an input object');
|
|
59
|
+
}
|
|
60
|
+
if (!input.agentId) return err('AQ_INVALID_INPUT', 'agentId is required');
|
|
61
|
+
if (!input.occurredAt) return err('AQ_INVALID_INPUT', 'occurredAt is required');
|
|
62
|
+
if (!input.source) return err('AQ_INVALID_INPUT', 'source is required');
|
|
63
|
+
if (!input.category) return err('AQ_INVALID_INPUT', 'category is required');
|
|
64
|
+
if (!input.text || typeof input.text !== 'string') {
|
|
65
|
+
return err('AQ_INVALID_INPUT', 'text is required');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const tenantId = input.tenantId || defaultTenantId || 'default';
|
|
69
|
+
const agentId = input.agentId;
|
|
70
|
+
const profile = resolveProfile(input.profile);
|
|
71
|
+
const idempotencyKey = input.idempotencyKey
|
|
72
|
+
|| defaultIdempotencyKey({
|
|
73
|
+
tenantId, agentId,
|
|
74
|
+
occurredAt: input.occurredAt,
|
|
75
|
+
category: input.category,
|
|
76
|
+
text: input.text,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Idempotent append: on conflict, fall back to SELECT so the caller
|
|
80
|
+
// always gets the canonical row (the row that *won* the insert).
|
|
81
|
+
const insertResult = await pool.query(
|
|
82
|
+
`INSERT INTO ${schema}.timeline_events (
|
|
83
|
+
tenant_id, agent_id, occurred_at, source, session_ref,
|
|
84
|
+
category, text, metadata, source_session_id,
|
|
85
|
+
consumer_profile_id, consumer_profile_version, consumer_schema_hash,
|
|
86
|
+
idempotency_key
|
|
87
|
+
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
|
88
|
+
ON CONFLICT (idempotency_key) DO NOTHING
|
|
89
|
+
RETURNING *`,
|
|
90
|
+
[
|
|
91
|
+
tenantId, agentId, input.occurredAt, input.source,
|
|
92
|
+
input.sessionRef || null, input.category, input.text,
|
|
93
|
+
input.metadata || {}, input.sessionId || null,
|
|
94
|
+
profile.id, profile.version, profile.schemaHash,
|
|
95
|
+
idempotencyKey,
|
|
96
|
+
],
|
|
97
|
+
);
|
|
98
|
+
let row = insertResult.rows[0];
|
|
99
|
+
if (!row) {
|
|
100
|
+
const existing = await pool.query(
|
|
101
|
+
`SELECT * FROM ${schema}.timeline_events WHERE idempotency_key = $1`,
|
|
102
|
+
[idempotencyKey],
|
|
103
|
+
);
|
|
104
|
+
row = existing.rows[0];
|
|
105
|
+
}
|
|
106
|
+
const mapped = mapRow(row);
|
|
107
|
+
return ok({ eventId: mapped.eventId, event: mapped });
|
|
108
|
+
} catch (e) {
|
|
109
|
+
if (e instanceof AqError) return err(e);
|
|
110
|
+
return err('AQ_INTERNAL', e.message, { cause: e });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function list(input = {}) {
|
|
115
|
+
try {
|
|
116
|
+
if (!input.agentId) return err('AQ_INVALID_INPUT', 'agentId is required');
|
|
117
|
+
const tenantId = input.tenantId || defaultTenantId || 'default';
|
|
118
|
+
const limit = Math.min(Math.max(input.limit || 50, 1), 500);
|
|
119
|
+
|
|
120
|
+
const params = [tenantId, input.agentId];
|
|
121
|
+
let where = 'tenant_id = $1 AND agent_id = $2';
|
|
122
|
+
if (Array.isArray(input.categories) && input.categories.length > 0) {
|
|
123
|
+
params.push(input.categories);
|
|
124
|
+
where += ` AND category = ANY($${params.length})`;
|
|
125
|
+
}
|
|
126
|
+
if (input.since) {
|
|
127
|
+
params.push(input.since);
|
|
128
|
+
where += ` AND occurred_at >= $${params.length}`;
|
|
129
|
+
}
|
|
130
|
+
if (input.until) {
|
|
131
|
+
params.push(input.until);
|
|
132
|
+
where += ` AND occurred_at <= $${params.length}`;
|
|
133
|
+
}
|
|
134
|
+
params.push(limit);
|
|
135
|
+
|
|
136
|
+
const { rows } = await pool.query(
|
|
137
|
+
`SELECT * FROM ${schema}.timeline_events
|
|
138
|
+
WHERE ${where}
|
|
139
|
+
ORDER BY occurred_at DESC, id DESC
|
|
140
|
+
LIMIT $${params.length}`,
|
|
141
|
+
params,
|
|
142
|
+
);
|
|
143
|
+
return ok({ rows: rows.map(mapRow) });
|
|
144
|
+
} catch (e) {
|
|
145
|
+
return err('AQ_INTERNAL', e.message, { cause: e });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { append, list };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = { createTimeline };
|
package/index.js
CHANGED
|
@@ -5,6 +5,8 @@ const { createEmbedder } = require('./pipeline/embed');
|
|
|
5
5
|
const { createReranker } = require('./pipeline/rerank');
|
|
6
6
|
const { normalizeEntityName } = require('./core/entity');
|
|
7
7
|
const { parseEntitySection } = require('./consumers/shared/entity-parser');
|
|
8
|
+
const { AqError, ok, err, asResult, isKnownCode, KNOWN_CODES } = require('./core/errors');
|
|
9
|
+
const { MCP_SERVER_NAME, MCP_TOOL_MANIFEST, getManifest, writeManifestFile } = require('./core/mcp-manifest');
|
|
8
10
|
|
|
9
11
|
module.exports = {
|
|
10
12
|
createAquifer,
|
|
@@ -12,4 +14,16 @@ module.exports = {
|
|
|
12
14
|
createReranker,
|
|
13
15
|
normalizeEntityName,
|
|
14
16
|
parseEntitySection,
|
|
17
|
+
// Completion-capability error envelope (P1 foundation).
|
|
18
|
+
AqError,
|
|
19
|
+
ok,
|
|
20
|
+
err,
|
|
21
|
+
asResult,
|
|
22
|
+
isKnownCode,
|
|
23
|
+
KNOWN_CODES,
|
|
24
|
+
// MCP tool manifest — canonical for gateway in-process + CC cross-process.
|
|
25
|
+
MCP_SERVER_NAME,
|
|
26
|
+
MCP_TOOL_MANIFEST,
|
|
27
|
+
getMcpManifest: getManifest,
|
|
28
|
+
writeMcpManifestFile: writeManifestFile,
|
|
15
29
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shadowforge0/aquifer-memory",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "PG-native long-term memory for AI agents. Turn-level embedding, hybrid RRF ranking, optional knowledge graph. MCP server, CLI, and library API.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"files": [
|