@yeaft/webchat-agent 0.1.531 → 0.1.532
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/package.json +1 -1
- package/unify/engine.js +3 -0
- package/unify/memory/migrate-r5-to-r6.js +61 -0
- package/unify/memory/recall-r6.js +291 -0
- package/unify/memory/schema.js +166 -0
- package/unify/memory/shard-store.js +373 -0
- package/unify/tools/index.js +4 -0
- package/unify/tools/memory-trace.js +135 -0
- package/unify/tools/open-source-message.js +49 -0
package/package.json
CHANGED
package/unify/engine.js
CHANGED
|
@@ -310,6 +310,9 @@ export class Engine {
|
|
|
310
310
|
memoryInjection,
|
|
311
311
|
compactSummary,
|
|
312
312
|
skillContent,
|
|
313
|
+
// task-334f: memory_trace tool is now registered (49 → 51 tools), so
|
|
314
|
+
// unlock the core_memory meta-line behind 334e's feature flag.
|
|
315
|
+
memoryTraceAvailable: true,
|
|
313
316
|
});
|
|
314
317
|
}
|
|
315
318
|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* migrate-r5-to-r6.js — task-334f §Δ23 migration stub.
|
|
3
|
+
*
|
|
4
|
+
* Legacy (R5): `~/.yeaft/memory/entries/<slug>.md` plus numeric shard files
|
|
5
|
+
* `memory-001.md`, `memory-002.md`, ...
|
|
6
|
+
*
|
|
7
|
+
* R6: `~/.yeaft/memory/vp/<vpId>/memory-<semantic>.md`
|
|
8
|
+
* Semantic shards: skill / relations / lessons / preferences /
|
|
9
|
+
* project-<slug>
|
|
10
|
+
*
|
|
11
|
+
* This slice (334f) only DEFINES the API surface and a dry-run classifier.
|
|
12
|
+
* The actual batch migration runs in 334i; 334f does not mutate disk.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, readdirSync, readFileSync } from 'fs';
|
|
16
|
+
import { join } from 'path';
|
|
17
|
+
import { parseEntry } from './store.js';
|
|
18
|
+
import { classifyLegacyEntryToShard } from './shard-store.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Produce a migration plan without applying it.
|
|
22
|
+
*
|
|
23
|
+
* @param {string} legacyEntriesDir e.g. ~/.yeaft/memory/entries
|
|
24
|
+
* @returns {{
|
|
25
|
+
* totalEntries: number,
|
|
26
|
+
* plan: Array<{ slug: string, shard: string, kind: string, tags: string[] }>,
|
|
27
|
+
* byShard: Record<string, number>
|
|
28
|
+
* }}
|
|
29
|
+
*/
|
|
30
|
+
export function planR5ToR6Migration(legacyEntriesDir) {
|
|
31
|
+
if (!legacyEntriesDir || !existsSync(legacyEntriesDir)) {
|
|
32
|
+
return { totalEntries: 0, plan: [], byShard: {} };
|
|
33
|
+
}
|
|
34
|
+
const files = readdirSync(legacyEntriesDir).filter(f => f.endsWith('.md'));
|
|
35
|
+
const plan = [];
|
|
36
|
+
const byShard = {};
|
|
37
|
+
for (const file of files) {
|
|
38
|
+
const raw = readFileSync(join(legacyEntriesDir, file), 'utf8');
|
|
39
|
+
const entry = parseEntry(raw);
|
|
40
|
+
if (!entry) continue;
|
|
41
|
+
const shard = classifyLegacyEntryToShard(entry);
|
|
42
|
+
plan.push({
|
|
43
|
+
slug: file.replace(/\.md$/, ''),
|
|
44
|
+
shard,
|
|
45
|
+
kind: entry.kind || 'fact',
|
|
46
|
+
tags: entry.tags || [],
|
|
47
|
+
});
|
|
48
|
+
byShard[shard] = (byShard[shard] || 0) + 1;
|
|
49
|
+
}
|
|
50
|
+
return { totalEntries: plan.length, plan, byShard };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Apply the migration. STUB — 334i will fill in the body writer. 334f keeps
|
|
55
|
+
* this function exported so downstream tests can assert the hook exists.
|
|
56
|
+
*
|
|
57
|
+
* @param {object} _opts { legacyEntriesDir, targetDir, vpId, dryRun }
|
|
58
|
+
*/
|
|
59
|
+
export async function applyR5ToR6Migration(_opts) {
|
|
60
|
+
throw new Error('applyR5ToR6Migration: not yet implemented (task-334i)');
|
|
61
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* recall-r6.js — task-334f R6 4-step recall pipeline (§Δ24.2).
|
|
3
|
+
*
|
|
4
|
+
* Pipeline:
|
|
5
|
+
* Step 1: Shard classifier — fastModel OR keyword heuristic → top-1~2 shards
|
|
6
|
+
* Step 2: In-shard candidate — scan only the selected shards, filter by kind/tags/pinned
|
|
7
|
+
* Step 3: LLM rerank — pick top-7 from candidates
|
|
8
|
+
* Step 4: Inject body only — return entries without sourceRef (§Δ23)
|
|
9
|
+
*
|
|
10
|
+
* The legacy R5 recall (agent/unify/memory/recall.js) remains for back-compat
|
|
11
|
+
* of callers still on MemoryStore. R6 callers should use `recallR6()` below.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createHash } from 'crypto';
|
|
15
|
+
import { pickEffort } from '../effort.js';
|
|
16
|
+
import {
|
|
17
|
+
VP_DEFAULT_SHARDS,
|
|
18
|
+
TASK_SHARDS,
|
|
19
|
+
USER_SHARDS,
|
|
20
|
+
} from './schema.js';
|
|
21
|
+
|
|
22
|
+
const MAX_RECALL_RESULTS = 7;
|
|
23
|
+
const MAX_CANDIDATES = 15;
|
|
24
|
+
|
|
25
|
+
// ─── Step 1: Shard Classifier ───────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Keyword → shard heuristic. Zero-cost fallback when fastModel is unavailable.
|
|
29
|
+
*
|
|
30
|
+
* The lexicons are intentionally small: classifier output only needs to point
|
|
31
|
+
* at the most likely shard; LLM rerank in Step 3 catches misses. Empirically
|
|
32
|
+
* this covers > 70% of queries with zero LLM cost.
|
|
33
|
+
*/
|
|
34
|
+
const SHARD_LEXICON = {
|
|
35
|
+
skill: ['code', 'api', 'library', 'framework', 'implement', 'debug', 'syntax', 'typescript', 'vue', 'pattern', '代码', '实现', '调试', '语法', '模式', '技术'],
|
|
36
|
+
lessons: ['mistake', 'avoid', 'lesson', 'pitfall', 'gotcha', 'bug', 'regression', '教训', '避坑', '坑', '踩坑', '反模式'],
|
|
37
|
+
preferences: ['prefer', 'like', 'style', 'convention', 'favorite', '偏好', '风格', '习惯', '喜欢'],
|
|
38
|
+
relations: ['colleague', 'partner', 'team', 'user', 'collaborator', 'vp', '同事', '队友', '协作', '关系'],
|
|
39
|
+
// Task-memory shards
|
|
40
|
+
decision: ['decide', 'decision', 'chose', 'picked', 'resolved', '决定', '决策', '选择'],
|
|
41
|
+
progress: ['done', 'progress', 'milestone', 'shipped', 'finished', '完成', '进度', '交付'],
|
|
42
|
+
context: ['background', 'context', 'requirement', 'scope', '背景', '需求', '范围'],
|
|
43
|
+
blocker: ['block', 'stuck', 'blocker', 'issue', 'waiting', '阻塞', '卡住', '等待'],
|
|
44
|
+
artifact: ['pr', 'commit', 'doc', 'file', 'link', 'artifact', 'deliverable', 'commit', '产出', '文档'],
|
|
45
|
+
// User-memory shards
|
|
46
|
+
profile: ['name', 'role', 'background', 'who', 'identity', '身份', '角色', '背景'],
|
|
47
|
+
projects: ['project', 'repo', '项目', '仓库'],
|
|
48
|
+
goals: ['goal', 'target', 'okr', 'plan', '目标', '计划'],
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Pick top-N shards via keyword heuristic.
|
|
53
|
+
* @param {string} prompt
|
|
54
|
+
* @param {string[]} availableShards all shards present in this store
|
|
55
|
+
* @param {number} [topN=2]
|
|
56
|
+
*/
|
|
57
|
+
export function classifyShardsByKeyword(prompt, availableShards, topN = 2) {
|
|
58
|
+
if (!prompt || !availableShards || availableShards.length === 0) return [];
|
|
59
|
+
const lower = prompt.toLowerCase();
|
|
60
|
+
const scores = new Map();
|
|
61
|
+
for (const shard of availableShards) {
|
|
62
|
+
// project-<slug> shards score via their slug
|
|
63
|
+
const lex = SHARD_LEXICON[shard] || [];
|
|
64
|
+
let score = 0;
|
|
65
|
+
for (const kw of lex) {
|
|
66
|
+
if (lower.includes(kw)) score += 1;
|
|
67
|
+
}
|
|
68
|
+
if (shard.startsWith('project-')) {
|
|
69
|
+
const slug = shard.slice('project-'.length);
|
|
70
|
+
if (slug && lower.includes(slug.toLowerCase())) score += 3;
|
|
71
|
+
}
|
|
72
|
+
if (score > 0) scores.set(shard, score);
|
|
73
|
+
}
|
|
74
|
+
const ranked = [...scores.entries()].sort((a, b) => b[1] - a[1]).map(([s]) => s);
|
|
75
|
+
if (ranked.length >= 1) return ranked.slice(0, topN);
|
|
76
|
+
// Fallback: first N defaults in the shard set
|
|
77
|
+
return availableShards.slice(0, topN);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Shard classifier — calls fastModel if adapter provided, else falls back to
|
|
82
|
+
* the keyword heuristic. Budget < 200 tokens (§Δ24.2).
|
|
83
|
+
*/
|
|
84
|
+
export async function classifyShards({
|
|
85
|
+
prompt,
|
|
86
|
+
availableShards,
|
|
87
|
+
adapter,
|
|
88
|
+
fastModel,
|
|
89
|
+
topN = 2,
|
|
90
|
+
}) {
|
|
91
|
+
if (!adapter || !fastModel) {
|
|
92
|
+
return classifyShardsByKeyword(prompt, availableShards, topN);
|
|
93
|
+
}
|
|
94
|
+
const system = `You classify user queries into memory shards. Return ONLY a JSON array of up to ${topN} shard names from the provided list. No prose.`;
|
|
95
|
+
const user = `Available shards: ${JSON.stringify(availableShards)}
|
|
96
|
+
Query: ${JSON.stringify(prompt)}
|
|
97
|
+
Return JSON array of up to ${topN} most relevant shard names.`;
|
|
98
|
+
try {
|
|
99
|
+
const res = await adapter.call({
|
|
100
|
+
model: fastModel,
|
|
101
|
+
system,
|
|
102
|
+
messages: [{ role: 'user', content: user }],
|
|
103
|
+
maxTokens: 64,
|
|
104
|
+
effort: pickEffort({ scenario: 'recall' }),
|
|
105
|
+
});
|
|
106
|
+
const m = (res.text || '').match(/\[[\s\S]*?\]/);
|
|
107
|
+
if (!m) return classifyShardsByKeyword(prompt, availableShards, topN);
|
|
108
|
+
const arr = JSON.parse(m[0]);
|
|
109
|
+
const valid = arr.filter(s => typeof s === 'string' && availableShards.includes(s));
|
|
110
|
+
if (valid.length === 0) return classifyShardsByKeyword(prompt, availableShards, topN);
|
|
111
|
+
return valid.slice(0, topN);
|
|
112
|
+
} catch {
|
|
113
|
+
return classifyShardsByKeyword(prompt, availableShards, topN);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ─── Step 2: In-shard candidate generation ──────────────────────
|
|
118
|
+
|
|
119
|
+
function collectCandidates(memoryShardStore, { shards, kind, tags, pinned }) {
|
|
120
|
+
const filter = {};
|
|
121
|
+
if (shards && shards.length) filter.shard = shards.length === 1 ? shards[0] : shards;
|
|
122
|
+
if (kind) filter.kind = kind;
|
|
123
|
+
if (tags && tags.length) filter.tags = tags;
|
|
124
|
+
if (pinned !== undefined) filter.pinned = pinned;
|
|
125
|
+
const { results } = memoryShardStore.query(filter);
|
|
126
|
+
return results
|
|
127
|
+
// Drop superseded entries from the candidate pool (they stay on disk
|
|
128
|
+
// for memory_trace but should not compete for recall slots).
|
|
129
|
+
.filter(rec => !rec.supersededBy)
|
|
130
|
+
.slice(0, MAX_CANDIDATES);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ─── Step 3: LLM Rerank ─────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
async function llmRerank({ adapter, fastModel, prompt, candidates, memoryShardStore }) {
|
|
136
|
+
if (candidates.length <= MAX_RECALL_RESULTS) {
|
|
137
|
+
return candidates.map(c => c.id);
|
|
138
|
+
}
|
|
139
|
+
const lines = candidates.map((c, i) => {
|
|
140
|
+
return `${i + 1}. [id=${c.id}] shard=${c.shard} kind=${c.kind || '?'} tags=[${(c.tags || []).join(',')}]`;
|
|
141
|
+
}).join('\n');
|
|
142
|
+
const system = `You pick the most relevant memories for the user's prompt. Return ONLY a JSON array of entry ids (up to ${MAX_RECALL_RESULTS}). No prose.`;
|
|
143
|
+
const user = `User prompt: ${JSON.stringify(prompt)}
|
|
144
|
+
|
|
145
|
+
Candidate memories:
|
|
146
|
+
${lines}
|
|
147
|
+
|
|
148
|
+
Return JSON array of up to ${MAX_RECALL_RESULTS} ids.`;
|
|
149
|
+
try {
|
|
150
|
+
const res = await adapter.call({
|
|
151
|
+
model: fastModel,
|
|
152
|
+
system,
|
|
153
|
+
messages: [{ role: 'user', content: user }],
|
|
154
|
+
maxTokens: 256,
|
|
155
|
+
effort: pickEffort({ scenario: 'recall' }),
|
|
156
|
+
});
|
|
157
|
+
const m = (res.text || '').match(/\[[\s\S]*\]/);
|
|
158
|
+
if (!m) return candidates.slice(0, MAX_RECALL_RESULTS).map(c => c.id);
|
|
159
|
+
const arr = JSON.parse(m[0]).filter(x => typeof x === 'string');
|
|
160
|
+
const valid = arr.filter(id => candidates.find(c => c.id === id));
|
|
161
|
+
if (valid.length === 0) return candidates.slice(0, MAX_RECALL_RESULTS).map(c => c.id);
|
|
162
|
+
return valid.slice(0, MAX_RECALL_RESULTS);
|
|
163
|
+
} catch {
|
|
164
|
+
return candidates.slice(0, MAX_RECALL_RESULTS).map(c => c.id);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ─── Step 4: Inject (no sourceRef) ──────────────────────────────
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Produce the body-only injection payload (§Δ24.5). Prefix lines with
|
|
172
|
+
* `[mem:<shard>]` so the LLM knows the category without seeing the id.
|
|
173
|
+
*/
|
|
174
|
+
export function formatForInjection(entries) {
|
|
175
|
+
return entries.map(e => {
|
|
176
|
+
const prefix = `[mem:${e.shard}]`;
|
|
177
|
+
const body = (e.body || '').trim();
|
|
178
|
+
return `${prefix} ${body}`;
|
|
179
|
+
}).join('\n\n');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ─── Fingerprint cache ──────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
const _cache = new Map();
|
|
185
|
+
const CACHE_TTL = 5 * 60 * 1000;
|
|
186
|
+
|
|
187
|
+
function computeFingerprint({ shards, prompt, taskId }) {
|
|
188
|
+
const head = prompt.slice(0, 200);
|
|
189
|
+
const input = `${shards.join(',')}|${head}|${taskId || ''}`;
|
|
190
|
+
return createHash('sha256').update(input).digest('hex').slice(0, 16);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ─── Entry point ────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Run the R6 4-step recall pipeline.
|
|
197
|
+
*
|
|
198
|
+
* @param {{
|
|
199
|
+
* prompt: string,
|
|
200
|
+
* memoryShardStore: object, // openMemoryShardStore() handle
|
|
201
|
+
* adapter?: object, // LLM adapter (null = keyword-only)
|
|
202
|
+
* fastModel?: string, // fast model for classifier + rerank
|
|
203
|
+
* availableShards?: string[], // defaults to store stats
|
|
204
|
+
* taskId?: string,
|
|
205
|
+
* kind?: string, tags?: string[], pinned?: boolean,
|
|
206
|
+
* }} params
|
|
207
|
+
* @returns {Promise<{entries: object[], shards: string[], fingerprint: string, cached: boolean}>}
|
|
208
|
+
*/
|
|
209
|
+
export async function recallR6(params) {
|
|
210
|
+
const {
|
|
211
|
+
prompt,
|
|
212
|
+
memoryShardStore,
|
|
213
|
+
adapter,
|
|
214
|
+
fastModel,
|
|
215
|
+
availableShards,
|
|
216
|
+
taskId,
|
|
217
|
+
kind,
|
|
218
|
+
tags,
|
|
219
|
+
pinned,
|
|
220
|
+
} = params;
|
|
221
|
+
|
|
222
|
+
if (!prompt || !prompt.trim() || !memoryShardStore) {
|
|
223
|
+
return { entries: [], shards: [], fingerprint: '', cached: false };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const shardsFromStore = availableShards
|
|
227
|
+
|| Object.keys(memoryShardStore.stats().shards);
|
|
228
|
+
|
|
229
|
+
if (shardsFromStore.length === 0) {
|
|
230
|
+
return { entries: [], shards: [], fingerprint: '', cached: false };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Step 1
|
|
234
|
+
const chosenShards = await classifyShards({
|
|
235
|
+
prompt,
|
|
236
|
+
availableShards: shardsFromStore,
|
|
237
|
+
adapter,
|
|
238
|
+
fastModel,
|
|
239
|
+
topN: 2,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const fingerprint = computeFingerprint({ shards: chosenShards, prompt, taskId });
|
|
243
|
+
const cached = _cache.get(fingerprint);
|
|
244
|
+
if (cached && Date.now() - cached.t < CACHE_TTL) {
|
|
245
|
+
return { entries: cached.entries, shards: chosenShards, fingerprint, cached: true };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Step 2
|
|
249
|
+
const candidates = collectCandidates(memoryShardStore, {
|
|
250
|
+
shards: chosenShards,
|
|
251
|
+
kind,
|
|
252
|
+
tags,
|
|
253
|
+
pinned,
|
|
254
|
+
});
|
|
255
|
+
if (candidates.length === 0) {
|
|
256
|
+
_cache.set(fingerprint, { entries: [], t: Date.now() });
|
|
257
|
+
return { entries: [], shards: chosenShards, fingerprint, cached: false };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Step 3
|
|
261
|
+
const selectedIds = await llmRerank({
|
|
262
|
+
adapter, fastModel, prompt, candidates, memoryShardStore,
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// Step 4 — load full bodies (minus sourceRef for injection).
|
|
266
|
+
const entries = [];
|
|
267
|
+
for (const id of selectedIds) {
|
|
268
|
+
const full = memoryShardStore.get(id);
|
|
269
|
+
if (!full) continue;
|
|
270
|
+
entries.push({
|
|
271
|
+
id: full.id,
|
|
272
|
+
shard: full.shard,
|
|
273
|
+
kind: full.kind,
|
|
274
|
+
body: full.body,
|
|
275
|
+
tags: full.tags || [],
|
|
276
|
+
// deliberately do NOT expose sourceRef here (§Δ23)
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
_cache.set(fingerprint, { entries, t: Date.now() });
|
|
280
|
+
return { entries, shards: chosenShards, fingerprint, cached: false };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function clearR6RecallCache() {
|
|
284
|
+
_cache.clear();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export const R6_DEFAULTS = {
|
|
288
|
+
VP_DEFAULT_SHARDS,
|
|
289
|
+
TASK_SHARDS,
|
|
290
|
+
USER_SHARDS,
|
|
291
|
+
};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* schema.js — task-334f R6 Memory schema constants.
|
|
3
|
+
*
|
|
4
|
+
* References:
|
|
5
|
+
* §Δ22 Memory 本质定性 (6 公理)
|
|
6
|
+
* §Δ23 Memory Entry Schema 扩字段
|
|
7
|
+
* §Δ25 Shard 语义初始分类
|
|
8
|
+
* §Δ26.3 Shard 软上限
|
|
9
|
+
*
|
|
10
|
+
* This module is **data only** — no I/O, no LLM calls. It is safe to import
|
|
11
|
+
* from any layer (store, recall, tools) without circular hazards.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// ─── §Δ25.1 VP-memory default shard set ─────────────────────────
|
|
15
|
+
export const VP_DEFAULT_SHARDS = Object.freeze([
|
|
16
|
+
'skill',
|
|
17
|
+
'relations',
|
|
18
|
+
'lessons',
|
|
19
|
+
'preferences',
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
// ─── §Δ25.2 Task-memory fixed 5 shards ──────────────────────────
|
|
23
|
+
export const TASK_SHARDS = Object.freeze([
|
|
24
|
+
'decision',
|
|
25
|
+
'progress',
|
|
26
|
+
'context',
|
|
27
|
+
'blocker',
|
|
28
|
+
'artifact',
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
// ─── §Δ25.3 User-memory default shard set ───────────────────────
|
|
32
|
+
export const USER_SHARDS = Object.freeze([
|
|
33
|
+
'profile',
|
|
34
|
+
'preferences',
|
|
35
|
+
'projects',
|
|
36
|
+
'goals',
|
|
37
|
+
'relations',
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* §Δ26.3 soft-cap table. `project-<slug>` is matched via the dedicated
|
|
42
|
+
* helper `softCapFor()` below because its key is dynamic.
|
|
43
|
+
*
|
|
44
|
+
* Shape: { entries: number, bytes: number }
|
|
45
|
+
*/
|
|
46
|
+
export const SOFT_CAPS = Object.freeze({
|
|
47
|
+
// VP
|
|
48
|
+
skill: { entries: 80, bytes: 64 * 1024 },
|
|
49
|
+
lessons: { entries: 80, bytes: 64 * 1024 },
|
|
50
|
+
preferences: { entries: 80, bytes: 64 * 1024 },
|
|
51
|
+
relations: { entries: 50, bytes: 32 * 1024 },
|
|
52
|
+
// Task (Δ26.3 task-memory row)
|
|
53
|
+
decision: { entries: 40, bytes: 24 * 1024 },
|
|
54
|
+
progress: { entries: 40, bytes: 24 * 1024 },
|
|
55
|
+
context: { entries: 40, bytes: 24 * 1024 },
|
|
56
|
+
blocker: { entries: 40, bytes: 24 * 1024 },
|
|
57
|
+
artifact: { entries: 40, bytes: 24 * 1024 },
|
|
58
|
+
// User (Δ26.3 user-memory row — 60 entries / 48 KiB per shard)
|
|
59
|
+
profile: { entries: 60, bytes: 48 * 1024 },
|
|
60
|
+
projects: { entries: 60, bytes: 48 * 1024 },
|
|
61
|
+
goals: { entries: 60, bytes: 48 * 1024 },
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/** Project shards are dynamic: `project-<slug>` → 150 entries / 128 KiB. */
|
|
65
|
+
export const PROJECT_SHARD_SOFT_CAP = Object.freeze({
|
|
66
|
+
entries: 150,
|
|
67
|
+
bytes: 128 * 1024,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/** Default cap if callers ask for a shard not in the canonical set. */
|
|
71
|
+
export const DEFAULT_SOFT_CAP = Object.freeze({
|
|
72
|
+
entries: 80,
|
|
73
|
+
bytes: 64 * 1024,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Trigger threshold: when a dream sweep finds ≥ PROJECT_DERIVE_THRESHOLD
|
|
78
|
+
* memory entries tagged with the same groupId, it may derive a
|
|
79
|
+
* `project-<slug>` shard. (§Δ25.1)
|
|
80
|
+
*/
|
|
81
|
+
export const PROJECT_DERIVE_THRESHOLD = 30;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Max number of VP-memory shards — re-compression kicks in when exceeded
|
|
85
|
+
* (§Δ25.1: "最大 shard 数软上限 12").
|
|
86
|
+
*/
|
|
87
|
+
export const MAX_VP_SHARDS = 12;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Return the soft cap for a given shard name.
|
|
91
|
+
* Handles the dynamic `project-<slug>` case.
|
|
92
|
+
*/
|
|
93
|
+
export function softCapFor(shardName) {
|
|
94
|
+
if (typeof shardName !== 'string' || !shardName) return DEFAULT_SOFT_CAP;
|
|
95
|
+
if (shardName.startsWith('project-')) return PROJECT_SHARD_SOFT_CAP;
|
|
96
|
+
return SOFT_CAPS[shardName] || DEFAULT_SOFT_CAP;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Build a schema object suitable for `openShardStore(dir, schema)` (334o).
|
|
101
|
+
*
|
|
102
|
+
* @param {'vp'|'task'|'user'} kind
|
|
103
|
+
* @param {{ extraShards?: string[] }} [opts] e.g. existing project shards
|
|
104
|
+
* @returns {{ shards: string[], softCap: Record<string,{entries:number,bytes:number}>, defaultSoftCap: object }}
|
|
105
|
+
*/
|
|
106
|
+
export function buildShardSchema(kind, opts = {}) {
|
|
107
|
+
let shards;
|
|
108
|
+
switch (kind) {
|
|
109
|
+
case 'vp': shards = [...VP_DEFAULT_SHARDS]; break;
|
|
110
|
+
case 'task': shards = [...TASK_SHARDS]; break;
|
|
111
|
+
case 'user': shards = [...USER_SHARDS]; break;
|
|
112
|
+
default: throw new Error(`buildShardSchema: unknown kind "${kind}"`);
|
|
113
|
+
}
|
|
114
|
+
if (Array.isArray(opts.extraShards)) {
|
|
115
|
+
for (const s of opts.extraShards) {
|
|
116
|
+
if (typeof s === 'string' && s && !shards.includes(s)) shards.push(s);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const softCap = {};
|
|
120
|
+
for (const s of shards) softCap[s] = softCapFor(s);
|
|
121
|
+
return {
|
|
122
|
+
shards,
|
|
123
|
+
softCap,
|
|
124
|
+
defaultSoftCap: DEFAULT_SOFT_CAP,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* R6 memory-entry authored-by enum (§Δ23).
|
|
130
|
+
* Free-form strings are allowed; these are canonical examples.
|
|
131
|
+
*/
|
|
132
|
+
export const AUTHORED_BY = Object.freeze({
|
|
133
|
+
VP: (vpId) => `vp:${vpId}`,
|
|
134
|
+
USER: (uid) => `user:${uid}`,
|
|
135
|
+
SUMMARY: 'system:summary-extractor',
|
|
136
|
+
DREAM: 'system:dream',
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Validate an R6 entry shape (schema-level, no I/O).
|
|
141
|
+
* Throws on structural violation. Callers that want soft warnings should
|
|
142
|
+
* wrap in try/catch.
|
|
143
|
+
*/
|
|
144
|
+
export function validateR6Entry(entry) {
|
|
145
|
+
if (!entry || typeof entry !== 'object') throw new Error('entry must be an object');
|
|
146
|
+
if (!entry.id || typeof entry.id !== 'string') throw new Error('entry.id required');
|
|
147
|
+
if (!entry.shard || typeof entry.shard !== 'string') throw new Error('entry.shard required');
|
|
148
|
+
if (!entry.kind || typeof entry.kind !== 'string') throw new Error('entry.kind required');
|
|
149
|
+
// sourceRef is required except for identity/preference pure-declaration entries.
|
|
150
|
+
const needsSourceRef = !(entry.kind === 'identity' || entry.kind === 'preference');
|
|
151
|
+
if (needsSourceRef) {
|
|
152
|
+
if (!entry.sourceRef || typeof entry.sourceRef !== 'object') {
|
|
153
|
+
throw new Error('entry.sourceRef required for kind=' + entry.kind);
|
|
154
|
+
}
|
|
155
|
+
if (!Array.isArray(entry.sourceRef.msgIds) || entry.sourceRef.msgIds.length === 0) {
|
|
156
|
+
throw new Error('entry.sourceRef.msgIds required (non-empty array)');
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (entry.supersedes != null && !Array.isArray(entry.supersedes)) {
|
|
160
|
+
throw new Error('entry.supersedes must be an array when present');
|
|
161
|
+
}
|
|
162
|
+
if (entry.supersededBy != null && typeof entry.supersededBy !== 'string') {
|
|
163
|
+
throw new Error('entry.supersededBy must be a string when present');
|
|
164
|
+
}
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shard-store.js — task-334f R6 semantic-shard memory store (VP/task/user).
|
|
3
|
+
*
|
|
4
|
+
* This sits ON TOP OF 334o's `storage/shard-store.js` primitive. It adds:
|
|
5
|
+
* - R6 entry schema (shard / sourceRef / supersedes / supersededBy / authoredBy)
|
|
6
|
+
* - Frontmatter body serialisation (markdown-friendly, grep-able on disk)
|
|
7
|
+
* - Supersede chain management (Δ26.2 Phase B)
|
|
8
|
+
* - Atomic re-compression handoff (`memory-<shard>.md.compacting`)
|
|
9
|
+
* - Migration stub from legacy R5 `memory-NNN.md` → semantic shards (334i)
|
|
10
|
+
*
|
|
11
|
+
* Hard boundaries (task-334f guardrails):
|
|
12
|
+
* - does NOT touch 334o's jsonl-log layer
|
|
13
|
+
* - does NOT run dream extract / re-compression decisions (334g)
|
|
14
|
+
* - does NOT implement user-memory business logic (334l reuses this lib)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync } from 'fs';
|
|
18
|
+
import { join } from 'path';
|
|
19
|
+
import { openShardStore, writeAtomic } from '../storage/index.js';
|
|
20
|
+
import {
|
|
21
|
+
rebuildShardIndexFromDisk,
|
|
22
|
+
saveShardIndex,
|
|
23
|
+
} from '../storage/shard-index.js';
|
|
24
|
+
import {
|
|
25
|
+
buildShardSchema,
|
|
26
|
+
softCapFor,
|
|
27
|
+
PROJECT_DERIVE_THRESHOLD,
|
|
28
|
+
MAX_VP_SHARDS,
|
|
29
|
+
validateR6Entry,
|
|
30
|
+
AUTHORED_BY,
|
|
31
|
+
} from './schema.js';
|
|
32
|
+
|
|
33
|
+
const COMPACTING_SUFFIX = '.compacting';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Open (or create) an R6 memory shard store rooted at `dir`.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} dir filesystem directory (e.g. `~/.yeaft/memory/vp/<vpId>`)
|
|
39
|
+
* @param {'vp'|'task'|'user'} kind
|
|
40
|
+
* @param {{ extraShards?: string[] }} [opts]
|
|
41
|
+
* @returns {object} handle with put/get/query/remove/compact/...
|
|
42
|
+
*/
|
|
43
|
+
export function openMemoryShardStore(dir, kind = 'vp', opts = {}) {
|
|
44
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
45
|
+
|
|
46
|
+
// Hydrate any pre-existing project-* shards from disk so the schema
|
|
47
|
+
// allow-list recognises them on this open.
|
|
48
|
+
const extraShards = new Set(opts.extraShards || []);
|
|
49
|
+
for (const name of discoverOnDiskShards(dir)) {
|
|
50
|
+
if (!extraShards.has(name)) extraShards.add(name);
|
|
51
|
+
}
|
|
52
|
+
const schema = buildShardSchema(kind, { extraShards: [...extraShards] });
|
|
53
|
+
const inner = openShardStore(dir, schema);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Put an R6 entry. Merges the rich entry schema into `body` (frontmatter)
|
|
57
|
+
* and surfaces the 5 filter-worthy fields through `meta`.
|
|
58
|
+
*/
|
|
59
|
+
function put(entry) {
|
|
60
|
+
validateR6Entry(entry);
|
|
61
|
+
const body = serialiseR6Body(entry);
|
|
62
|
+
const meta = pickMeta(entry);
|
|
63
|
+
const res = inner.put({
|
|
64
|
+
id: entry.id,
|
|
65
|
+
shard: entry.shard,
|
|
66
|
+
body,
|
|
67
|
+
meta,
|
|
68
|
+
});
|
|
69
|
+
return res;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Retrieve an R6 entry (returns the parsed frontmatter + body). */
|
|
73
|
+
function get(id) {
|
|
74
|
+
const raw = inner.get(id);
|
|
75
|
+
if (!raw) return null;
|
|
76
|
+
const parsed = parseR6Body(raw.body);
|
|
77
|
+
return {
|
|
78
|
+
...parsed,
|
|
79
|
+
id: raw.id,
|
|
80
|
+
shard: raw.shard,
|
|
81
|
+
_meta: raw.meta || {},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Query. See storage/shard-store.js for the filter shape. */
|
|
86
|
+
function query(filter = {}) {
|
|
87
|
+
const res = inner.query(filter);
|
|
88
|
+
return {
|
|
89
|
+
results: res.results.map(mapRecordToThinEntry),
|
|
90
|
+
needsRecompression: res.needsRecompression.slice(),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Remove an entry; underlying shard compacts immediately. */
|
|
95
|
+
function remove(id) { return inner.remove(id); }
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Supersede: create a new entry N that replaces olds M[...].
|
|
99
|
+
* Writes N with supersedes=M[...], then marks each M.supersededBy=N.
|
|
100
|
+
* Old entries are NOT removed — they stay for audit / memory_trace.
|
|
101
|
+
*/
|
|
102
|
+
function supersede({ newEntry, oldIds }) {
|
|
103
|
+
validateR6Entry(newEntry);
|
|
104
|
+
if (!Array.isArray(oldIds) || oldIds.length === 0) {
|
|
105
|
+
throw new Error('supersede: oldIds required (non-empty)');
|
|
106
|
+
}
|
|
107
|
+
const supersedes = oldIds.slice();
|
|
108
|
+
const write = { ...newEntry, supersedes };
|
|
109
|
+
const r = put(write);
|
|
110
|
+
|
|
111
|
+
for (const oldId of oldIds) {
|
|
112
|
+
const existing = get(oldId);
|
|
113
|
+
if (!existing) continue;
|
|
114
|
+
const updated = { ...existing, supersededBy: newEntry.id };
|
|
115
|
+
put(updated);
|
|
116
|
+
}
|
|
117
|
+
return r;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Atomic re-compression handoff:
|
|
122
|
+
* caller writes the new shard body to `memory-<shard>.md.compacting`,
|
|
123
|
+
* then calls `commitRecompression(shard)` which atomically renames it
|
|
124
|
+
* over the live file. Readers always see either the old or the new file.
|
|
125
|
+
*
|
|
126
|
+
* Consumers (334g dream) build the new body themselves; we just manage
|
|
127
|
+
* the rename + stats recomputation.
|
|
128
|
+
*/
|
|
129
|
+
function stageRecompression(shardName, newBody) {
|
|
130
|
+
const tmpPath = join(dir, `memory-${shardName}.md${COMPACTING_SUFFIX}`);
|
|
131
|
+
writeAtomic(tmpPath, newBody);
|
|
132
|
+
return tmpPath;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function commitRecompression(shardName) {
|
|
136
|
+
const livePath = join(dir, `memory-${shardName}.md`);
|
|
137
|
+
const tmpPath = join(dir, `memory-${shardName}.md${COMPACTING_SUFFIX}`);
|
|
138
|
+
if (!existsSync(tmpPath)) {
|
|
139
|
+
throw new Error(`commitRecompression: no tmp file at ${tmpPath}`);
|
|
140
|
+
}
|
|
141
|
+
renameSync(tmpPath, livePath);
|
|
142
|
+
// The caller-supplied body replaced the whole shard. The old index rows
|
|
143
|
+
// for this shard are stale (new ids / offsets). Rebuild that shard's
|
|
144
|
+
// index rows from disk by delegating to the storage primitive — it walks
|
|
145
|
+
// START/END markers and recomputes offsets. We keep other shards intact.
|
|
146
|
+
const schema = buildShardSchema(kind, { extraShards: [...extraShards] });
|
|
147
|
+
const rebuilt = rebuildShardIndexFromDisk(dir, schema);
|
|
148
|
+
const innerIndex = inner.getIndex();
|
|
149
|
+
// Swap this shard's rows + bucket.
|
|
150
|
+
innerIndex.entries = innerIndex.entries.filter(e => e.shard !== shardName)
|
|
151
|
+
.concat(rebuilt.entries.filter(e => e.shard === shardName));
|
|
152
|
+
if (rebuilt.shards[shardName]) {
|
|
153
|
+
innerIndex.shards[shardName] = rebuilt.shards[shardName];
|
|
154
|
+
}
|
|
155
|
+
saveShardIndex(dir, innerIndex);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function abortRecompression(shardName) {
|
|
159
|
+
const tmpPath = join(dir, `memory-${shardName}.md${COMPACTING_SUFFIX}`);
|
|
160
|
+
if (existsSync(tmpPath)) unlinkSync(tmpPath);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Return shard-level stats (byte count, entry count, soft cap). */
|
|
164
|
+
function stats() {
|
|
165
|
+
const raw = inner.stats();
|
|
166
|
+
const shards = {};
|
|
167
|
+
for (const [name, bucket] of Object.entries(raw.shards)) {
|
|
168
|
+
shards[name] = { ...bucket, softCap: softCapFor(name) };
|
|
169
|
+
}
|
|
170
|
+
return { shards, count: raw.count };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Hint for the dream layer — returns `project-<slug>` candidate when
|
|
175
|
+
* ≥ PROJECT_DERIVE_THRESHOLD entries share a groupId and no project
|
|
176
|
+
* shard exists for that slug yet. Returns `null` otherwise.
|
|
177
|
+
*
|
|
178
|
+
* The actual derive (creating the shard file + re-compressing entries
|
|
179
|
+
* into it) is 334g dream work; this slice only advertises the hint so
|
|
180
|
+
* dream can schedule.
|
|
181
|
+
*/
|
|
182
|
+
function projectDeriveHint() {
|
|
183
|
+
const groupCounts = new Map();
|
|
184
|
+
const { results } = inner.query({});
|
|
185
|
+
for (const rec of results) {
|
|
186
|
+
const gid = rec.meta?.groupId;
|
|
187
|
+
if (!gid) continue;
|
|
188
|
+
groupCounts.set(gid, (groupCounts.get(gid) || 0) + 1);
|
|
189
|
+
}
|
|
190
|
+
for (const [gid, count] of groupCounts) {
|
|
191
|
+
if (count < PROJECT_DERIVE_THRESHOLD) continue;
|
|
192
|
+
const slug = slugify(gid);
|
|
193
|
+
const shardName = `project-${slug}`;
|
|
194
|
+
const shardNames = Object.keys(inner.stats().shards);
|
|
195
|
+
if (shardNames.includes(shardName)) continue;
|
|
196
|
+
if (shardNames.length >= MAX_VP_SHARDS) continue;
|
|
197
|
+
return { groupId: gid, shard: shardName, count };
|
|
198
|
+
}
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function close() { /* underlying store is stateless (index saved on every op) */ }
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
put,
|
|
206
|
+
get,
|
|
207
|
+
query,
|
|
208
|
+
remove,
|
|
209
|
+
supersede,
|
|
210
|
+
stageRecompression,
|
|
211
|
+
commitRecompression,
|
|
212
|
+
abortRecompression,
|
|
213
|
+
stats,
|
|
214
|
+
projectDeriveHint,
|
|
215
|
+
close,
|
|
216
|
+
_innerForTest: inner,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ─── Serialisation ──────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
function serialiseR6Body(entry) {
|
|
223
|
+
const fm = ['---'];
|
|
224
|
+
fm.push(`id: ${entry.id}`);
|
|
225
|
+
if (entry.vp) fm.push(`vp: ${entry.vp}`);
|
|
226
|
+
if (entry.taskId) fm.push(`taskId: ${entry.taskId}`);
|
|
227
|
+
fm.push(`kind: ${entry.kind}`);
|
|
228
|
+
fm.push(`shard: ${entry.shard}`);
|
|
229
|
+
if (entry.sourceRef) {
|
|
230
|
+
fm.push('sourceRef:');
|
|
231
|
+
if (entry.sourceRef.groupId) fm.push(` groupId: ${entry.sourceRef.groupId}`);
|
|
232
|
+
if (entry.sourceRef.taskId) fm.push(` taskId: ${entry.sourceRef.taskId}`);
|
|
233
|
+
if (Array.isArray(entry.sourceRef.msgIds) && entry.sourceRef.msgIds.length) {
|
|
234
|
+
fm.push(` msgIds: [${entry.sourceRef.msgIds.join(', ')}]`);
|
|
235
|
+
}
|
|
236
|
+
if (entry.sourceRef.timeWindow) fm.push(` timeWindow: ${entry.sourceRef.timeWindow}`);
|
|
237
|
+
if (entry.sourceRef.hint) fm.push(` hint: ${JSON.stringify(entry.sourceRef.hint)}`);
|
|
238
|
+
}
|
|
239
|
+
if (Array.isArray(entry.supersedes) && entry.supersedes.length) {
|
|
240
|
+
fm.push(`supersedes: [${entry.supersedes.join(', ')}]`);
|
|
241
|
+
}
|
|
242
|
+
if (entry.supersededBy) fm.push(`supersededBy: ${entry.supersededBy}`);
|
|
243
|
+
if (entry.pinned != null) fm.push(`pinned: ${entry.pinned ? 'true' : 'false'}`);
|
|
244
|
+
if (Array.isArray(entry.tags) && entry.tags.length) {
|
|
245
|
+
fm.push(`tags: [${entry.tags.join(', ')}]`);
|
|
246
|
+
}
|
|
247
|
+
if (entry.authoredBy) fm.push(`authoredBy: ${entry.authoredBy}`);
|
|
248
|
+
const now = new Date().toISOString();
|
|
249
|
+
fm.push(`createdAt: ${entry.createdAt || now}`);
|
|
250
|
+
fm.push(`updatedAt: ${now}`);
|
|
251
|
+
fm.push('---');
|
|
252
|
+
fm.push('');
|
|
253
|
+
fm.push(entry.body || entry.content || '');
|
|
254
|
+
return fm.join('\n');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function parseR6Body(raw) {
|
|
258
|
+
if (!raw || !raw.startsWith('---')) return { body: raw || '' };
|
|
259
|
+
const endIdx = raw.indexOf('\n---', 3);
|
|
260
|
+
if (endIdx === -1) return { body: raw };
|
|
261
|
+
const fm = raw.slice(4, endIdx).trim();
|
|
262
|
+
const body = raw.slice(endIdx + 4).replace(/^\n+/, '');
|
|
263
|
+
const out = { body };
|
|
264
|
+
let inSourceRef = false;
|
|
265
|
+
const sourceRef = {};
|
|
266
|
+
for (const line of fm.split('\n')) {
|
|
267
|
+
if (/^sourceRef:\s*$/.test(line)) { inSourceRef = true; continue; }
|
|
268
|
+
if (inSourceRef && /^\s+/.test(line)) {
|
|
269
|
+
const m = line.match(/^\s+(\w+):\s*(.*)$/);
|
|
270
|
+
if (!m) continue;
|
|
271
|
+
const [, k, v] = m;
|
|
272
|
+
if (k === 'msgIds') {
|
|
273
|
+
sourceRef.msgIds = v.replace(/^\[|\]$/g, '').split(',').map(s => s.trim()).filter(Boolean);
|
|
274
|
+
} else if (k === 'hint') {
|
|
275
|
+
try { sourceRef.hint = JSON.parse(v); } catch { sourceRef.hint = v; }
|
|
276
|
+
} else {
|
|
277
|
+
sourceRef[k] = v;
|
|
278
|
+
}
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
inSourceRef = false;
|
|
282
|
+
const m = line.match(/^(\w+):\s*(.*)$/);
|
|
283
|
+
if (!m) continue;
|
|
284
|
+
const [, k, v] = m;
|
|
285
|
+
switch (k) {
|
|
286
|
+
case 'id': out.id = v; break;
|
|
287
|
+
case 'vp': out.vp = v; break;
|
|
288
|
+
case 'taskId': out.taskId = v; break;
|
|
289
|
+
case 'kind': out.kind = v; break;
|
|
290
|
+
case 'shard': out.shard = v; break;
|
|
291
|
+
case 'supersededBy': out.supersededBy = v; break;
|
|
292
|
+
case 'pinned': out.pinned = v === 'true'; break;
|
|
293
|
+
case 'authoredBy': out.authoredBy = v; break;
|
|
294
|
+
case 'createdAt': out.createdAt = v; break;
|
|
295
|
+
case 'updatedAt': out.updatedAt = v; break;
|
|
296
|
+
case 'supersedes':
|
|
297
|
+
out.supersedes = v.replace(/^\[|\]$/g, '').split(',').map(s => s.trim()).filter(Boolean);
|
|
298
|
+
break;
|
|
299
|
+
case 'tags':
|
|
300
|
+
out.tags = v.replace(/^\[|\]$/g, '').split(',').map(s => s.trim()).filter(Boolean);
|
|
301
|
+
break;
|
|
302
|
+
default: break;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (Object.keys(sourceRef).length > 0) out.sourceRef = sourceRef;
|
|
306
|
+
return out;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function pickMeta(entry) {
|
|
310
|
+
// Meta is only the fields the 334o query() layer filters on.
|
|
311
|
+
const meta = {};
|
|
312
|
+
if (entry.kind) meta.kind = entry.kind;
|
|
313
|
+
if (entry.tags) meta.tags = entry.tags.slice();
|
|
314
|
+
if (entry.pinned) meta.pinned = true;
|
|
315
|
+
if (entry.sourceRef?.groupId) meta.groupId = entry.sourceRef.groupId;
|
|
316
|
+
if (entry.sourceRef?.taskId) meta.taskId = entry.sourceRef.taskId;
|
|
317
|
+
if (entry.supersededBy) meta.supersededBy = entry.supersededBy;
|
|
318
|
+
return meta;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function mapRecordToThinEntry(rec) {
|
|
322
|
+
return {
|
|
323
|
+
id: rec.id,
|
|
324
|
+
shard: rec.shard,
|
|
325
|
+
kind: rec.meta?.kind,
|
|
326
|
+
tags: rec.meta?.tags || [],
|
|
327
|
+
pinned: Boolean(rec.meta?.pinned),
|
|
328
|
+
groupId: rec.meta?.groupId,
|
|
329
|
+
taskId: rec.meta?.taskId,
|
|
330
|
+
supersededBy: rec.meta?.supersededBy || null,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function slugify(s) {
|
|
335
|
+
return String(s || '')
|
|
336
|
+
.toLowerCase()
|
|
337
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
338
|
+
.replace(/^-+|-+$/g, '')
|
|
339
|
+
.slice(0, 40);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function discoverOnDiskShards(dir) {
|
|
343
|
+
try {
|
|
344
|
+
return readdirSync(dir)
|
|
345
|
+
.filter(f => /^memory-[A-Za-z0-9_-]+\.md$/.test(f))
|
|
346
|
+
.map(f => f.replace(/^memory-/, '').replace(/\.md$/, ''));
|
|
347
|
+
} catch { return []; }
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ─── Migration stub (§Δ23 / 334i) ───────────────────────────────
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Migration stub: map a legacy R5 `memory-NNN.md` shard file path into a
|
|
354
|
+
* semantic shard assignment. Actual batch migration runs in 334i; this
|
|
355
|
+
* slice only defines the classifier API so dependent code can stub it.
|
|
356
|
+
*
|
|
357
|
+
* @param {object} legacyEntry parsed legacy entry { kind, scope, tags, ... }
|
|
358
|
+
* @returns {string} semantic shard name ("skill" / "lessons" / ...)
|
|
359
|
+
*/
|
|
360
|
+
export function classifyLegacyEntryToShard(legacyEntry) {
|
|
361
|
+
if (!legacyEntry || typeof legacyEntry !== 'object') return 'skill';
|
|
362
|
+
const kind = legacyEntry.kind || 'fact';
|
|
363
|
+
switch (kind) {
|
|
364
|
+
case 'lesson': return 'lessons';
|
|
365
|
+
case 'preference': return 'preferences';
|
|
366
|
+
case 'identity': return 'preferences';
|
|
367
|
+
case 'relation': return 'relations';
|
|
368
|
+
case 'skill': return 'skill';
|
|
369
|
+
default: return 'skill';
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export { AUTHORED_BY };
|
package/unify/tools/index.js
CHANGED
|
@@ -22,6 +22,8 @@ import memoryRead from './memory-read.js';
|
|
|
22
22
|
import memoryWrite from './memory-write.js';
|
|
23
23
|
import memorySearch, { memorySearchAlias } from './memory-search.js';
|
|
24
24
|
import memoryQuery from './memory-query.js';
|
|
25
|
+
import memoryTrace from './memory-trace.js';
|
|
26
|
+
import openSourceMessage from './open-source-message.js';
|
|
25
27
|
import webSearch from './web-search.js';
|
|
26
28
|
import webFetch from './web-fetch.js';
|
|
27
29
|
import historySearch from './history-search.js';
|
|
@@ -98,6 +100,8 @@ export const allTools = [
|
|
|
98
100
|
memorySearch,
|
|
99
101
|
memorySearchAlias,
|
|
100
102
|
memoryQuery,
|
|
103
|
+
memoryTrace,
|
|
104
|
+
openSourceMessage,
|
|
101
105
|
webSearch,
|
|
102
106
|
webFetch,
|
|
103
107
|
historySearch,
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memory-trace.js — task-334f R6 §Δ24.3.
|
|
3
|
+
*
|
|
4
|
+
* Given a memory id, return the full entry (including sourceRef) plus the
|
|
5
|
+
* original source messages referenced by sourceRef.msgIds / timeWindow.
|
|
6
|
+
*
|
|
7
|
+
* Hard guardrails (task-334f):
|
|
8
|
+
* - Results are returned to the current turn ONLY. Nothing is written back
|
|
9
|
+
* to memory; the extraction lane sees its own copy.
|
|
10
|
+
* - Does not do cross-group fan-out. A trace is anchored to one groupId.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { defineTool } from './types.js';
|
|
14
|
+
|
|
15
|
+
const MAX_BYTES = 64 * 1024;
|
|
16
|
+
|
|
17
|
+
export default defineTool({
|
|
18
|
+
name: 'memory_trace',
|
|
19
|
+
description: `Trace a memory entry back to its original source messages.
|
|
20
|
+
|
|
21
|
+
Use this when a recalled memory body is insufficient and you need the raw
|
|
22
|
+
discussion. Returns the full memory entry (with sourceRef) plus the source
|
|
23
|
+
messages from the group jsonl log.
|
|
24
|
+
|
|
25
|
+
Parameters:
|
|
26
|
+
- memId (required): the memory id (from recall)
|
|
27
|
+
- expand: "full" (default, exact msgIds) | "window" (expand around timeWindow)
|
|
28
|
+
|
|
29
|
+
Returns JSON: { memory, messages[], truncated? }.
|
|
30
|
+
The result is NOT written back to memory — it is context for the current turn
|
|
31
|
+
only.`,
|
|
32
|
+
parameters: {
|
|
33
|
+
type: 'object',
|
|
34
|
+
properties: {
|
|
35
|
+
memId: { type: 'string', description: 'Memory entry id' },
|
|
36
|
+
expand: { type: 'string', enum: ['full', 'window'], default: 'full' },
|
|
37
|
+
},
|
|
38
|
+
required: ['memId'],
|
|
39
|
+
},
|
|
40
|
+
isConcurrencySafe: () => true,
|
|
41
|
+
isReadOnly: () => true,
|
|
42
|
+
async execute(input, ctx) {
|
|
43
|
+
const memId = input?.memId;
|
|
44
|
+
if (!memId || typeof memId !== 'string') {
|
|
45
|
+
return JSON.stringify({ error: 'memId required (string)' });
|
|
46
|
+
}
|
|
47
|
+
const expand = input?.expand === 'window' ? 'window' : 'full';
|
|
48
|
+
|
|
49
|
+
const store = ctx?.memoryShardStore;
|
|
50
|
+
if (!store) {
|
|
51
|
+
return JSON.stringify({ error: 'R6 memory shard store not initialised' });
|
|
52
|
+
}
|
|
53
|
+
const entry = store.get(memId);
|
|
54
|
+
if (!entry) {
|
|
55
|
+
return JSON.stringify({ error: `memory entry not found: ${memId}` });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const sourceRef = entry.sourceRef || null;
|
|
59
|
+
if (!sourceRef) {
|
|
60
|
+
return JSON.stringify({
|
|
61
|
+
memory: entry,
|
|
62
|
+
messages: [],
|
|
63
|
+
note: 'entry has no sourceRef (pure declaration)',
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const coordinator = ctx?.coordinator;
|
|
68
|
+
const groupId = sourceRef.groupId;
|
|
69
|
+
if (!coordinator || !groupId) {
|
|
70
|
+
return JSON.stringify({
|
|
71
|
+
memory: entry,
|
|
72
|
+
messages: [],
|
|
73
|
+
note: 'no group coordinator available',
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const group = typeof coordinator.openGroup === 'function'
|
|
78
|
+
? coordinator.openGroup(groupId)
|
|
79
|
+
: null;
|
|
80
|
+
if (!group) {
|
|
81
|
+
return JSON.stringify({
|
|
82
|
+
memory: entry,
|
|
83
|
+
messages: [],
|
|
84
|
+
note: `group ${groupId} not resolvable`,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const messages = [];
|
|
89
|
+
let bytes = 0;
|
|
90
|
+
let truncated = false;
|
|
91
|
+
|
|
92
|
+
if (expand === 'full' && Array.isArray(sourceRef.msgIds) && sourceRef.msgIds.length) {
|
|
93
|
+
const targetSet = new Set(sourceRef.msgIds);
|
|
94
|
+
// Walk only the smallest overlapping range instead of streaming all.
|
|
95
|
+
const first = sourceRef.msgIds[0];
|
|
96
|
+
const last = sourceRef.msgIds[sourceRef.msgIds.length - 1];
|
|
97
|
+
const iter = typeof group.readMessageRange === 'function'
|
|
98
|
+
? group.readMessageRange(first, last)
|
|
99
|
+
: group.streamMessages();
|
|
100
|
+
for (const msg of iter) {
|
|
101
|
+
if (!targetSet.has(msg.id)) continue;
|
|
102
|
+
const chunk = estimateBytes(msg);
|
|
103
|
+
if (bytes + chunk > MAX_BYTES) { truncated = true; break; }
|
|
104
|
+
messages.push(msg);
|
|
105
|
+
bytes += chunk;
|
|
106
|
+
}
|
|
107
|
+
} else if (expand === 'window' && sourceRef.timeWindow) {
|
|
108
|
+
// timeWindow is "ISO..ISO"; best-effort textual compare works for ULIDs/ISO.
|
|
109
|
+
const [t0, t1] = String(sourceRef.timeWindow).split('..');
|
|
110
|
+
for (const msg of group.streamMessages()) {
|
|
111
|
+
const ts = msg.ts || '';
|
|
112
|
+
if (t0 && ts < t0) continue;
|
|
113
|
+
if (t1 && ts > t1) break;
|
|
114
|
+
const chunk = estimateBytes(msg);
|
|
115
|
+
if (bytes + chunk > MAX_BYTES) { truncated = true; break; }
|
|
116
|
+
messages.push(msg);
|
|
117
|
+
bytes += chunk;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return JSON.stringify({
|
|
122
|
+
memory: entry,
|
|
123
|
+
messages,
|
|
124
|
+
...(truncated ? { truncated: true } : {}),
|
|
125
|
+
});
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
function estimateBytes(msg) {
|
|
130
|
+
try {
|
|
131
|
+
return Buffer.byteLength(JSON.stringify(msg), 'utf8');
|
|
132
|
+
} catch {
|
|
133
|
+
return 512;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* open-source-message.js — task-334f R6 §Δ24.4.
|
|
3
|
+
*
|
|
4
|
+
* Low-level random access: given a (groupId, msgId), fetch the raw message
|
|
5
|
+
* from the group's jsonl log. Used when a VP has an exact pointer but does
|
|
6
|
+
* not want to run the memory_trace wrapper (5% case: audit / debug).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { defineTool } from './types.js';
|
|
10
|
+
|
|
11
|
+
export default defineTool({
|
|
12
|
+
name: 'open_source_message',
|
|
13
|
+
description: `Open a single source message by (groupId, msgId).
|
|
14
|
+
|
|
15
|
+
This is the low-level random-access primitive. Prefer memory_trace if you are
|
|
16
|
+
starting from a memory entry. Returns JSON: { message } or { error }.`,
|
|
17
|
+
parameters: {
|
|
18
|
+
type: 'object',
|
|
19
|
+
properties: {
|
|
20
|
+
groupId: { type: 'string', description: 'Group id' },
|
|
21
|
+
msgId: { type: 'string', description: 'Message id' },
|
|
22
|
+
},
|
|
23
|
+
required: ['groupId', 'msgId'],
|
|
24
|
+
},
|
|
25
|
+
isConcurrencySafe: () => true,
|
|
26
|
+
isReadOnly: () => true,
|
|
27
|
+
async execute(input, ctx) {
|
|
28
|
+
const { groupId, msgId } = input || {};
|
|
29
|
+
if (!groupId || !msgId) {
|
|
30
|
+
return JSON.stringify({ error: 'groupId and msgId required' });
|
|
31
|
+
}
|
|
32
|
+
const coordinator = ctx?.coordinator;
|
|
33
|
+
if (!coordinator || typeof coordinator.openGroup !== 'function') {
|
|
34
|
+
return JSON.stringify({ error: 'group coordinator not available' });
|
|
35
|
+
}
|
|
36
|
+
const group = coordinator.openGroup(groupId);
|
|
37
|
+
if (!group) return JSON.stringify({ error: `group not found: ${groupId}` });
|
|
38
|
+
|
|
39
|
+
const iter = typeof group.readMessageRange === 'function'
|
|
40
|
+
? group.readMessageRange(msgId, msgId)
|
|
41
|
+
: group.streamMessages();
|
|
42
|
+
for (const msg of iter) {
|
|
43
|
+
if (msg.id === msgId) {
|
|
44
|
+
return JSON.stringify({ message: msg });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return JSON.stringify({ error: `message not found: ${msgId} in ${groupId}` });
|
|
48
|
+
},
|
|
49
|
+
});
|