@yeaft/webchat-agent 0.1.658 → 0.1.660
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/config.js +6 -15
- package/unify/engine.js +18 -80
- package/unify/session.js +16 -15
- package/unify/memory/recall-r6.js +0 -291
- package/unify/memory/recall-v2.js +0 -258
package/package.json
CHANGED
package/unify/config.js
CHANGED
|
@@ -219,8 +219,6 @@ function loadLegacyConfig(dir, overrides) {
|
|
|
219
219
|
unify: normaliseUnifySection(null),
|
|
220
220
|
// DESIGN-v2 feature flag. Default true (PR-E flipped). Override wins.
|
|
221
221
|
memoryV2: overrides.memoryV2 !== undefined ? !!overrides.memoryV2 : true,
|
|
222
|
-
// GC.1: FTS pre-flow flag (legacy fallback config — defaults true).
|
|
223
|
-
memoryPreflow: overrides.memoryPreflow !== undefined ? !!overrides.memoryPreflow : true,
|
|
224
222
|
providers: null,
|
|
225
223
|
primaryModel: null,
|
|
226
224
|
fastModel: null,
|
|
@@ -317,22 +315,15 @@ export function loadConfig(overrides = {}) {
|
|
|
317
315
|
// don't pollute the flat config namespace used by chat/crew code.
|
|
318
316
|
unify: normaliseUnifySection(jsonConfig.unify),
|
|
319
317
|
|
|
320
|
-
// DESIGN-v2 feature flag. When true the
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
// flipped the default to true; users who
|
|
324
|
-
// can opt out via `"memoryV2": false` in
|
|
318
|
+
// DESIGN-v2 feature flag. When true the session wires the v2 dream
|
|
319
|
+
// pipeline (dream-v2/runner.js) and opens the FTS5 SegmentIndex used
|
|
320
|
+
// by the engine's pre-turn recall (groups/pre-flow.js →
|
|
321
|
+
// memory/preflow.js). PR-E flipped the default to true; users who
|
|
322
|
+
// need the legacy R6 paths can opt out via `"memoryV2": false` in
|
|
323
|
+
// ~/.yeaft/config.json.
|
|
325
324
|
memoryV2: overrides.memoryV2 !== undefined ? !!overrides.memoryV2
|
|
326
325
|
: (jsonConfig.memoryV2 !== undefined ? !!jsonConfig.memoryV2 : true),
|
|
327
326
|
|
|
328
|
-
// GC.1 feature flag — route pre-turn memory recall through
|
|
329
|
-
// memory/preflow.js (SQLite FTS5) instead of memory/recall-v2.js
|
|
330
|
-
// (per-scope file reads). When OFF the engine falls back to v2.
|
|
331
|
-
// Default ON. Users can opt out via `"memoryPreflow": false` in
|
|
332
|
-
// ~/.yeaft/config.json. Only applies when memoryV2 is also ON.
|
|
333
|
-
memoryPreflow: overrides.memoryPreflow !== undefined ? !!overrides.memoryPreflow
|
|
334
|
-
: (jsonConfig.memoryPreflow !== undefined ? !!jsonConfig.memoryPreflow : true),
|
|
335
|
-
|
|
336
327
|
// Legacy fields (null when using config.json)
|
|
337
328
|
apiKey: overrides.apiKey || null,
|
|
338
329
|
openaiApiKey: null,
|
package/unify/engine.js
CHANGED
|
@@ -20,8 +20,6 @@
|
|
|
20
20
|
import { randomUUID } from 'crypto';
|
|
21
21
|
import { buildSystemPrompt, buildWorkerPrompt } from './prompts.js';
|
|
22
22
|
import { LLMContextError, LLMAbortError } from './llm/adapter.js';
|
|
23
|
-
import { recallR6, formatForInjection } from './memory/recall-r6.js';
|
|
24
|
-
import { recallV2 } from './memory/recall-v2.js';
|
|
25
23
|
import { runMemoryPreflow } from './groups/pre-flow.js';
|
|
26
24
|
import { shouldConsolidate, consolidate, partitionMessages } from './memory/consolidate.js';
|
|
27
25
|
import { extractMemories } from './memory/extract.js';
|
|
@@ -30,7 +28,6 @@ import { evaluateCompactTriggers } from './compact/triggers.js';
|
|
|
30
28
|
import { archiveTurn } from './archive/turn-archive.js';
|
|
31
29
|
import { archiveToolResults } from './archive/tool-results.js';
|
|
32
30
|
import { buildMemoryInjection } from './memory/layout.js';
|
|
33
|
-
import { buildUserProfile } from './memory/user-memory-store.js';
|
|
34
31
|
import { readSummary as readScopeSummary } from './memory/store-v2.js';
|
|
35
32
|
import { runStopHooks } from './stop-hooks.js';
|
|
36
33
|
// H2.f.5: threads/ retired. Persisted messages still carry a `threadId`
|
|
@@ -500,9 +497,11 @@ export class Engine {
|
|
|
500
497
|
/**
|
|
501
498
|
* Perform memory recall for a given prompt.
|
|
502
499
|
*
|
|
503
|
-
*
|
|
504
|
-
*
|
|
505
|
-
*
|
|
500
|
+
* Single path (GC.1 follow-up): SQLite FTS5 pre-flow via
|
|
501
|
+
* `groups/pre-flow.js` → `memory/preflow.js`. When the index isn't
|
|
502
|
+
* wired (e.g. read-only sessions or pre-FTS yeaft dirs) recall is
|
|
503
|
+
* skipped and an empty memory shape is returned — engine continues
|
|
504
|
+
* without injection.
|
|
506
505
|
*
|
|
507
506
|
* @param {string} prompt
|
|
508
507
|
* @param {{ groupId?: string, vpId?: string, featureId?: string }} [ctx]
|
|
@@ -510,82 +509,20 @@ export class Engine {
|
|
|
510
509
|
*/
|
|
511
510
|
async #recallMemory(prompt, ctx = {}) {
|
|
512
511
|
const memory = { profile: '', entries: [], formatted: '' };
|
|
513
|
-
|
|
514
|
-
// ─── GC.1: FTS5 pre-flow path ──────────────────────────────
|
|
515
|
-
// When the SegmentIndex is wired and the feature flag is on,
|
|
516
|
-
// route recall through groups/pre-flow.js → memory/preflow.js
|
|
517
|
-
// (SQLite FTS5). On any failure fall through to v2.
|
|
518
|
-
if (this.#memoryIndex && this.#config && this.#config.memoryPreflow) {
|
|
519
|
-
try {
|
|
520
|
-
const result = runMemoryPreflow(this.#memoryIndex, {
|
|
521
|
-
userMsg: prompt,
|
|
522
|
-
groupId: ctx.groupId,
|
|
523
|
-
vpId: ctx.vpId,
|
|
524
|
-
featureId: ctx.featureId,
|
|
525
|
-
});
|
|
526
|
-
memory.profile = result.profile || '';
|
|
527
|
-
memory.entries = result.entries || [];
|
|
528
|
-
memory.formatted = result.formatted || '';
|
|
529
|
-
return memory;
|
|
530
|
-
} catch {
|
|
531
|
-
// Fall through to v2 / R6 paths.
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
// ─── v2 path (DESIGN-v2) ───────────────────────────────────
|
|
536
|
-
if (this.#config && this.#config.memoryV2 && this.#yeaftDir) {
|
|
537
|
-
try {
|
|
538
|
-
const result = await recallV2({
|
|
539
|
-
prompt,
|
|
540
|
-
root: `${this.#yeaftDir}/memory`,
|
|
541
|
-
groupId: ctx.groupId,
|
|
542
|
-
vpId: ctx.vpId,
|
|
543
|
-
featureId: ctx.featureId,
|
|
544
|
-
});
|
|
545
|
-
memory.entries = result.sections || [];
|
|
546
|
-
memory.formatted = result.formatted || '';
|
|
547
|
-
// Profile concept: in v2 the user/memory.md IS the profile.
|
|
548
|
-
const userSec = (result.sections || []).find(s => s.kind === 'user');
|
|
549
|
-
memory.profile = userSec ? (userSec.summary || '') : '';
|
|
550
|
-
} catch {
|
|
551
|
-
// Fail soft — empty injection.
|
|
552
|
-
}
|
|
553
|
-
return memory;
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
// ─── R6 legacy path ────────────────────────────────────────
|
|
557
|
-
// Build user profile from user-memory shard store (R6 path),
|
|
558
|
-
// falling back to legacy readProfile if shard store unavailable.
|
|
512
|
+
if (!this.#memoryIndex) return memory;
|
|
559
513
|
try {
|
|
560
|
-
const
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
}
|
|
514
|
+
const result = runMemoryPreflow(this.#memoryIndex, {
|
|
515
|
+
userMsg: prompt,
|
|
516
|
+
groupId: ctx.groupId,
|
|
517
|
+
vpId: ctx.vpId,
|
|
518
|
+
featureId: ctx.featureId,
|
|
519
|
+
});
|
|
520
|
+
memory.profile = result.profile || '';
|
|
521
|
+
memory.entries = result.entries || [];
|
|
522
|
+
memory.formatted = result.formatted || '';
|
|
566
523
|
} catch {
|
|
567
|
-
//
|
|
568
|
-
if (this.#memoryStore) {
|
|
569
|
-
try { memory.profile = this.#memoryStore.readProfile(); } catch { /* */ }
|
|
570
|
-
}
|
|
524
|
+
// Fail soft — empty injection.
|
|
571
525
|
}
|
|
572
|
-
|
|
573
|
-
// R6 shard-based recall (preferred path)
|
|
574
|
-
if (this.#memoryShardStore) {
|
|
575
|
-
try {
|
|
576
|
-
const result = await recallR6({
|
|
577
|
-
prompt,
|
|
578
|
-
memoryShardStore: this.#memoryShardStore,
|
|
579
|
-
adapter: this.#adapter,
|
|
580
|
-
fastModel: this.#fastConfig?.model,
|
|
581
|
-
});
|
|
582
|
-
memory.entries = result.entries;
|
|
583
|
-
memory.formatted = formatForInjection(result.entries);
|
|
584
|
-
} catch {
|
|
585
|
-
// Recall failure is non-critical
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
|
|
589
526
|
return memory;
|
|
590
527
|
}
|
|
591
528
|
|
|
@@ -901,7 +838,8 @@ export class Engine {
|
|
|
901
838
|
// ─── Pre-query: Memory Injection (task-287) + Compact Summary ──
|
|
902
839
|
// Two-layer recall:
|
|
903
840
|
// 1. Static memory index injection (buildMemoryInjection — always)
|
|
904
|
-
// 2.
|
|
841
|
+
// 2. FTS5 pre-flow recall (#recallMemory → groups/pre-flow.js →
|
|
842
|
+
// memory/preflow.js — when memoryIndex is wired)
|
|
905
843
|
// No per-turn fuzzy recall via old recall.js — LLM calls memory_load /
|
|
906
844
|
// memory_query on demand (memory_search still works as a deprecated alias).
|
|
907
845
|
let memoryInjection = '';
|
package/unify/session.js
CHANGED
|
@@ -28,13 +28,14 @@ import { Engine } from './engine.js';
|
|
|
28
28
|
// H2.f.5: threads/, pipeline/dispatcher and input-queue retired. The
|
|
29
29
|
// session now exposes a single Engine.
|
|
30
30
|
//
|
|
31
|
-
// GC.1
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
31
|
+
// GC.1 (final): when config.memoryV2 is on, the session opens a
|
|
32
|
+
// SegmentIndex (SQLite FTS5 over memory.md) and passes it to the
|
|
33
|
+
// Engine. Engine.#recallMemory routes pre-turn recall through
|
|
34
|
+
// groups/pre-flow.js → memory/preflow.js (the previous per-scope
|
|
35
|
+
// file reader recall-v2.js has been deleted). Post-turn AMS
|
|
36
|
+
// correction (memory/adjust.js) is implemented but not yet wired —
|
|
37
|
+
// requires session-level AMS instance + scope resolution. Tracked
|
|
38
|
+
// as a follow-up.
|
|
38
39
|
import { ensureDefaultGroupIfEmpty } from './groups/group-crud.js';
|
|
39
40
|
import { seedDefaultVps } from './vp/seed-defaults.js';
|
|
40
41
|
import { createDreamScheduler } from './memory/dream-scheduler.js';
|
|
@@ -223,15 +224,15 @@ export async function loadSession(options = {}) {
|
|
|
223
224
|
}
|
|
224
225
|
|
|
225
226
|
// ─── 5-fts. (GC.1) Open SegmentIndex for FTS pre-flow ────
|
|
226
|
-
// When config.memoryV2
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
227
|
+
// When config.memoryV2 is on, build a SQLite FTS5 index over
|
|
228
|
+
// ~/.yeaft/memory/<scope>/memory.md and pass it to the Engine.
|
|
229
|
+
// Engine.#recallMemory uses it via groups/pre-flow.js →
|
|
230
|
+
// memory/preflow.js. Disk is the source of truth; on boot we
|
|
231
|
+
// reconcile disk → index via syncAll. Failure to open the index
|
|
232
|
+
// is non-fatal: #recallMemory returns an empty result and the
|
|
233
|
+
// turn proceeds without pre-injected memory.
|
|
233
234
|
let memoryIndex = null;
|
|
234
|
-
if (config.memoryV2 &&
|
|
235
|
+
if (config.memoryV2 && !config._readOnly) {
|
|
235
236
|
try {
|
|
236
237
|
const indexPath = join(yeaftDir, 'memory', 'index.db');
|
|
237
238
|
memoryIndex = openSegmentIndex(indexPath);
|
|
@@ -1,291 +0,0 @@
|
|
|
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
|
-
FEATURE_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
|
-
FEATURE_SHARDS,
|
|
290
|
-
USER_SHARDS,
|
|
291
|
-
};
|
|
@@ -1,258 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* memory/recall-v2.js — DESIGN-v2 Part II: scope-based memory recall.
|
|
3
|
-
*
|
|
4
|
-
* Recall under v2 is structurally different from R6: instead of selecting
|
|
5
|
-
* individual entry shards by tag/keyword, we assemble per-scope `memory.md`
|
|
6
|
-
* + `summary.md` for the scopes that are *known* to be relevant from the
|
|
7
|
-
* current turn's context (always-include rules) plus the topic scopes whose
|
|
8
|
-
* summary best matches the user's prompt keywords.
|
|
9
|
-
*
|
|
10
|
-
* Always-include scopes (no LLM):
|
|
11
|
-
* - user (every turn)
|
|
12
|
-
* - group/<groupId> (when groupId is provided)
|
|
13
|
-
* - vp/<vpId> (when vpId is provided AND not a foreign vp)
|
|
14
|
-
* - feature/<featureId> (when featureId is provided)
|
|
15
|
-
*
|
|
16
|
-
* Topic scopes:
|
|
17
|
-
* - Score each topic by simple keyword overlap between the prompt's
|
|
18
|
-
* extracted keywords (via recall.js → extractKeywords) and the topic's
|
|
19
|
-
* `summary.md` body. Top-N by score join the bundle.
|
|
20
|
-
* - This is a heuristic — no LLM call. Topics that the dream pipeline
|
|
21
|
-
* created already correlate with the conversation's natural language,
|
|
22
|
-
* so a cheap keyword overlap is a good first cut.
|
|
23
|
-
*
|
|
24
|
-
* What this module deliberately does NOT do:
|
|
25
|
-
* - No LLM side-query. R6's recall.js does a 3rd-step LLM-select; v2
|
|
26
|
-
* skips it because the unit of selection is now whole scopes (5 + N
|
|
27
|
-
* topics) instead of dozens of individual entries.
|
|
28
|
-
* - No frontmatter parsing. memory.md is markdown; the dream-state tail
|
|
29
|
-
* marker is stripped before injection (so the LLM doesn't see internal
|
|
30
|
-
* bookkeeping bytes).
|
|
31
|
-
* - No write side effects. Pure read.
|
|
32
|
-
*
|
|
33
|
-
* Reference: agent/unify/memory/DESIGN-v2.md §6 (recall surface).
|
|
34
|
-
*/
|
|
35
|
-
|
|
36
|
-
import { join } from 'path';
|
|
37
|
-
import { promises as fsp, existsSync } from 'fs';
|
|
38
|
-
|
|
39
|
-
import {
|
|
40
|
-
DEFAULT_MEMORY_ROOT, scopeDir, readMemory, readSummary,
|
|
41
|
-
} from './store-v2.js';
|
|
42
|
-
import { extractKeywords } from './recall.js';
|
|
43
|
-
|
|
44
|
-
/** Default cap for how many topic scopes recall pulls in. */
|
|
45
|
-
export const DEFAULT_TOPIC_LIMIT = 3;
|
|
46
|
-
|
|
47
|
-
/** Marker block written by dream-v2/state.js — stripped from injection. */
|
|
48
|
-
const DREAM_MARKER_RE = /\n*<!-- dream-state -->[\s\S]*?<!-- \/dream-state -->\s*$/;
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Strip the trailing dream-state marker block (if any) from a memory.md body.
|
|
52
|
-
*
|
|
53
|
-
* @param {string} body
|
|
54
|
-
* @returns {string}
|
|
55
|
-
*/
|
|
56
|
-
export function stripDreamMarker(body) {
|
|
57
|
-
if (!body || typeof body !== 'string') return '';
|
|
58
|
-
return body.replace(DREAM_MARKER_RE, '').trimEnd();
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* List all topic scopes present under <root>/topic/. Returns paths like
|
|
63
|
-
* ['science', 'physics'] (level 1) or ['life', 'parenting'] (level 2).
|
|
64
|
-
*
|
|
65
|
-
* @param {string} root
|
|
66
|
-
* @returns {Promise<string[][]>}
|
|
67
|
-
*/
|
|
68
|
-
async function listTopicPaths(root) {
|
|
69
|
-
const out = [];
|
|
70
|
-
const topicRoot = join(root, 'topic');
|
|
71
|
-
if (!existsSync(topicRoot)) return out;
|
|
72
|
-
let l1Names;
|
|
73
|
-
try { l1Names = await fsp.readdir(topicRoot, { withFileTypes: true }); }
|
|
74
|
-
catch { return out; }
|
|
75
|
-
for (const e1 of l1Names) {
|
|
76
|
-
if (!e1.isDirectory()) continue;
|
|
77
|
-
if (e1.name.startsWith('.')) continue;
|
|
78
|
-
// Level-1 topic is itself a scope (memory.md may sit at this level).
|
|
79
|
-
out.push([e1.name]);
|
|
80
|
-
// Walk one more level.
|
|
81
|
-
let l2Names;
|
|
82
|
-
try { l2Names = await fsp.readdir(join(topicRoot, e1.name), { withFileTypes: true }); }
|
|
83
|
-
catch { continue; }
|
|
84
|
-
for (const e2 of l2Names) {
|
|
85
|
-
if (!e2.isDirectory()) continue;
|
|
86
|
-
if (e2.name.startsWith('.')) continue;
|
|
87
|
-
out.push([e1.name, e2.name]);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
return out;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Score a topic by how many of its summary's tokens overlap the prompt's
|
|
95
|
-
* keyword set. Topics with no summary score 0.
|
|
96
|
-
*
|
|
97
|
-
* @param {string} summary
|
|
98
|
-
* @param {Set<string>} keywordSet
|
|
99
|
-
* @returns {number}
|
|
100
|
-
*/
|
|
101
|
-
function scoreTopic(summary, keywordSet) {
|
|
102
|
-
if (!summary || keywordSet.size === 0) return 0;
|
|
103
|
-
const tokens = (summary.toLowerCase()
|
|
104
|
-
.match(/[\p{L}\p{N}_-]+/gu) || [])
|
|
105
|
-
.filter(t => t.length > 1);
|
|
106
|
-
if (tokens.length === 0) return 0;
|
|
107
|
-
let hits = 0;
|
|
108
|
-
for (const t of tokens) {
|
|
109
|
-
if (keywordSet.has(t)) hits += 1;
|
|
110
|
-
}
|
|
111
|
-
return hits;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* @typedef {Object} RecallV2Section
|
|
116
|
-
* @property {string} scope — human label, e.g. "user", "group/g-eng"
|
|
117
|
-
* @property {string} kind — 'user' | 'vp' | 'group' | 'feature' | 'topic'
|
|
118
|
-
* @property {string} memory — memory.md body (dream marker stripped)
|
|
119
|
-
* @property {string} summary — summary.md body
|
|
120
|
-
*/
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* @typedef {Object} RecallV2Result
|
|
124
|
-
* @property {RecallV2Section[]} sections
|
|
125
|
-
* @property {string[]} keywords
|
|
126
|
-
* @property {string} formatted — ready to splice into the system prompt
|
|
127
|
-
*/
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Build a scope label suitable for the formatted block heading.
|
|
131
|
-
*
|
|
132
|
-
* @param {import('./store-v2.js').Scope} scope
|
|
133
|
-
* @returns {string}
|
|
134
|
-
*/
|
|
135
|
-
export function scopeLabel(scope) {
|
|
136
|
-
if (scope.kind === 'user') return 'user';
|
|
137
|
-
if (scope.kind === 'topic') return `topic/${(scope.path || []).join('/')}`;
|
|
138
|
-
return `${scope.kind}/${scope.id || ''}`;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Format the bundle for direct injection into the system prompt.
|
|
143
|
-
*
|
|
144
|
-
* @param {RecallV2Section[]} sections
|
|
145
|
-
* @returns {string}
|
|
146
|
-
*/
|
|
147
|
-
export function formatRecallV2(sections) {
|
|
148
|
-
if (!sections || sections.length === 0) return '';
|
|
149
|
-
const blocks = [];
|
|
150
|
-
for (const s of sections) {
|
|
151
|
-
const memBlock = s.memory ? s.memory.trim() : '';
|
|
152
|
-
const sumBlock = s.summary ? s.summary.trim() : '';
|
|
153
|
-
if (!memBlock && !sumBlock) continue;
|
|
154
|
-
const parts = [`### ${s.scope}`];
|
|
155
|
-
if (sumBlock) parts.push(`**Summary**\n${sumBlock}`);
|
|
156
|
-
if (memBlock) parts.push(`**Memory**\n${memBlock}`);
|
|
157
|
-
blocks.push(parts.join('\n\n'));
|
|
158
|
-
}
|
|
159
|
-
if (blocks.length === 0) return '';
|
|
160
|
-
return ['## Recalled Memory (v2)', ...blocks].join('\n\n');
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
* Read one scope's pair (memory.md + summary.md) and translate to a section.
|
|
165
|
-
* Returns null when both files are empty/missing or VP ACL refuses.
|
|
166
|
-
*
|
|
167
|
-
* @param {import('./store-v2.js').Scope} scope
|
|
168
|
-
* @param {{ root: string, currentVpId?: string }} opts
|
|
169
|
-
* @returns {Promise<RecallV2Section|null>}
|
|
170
|
-
*/
|
|
171
|
-
async function readScopeSection(scope, opts) {
|
|
172
|
-
let memory = '';
|
|
173
|
-
let summary = '';
|
|
174
|
-
try { memory = stripDreamMarker(await readMemory(scope, opts)); }
|
|
175
|
-
catch { return null; } // VP ACL or other → skip silently
|
|
176
|
-
try { summary = await readSummary(scope, opts); } catch { /* */ }
|
|
177
|
-
if (!memory && !summary) return null;
|
|
178
|
-
return {
|
|
179
|
-
scope: scopeLabel(scope),
|
|
180
|
-
kind: scope.kind,
|
|
181
|
-
memory,
|
|
182
|
-
summary,
|
|
183
|
-
};
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* Recall v2: assemble per-scope memory.md + summary.md for the current turn.
|
|
188
|
-
*
|
|
189
|
-
* @param {Object} params
|
|
190
|
-
* @param {string} params.prompt — the user's turn prompt
|
|
191
|
-
* @param {string} [params.root] — memory root (defaults to DEFAULT_MEMORY_ROOT)
|
|
192
|
-
* @param {string} [params.groupId] — active group, if any
|
|
193
|
-
* @param {string} [params.vpId] — active VP for this turn (NOT used as ACL)
|
|
194
|
-
* @param {string} [params.currentVpId] — current session's VP, gates vp/<other> reads
|
|
195
|
-
* @param {string} [params.featureId] — active feature, if any
|
|
196
|
-
* @param {number} [params.topicLimit] — cap on topic scopes (default DEFAULT_TOPIC_LIMIT)
|
|
197
|
-
* @returns {Promise<RecallV2Result>}
|
|
198
|
-
*/
|
|
199
|
-
export async function recallV2({
|
|
200
|
-
prompt,
|
|
201
|
-
root = DEFAULT_MEMORY_ROOT,
|
|
202
|
-
groupId,
|
|
203
|
-
vpId,
|
|
204
|
-
currentVpId,
|
|
205
|
-
featureId,
|
|
206
|
-
topicLimit = DEFAULT_TOPIC_LIMIT,
|
|
207
|
-
} = {}) {
|
|
208
|
-
const sections = [];
|
|
209
|
-
const opts = { root, currentVpId };
|
|
210
|
-
const keywords = extractKeywords(prompt || '');
|
|
211
|
-
|
|
212
|
-
// Always: user.
|
|
213
|
-
const userSec = await readScopeSection({ kind: 'user' }, opts);
|
|
214
|
-
if (userSec) sections.push(userSec);
|
|
215
|
-
|
|
216
|
-
// Conditional: group/<groupId>
|
|
217
|
-
if (groupId && typeof groupId === 'string' && groupId !== '_no-group') {
|
|
218
|
-
const sec = await readScopeSection({ kind: 'group', id: groupId }, opts);
|
|
219
|
-
if (sec) sections.push(sec);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
// Conditional: vp/<vpId>
|
|
223
|
-
if (vpId && typeof vpId === 'string') {
|
|
224
|
-
const sec = await readScopeSection({ kind: 'vp', id: vpId }, opts);
|
|
225
|
-
if (sec) sections.push(sec);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// Conditional: feature/<featureId>
|
|
229
|
-
if (featureId && typeof featureId === 'string') {
|
|
230
|
-
const sec = await readScopeSection({ kind: 'feature', id: featureId }, opts);
|
|
231
|
-
if (sec) sections.push(sec);
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// Topics: rank by keyword overlap on summary.
|
|
235
|
-
if (topicLimit > 0 && keywords.length > 0) {
|
|
236
|
-
const keywordSet = new Set(keywords.map(k => k.toLowerCase()));
|
|
237
|
-
const candidates = [];
|
|
238
|
-
const paths = await listTopicPaths(root);
|
|
239
|
-
for (const path of paths) {
|
|
240
|
-
const scope = { kind: 'topic', path };
|
|
241
|
-
let summary = '';
|
|
242
|
-
try { summary = await readSummary(scope, opts); } catch { /* */ }
|
|
243
|
-
const score = scoreTopic(summary, keywordSet);
|
|
244
|
-
if (score > 0) candidates.push({ scope, score });
|
|
245
|
-
}
|
|
246
|
-
candidates.sort((a, b) => b.score - a.score);
|
|
247
|
-
for (const c of candidates.slice(0, topicLimit)) {
|
|
248
|
-
const sec = await readScopeSection(c.scope, opts);
|
|
249
|
-
if (sec) sections.push(sec);
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
return {
|
|
254
|
-
sections,
|
|
255
|
-
keywords,
|
|
256
|
-
formatted: formatRecallV2(sections),
|
|
257
|
-
};
|
|
258
|
-
}
|