@shadowforge0/aquifer-memory 1.5.12 → 1.7.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/.env.example +23 -0
- package/README.md +84 -73
- package/README_CN.md +676 -0
- package/README_TW.md +684 -0
- package/aquifer.config.example.json +34 -0
- package/consumers/claude-code.js +11 -11
- package/consumers/cli.js +421 -53
- package/consumers/codex-handoff.js +258 -0
- package/consumers/codex.js +1676 -0
- package/consumers/default/daily-entries.js +23 -4
- package/consumers/default/index.js +2 -2
- package/consumers/default/prompts/summary.js +6 -6
- package/consumers/mcp.js +96 -5
- package/consumers/openclaw-ext/index.js +0 -1
- package/consumers/openclaw-plugin.js +1 -1
- package/consumers/shared/config.js +8 -0
- package/consumers/shared/factory.js +1 -0
- package/consumers/shared/ingest.js +1 -1
- package/consumers/shared/normalize.js +14 -3
- package/consumers/shared/recall-format.js +27 -0
- package/consumers/shared/summary-parser.js +151 -0
- package/core/aquifer.js +380 -18
- package/core/finalization-review.js +319 -0
- package/core/mcp-manifest.js +52 -2
- package/core/memory-bootstrap.js +200 -0
- package/core/memory-consolidation.js +1590 -0
- package/core/memory-promotion.js +544 -0
- package/core/memory-recall.js +247 -0
- package/core/memory-records.js +797 -0
- package/core/memory-safety-gate.js +224 -0
- package/core/session-finalization.js +365 -0
- package/core/storage.js +385 -2
- package/docs/getting-started.md +105 -0
- package/docs/postprocess-contract.md +2 -2
- package/docs/setup.md +92 -2
- package/package.json +25 -11
- package/pipeline/normalize/adapters/codex.js +106 -0
- package/pipeline/normalize/detect.js +3 -2
- package/schema/001-base.sql +3 -0
- package/schema/007-v1-foundation.sql +273 -0
- package/schema/008-session-finalizations.sql +50 -0
- package/schema/009-v1-assertion-plane.sql +193 -0
- package/schema/010-v1-finalization-review.sql +160 -0
- package/schema/011-v1-compaction-claim.sql +46 -0
- package/schema/012-v1-compaction-lease.sql +39 -0
- package/schema/013-v1-compaction-lineage.sql +193 -0
- package/scripts/codex-recovery.js +672 -0
- package/consumers/miranda/context-inject.js +0 -120
- package/consumers/miranda/daily-entries.js +0 -224
- package/consumers/miranda/index.js +0 -364
- package/consumers/miranda/instance.js +0 -55
- package/consumers/miranda/llm.js +0 -99
- package/consumers/miranda/profile.json +0 -145
- package/consumers/miranda/prompts/summary.js +0 -303
- package/consumers/miranda/recall-format.js +0 -76
- package/consumers/miranda/render-daily-md.js +0 -186
- package/consumers/miranda/workspace-files.js +0 -91
- package/scripts/drop-entity-state-history.sql +0 -17
- package/scripts/drop-insights.sql +0 -12
- package/scripts/install-openclaw.sh +0 -59
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// CREATE TABLE jenny.daily_entries (LIKE miranda.daily_entries INCLUDING ALL);
|
|
10
10
|
|
|
11
11
|
const crypto = require('crypto');
|
|
12
|
-
const { parseHandoffSection } = require('../
|
|
12
|
+
const { parseHandoffSection } = require('../shared/summary-parser');
|
|
13
13
|
|
|
14
14
|
const UPSERT_TAGS = new Set(['[FOCUS]', '[TODO]', '[STATS]', '[HIGHLIGHT]', '[SYSTEM]', '[HANDOFF]']);
|
|
15
15
|
|
|
@@ -27,10 +27,27 @@ function textHash6(text) {
|
|
|
27
27
|
return crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 6);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
function quoteIdentifier(identifier) {
|
|
31
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]{0,62}$/.test(identifier)) {
|
|
32
|
+
throw new Error(`Invalid dailyTable identifier: ${identifier}`);
|
|
33
|
+
}
|
|
34
|
+
return `"${identifier}"`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function quoteTableName(tableName) {
|
|
38
|
+
if (typeof tableName !== 'string') throw new Error('dailyTable must be a string');
|
|
39
|
+
const parts = tableName.split('.');
|
|
40
|
+
if (parts.length < 1 || parts.length > 2 || parts.some(part => !part)) {
|
|
41
|
+
throw new Error(`Invalid dailyTable name: ${tableName}`);
|
|
42
|
+
}
|
|
43
|
+
return parts.map(quoteIdentifier).join('.');
|
|
44
|
+
}
|
|
45
|
+
|
|
30
46
|
async function insertDailyEntry(pool, tableName, { eventAt, source, tag, text, agentId, sessionId, metadata, dedupeKey }) {
|
|
47
|
+
const tableSql = quoteTableName(tableName);
|
|
31
48
|
const shouldUpsert = dedupeKey && UPSERT_TAGS.has(tag);
|
|
32
49
|
const sql = shouldUpsert
|
|
33
|
-
? `INSERT INTO ${
|
|
50
|
+
? `INSERT INTO ${tableSql}
|
|
34
51
|
(event_at, source, tag, text, agent_id, session_id, metadata, dedupe_key)
|
|
35
52
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
36
53
|
ON CONFLICT (dedupe_key) DO UPDATE SET
|
|
@@ -38,7 +55,7 @@ async function insertDailyEntry(pool, tableName, { eventAt, source, tag, text, a
|
|
|
38
55
|
event_at = EXCLUDED.event_at,
|
|
39
56
|
metadata = EXCLUDED.metadata
|
|
40
57
|
RETURNING id, event_at, source, tag, text`
|
|
41
|
-
: `INSERT INTO ${
|
|
58
|
+
: `INSERT INTO ${tableSql}
|
|
42
59
|
(event_at, source, tag, text, agent_id, session_id, metadata, dedupe_key)
|
|
43
60
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
44
61
|
ON CONFLICT (dedupe_key) DO NOTHING
|
|
@@ -53,8 +70,9 @@ async function insertDailyEntry(pool, tableName, { eventAt, source, tag, text, a
|
|
|
53
70
|
}
|
|
54
71
|
|
|
55
72
|
async function getDailyEntries(pool, tableName, date, agentId) {
|
|
73
|
+
const tableSql = quoteTableName(tableName);
|
|
56
74
|
const result = await pool.query(
|
|
57
|
-
`SELECT * FROM ${
|
|
75
|
+
`SELECT * FROM ${tableSql}
|
|
58
76
|
WHERE (event_at AT TIME ZONE 'Asia/Taipei')::date = $1
|
|
59
77
|
AND ($2::text IS NULL OR agent_id = $2)
|
|
60
78
|
ORDER BY event_at ASC`,
|
|
@@ -191,6 +209,7 @@ async function writeDailyEntries({
|
|
|
191
209
|
|
|
192
210
|
module.exports = {
|
|
193
211
|
taipeiDateString, textHash6,
|
|
212
|
+
quoteIdentifier, quoteTableName,
|
|
194
213
|
insertDailyEntry, getDailyEntries, fetchDailyContext, writeDailyEntries,
|
|
195
214
|
UPSERT_TAGS,
|
|
196
215
|
};
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// briefingIntro: '你是 Dobby。以下是現況...', // optional context-inject preamble
|
|
16
16
|
// });
|
|
17
17
|
//
|
|
18
|
-
// Returns a persona module with the
|
|
18
|
+
// Returns a persona module with the standard persona adapter shape — host
|
|
19
19
|
// can do `AQUIFER_PERSONA=<host-path>` where <host-path>/index.js does:
|
|
20
20
|
// module.exports = require('@shadowforge0/aquifer-memory/consumers/default')
|
|
21
21
|
// .createPersona({ ... });
|
|
@@ -222,7 +222,7 @@ function createPersona(personaOpts = {}) {
|
|
|
222
222
|
if ((ctx?.sessionKey || '').includes('subagent')) return null;
|
|
223
223
|
return {
|
|
224
224
|
name: 'session_recall',
|
|
225
|
-
description: 'Search
|
|
225
|
+
description: 'Search Aquifer memory by keyword or natural language. In curated serving mode this searches active curated memory; legacy/evidence lookup is exposed by the MCP stdio evidence_recall tool. Use entities when the user names specific people, projects, files, tools, or concepts; use entity_mode="all" when every named entity must co-occur (default "any" boosts). Use mode to force fts/vector/hybrid (default hybrid).',
|
|
226
226
|
parameters: {
|
|
227
227
|
type: 'object',
|
|
228
228
|
properties: {
|
|
@@ -139,15 +139,15 @@ TODO_DONE: 已完成待辦(需匹配既有)
|
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
// ---------------------------------------------------------------------------
|
|
142
|
-
// parseSummaryOutput / parseRecapLines —
|
|
143
|
-
// The output format is intentionally
|
|
142
|
+
// parseSummaryOutput / parseRecapLines — shared parser used by all personas.
|
|
143
|
+
// The output format is intentionally stable so downstream daily entries work.
|
|
144
144
|
// ---------------------------------------------------------------------------
|
|
145
145
|
|
|
146
|
-
const
|
|
146
|
+
const summaryParser = require('../../shared/summary-parser');
|
|
147
147
|
|
|
148
148
|
module.exports = {
|
|
149
149
|
buildSummaryPrompt,
|
|
150
|
-
parseSummaryOutput:
|
|
151
|
-
parseRecapLines:
|
|
152
|
-
parseWorkingFacts:
|
|
150
|
+
parseSummaryOutput: summaryParser.parseSummaryOutput,
|
|
151
|
+
parseRecapLines: summaryParser.parseRecapLines,
|
|
152
|
+
parseWorkingFacts: summaryParser.parseWorkingFacts,
|
|
153
153
|
};
|
package/consumers/mcp.js
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
* This is the primary integration surface for Aquifer. Agent hosts (Claude Code,
|
|
8
8
|
* Codex, OpenCode, etc.) should integrate through this MCP server.
|
|
9
9
|
*
|
|
10
|
-
* Tools: session_recall, session_feedback,
|
|
11
|
-
* session_bootstrap, memory_stats, memory_pending
|
|
10
|
+
* Tools: session_recall, evidence_recall, session_feedback, memory_feedback,
|
|
11
|
+
* feedback_stats, session_bootstrap, memory_stats, memory_pending
|
|
12
12
|
*
|
|
13
13
|
* Usage:
|
|
14
14
|
* npx aquifer mcp
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
const { createAquiferFromConfig } = require('./shared/factory');
|
|
22
|
+
const { version: packageVersion } = require('../package.json');
|
|
22
23
|
|
|
23
24
|
let _aquifer = null;
|
|
24
25
|
|
|
@@ -59,12 +60,12 @@ async function main() {
|
|
|
59
60
|
|
|
60
61
|
const server = new McpServer({
|
|
61
62
|
name: 'aquifer-memory',
|
|
62
|
-
version:
|
|
63
|
+
version: packageVersion,
|
|
63
64
|
});
|
|
64
65
|
|
|
65
66
|
server.tool(
|
|
66
67
|
'session_recall',
|
|
67
|
-
'Search
|
|
68
|
+
'Search Aquifer memory. In curated serving mode this searches active curated memory only; use evidence_recall for legacy session/evidence lookup.',
|
|
68
69
|
{
|
|
69
70
|
query: z.string().min(1).describe('Search query (keyword or natural language)'),
|
|
70
71
|
limit: z.number().int().min(1).max(20).optional().describe('Max results (default 5)'),
|
|
@@ -76,6 +77,8 @@ async function main() {
|
|
|
76
77
|
entityMode: z.enum(['any', 'all']).optional().describe('"any" (default, boost) or "all" (only sessions with every entity)'),
|
|
77
78
|
mode: z.enum(['fts', 'hybrid', 'vector']).optional().describe('Recall mode: "fts" (keyword only, no embed needed), "hybrid" (default, FTS + vector), "vector" (vector only)'),
|
|
78
79
|
explain: z.boolean().optional().describe('Include per-result score breakdown (diagnostic)'),
|
|
80
|
+
activeScopeKey: z.string().optional().describe('Active curated memory scope key'),
|
|
81
|
+
activeScopePath: z.array(z.string()).optional().describe('Ordered curated scope path'),
|
|
79
82
|
},
|
|
80
83
|
async (params) => {
|
|
81
84
|
try {
|
|
@@ -87,6 +90,8 @@ async function main() {
|
|
|
87
90
|
source: params.source || undefined,
|
|
88
91
|
dateFrom: params.dateFrom || undefined,
|
|
89
92
|
dateTo: params.dateTo || undefined,
|
|
93
|
+
activeScopeKey: params.activeScopeKey || undefined,
|
|
94
|
+
activeScopePath: params.activeScopePath || undefined,
|
|
90
95
|
};
|
|
91
96
|
if (params.entities && params.entities.length > 0) {
|
|
92
97
|
recallOpts.entities = params.entities;
|
|
@@ -106,9 +111,55 @@ async function main() {
|
|
|
106
111
|
}
|
|
107
112
|
);
|
|
108
113
|
|
|
114
|
+
server.tool(
|
|
115
|
+
'evidence_recall',
|
|
116
|
+
'Explicit legacy/evidence search over stored sessions and summaries. Use for audit/debug/distillation; it does not feed bootstrap implicitly.',
|
|
117
|
+
{
|
|
118
|
+
query: z.string().min(1).describe('Evidence search query (keyword or natural language)'),
|
|
119
|
+
limit: z.number().int().min(1).max(20).optional().describe('Max results (default 5)'),
|
|
120
|
+
agentId: z.string().optional().describe('Filter by agent ID'),
|
|
121
|
+
source: z.string().optional().describe('Filter by source (e.g., gateway, cc)'),
|
|
122
|
+
dateFrom: z.string().optional().describe('Start date YYYY-MM-DD'),
|
|
123
|
+
dateTo: z.string().optional().describe('End date YYYY-MM-DD'),
|
|
124
|
+
entities: z.array(z.string()).optional().describe('Entity names to match'),
|
|
125
|
+
entityMode: z.enum(['any', 'all']).optional().describe('"any" (default, boost) or "all" (only sessions with every entity)'),
|
|
126
|
+
mode: z.enum(['fts', 'hybrid', 'vector']).optional().describe('Legacy evidence recall mode'),
|
|
127
|
+
explain: z.boolean().optional().describe('Include per-result score breakdown (diagnostic)'),
|
|
128
|
+
allowUnsafeDebug: z.boolean().optional().describe('Allow broad evidence/debug search without an audit boundary'),
|
|
129
|
+
},
|
|
130
|
+
async (params) => {
|
|
131
|
+
try {
|
|
132
|
+
const aquifer = getAquifer();
|
|
133
|
+
const limit = params.limit || 5;
|
|
134
|
+
const recallOpts = {
|
|
135
|
+
limit,
|
|
136
|
+
agentId: params.agentId || undefined,
|
|
137
|
+
source: params.source || undefined,
|
|
138
|
+
dateFrom: params.dateFrom || undefined,
|
|
139
|
+
dateTo: params.dateTo || undefined,
|
|
140
|
+
};
|
|
141
|
+
if (params.entities && params.entities.length > 0) {
|
|
142
|
+
recallOpts.entities = params.entities;
|
|
143
|
+
recallOpts.entityMode = params.entityMode || 'any';
|
|
144
|
+
}
|
|
145
|
+
if (params.mode) recallOpts.mode = params.mode;
|
|
146
|
+
if (params.allowUnsafeDebug) recallOpts.allowUnsafeDebug = true;
|
|
147
|
+
|
|
148
|
+
const results = await aquifer.evidenceRecall(params.query, recallOpts);
|
|
149
|
+
const text = formatResults(results, params.query, params.explain);
|
|
150
|
+
return { content: [{ type: 'text', text }] };
|
|
151
|
+
} catch (err) {
|
|
152
|
+
return {
|
|
153
|
+
content: [{ type: 'text', text: `evidence_recall error: ${err.message}` }],
|
|
154
|
+
isError: true,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
);
|
|
159
|
+
|
|
109
160
|
server.tool(
|
|
110
161
|
'session_feedback',
|
|
111
|
-
'After using session_recall, mark the
|
|
162
|
+
'After using legacy session_recall or bootstrap, mark the recalled session helpful or unhelpful. This only targets legacy session trust, not curated memory rows.',
|
|
112
163
|
{
|
|
113
164
|
sessionId: z.string().min(1).describe('Session ID to give feedback on'),
|
|
114
165
|
verdict: z.enum(['helpful', 'unhelpful']).describe('Was the recalled session useful?'),
|
|
@@ -135,6 +186,42 @@ async function main() {
|
|
|
135
186
|
}
|
|
136
187
|
);
|
|
137
188
|
|
|
189
|
+
server.tool(
|
|
190
|
+
'memory_feedback',
|
|
191
|
+
'Record append-only feedback on a curated memory row. This affects curated ranking/review priority only and does not mutate memory truth.',
|
|
192
|
+
{
|
|
193
|
+
memoryId: z.string().min(1).optional().describe('Curated memory record ID to give feedback on'),
|
|
194
|
+
canonicalKey: z.string().min(1).optional().describe('Canonical key of the active curated memory record'),
|
|
195
|
+
feedbackType: z.enum(['helpful', 'confirm', 'irrelevant', 'scope_mismatch', 'stale', 'incorrect']).describe('Curated memory feedback event type'),
|
|
196
|
+
note: z.string().optional().describe('Optional reason'),
|
|
197
|
+
agentId: z.string().optional().describe('Optional actor/agent label for audit metadata'),
|
|
198
|
+
},
|
|
199
|
+
async (params) => {
|
|
200
|
+
try {
|
|
201
|
+
if (!params.memoryId && !params.canonicalKey) {
|
|
202
|
+
throw new Error('memory_feedback requires memoryId or canonicalKey');
|
|
203
|
+
}
|
|
204
|
+
const aquifer = getAquifer();
|
|
205
|
+
const result = await aquifer.memoryFeedback({
|
|
206
|
+
memoryId: params.memoryId || undefined,
|
|
207
|
+
canonicalKey: params.canonicalKey || undefined,
|
|
208
|
+
}, {
|
|
209
|
+
feedbackType: params.feedbackType,
|
|
210
|
+
note: params.note || undefined,
|
|
211
|
+
agentId: params.agentId || undefined,
|
|
212
|
+
});
|
|
213
|
+
return {
|
|
214
|
+
content: [{ type: 'text', text: `Memory feedback: ${result.feedbackType}` }],
|
|
215
|
+
};
|
|
216
|
+
} catch (err) {
|
|
217
|
+
return {
|
|
218
|
+
content: [{ type: 'text', text: `memory_feedback error: ${err.message}` }],
|
|
219
|
+
isError: true,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
);
|
|
224
|
+
|
|
138
225
|
server.tool(
|
|
139
226
|
'feedback_stats',
|
|
140
227
|
'Return trust feedback statistics: total feedback count, helpful/unhelpful breakdown, trust score distribution, and coverage.',
|
|
@@ -229,6 +316,8 @@ async function main() {
|
|
|
229
316
|
limit: z.number().int().min(1).max(20).optional().describe('Max sessions (default 5)'),
|
|
230
317
|
lookbackDays: z.number().int().min(1).max(90).optional().describe('How far back in days (default 14)'),
|
|
231
318
|
maxChars: z.number().int().min(500).max(12000).optional().describe('Max output characters (default 4000)'),
|
|
319
|
+
activeScopeKey: z.string().optional().describe('Active curated memory scope key'),
|
|
320
|
+
activeScopePath: z.array(z.string()).optional().describe('Ordered curated scope path'),
|
|
232
321
|
},
|
|
233
322
|
async (params) => {
|
|
234
323
|
try {
|
|
@@ -238,6 +327,8 @@ async function main() {
|
|
|
238
327
|
limit: params.limit,
|
|
239
328
|
lookbackDays: params.lookbackDays,
|
|
240
329
|
maxChars: params.maxChars,
|
|
330
|
+
activeScopeKey: params.activeScopeKey || undefined,
|
|
331
|
+
activeScopePath: params.activeScopePath || undefined,
|
|
241
332
|
format: 'text',
|
|
242
333
|
});
|
|
243
334
|
return { content: [{ type: 'text', text: result.text }] };
|
|
@@ -201,7 +201,7 @@ function register(api) {
|
|
|
201
201
|
|
|
202
202
|
return {
|
|
203
203
|
name: 'session_recall',
|
|
204
|
-
description: 'Search
|
|
204
|
+
description: 'Search Aquifer memory by keyword. In curated serving mode this searches active curated memory; legacy/evidence lookup is exposed by the MCP stdio evidence_recall tool. Supports entity intersection for precise multi-entity queries.',
|
|
205
205
|
parameters: {
|
|
206
206
|
type: 'object',
|
|
207
207
|
properties: {
|
|
@@ -40,6 +40,11 @@ const DEFAULTS = {
|
|
|
40
40
|
},
|
|
41
41
|
},
|
|
42
42
|
rank: { rrf: 0.65, timeDecay: 0.25, access: 0.10, entityBoost: 0.18 },
|
|
43
|
+
memory: {
|
|
44
|
+
servingMode: 'legacy', // 'legacy' | 'curated'
|
|
45
|
+
activeScopeKey: null,
|
|
46
|
+
activeScopePath: null,
|
|
47
|
+
},
|
|
43
48
|
rerank: {
|
|
44
49
|
enabled: false,
|
|
45
50
|
provider: null, // 'tei' | 'jina' | 'openrouter' | 'custom'
|
|
@@ -87,6 +92,9 @@ const ENV_MAP = [
|
|
|
87
92
|
['AQUIFER_INSIGHTS_DEDUP_MODE', 'insights.dedup.mode'],
|
|
88
93
|
['AQUIFER_INSIGHTS_DEDUP_COSINE', 'insights.dedup.cosineThreshold', Number],
|
|
89
94
|
['AQUIFER_INSIGHTS_DEDUP_CLOSE_BAND_FROM', 'insights.dedup.closeBandFrom', Number],
|
|
95
|
+
['AQUIFER_MEMORY_SERVING_MODE', 'memory.servingMode'],
|
|
96
|
+
['AQUIFER_MEMORY_ACTIVE_SCOPE_KEY', 'memory.activeScopeKey'],
|
|
97
|
+
['AQUIFER_MEMORY_ACTIVE_SCOPE_PATH', 'memory.activeScopePath'],
|
|
90
98
|
['AQUIFER_RERANK_ENABLED', 'rerank.enabled', Boolean],
|
|
91
99
|
['AQUIFER_RERANK_PROVIDER', 'rerank.provider'],
|
|
92
100
|
['AQUIFER_RERANK_BASE_URL', 'rerank.baseUrl'],
|
|
@@ -37,7 +37,7 @@ function evictStale(dedupMap, now = Date.now()) {
|
|
|
37
37
|
* @param {string} [opts.source] — caller-provided source tag (e.g. 'openclaw', 'cc', 'opencode')
|
|
38
38
|
* @param {string} [opts.sessionKey] — passed through to commit()
|
|
39
39
|
* @param {any[]} opts.rawEntries — host-native session entries
|
|
40
|
-
* @param {'gateway'|'cc'|'claude-code'|'preNormalized'} [opts.adapter]
|
|
40
|
+
* @param {'gateway'|'cc'|'claude-code'|'codex'|'preNormalized'} [opts.adapter]
|
|
41
41
|
* 'preNormalized' means rawEntries already matches normalizeMessages output
|
|
42
42
|
* (used by OpenCode which reads SQLite directly).
|
|
43
43
|
* @param {object} [opts.preNormalized] — { messages, userCount, ... } ready to commit,
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// session-level metadata. Wraps pipeline/normalize so consumers don't each
|
|
6
6
|
// reinvent their own role/content extraction.
|
|
7
7
|
//
|
|
8
|
-
// Supported adapters: 'gateway' | 'cc' (alias of 'claude-code'). The OpenCode
|
|
8
|
+
// Supported adapters: 'gateway' | 'cc' (alias of 'claude-code') | 'codex'. The OpenCode
|
|
9
9
|
// consumer reads from SQLite and constructs the output shape directly; it is
|
|
10
10
|
// not expected to route through here.
|
|
11
11
|
//
|
|
@@ -21,13 +21,14 @@ const ADAPTER_ALIASES = {
|
|
|
21
21
|
'cc': 'claude-code',
|
|
22
22
|
'claude-code': 'claude-code',
|
|
23
23
|
'gateway': 'gateway',
|
|
24
|
+
'codex': 'codex',
|
|
24
25
|
};
|
|
25
26
|
|
|
26
27
|
function resolveAdapter(adapter) {
|
|
27
28
|
if (!adapter) return null; // auto-detect
|
|
28
29
|
const name = ADAPTER_ALIASES[adapter];
|
|
29
30
|
if (!name) {
|
|
30
|
-
throw new Error(`Unknown adapter: "${adapter}". Supported: gateway, cc (alias claude-code).`);
|
|
31
|
+
throw new Error(`Unknown adapter: "${adapter}". Supported: gateway, cc (alias claude-code), codex.`);
|
|
31
32
|
}
|
|
32
33
|
return name;
|
|
33
34
|
}
|
|
@@ -39,6 +40,16 @@ function extractRawMeta(rawEntries) {
|
|
|
39
40
|
|
|
40
41
|
for (const entry of rawEntries || []) {
|
|
41
42
|
if (!entry || typeof entry !== 'object') continue;
|
|
43
|
+
|
|
44
|
+
if (entry.type === 'turn_context' && entry.payload?.model && !model) {
|
|
45
|
+
model = entry.payload.model;
|
|
46
|
+
}
|
|
47
|
+
if (entry.type === 'event_msg' && entry.payload?.type === 'token_count') {
|
|
48
|
+
const usage = entry.payload?.info?.last_token_usage || {};
|
|
49
|
+
tokensIn += usage.input_tokens || usage.input || 0;
|
|
50
|
+
tokensOut += usage.output_tokens || usage.output || 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
42
53
|
const msg = entry.message || entry;
|
|
43
54
|
if (msg && typeof msg === 'object') {
|
|
44
55
|
if (msg.model && !model) model = msg.model;
|
|
@@ -57,7 +68,7 @@ function extractRawMeta(rawEntries) {
|
|
|
57
68
|
*
|
|
58
69
|
* @param {any[]} rawEntries
|
|
59
70
|
* @param {object} [opts]
|
|
60
|
-
* @param {'gateway'|'cc'|'claude-code'} [opts.adapter] — host adapter; auto-detected if omitted
|
|
71
|
+
* @param {'gateway'|'cc'|'claude-code'|'codex'} [opts.adapter] — host adapter; auto-detected if omitted
|
|
61
72
|
* @returns {{
|
|
62
73
|
* messages: {role:string,content:string,timestamp:string|null}[],
|
|
63
74
|
* userCount: number, assistantCount: number,
|
|
@@ -18,6 +18,24 @@ function formatDateIso(value) {
|
|
|
18
18
|
return Number.isNaN(d.getTime()) ? 'unknown' : d.toISOString().slice(0, 10);
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
function isCuratedMemoryResult(result) {
|
|
22
|
+
return Boolean(result && (
|
|
23
|
+
result.memoryType || result.memory_type ||
|
|
24
|
+
result.canonicalKey || result.canonical_key ||
|
|
25
|
+
result.scopeKey || result.scope_key
|
|
26
|
+
));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function curatedTitle(result) {
|
|
30
|
+
const type = result.memoryType || result.memory_type || 'memory';
|
|
31
|
+
const title = result.title || result.summary || result.canonicalKey || result.canonical_key || type;
|
|
32
|
+
return truncate(title, 80);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function resultDate(result) {
|
|
36
|
+
return result.startedAt || result.acceptedAt || result.accepted_at || result.observedAt || result.observed_at || null;
|
|
37
|
+
}
|
|
38
|
+
|
|
21
39
|
// Humanize a past timestamp into zh-TW relative form (e.g. "3 天前", "昨天").
|
|
22
40
|
// Bucketed on raw ms-diff — good enough for model intuition, not calendar-precise.
|
|
23
41
|
// Returns null for invalid / future timestamps so callers can fall back.
|
|
@@ -48,6 +66,11 @@ const defaultRenderers = {
|
|
|
48
66
|
return query ? `No results found for "${query}".` : 'No matching sessions found.';
|
|
49
67
|
},
|
|
50
68
|
title(result, index) {
|
|
69
|
+
if (isCuratedMemoryResult(result)) {
|
|
70
|
+
const date = formatDateIso(resultDate(result));
|
|
71
|
+
const scope = result.scopeKey || result.scope_key || 'scope:unknown';
|
|
72
|
+
return `### ${index + 1}. ${curatedTitle(result)} (${date}, ${scope})`;
|
|
73
|
+
}
|
|
51
74
|
const ss = result.structuredSummary || {};
|
|
52
75
|
const title = ss.title || truncate(result.summaryText, 60) || '(untitled)';
|
|
53
76
|
const date = formatDateIso(result.startedAt);
|
|
@@ -55,6 +78,10 @@ const defaultRenderers = {
|
|
|
55
78
|
return `### ${index + 1}. ${title} (${date}, ${agent})`;
|
|
56
79
|
},
|
|
57
80
|
body(result) {
|
|
81
|
+
if (isCuratedMemoryResult(result)) {
|
|
82
|
+
const text = result.summary || result.title || result.canonicalKey || result.canonical_key || '';
|
|
83
|
+
return text ? truncate(text, 300) : null;
|
|
84
|
+
}
|
|
58
85
|
const ss = result.structuredSummary || {};
|
|
59
86
|
const text = ss.overview || result.summaryText || '';
|
|
60
87
|
return text ? truncate(text, 300) : null;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_SUMMARY_MARKERS = [
|
|
4
|
+
'===SESSION_ENTRIES===',
|
|
5
|
+
'===EMOTIONAL_STATE===',
|
|
6
|
+
'===RECAP===',
|
|
7
|
+
'===ENTITIES===',
|
|
8
|
+
'===WORKING_FACTS===',
|
|
9
|
+
'===HANDOFF===',
|
|
10
|
+
];
|
|
11
|
+
const SUMMARY_MARKERS = DEFAULT_SUMMARY_MARKERS;
|
|
12
|
+
|
|
13
|
+
function parseSummaryOutput(output, markers = DEFAULT_SUMMARY_MARKERS) {
|
|
14
|
+
const sections = {};
|
|
15
|
+
for (let i = 0; i < markers.length; i++) {
|
|
16
|
+
const start = output.indexOf(markers[i]);
|
|
17
|
+
if (start === -1) continue;
|
|
18
|
+
const contentStart = start + markers[i].length;
|
|
19
|
+
let end = output.length;
|
|
20
|
+
for (let j = i + 1; j < markers.length; j++) {
|
|
21
|
+
const candidate = output.indexOf(markers[j], contentStart);
|
|
22
|
+
if (candidate !== -1) { end = candidate; break; }
|
|
23
|
+
}
|
|
24
|
+
const key = markers[i].replace(/===/g, '').toLowerCase();
|
|
25
|
+
sections[key] = (end > contentStart ? output.slice(contentStart, end) : output.slice(contentStart)).trim();
|
|
26
|
+
}
|
|
27
|
+
return sections;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function normalizeOpenOwner(raw) {
|
|
31
|
+
const owner = (raw || 'unknown').trim().toLowerCase();
|
|
32
|
+
if (['mk', 'agent', 'unknown'].includes(owner)) return owner;
|
|
33
|
+
if (/^[a-z][a-z0-9_-]{0,63}$/.test(owner)) return owner;
|
|
34
|
+
return 'unknown';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parseRecapLines(text) {
|
|
38
|
+
const recap = {
|
|
39
|
+
title: '', overview: '', topics: [], decisions: [], actions_completed: [],
|
|
40
|
+
open_loops: [], files_mentioned: [], important_facts: [], reusable_patterns: [],
|
|
41
|
+
focus_decision: 'keep', focus: '', todo_new: [], todo_done: [],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
for (const line of (text || '').split('\n')) {
|
|
45
|
+
const trimmed = line.trim();
|
|
46
|
+
if (!trimmed) continue;
|
|
47
|
+
const match = trimmed.match(/^([A-Z_]+):\s*(.*)/);
|
|
48
|
+
if (!match) continue;
|
|
49
|
+
const [, tag, value] = match;
|
|
50
|
+
|
|
51
|
+
switch (tag) {
|
|
52
|
+
case 'TITLE': recap.title = value; break;
|
|
53
|
+
case 'OVERVIEW': recap.overview = value; break;
|
|
54
|
+
case 'TOPIC': {
|
|
55
|
+
const p = value.split('|').map(s => s.trim());
|
|
56
|
+
if (p[0]) recap.topics.push({ name: p[0], summary: p[1] || '' });
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
case 'DECISION': {
|
|
60
|
+
const p = value.split('|').map(s => s.trim());
|
|
61
|
+
if (p[0]) recap.decisions.push({ decision: p[0], reason: p[1] || '' });
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
case 'ACTION': {
|
|
65
|
+
const p = value.split('|').map(s => s.trim());
|
|
66
|
+
if (p[0]) recap.actions_completed.push({
|
|
67
|
+
action: p[0],
|
|
68
|
+
status: (p[1] || 'done').toLowerCase() === 'partial' ? 'partial' : 'done',
|
|
69
|
+
});
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
case 'OPEN': {
|
|
73
|
+
const p = value.split('|').map(s => s.trim());
|
|
74
|
+
if (p[0]) recap.open_loops.push({
|
|
75
|
+
item: p[0],
|
|
76
|
+
owner: normalizeOpenOwner(p[1]),
|
|
77
|
+
});
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
case 'FACT': if (value) recap.important_facts.push(value); break;
|
|
81
|
+
case 'PATTERN': {
|
|
82
|
+
const p = value.split('|').map(s => s.trim());
|
|
83
|
+
if (p[0] && p[1]) recap.reusable_patterns.push({
|
|
84
|
+
pattern: p[0], trigger: p[1], action: p[2] || '',
|
|
85
|
+
durability: (p[3] || 'derived').toLowerCase() === 'invariant' ? 'invariant' : 'derived',
|
|
86
|
+
});
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
case 'FOCUS_DECISION':
|
|
90
|
+
recap.focus_decision = value.toLowerCase().trim() === 'update' ? 'update' : 'keep';
|
|
91
|
+
break;
|
|
92
|
+
case 'FOCUS': recap.focus = value; break;
|
|
93
|
+
case 'TODO_NEW': if (value) recap.todo_new.push(value); break;
|
|
94
|
+
case 'TODO_DONE': if (value) recap.todo_done.push(value); break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return recap;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function parseWorkingFacts(text) {
|
|
101
|
+
if (!text || typeof text !== 'string') return [];
|
|
102
|
+
const facts = [];
|
|
103
|
+
for (const line of text.split('\n')) {
|
|
104
|
+
const m = line.trim().match(/^WFACT:\s*(.+?)\s*\|\s*(.+)/);
|
|
105
|
+
if (!m) continue;
|
|
106
|
+
const subject = m[1].trim().slice(0, 100);
|
|
107
|
+
const statement = m[2].trim().slice(0, 500);
|
|
108
|
+
if (!subject || !statement) continue;
|
|
109
|
+
facts.push({ subject, statement });
|
|
110
|
+
if (facts.length >= 5) break;
|
|
111
|
+
}
|
|
112
|
+
return facts;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const VALID_HANDOFF_STATUS = new Set(['in_progress', 'interrupted', 'completed', 'blocked']);
|
|
116
|
+
const VALID_STOP_REASON = new Set(['natural', 'interrupted', 'blocked', 'context_full']);
|
|
117
|
+
|
|
118
|
+
function normalizeEnum(raw, validSet) {
|
|
119
|
+
const v = raw.trim().toLowerCase().replace(/-/g, '_').replace(/\s+/g, '_');
|
|
120
|
+
return validSet.has(v) ? v : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function parseHandoffSection(text) {
|
|
124
|
+
if (!text || typeof text !== 'string') return null;
|
|
125
|
+
const handoff = { status: 'completed', lastStep: '', next: '', stopReason: 'natural', decided: '', blocker: '' };
|
|
126
|
+
for (const line of text.split('\n')) {
|
|
127
|
+
const m = line.trim().match(/^([A-Z_]+):\s*(.*)/);
|
|
128
|
+
if (!m) continue;
|
|
129
|
+
const [, tag, value] = m;
|
|
130
|
+
switch (tag) {
|
|
131
|
+
case 'STATUS': handoff.status = normalizeEnum(value, VALID_HANDOFF_STATUS) || 'completed'; break;
|
|
132
|
+
case 'LAST_STEP': handoff.lastStep = value.trim().slice(0, 200); break;
|
|
133
|
+
case 'NEXT': handoff.next = value.trim().slice(0, 200); break;
|
|
134
|
+
case 'STOP_REASON': handoff.stopReason = normalizeEnum(value, VALID_STOP_REASON) || 'natural'; break;
|
|
135
|
+
case 'DECIDED': handoff.decided = value.trim().slice(0, 200); break;
|
|
136
|
+
case 'BLOCKER': handoff.blocker = value.trim().slice(0, 200); break;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (!handoff.lastStep || !handoff.next) return null;
|
|
140
|
+
return handoff;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
module.exports = {
|
|
144
|
+
DEFAULT_SUMMARY_MARKERS,
|
|
145
|
+
SUMMARY_MARKERS,
|
|
146
|
+
parseSummaryOutput,
|
|
147
|
+
parseRecapLines,
|
|
148
|
+
parseWorkingFacts,
|
|
149
|
+
parseHandoffSection,
|
|
150
|
+
normalizeOpenOwner,
|
|
151
|
+
};
|