@yeaft/webchat-agent 1.0.349 → 1.0.351

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.
Files changed (41) hide show
  1. package/local-runtime/version.json +1 -1
  2. package/local-runtime/web/app.bundle.js +91 -97
  3. package/local-runtime/web/app.bundle.js.gz +0 -0
  4. package/local-runtime/web/index.html +2 -2
  5. package/local-runtime/web/style.bundle.css +1 -1
  6. package/local-runtime/web/style.bundle.css.gz +0 -0
  7. package/package.json +1 -1
  8. package/yeaft/dream/apply.js +59 -30
  9. package/yeaft/dream/output-snapshot.js +8 -4
  10. package/yeaft/dream/prompts/consolidate-topics.md +31 -0
  11. package/yeaft/dream/prompts/create.md +8 -8
  12. package/yeaft/dream/prompts/index.js +4 -2
  13. package/yeaft/dream/prompts/merge-topics.md +35 -0
  14. package/yeaft/dream/prompts/triage-pass1.md +2 -2
  15. package/yeaft/dream/prompts/triage-pass2.md +4 -2
  16. package/yeaft/dream/prompts/update.md +16 -14
  17. package/yeaft/dream/runner.js +69 -13
  18. package/yeaft/dream/segment-extract.js +16 -13
  19. package/yeaft/dream/session-wiring.js +2 -2
  20. package/yeaft/dream/snapshot.js +3 -3
  21. package/yeaft/dream/topic-consolidation.js +316 -0
  22. package/yeaft/dream/triage.js +7 -2
  23. package/yeaft/engine.js +221 -236
  24. package/yeaft/memory/ams-registry.js +42 -61
  25. package/yeaft/memory/ams.js +17 -9
  26. package/yeaft/memory/budget.js +15 -18
  27. package/yeaft/memory/content-backfill.js +118 -0
  28. package/yeaft/memory/index-db.js +10 -3
  29. package/yeaft/memory/keywords.js +25 -7
  30. package/yeaft/memory/preflow.js +26 -9
  31. package/yeaft/memory/segment-store.js +44 -8
  32. package/yeaft/memory/segment-sync.js +8 -4
  33. package/yeaft/memory/segment.js +10 -3
  34. package/yeaft/memory/store.js +88 -38
  35. package/yeaft/memory/summary-store.js +3 -3
  36. package/yeaft/memory/topic-redirect.js +28 -0
  37. package/yeaft/session.js +18 -19
  38. package/yeaft/sessions/pre-flow.js +7 -3
  39. package/yeaft/sub-agent/runner.js +10 -1
  40. package/yeaft/work-center/bridge.js +1 -0
  41. package/yeaft/work-center/runner.js +33 -4
@@ -1,10 +1,10 @@
1
1
  /**
2
- * memory/ams-registry.js — session-keyed AMS lifecycle.
2
+ * memory/ams-registry.js — Session + VP keyed AMS lifecycle.
3
3
  *
4
- * The Active Memory Set is conceptually session-scoped, with `sessionId`
5
- * as the unit. A deactivated session can be reactivated later and should
6
- * resume with the AMS state it had on disconnect (the onDemand segments
7
- * it had pulled in, the recent LRU touches, whether `adjust` already ran).
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 NOT serialised they're re-hydrated from the SegmentIndex
22
- * on load, so a body edited by Dream after save still surfaces correctly
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
- * Group-keyed in-memory cache + disk persistence for AMS instances.
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 group's ams.json.
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 group, creating it on first access.
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 key = sessionId || DEFAULT_SESSION_KEY;
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 from disk populates ams + entry flags.
124
- this._hydrate(key, entry);
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
- * group. Engine consults this on first AMS access so a reactivated group
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 key = sessionId || DEFAULT_SESSION_KEY;
139
- return this._cache.get(key)?.adjustRanThisSession === true;
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). Engine flips this true after
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 key = sessionId || DEFAULT_SESSION_KEY;
152
- const entry = this._cache.get(key);
153
- if (entry) entry.adjustRanThisSession = Boolean(value);
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 group's AMS as dirty so the next persist() actually writes.
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 group's AMS to disk. No-op when the cached entry
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.get(key);
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: read ams.json, re-resolve segment ids via the
226
- * SegmentIndex (skipping ids that no longer exist), populate AMS, and
227
- * restore the persisted `adjustRanThisSession` flag onto the cache entry.
228
- * Silent on every error a corrupt or missing file is the cold-start
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, entry) {
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
- if (payload.adjustRanThisSession === true) {
244
- entry.adjustRanThisSession = true;
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
 
@@ -1,15 +1,13 @@
1
1
  /**
2
2
  * memory/ams.js — DESIGN-H2-AMS §5. Active Memory Set.
3
3
  *
4
- * Per-group session state. Three layers:
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
- * resident summaries of all relevant scopes always-on, high precision
7
- * recent LRU of segments touched in last N turns — warm cache
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 group's own vp first, then user, etc.).
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,
@@ -1,35 +1,32 @@
1
1
  /**
2
2
  * memory/budget.js — DESIGN-PROMPT §3 ③ Memory.
3
3
  *
4
- * Memory budget = `min(100_000, modelMaxContext * 0.20)`.
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
- * Then split across the three AMS layers (resident / recent / onDemand)
7
- * with a configurable ratio. The defaults are tuned per DESIGN-PROMPT §3
8
- * (Resident gets the largest share because UserProfile + CoreMemory
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 → 40K total (20% × 200K)
18
- * 1M context → 100K total (capped)
19
- * 128K context → 25.6K total
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 = 100_000;
27
- export const MODEL_FRACTION = 0.20;
23
+ export const ABSOLUTE_CAP = 6_000;
24
+ export const MODEL_FRACTION = 0.05;
28
25
 
29
26
  export const DEFAULT_RATIO = {
30
- resident: 0.60,
31
- recent: 0.15,
32
- onDemand: 0.25,
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
+ }
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * memory/index-db.js — DESIGN-H2-AMS §4. SQLite + FTS5 segment index.
3
3
  *
4
- * Source of truth: on-disk `~/.yeaft/memory/<scope>/memory.md` files.
5
- * SQLite is a derived index rebuildable from disk at any time.
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
- // For now, simple drop-and-recreate. v2 may add migrations.
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
 
@@ -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 `groups/pre-flow.js` to derive FTS
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
- // Tokenize: split on whitespace + punctuation, keep CJK chars.
42
- const tokens = prompt
43
- .toLowerCase()
44
- .replace(/[^\w\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff]+/g, ' ')
45
- .split(/\s+/)
46
- .filter(t => t.length > 1 && !STOP_WORDS.has(t));
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) {
@@ -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', 'group/g1', 'vp/alice']
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 group/feature context
30
- * @property {number} [topK] max FTS rows to fetch (default 50)
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 : 50;
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({ query: ftsQuery, scopeFilter, limit: topK });
81
- const reranked = rerank(hits, { currentTags });
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 group/feature context (subtract penalty)
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 tagBonus = Math.min(2, overlap * 0.5); // up to 2 points
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;