@yeaft/webchat-agent 1.0.349 → 1.0.350
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/local-runtime/version.json +1 -1
- package/package.json +1 -1
- package/yeaft/dream/apply.js +59 -30
- package/yeaft/dream/output-snapshot.js +8 -4
- package/yeaft/dream/prompts/consolidate-topics.md +31 -0
- package/yeaft/dream/prompts/create.md +8 -8
- package/yeaft/dream/prompts/index.js +4 -2
- package/yeaft/dream/prompts/merge-topics.md +35 -0
- package/yeaft/dream/prompts/triage-pass1.md +2 -2
- package/yeaft/dream/prompts/triage-pass2.md +4 -2
- package/yeaft/dream/prompts/update.md +16 -14
- package/yeaft/dream/runner.js +69 -13
- package/yeaft/dream/segment-extract.js +16 -13
- package/yeaft/dream/session-wiring.js +2 -2
- package/yeaft/dream/snapshot.js +3 -3
- package/yeaft/dream/topic-consolidation.js +316 -0
- package/yeaft/dream/triage.js +7 -2
- package/yeaft/engine.js +221 -236
- package/yeaft/memory/ams-registry.js +42 -61
- package/yeaft/memory/ams.js +17 -9
- package/yeaft/memory/budget.js +15 -18
- package/yeaft/memory/content-backfill.js +118 -0
- package/yeaft/memory/index-db.js +10 -3
- package/yeaft/memory/keywords.js +25 -7
- package/yeaft/memory/preflow.js +26 -9
- package/yeaft/memory/segment-store.js +44 -8
- package/yeaft/memory/segment-sync.js +8 -4
- package/yeaft/memory/segment.js +10 -3
- package/yeaft/memory/store.js +88 -38
- package/yeaft/memory/summary-store.js +3 -3
- package/yeaft/memory/topic-redirect.js +28 -0
- package/yeaft/session.js +18 -19
- package/yeaft/sessions/pre-flow.js +7 -3
- package/yeaft/sub-agent/runner.js +10 -1
- package/yeaft/work-center/bridge.js +1 -0
- package/yeaft/work-center/runner.js +33 -4
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* memory/ams-registry.js —
|
|
2
|
+
* memory/ams-registry.js — Session + VP keyed AMS lifecycle.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* A Session can run multiple VPs concurrently. Each VP needs an isolated
|
|
5
|
+
* prompt snapshot because `ownVpId` is part of the memory ACL. Historical
|
|
6
|
+
* onDemand/recent ids stay in the version-1 payload for disk compatibility,
|
|
7
|
+
* but prompt state is rebuilt from query-selected canonical content each turn.
|
|
8
8
|
*
|
|
9
9
|
* Persistence is identity-only:
|
|
10
10
|
*
|
|
@@ -18,10 +18,8 @@
|
|
|
18
18
|
* "savedAt": "2026-04-29T..."
|
|
19
19
|
* }
|
|
20
20
|
*
|
|
21
|
-
* Bodies are
|
|
22
|
-
*
|
|
23
|
-
* the next time the session is opened. Resident layer is derived state
|
|
24
|
-
* (rebuilt every turn from `<scope>/summary.md`) — never persisted.
|
|
21
|
+
* Bodies are never serialised. Resident is derived state rebuilt every turn
|
|
22
|
+
* from selected content.md files and is never persisted.
|
|
25
23
|
*
|
|
26
24
|
* For the single-VP Yeaft path (no session id supplied), the registry uses
|
|
27
25
|
* the literal key `"default"` so there's still a stable home for AMS state.
|
|
@@ -47,11 +45,12 @@ export const DEFAULT_SESSION_KEY = 'default';
|
|
|
47
45
|
* @typedef {object} AmsCacheEntry
|
|
48
46
|
* @property {ActiveMemorySet} ams
|
|
49
47
|
* @property {string|null} ownVpId
|
|
48
|
+
* @property {string} sessionKey
|
|
50
49
|
* @property {boolean} adjustRanThisSession
|
|
51
50
|
*/
|
|
52
51
|
|
|
53
52
|
/**
|
|
54
|
-
*
|
|
53
|
+
* Session + VP keyed in-memory cache with Session-level disk compatibility.
|
|
55
54
|
*
|
|
56
55
|
* Lifecycle:
|
|
57
56
|
* - getOrCreate(sessionId, {ownVpId}) — returns the cached AMS or loads
|
|
@@ -75,7 +74,7 @@ export class AmsRegistry {
|
|
|
75
74
|
}
|
|
76
75
|
|
|
77
76
|
/**
|
|
78
|
-
* Resolve the on-disk path for a
|
|
77
|
+
* Resolve the on-disk path for a Session's ams.json.
|
|
79
78
|
*
|
|
80
79
|
* `sessionId` is trusted: `nextSessionId()` (sessions/ids.js) emits ids matching
|
|
81
80
|
* `grp_[a-z0-9_-]+_[0-9A-HJKMNP-TV-Z]{8}` (slug + 8-char crockford suffix),
|
|
@@ -103,7 +102,7 @@ export class AmsRegistry {
|
|
|
103
102
|
}
|
|
104
103
|
|
|
105
104
|
/**
|
|
106
|
-
* Get the AMS for a
|
|
105
|
+
* Get the AMS for a Session, creating it on first access.
|
|
107
106
|
* Loads persisted state from disk if any; on cold start returns an
|
|
108
107
|
* empty AMS keyed to the supplied ownVpId.
|
|
109
108
|
*
|
|
@@ -112,50 +111,55 @@ export class AmsRegistry {
|
|
|
112
111
|
* @returns {ActiveMemorySet}
|
|
113
112
|
*/
|
|
114
113
|
getOrCreate(sessionId, opts = {}) {
|
|
115
|
-
const
|
|
114
|
+
const sessionKey = sessionId || DEFAULT_SESSION_KEY;
|
|
115
|
+
const ownVpId = opts.ownVpId || null;
|
|
116
|
+
const key = this._cacheKey(sessionKey, ownVpId);
|
|
116
117
|
const cached = this._cache.get(key);
|
|
117
118
|
if (cached) return cached.ams;
|
|
118
119
|
|
|
119
|
-
const ownVpId = opts.ownVpId || null;
|
|
120
120
|
const budget = this._budget();
|
|
121
121
|
const ams = new ActiveMemorySet({ ownVpId, budget });
|
|
122
|
-
const entry = { ams, ownVpId, adjustRanThisSession: false };
|
|
123
|
-
// Best-effort hydrate
|
|
124
|
-
this._hydrate(
|
|
122
|
+
const entry = { ams, ownVpId, adjustRanThisSession: false, sessionKey };
|
|
123
|
+
// Best-effort hydrate Session-level compatibility metadata only.
|
|
124
|
+
this._hydrate(sessionKey, entry);
|
|
125
125
|
this._cache.set(key, entry);
|
|
126
126
|
return ams;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
_cacheKey(sessionId, ownVpId) {
|
|
130
|
+
return `${sessionId || DEFAULT_SESSION_KEY}\u0000${ownVpId || ''}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
129
133
|
/**
|
|
130
134
|
* Read the persisted-and-rehydrated `adjustRanThisSession` flag for a
|
|
131
|
-
*
|
|
132
|
-
* doesn't re-run `runAdjust` on its first turn back online.
|
|
135
|
+
* Session. Kept only for version-1 payload compatibility.
|
|
133
136
|
*
|
|
134
137
|
* @param {string|null|undefined} sessionId
|
|
135
138
|
* @returns {boolean}
|
|
136
139
|
*/
|
|
137
140
|
adjustRanThisSession(sessionId) {
|
|
138
|
-
const
|
|
139
|
-
return this._cache.
|
|
141
|
+
const sessionKey = sessionId || DEFAULT_SESSION_KEY;
|
|
142
|
+
return [...this._cache.values()].some(entry => (
|
|
143
|
+
entry.sessionKey === sessionKey && entry.adjustRanThisSession === true
|
|
144
|
+
));
|
|
140
145
|
}
|
|
141
146
|
|
|
142
147
|
/**
|
|
143
148
|
* Update the cached `adjustRanThisSession` flag (does not persist on its
|
|
144
|
-
* own — call `persist()` to flush).
|
|
145
|
-
* `runAdjust` actually ran.
|
|
149
|
+
* own — call `persist()` to flush). Kept for old callers and payloads.
|
|
146
150
|
*
|
|
147
151
|
* @param {string|null|undefined} sessionId
|
|
148
152
|
* @param {boolean} value
|
|
149
153
|
*/
|
|
150
154
|
setAdjustRanThisSession(sessionId, value) {
|
|
151
|
-
const
|
|
152
|
-
const entry
|
|
153
|
-
|
|
155
|
+
const sessionKey = sessionId || DEFAULT_SESSION_KEY;
|
|
156
|
+
for (const entry of this._cache.values()) {
|
|
157
|
+
if (entry.sessionKey === sessionKey) entry.adjustRanThisSession = Boolean(value);
|
|
158
|
+
}
|
|
154
159
|
}
|
|
155
160
|
|
|
156
161
|
/**
|
|
157
|
-
* Mark a
|
|
158
|
-
* The engine calls this after `runAdjust` mutates membership.
|
|
162
|
+
* Mark a Session's AMS metadata as dirty so the next persist() writes.
|
|
159
163
|
*
|
|
160
164
|
* @param {string|null|undefined} sessionId
|
|
161
165
|
*/
|
|
@@ -164,7 +168,7 @@ export class AmsRegistry {
|
|
|
164
168
|
}
|
|
165
169
|
|
|
166
170
|
/**
|
|
167
|
-
* Persist a single
|
|
171
|
+
* Persist a single Session's AMS metadata to disk. No-op when the cached entry
|
|
168
172
|
* is missing or hasn't been marked dirty.
|
|
169
173
|
*
|
|
170
174
|
* `opts.adjustRanThisSession`, when supplied, also updates the cached
|
|
@@ -177,7 +181,7 @@ export class AmsRegistry {
|
|
|
177
181
|
*/
|
|
178
182
|
persist(sessionId, opts = {}) {
|
|
179
183
|
const key = sessionId || DEFAULT_SESSION_KEY;
|
|
180
|
-
const entry = this._cache.
|
|
184
|
+
const entry = [...this._cache.values()].find(item => item.sessionKey === key);
|
|
181
185
|
if (!entry) return false;
|
|
182
186
|
if (!opts.force && !this._dirty.has(key)) return false;
|
|
183
187
|
|
|
@@ -222,48 +226,25 @@ export class AmsRegistry {
|
|
|
222
226
|
}
|
|
223
227
|
|
|
224
228
|
/**
|
|
225
|
-
* Best-effort hydrate
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
* case, indistinguishable from "first use of this group".
|
|
229
|
+
* Best-effort hydrate of version-1 metadata. Persisted segment ids are
|
|
230
|
+
* intentionally ignored because Engine rebuilds prompt state from selected
|
|
231
|
+
* canonical content on every query. Silent on every error; corrupt or
|
|
232
|
+
* missing metadata is equivalent to a cold start.
|
|
230
233
|
*
|
|
231
234
|
* @private
|
|
232
235
|
* @param {string} key
|
|
233
236
|
* @param {AmsCacheEntry} entry
|
|
234
237
|
*/
|
|
235
|
-
_hydrate(key,
|
|
238
|
+
_hydrate(key, _entry) {
|
|
236
239
|
const path = this.amsPath(key);
|
|
237
240
|
if (!existsSync(path)) return;
|
|
238
241
|
let payload;
|
|
239
242
|
try { payload = JSON.parse(readFileSync(path, 'utf8') || '{}'); }
|
|
240
243
|
catch { return; }
|
|
241
244
|
if (!payload || typeof payload !== 'object') return;
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
if (!this.memoryIndex) return;
|
|
248
|
-
|
|
249
|
-
const onDemandIds = Array.isArray(payload.onDemandIds) ? payload.onDemandIds : [];
|
|
250
|
-
const recentIds = Array.isArray(payload.recentIds) ? payload.recentIds : [];
|
|
251
|
-
|
|
252
|
-
const onDemandSegs = [];
|
|
253
|
-
for (const id of onDemandIds) {
|
|
254
|
-
try {
|
|
255
|
-
const seg = this.memoryIndex.get(id);
|
|
256
|
-
if (seg) onDemandSegs.push(seg);
|
|
257
|
-
} catch { /* skip unresolvable */ }
|
|
258
|
-
}
|
|
259
|
-
if (onDemandSegs.length > 0) entry.ams.setOnDemand(onDemandSegs);
|
|
260
|
-
|
|
261
|
-
for (const id of recentIds) {
|
|
262
|
-
try {
|
|
263
|
-
const seg = this.memoryIndex.get(id);
|
|
264
|
-
if (seg) entry.ams.touchRecent(seg);
|
|
265
|
-
} catch { /* skip */ }
|
|
266
|
-
}
|
|
245
|
+
// Segment ids in older snapshots are evidence-only now. Engine rebuilds
|
|
246
|
+
// query-selected canonical content every turn, so hydrating these ids would
|
|
247
|
+
// reintroduce raw segment bodies into prompt state.
|
|
267
248
|
}
|
|
268
249
|
}
|
|
269
250
|
|
package/yeaft/memory/ams.js
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* memory/ams.js — DESIGN-H2-AMS §5. Active Memory Set.
|
|
3
3
|
*
|
|
4
|
-
* Per-
|
|
4
|
+
* Per-Session prompt state. The data structure retains three compatible
|
|
5
|
+
* layers, but normal query flow now rebuilds only `resident` from ranked,
|
|
6
|
+
* canonical `content.md` files. `recent` and `onDemand` segment membership is
|
|
7
|
+
* cleared before rendering because `memory.md` is evidence, not prompt prose.
|
|
5
8
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* onDemand segments the pre-flow FTS pulled in this turn — hot recall
|
|
9
|
-
*
|
|
10
|
-
* AMS is in-memory; it's rebuilt at session start. The disk source of
|
|
11
|
-
* truth is `<scope>/memory.md` + `<scope>/summary.md`. AMS itself
|
|
12
|
-
* doesn't write to disk — that's Dream's job.
|
|
9
|
+
* AMS is in-memory and does not write to disk; Dream owns canonical content,
|
|
10
|
+
* catalog summaries, and evidence persistence.
|
|
13
11
|
*
|
|
14
12
|
* Privacy: `vp/<other>` scopes are ALWAYS filtered out
|
|
15
13
|
* for any worker that isn't `<other>`. The owning code passes its own
|
|
@@ -145,6 +143,16 @@ export class ActiveMemorySet {
|
|
|
145
143
|
for (const id of ids) this._onDemand.delete(id);
|
|
146
144
|
}
|
|
147
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Clear persisted segment membership before prompt assembly. Segment ids are
|
|
148
|
+
* retained on disk for migration/debug compatibility, but canonical content
|
|
149
|
+
* is now the only prompt-facing representation.
|
|
150
|
+
*/
|
|
151
|
+
clearSegmentLayers() {
|
|
152
|
+
this._recent.clear();
|
|
153
|
+
this._onDemand.clear();
|
|
154
|
+
}
|
|
155
|
+
|
|
148
156
|
// ────────────────────────── snapshot ──────────────────────────
|
|
149
157
|
|
|
150
158
|
/**
|
|
@@ -160,7 +168,7 @@ export class ActiveMemorySet {
|
|
|
160
168
|
const seenPromptText = new Set();
|
|
161
169
|
|
|
162
170
|
// Resident: pack scopes by priority order (caller provides via insert
|
|
163
|
-
// order — current
|
|
171
|
+
// order — current Session's own VP first, then user, etc.).
|
|
164
172
|
const { picked: resPicked, cost: resCost } = pickMemoryItems({
|
|
165
173
|
items: [...this._resident.entries()].map(([scope, entry]) => ({
|
|
166
174
|
scope,
|
package/yeaft/memory/budget.js
CHANGED
|
@@ -1,35 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* memory/budget.js — DESIGN-PROMPT §3 ③ Memory.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Prompt-facing memory budget = `min(6_000, modelMaxContext * 0.05)`.
|
|
5
|
+
* Memory is supporting context, not a second transcript. A small hard ceiling
|
|
6
|
+
* prevents large-context models from turning weakly related memory into tens of
|
|
7
|
+
* thousands of prompt tokens.
|
|
5
8
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* collapse into Resident now):
|
|
10
|
-
*
|
|
11
|
-
* resident 60% → 24k of a 40k pool (Layer-A summaries +
|
|
12
|
-
* UserProfile + CoreMemory pinned)
|
|
13
|
-
* recent 15% → 6k (LRU of recently-used segments)
|
|
14
|
-
* onDemand 25% → 10k (this turn's FTS recall)
|
|
9
|
+
* Canonical content is packed into Resident. Recent and OnDemand retain zero
|
|
10
|
+
* prompt budget because their segment bodies are evidence, not user-facing
|
|
11
|
+
* memory. FTS still ranks those segments to select the relevant content scopes.
|
|
15
12
|
*
|
|
16
13
|
* Concrete budgets for common models:
|
|
17
|
-
* 200K context →
|
|
18
|
-
*
|
|
19
|
-
*
|
|
14
|
+
* 200K context → 6K total (hard cap)
|
|
15
|
+
* 128K context → 6K total (hard cap)
|
|
16
|
+
* 64K context → 3.2K total
|
|
20
17
|
*
|
|
21
18
|
* Token counting here is approximate (chars / 4) — accurate enough for
|
|
22
19
|
* budget enforcement. The engine has a real tokenizer for prompt
|
|
23
20
|
* assembly; budget here is a guard rail, not the source of truth.
|
|
24
21
|
*/
|
|
25
22
|
|
|
26
|
-
export const ABSOLUTE_CAP =
|
|
27
|
-
export const MODEL_FRACTION = 0.
|
|
23
|
+
export const ABSOLUTE_CAP = 6_000;
|
|
24
|
+
export const MODEL_FRACTION = 0.05;
|
|
28
25
|
|
|
29
26
|
export const DEFAULT_RATIO = {
|
|
30
|
-
resident:
|
|
31
|
-
recent: 0
|
|
32
|
-
onDemand: 0
|
|
27
|
+
resident: 1,
|
|
28
|
+
recent: 0,
|
|
29
|
+
onDemand: 0,
|
|
33
30
|
};
|
|
34
31
|
|
|
35
32
|
/**
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { listScopes, readScope } from './segment-store.js';
|
|
5
|
+
import {
|
|
6
|
+
KIND_VALUES,
|
|
7
|
+
isValidSegmentScope,
|
|
8
|
+
parseSegments,
|
|
9
|
+
serializeSegments,
|
|
10
|
+
} from './segment.js';
|
|
11
|
+
import { stripDreamStateBlocks } from './prompt-cleanup.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Seed canonical content.md for legacy scopes that only have Dream evidence
|
|
15
|
+
* segments or the old body-only memory.md format. The migration is
|
|
16
|
+
* deterministic, idempotent, and never reads raw transcripts. Dream can later
|
|
17
|
+
* reorganize this seed without losing
|
|
18
|
+
* the segment provenance kept in memory.md.
|
|
19
|
+
*/
|
|
20
|
+
export function backfillCanonicalContent(memoryRoot) {
|
|
21
|
+
let created = 0;
|
|
22
|
+
for (const scope of listScopes(memoryRoot)) {
|
|
23
|
+
if (scope.startsWith('.legacy/') || scope.startsWith('.dream-bak/')) continue;
|
|
24
|
+
const contentPath = join(memoryRoot, scope, 'content.md');
|
|
25
|
+
const rawMemory = readMemoryFile(memoryRoot, scope);
|
|
26
|
+
const segments = hasSerializedSegmentEnvelope(rawMemory)
|
|
27
|
+
? readScope(memoryRoot, scope)
|
|
28
|
+
: [];
|
|
29
|
+
const bodies = uniqueBodies(segments);
|
|
30
|
+
if (bodies.length === 0 && rawMemory) bodies.push(rawMemory);
|
|
31
|
+
const canonicalBody = bodies.length > 0 ? `${bodies.join('\n\n')}\n` : '';
|
|
32
|
+
const currentContent = existsSync(contentPath) ? readFileSync(contentPath, 'utf8') : '';
|
|
33
|
+
if (currentContent.trim()) {
|
|
34
|
+
// A canonical document already exists. Preserve the raw legacy file in
|
|
35
|
+
// cold storage, but never leave body-only prose live as evidence.
|
|
36
|
+
if (segments.length === 0 && rawMemory) archivePlainLegacyMemory(memoryRoot, scope);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (!canonicalBody) continue;
|
|
40
|
+
mkdirSync(dirname(contentPath), { recursive: true });
|
|
41
|
+
const tmp = `${contentPath}.tmp.${process.pid}.${Date.now()}`;
|
|
42
|
+
writeFileSync(tmp, canonicalBody, 'utf8');
|
|
43
|
+
renameSync(tmp, contentPath);
|
|
44
|
+
if (readFileSync(contentPath, 'utf8') !== canonicalBody) {
|
|
45
|
+
throw new Error(`backfillCanonicalContent: canonical write verification failed for ${scope}`);
|
|
46
|
+
}
|
|
47
|
+
if (segments.length === 0 && rawMemory) archivePlainLegacyMemory(memoryRoot, scope);
|
|
48
|
+
created += 1;
|
|
49
|
+
}
|
|
50
|
+
return { created };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function archivePlainLegacyMemory(memoryRoot, scope) {
|
|
54
|
+
const source = join(memoryRoot, scope, 'memory.md');
|
|
55
|
+
if (!existsSync(source)) return;
|
|
56
|
+
const archive = join(memoryRoot, '.legacy', 'plain-memory', scope, 'memory.md');
|
|
57
|
+
mkdirSync(dirname(archive), { recursive: true });
|
|
58
|
+
let destination = archive;
|
|
59
|
+
if (existsSync(destination)) {
|
|
60
|
+
destination = `${archive}.${Date.now()}`;
|
|
61
|
+
}
|
|
62
|
+
renameSync(source, destination);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function readMemoryFile(memoryRoot, scope) {
|
|
66
|
+
const path = join(memoryRoot, scope, 'memory.md');
|
|
67
|
+
if (!existsSync(path)) return '';
|
|
68
|
+
return stripDreamStateBlocks(readFileSync(path, 'utf8'));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function hasSerializedSegmentEnvelope(text) {
|
|
72
|
+
const source = String(text || '').replace(/^/, '').trim();
|
|
73
|
+
if (!source.startsWith('---\n')) return false;
|
|
74
|
+
|
|
75
|
+
const segments = parseSegments(source);
|
|
76
|
+
if (segments.length === 0 || segments.some(segment => !isWriterSegment(segment))) return false;
|
|
77
|
+
|
|
78
|
+
// The internal writer owns one exact wire format. Re-serializing parsed
|
|
79
|
+
// segments must reproduce the file byte-for-byte after outer trimming;
|
|
80
|
+
// otherwise this is user-authored Markdown and must remain opaque.
|
|
81
|
+
return serializeSegments(segments).trim() === source;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isWriterSegment(segment) {
|
|
85
|
+
return /^seg_[0-9a-f]{8}$/.test(segment.id)
|
|
86
|
+
&& isValidSegmentScope(segment.scope)
|
|
87
|
+
&& KIND_VALUES.has(segment.kind)
|
|
88
|
+
&& isStringArray(segment.tags)
|
|
89
|
+
&& isStringArray(segment.sourceMessages)
|
|
90
|
+
&& isValidTimestamp(segment.createdAt)
|
|
91
|
+
&& isValidTimestamp(segment.updatedAt)
|
|
92
|
+
&& typeof segment.body === 'string'
|
|
93
|
+
&& segment.body.length > 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isStringArray(value) {
|
|
97
|
+
return Array.isArray(value) && value.every(item => typeof item === 'string');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isValidTimestamp(value) {
|
|
101
|
+
return typeof value === 'string'
|
|
102
|
+
&& /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value)
|
|
103
|
+
&& Number.isFinite(Date.parse(value));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function uniqueBodies(segments) {
|
|
107
|
+
const seen = new Set();
|
|
108
|
+
const bodies = [];
|
|
109
|
+
for (const segment of segments) {
|
|
110
|
+
const body = String(segment?.body || '').trim();
|
|
111
|
+
if (!body) continue;
|
|
112
|
+
const key = body.replace(/\s+/g, ' ').toLowerCase();
|
|
113
|
+
if (seen.has(key)) continue;
|
|
114
|
+
seen.add(key);
|
|
115
|
+
bodies.push(body);
|
|
116
|
+
}
|
|
117
|
+
return bodies;
|
|
118
|
+
}
|
package/yeaft/memory/index-db.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* memory/index-db.js — DESIGN-H2-AMS §4. SQLite + FTS5 segment index.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* SQLite is a derived index
|
|
4
|
+
* Sources of truth: on-disk evidence memory.md and canonical content.md.
|
|
5
|
+
* SQLite is a derived scope-selection index, rebuildable from disk at any time.
|
|
6
6
|
*
|
|
7
7
|
* Schema is created idempotently; opening an older DB without the
|
|
8
8
|
* required tables triggers a fresh CREATE. A version PRAGMA guards
|
|
@@ -89,7 +89,7 @@ export function openSegmentIndex(dbPath) {
|
|
|
89
89
|
db.prepare('INSERT INTO schema_meta(key,value) VALUES(?,?)')
|
|
90
90
|
.run('schema_version', String(SCHEMA_VERSION));
|
|
91
91
|
} else if (cur.value !== String(SCHEMA_VERSION)) {
|
|
92
|
-
//
|
|
92
|
+
// The index is derived state, so schema changes use drop-and-recreate.
|
|
93
93
|
db.exec('DROP TABLE IF EXISTS memory_fts');
|
|
94
94
|
db.exec('DROP TABLE IF EXISTS memory_segments');
|
|
95
95
|
db.exec('DELETE FROM schema_meta');
|
|
@@ -119,6 +119,7 @@ export function openSegmentIndex(dbPath) {
|
|
|
119
119
|
* @property {string} query FTS5 MATCH expression
|
|
120
120
|
* @property {string[]=} scopeFilter if set, restrict to these scopes
|
|
121
121
|
* @property {number=} limit default 50
|
|
122
|
+
* @property {string=} requiredTag restrict rows to a derived record tag
|
|
122
123
|
*/
|
|
123
124
|
|
|
124
125
|
/**
|
|
@@ -203,6 +204,8 @@ function makeHandle(db) {
|
|
|
203
204
|
? Math.min(500, Math.floor(opts.limit)) : 50;
|
|
204
205
|
const scopeFilter = Array.isArray(opts.scopeFilter) && opts.scopeFilter.length > 0
|
|
205
206
|
? opts.scopeFilter : null;
|
|
207
|
+
const requiredTag = typeof opts.requiredTag === 'string' && opts.requiredTag.trim()
|
|
208
|
+
? opts.requiredTag.trim() : null;
|
|
206
209
|
|
|
207
210
|
// Build the SQL dynamically — scope IN (...) needs N placeholders.
|
|
208
211
|
let sql = `
|
|
@@ -215,6 +218,10 @@ function makeHandle(db) {
|
|
|
215
218
|
sql += ` AND s.scope IN (${scopeFilter.map(() => '?').join(',')})`;
|
|
216
219
|
params.push(...scopeFilter);
|
|
217
220
|
}
|
|
221
|
+
if (requiredTag) {
|
|
222
|
+
sql += " AND EXISTS (SELECT 1 FROM json_each(s.tags) WHERE json_each.value = ?)";
|
|
223
|
+
params.push(requiredTag);
|
|
224
|
+
}
|
|
218
225
|
sql += ` ORDER BY rank LIMIT ?`;
|
|
219
226
|
params.push(limit);
|
|
220
227
|
|
package/yeaft/memory/keywords.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* keywords.js — pure-rule keyword extraction shared by memory recall paths.
|
|
3
3
|
*
|
|
4
|
-
* Pure CPU, no LLM, <1ms. Used by `
|
|
4
|
+
* Pure CPU, no LLM, <1ms. Used by `sessions/pre-flow.js` to derive FTS
|
|
5
5
|
* query terms from the user message before hitting `memory/preflow.js`.
|
|
6
6
|
*/
|
|
7
7
|
|
|
@@ -27,6 +27,14 @@ const STOP_WORDS = new Set([
|
|
|
27
27
|
'他', '她', '吗', '呢', '吧', '把', '被',
|
|
28
28
|
'那', '它', '让', '给', '可以', '什么',
|
|
29
29
|
'怎么', '帮', '帮我', '请', '能', '想',
|
|
30
|
+
// Generic execution / memory vocabulary is not discriminative enough to
|
|
31
|
+
// select persistent context on its own.
|
|
32
|
+
'current', 'state', 'work', 'task', 'item', 'items', 'todo', 'next', 'step',
|
|
33
|
+
'merge', 'review', 'release', 'tag', 'tags', 'pr', 'pull', 'request', 'issue',
|
|
34
|
+
'fix', 'feat', 'test', 'tests', 'dream', 'memory', 'session', 'topic', 'status',
|
|
35
|
+
'blocker', 'blocked', 'context', 'latest',
|
|
36
|
+
'当前', '状态', '任务', '工作', '工作项', '待办', '下一步', '完成', '合并',
|
|
37
|
+
'评审', '发布', '标签', '记忆', '主题', '阻塞', '正在', '上下文', '最新',
|
|
30
38
|
]);
|
|
31
39
|
|
|
32
40
|
/**
|
|
@@ -38,12 +46,22 @@ const STOP_WORDS = new Set([
|
|
|
38
46
|
export function extractKeywords(prompt) {
|
|
39
47
|
if (!prompt || !prompt.trim()) return [];
|
|
40
48
|
|
|
41
|
-
|
|
42
|
-
const tokens =
|
|
43
|
-
|
|
44
|
-
.
|
|
45
|
-
|
|
46
|
-
|
|
49
|
+
const normalized = prompt.toLowerCase();
|
|
50
|
+
const tokens = [];
|
|
51
|
+
for (const match of normalized.matchAll(/[a-z0-9_]{2,}/g)) {
|
|
52
|
+
if (!STOP_WORDS.has(match[0])) tokens.push(match[0]);
|
|
53
|
+
}
|
|
54
|
+
for (const match of normalized.matchAll(/[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff]{2,}/g)) {
|
|
55
|
+
const run = match[0];
|
|
56
|
+
if (STOP_WORDS.has(run)) continue;
|
|
57
|
+
if (run.length === 2) tokens.push(run);
|
|
58
|
+
else {
|
|
59
|
+
for (let i = 0; i < run.length - 1; i += 1) {
|
|
60
|
+
const term = run.slice(i, i + 2);
|
|
61
|
+
if (!STOP_WORDS.has(term)) tokens.push(term);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
47
65
|
|
|
48
66
|
const freq = new Map();
|
|
49
67
|
for (const t of tokens) {
|
package/yeaft/memory/preflow.js
CHANGED
|
@@ -24,12 +24,14 @@ export const DEFAULT_PICK_LIMIT = 8;
|
|
|
24
24
|
/**
|
|
25
25
|
* @typedef {object} PreflowOptions
|
|
26
26
|
* @property {string} userMsg
|
|
27
|
-
* @property {string[]} relevantScopes e.g. ['user', '
|
|
27
|
+
* @property {string[]} relevantScopes e.g. ['user', 'sessions/s1', 'sessions/s1/vp/alice']
|
|
28
28
|
* @property {string|null} [ownVpId]
|
|
29
|
-
* @property {string[]} [currentTags] tags from the current
|
|
30
|
-
* @property {number} [topK] max FTS rows to fetch (default
|
|
29
|
+
* @property {string[]} [currentTags] tags from the current Session context
|
|
30
|
+
* @property {number} [topK] max FTS rows to fetch (default 200)
|
|
31
31
|
* @property {number} [budgetTokens] onDemand budget (caller-supplied)
|
|
32
32
|
* @property {number} [pickLimit] max picked segments (default 8)
|
|
33
|
+
* @property {boolean} [uniqueScopes] pick at most one best hit per scope
|
|
34
|
+
* @property {boolean} [canonicalOnly] search canonical content records only
|
|
33
35
|
*/
|
|
34
36
|
|
|
35
37
|
/**
|
|
@@ -54,7 +56,7 @@ export function runPreflow(index, opts) {
|
|
|
54
56
|
const relevantScopes = Array.isArray(opts.relevantScopes) ? opts.relevantScopes : [];
|
|
55
57
|
const ownVpId = opts.ownVpId || null;
|
|
56
58
|
const currentTags = Array.isArray(opts.currentTags) ? opts.currentTags : [];
|
|
57
|
-
const topK = Number.isFinite(opts.topK) && opts.topK > 0 ? opts.topK :
|
|
59
|
+
const topK = Number.isFinite(opts.topK) && opts.topK > 0 ? opts.topK : 200;
|
|
58
60
|
const budgetTokens = Number.isFinite(opts.budgetTokens) && opts.budgetTokens > 0
|
|
59
61
|
? opts.budgetTokens : Infinity;
|
|
60
62
|
const pickLimit = Number.isFinite(opts.pickLimit) && opts.pickLimit > 0
|
|
@@ -77,16 +79,27 @@ export function runPreflow(index, opts) {
|
|
|
77
79
|
};
|
|
78
80
|
}
|
|
79
81
|
|
|
80
|
-
const hits = index.search({
|
|
81
|
-
|
|
82
|
+
const hits = index.search({
|
|
83
|
+
query: ftsQuery,
|
|
84
|
+
scopeFilter,
|
|
85
|
+
limit: topK,
|
|
86
|
+
...(opts.canonicalOnly ? { requiredTag: 'canonical-content' } : {}),
|
|
87
|
+
});
|
|
88
|
+
const reranked = rerank(hits, { currentTags, keywords });
|
|
82
89
|
|
|
83
90
|
const picked = [];
|
|
91
|
+
const pickedScopes = new Set();
|
|
84
92
|
let cost = 0;
|
|
85
93
|
let dropped = 0;
|
|
86
94
|
for (const h of reranked) {
|
|
95
|
+
if (opts.uniqueScopes && pickedScopes.has(h.scope)) {
|
|
96
|
+
dropped += 1;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
87
99
|
const tk = approxTokens(h.body);
|
|
88
100
|
if (picked.length < pickLimit && cost + tk <= budgetTokens) {
|
|
89
101
|
picked.push(toSegment(h));
|
|
102
|
+
pickedScopes.add(h.scope);
|
|
90
103
|
cost += tk;
|
|
91
104
|
} else {
|
|
92
105
|
dropped += 1;
|
|
@@ -129,7 +142,7 @@ export function filterScopes(scopes, ownVpId) {
|
|
|
129
142
|
|
|
130
143
|
/**
|
|
131
144
|
* Rerank FTS hits with two soft signals on top of bm25:
|
|
132
|
-
* - tag overlap with the current
|
|
145
|
+
* - tag overlap with the current Session context (subtract penalty)
|
|
133
146
|
* - recency: recent items get a small bonus
|
|
134
147
|
*
|
|
135
148
|
* SQLite FTS5 bm25 returns NEGATIVE numbers (more negative = better
|
|
@@ -138,7 +151,7 @@ export function filterScopes(scopes, ownVpId) {
|
|
|
138
151
|
* score more negative).
|
|
139
152
|
*
|
|
140
153
|
* @param {import('./index-db.js').SearchHit[]} hits
|
|
141
|
-
* @param {{ currentTags: string[] }} ctx
|
|
154
|
+
* @param {{ currentTags: string[], keywords?: string[] }} ctx
|
|
142
155
|
* @returns {import('./index-db.js').SearchHit[]}
|
|
143
156
|
*/
|
|
144
157
|
export function rerank(hits, ctx) {
|
|
@@ -149,7 +162,11 @@ export function rerank(hits, ctx) {
|
|
|
149
162
|
const overlap = (h.tags || []).reduce(
|
|
150
163
|
(n, t) => n + (tagSet.has(String(t).toLowerCase()) ? 1 : 0), 0,
|
|
151
164
|
);
|
|
152
|
-
const
|
|
165
|
+
const queryTerms = new Set((ctx.keywords || []).map(term => String(term).toLowerCase()));
|
|
166
|
+
const canonicalOverlap = (h.tags || []).reduce(
|
|
167
|
+
(n, tag) => n + (queryTerms.has(String(tag).toLowerCase()) ? 1 : 0), 0,
|
|
168
|
+
);
|
|
169
|
+
const tagBonus = Math.min(2, overlap * 0.5) + Math.min(1.5, canonicalOverlap * 0.15);
|
|
153
170
|
const ageDays = Math.max(0, (now - Date.parse(h.updatedAt || h.createdAt || '')) / 86400000);
|
|
154
171
|
const recencyBonus = Math.min(0.5, 0.2 / Math.max(0.5, ageDays + 1));
|
|
155
172
|
const base = h.rank ?? 0;
|