dsh-memoir 0.4.3

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/lib/routes.js ADDED
@@ -0,0 +1,253 @@
1
+ /**
2
+ * /api/dsh-memoir/* route layer for the web panel: a JSON envelope (ok /
3
+ * error) over the structured store. Reads are GET with query params; writes
4
+ * require an explicit application/json content-type (blocks form-based CSRF,
5
+ * same stance as the sibling aionui-panel routes).
6
+ *
7
+ * v0.4.2 additions: ranked /search (shared RetrievalEngine with memoir_read),
8
+ * /hot-memory preview, extended diagnostics (retrieval index + query cache +
9
+ * last query + session snapshot), and workspace authorization on writes.
10
+ */
11
+ import { SECTIONS, SECTION_KEYS, projectKey, projectTitle } from './store.js';
12
+ const BAD_REQUEST = { code: 'bad-request', message: 'malformed request' };
13
+ const NOT_FOUND = { code: 'not-found', message: 'unknown route' };
14
+ const METHOD = { code: 'method', message: 'method not allowed' };
15
+ const CONTENT_TYPE = { code: 'content-type', message: 'application/json content-type required' };
16
+ const OK = (value) => ({ ok: true, value });
17
+ const FAIL = (error) => ({ ok: false, error });
18
+ /** Write one JSON envelope response. */
19
+ export function json(res, envelope, status = 200) {
20
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
21
+ res.end(JSON.stringify(envelope));
22
+ }
23
+ /** Read a bounded JSON request body; null when unparseable or oversized. */
24
+ export async function readJsonBody(req, limit = 1 << 20) {
25
+ const chunks = [];
26
+ let total = 0;
27
+ for await (const chunk of req) {
28
+ const buffer = chunk;
29
+ chunks.push(buffer);
30
+ total += buffer.length;
31
+ if (total > limit)
32
+ return null;
33
+ }
34
+ const text = Buffer.concat(chunks).toString('utf8');
35
+ if (text === '')
36
+ return null;
37
+ try {
38
+ return JSON.parse(text);
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
44
+ /** Extract a string field; null when missing/empty or not a string. */
45
+ function strField(payload, key, allowEmpty = false) {
46
+ if (typeof payload !== 'object' || payload === null)
47
+ return null;
48
+ const value = payload[key];
49
+ if (typeof value !== 'string')
50
+ return null;
51
+ if (!allowEmpty && value === '')
52
+ return null;
53
+ return value;
54
+ }
55
+ /** Validate a workspace path field for writes (absolute on win32/posix). */
56
+ function validPath(value) {
57
+ return /^[A-Za-z]:[\\/]|[\\/]/.test(value);
58
+ }
59
+ /** Filter one entry by optional section + query. */
60
+ function entryFilter(section, query) {
61
+ const q = query.toLowerCase();
62
+ return (entry) => (section === undefined || entry.section === section) &&
63
+ (q === '' || `${entry.title ?? ''} ${entry.content}`.toLowerCase().includes(q));
64
+ }
65
+ /** Project one project record into the wire shape. */
66
+ function wireProject(key, project, filter) {
67
+ return {
68
+ key,
69
+ path: project.path,
70
+ title: project.title || projectTitle(project.path),
71
+ updatedAt: project.updatedAt,
72
+ entries: project.entries.filter(filter),
73
+ };
74
+ }
75
+ /**
76
+ * Build the /api/dsh-memoir prefix route.
77
+ * @param store - the structured MemoirStore.
78
+ * @param diagnostics - optional runtime diagnostics provider.
79
+ * @param retrieval - optional RetrievalEngine (ranked /search endpoint).
80
+ * @param hotMemory - optional hot-memory preview provider (inspector).
81
+ * @param allowedWorkspace - optional write guard: only paths it accepts may
82
+ * be written via the panel API (v0.4.2 host safety, roadmap §3.5).
83
+ * @param touchWorkspace - optional hook recording seen workspace paths
84
+ * (feeds the plugin's active-workspace set for write authorization).
85
+ * @returns route definitions for ctx.webServer.register.
86
+ */
87
+ export function makeRoutes(store, diagnostics, retrieval, hotMemory, allowedWorkspace, touchWorkspace) {
88
+ const handler = async (req, res) => {
89
+ const url = new URL(req.url ?? '/', 'http://x');
90
+ const pathname = url.pathname;
91
+ const method = (req.method ?? 'GET').toUpperCase();
92
+ // ------------------------------------------------------------ reads
93
+ if (method === 'GET') {
94
+ if (pathname === '/api/dsh-memoir/search') {
95
+ // v0.4.2: the GUI and memoir_read share this RetrievalEngine.
96
+ if (retrieval === undefined) {
97
+ json(res, FAIL(NOT_FOUND), 404);
98
+ return;
99
+ }
100
+ const scope = url.searchParams.get('scope') ?? 'all';
101
+ const path = url.searchParams.get('path') ?? undefined;
102
+ const section = url.searchParams.get('section') ?? undefined;
103
+ if (section !== undefined && !SECTION_KEYS.includes(section)) {
104
+ json(res, FAIL(BAD_REQUEST), 400);
105
+ return;
106
+ }
107
+ const query = url.searchParams.get('query') ?? '';
108
+ if (query === '') {
109
+ json(res, OK({ results: [] }));
110
+ return;
111
+ }
112
+ if (scope === 'project' && (path === undefined || path === '')) {
113
+ json(res, FAIL(BAD_REQUEST), 400);
114
+ return;
115
+ }
116
+ if (touchWorkspace !== undefined && path !== undefined && path !== '')
117
+ touchWorkspace(path);
118
+ const rawLimit = Number(url.searchParams.get('limit') ?? 30);
119
+ const limit = Math.min(100, Math.max(1, Number.isFinite(rawLimit) ? Math.floor(rawLimit) : 30));
120
+ const cwd = scope === 'project' ? path : undefined;
121
+ const ranked = retrieval.cachedSearch(query, { section: section, cwd }).slice(0, limit);
122
+ json(res, OK({ results: ranked }));
123
+ return;
124
+ }
125
+ if (pathname === '/api/dsh-memoir/hot-memory') {
126
+ // v0.4.2: inspector preview of what the next session inherits.
127
+ if (hotMemory === undefined) {
128
+ json(res, FAIL(NOT_FOUND), 404);
129
+ return;
130
+ }
131
+ const path = url.searchParams.get('path') ?? '';
132
+ if (path === '') {
133
+ json(res, FAIL(BAD_REQUEST), 400);
134
+ return;
135
+ }
136
+ if (touchWorkspace !== undefined)
137
+ touchWorkspace(path);
138
+ json(res, OK({ hotMemory: hotMemory(path) }));
139
+ return;
140
+ }
141
+ if (pathname === '/api/dsh-memoir/diagnostics') {
142
+ if (diagnostics === undefined) {
143
+ json(res, FAIL(NOT_FOUND), 404);
144
+ return;
145
+ }
146
+ const path = url.searchParams.get('path') ?? undefined;
147
+ if (touchWorkspace !== undefined && path !== undefined && path !== '')
148
+ touchWorkspace(path);
149
+ json(res, OK(diagnostics(path)));
150
+ return;
151
+ }
152
+ if (pathname === '/api/dsh-memoir/project') {
153
+ const path = url.searchParams.get('path');
154
+ if (path === null || path === '') {
155
+ json(res, FAIL(BAD_REQUEST), 400);
156
+ return;
157
+ }
158
+ if (touchWorkspace !== undefined)
159
+ touchWorkspace(path);
160
+ const section = url.searchParams.get('section') ?? undefined;
161
+ if (section !== undefined && !SECTION_KEYS.includes(section)) {
162
+ json(res, FAIL(BAD_REQUEST), 400);
163
+ return;
164
+ }
165
+ const query = url.searchParams.get('query') ?? '';
166
+ const key = projectKey(path);
167
+ const project = store.project(path);
168
+ const filter = entryFilter(section, query);
169
+ const value = project === undefined
170
+ ? { project: { key, path, title: projectTitle(path), updatedAt: 0, entries: [] } }
171
+ : { project: wireProject(key, project, filter) };
172
+ json(res, OK(value));
173
+ return;
174
+ }
175
+ if (pathname === '/api/dsh-memoir/global') {
176
+ const section = url.searchParams.get('section') ?? undefined;
177
+ if (section !== undefined && !SECTION_KEYS.includes(section)) {
178
+ json(res, FAIL(BAD_REQUEST), 400);
179
+ return;
180
+ }
181
+ const query = url.searchParams.get('query') ?? '';
182
+ const filter = entryFilter(section, query);
183
+ const storeFile = store.load();
184
+ const projects = Object.entries(storeFile.projects)
185
+ .map(([key, project]) => wireProject(key, project, filter))
186
+ .filter((project) => project.entries.length > 0)
187
+ // Newest first; deterministic tiebreak when times collide (same ms).
188
+ .sort((a, b) => b.updatedAt - a.updatedAt || a.path.localeCompare(b.path));
189
+ json(res, OK({ projects }));
190
+ return;
191
+ }
192
+ json(res, FAIL(NOT_FOUND), 404);
193
+ return;
194
+ }
195
+ // ----------------------------------------------------------- writes
196
+ if (method !== 'POST' && method !== 'DELETE') {
197
+ json(res, FAIL(METHOD), 405);
198
+ return;
199
+ }
200
+ const contentType = req.headers['content-type'] ?? '';
201
+ if (!contentType.toLowerCase().startsWith('application/json')) {
202
+ json(res, FAIL(CONTENT_TYPE), 415);
203
+ return;
204
+ }
205
+ const payload = await readJsonBody(req);
206
+ if (payload === null) {
207
+ json(res, FAIL(BAD_REQUEST), 400);
208
+ return;
209
+ }
210
+ if (pathname !== '/api/dsh-memoir/entries') {
211
+ json(res, FAIL(NOT_FOUND), 404);
212
+ return;
213
+ }
214
+ const path = strField(payload, 'path');
215
+ if (path === null || !validPath(path)) {
216
+ json(res, FAIL({ code: 'bad-request', message: 'path must be an absolute workspace path' }), 400);
217
+ return;
218
+ }
219
+ // v0.4.2 workspace authorization: a browser-submitted absolute path is
220
+ // not authorization — writes are limited to the active workspace(s) or
221
+ // projects already in the store.
222
+ if (allowedWorkspace !== undefined && !allowedWorkspace(path)) {
223
+ json(res, FAIL({ code: 'forbidden', message: 'path 不在允许的工作区中:仅当前活动 cwd 或已有 store 项目可写' }), 403);
224
+ return;
225
+ }
226
+ if (method === 'POST') {
227
+ const section = strField(payload, 'section');
228
+ const content = strField(payload, 'content');
229
+ if (section === null || content === null) {
230
+ json(res, FAIL(BAD_REQUEST), 400);
231
+ return;
232
+ }
233
+ if (!Object.prototype.hasOwnProperty.call(SECTIONS, section)) {
234
+ json(res, FAIL({ code: 'bad-request', message: `section must be one of ${SECTION_KEYS.join('/')}` }), 400);
235
+ return;
236
+ }
237
+ const title = strField(payload, 'title', true) ?? undefined;
238
+ const sessionId = strField(payload, 'sessionId', true) ?? undefined;
239
+ const entry = store.record(path, { section: section, title, content }, sessionId);
240
+ json(res, OK({ entry }));
241
+ return;
242
+ }
243
+ // DELETE
244
+ const id = strField(payload, 'id');
245
+ if (id === null) {
246
+ json(res, FAIL(BAD_REQUEST), 400);
247
+ return;
248
+ }
249
+ const removed = store.remove(path, id);
250
+ json(res, OK({ removed }));
251
+ };
252
+ return [{ kind: 'prefix', path: '/api/dsh-memoir', handler }];
253
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Hot memory selector (roadmap §2.3) — picks the highest-value memory for
3
+ * system-prompt injection under a token budget, and renders it compactly.
4
+ *
5
+ * Deterministic: fixed entries + budget always produce the same text (a
6
+ * requirement for stable prompt prefixes). No ids / sessionIds / timestamps /
7
+ * repeated section labels in the injected text — those burn tokens without
8
+ * changing behavior.
9
+ */
10
+ import type { MemoirEntry, SectionKey } from './store.js';
11
+ /** Token budget for hot-memory injection. */
12
+ export interface MemoryBudget {
13
+ /** Soft target: stop adding entries once reached. */
14
+ targetTokens: number;
15
+ /** Hard ceiling: injected text never exceeds this. */
16
+ hardMaxTokens: number;
17
+ }
18
+ /** Defaults (roadmap §2.3 / config hotMemoryTokens / hotMemoryMaxTokens). */
19
+ export declare const DEFAULT_MEMORY_BUDGET: MemoryBudget;
20
+ /** Section weights for the v0.4 scoring (roadmap §2.3). */
21
+ export declare const SECTION_WEIGHTS: Record<SectionKey, number>;
22
+ /**
23
+ * Canonical group render order inside the injected text. v0.4.2: work is
24
+ * intentionally NOT a rendered group — work entries appear only in the
25
+ * "Recent state" block, so the same work line is never injected twice.
26
+ */
27
+ export declare const HOT_SECTION_ORDER: SectionKey[];
28
+ /** Recent-work entries shown in the "Recent state" block. */
29
+ export declare const RECENT_WORK_COUNT = 3;
30
+ /**
31
+ * Conservative token approximation without a tokenizer library (roadmap
32
+ * §2.3): CJK chars ≈ 1 token each, everything else ≈ 4 chars/token.
33
+ */
34
+ export declare function estimateTokens(text: string): number;
35
+ /** One scored candidate. */
36
+ interface ScoredEntry {
37
+ entry: MemoirEntry;
38
+ score: number;
39
+ }
40
+ /**
41
+ * Score + order candidates deterministically:
42
+ * section weight + recency decay; ties break by newer time, then id.
43
+ * note entries are excluded by default (roadmap §1.2 B: notes never enter
44
+ * hot memory in v0.4).
45
+ */
46
+ export declare function rankEntries(entries: MemoirEntry[], now?: number): ScoredEntry[];
47
+ /** Compact bullet for one entry: title prefix + content (no ids/timestamps). */
48
+ export declare function compactLine(entry: MemoirEntry): string;
49
+ /** The injected header line. */
50
+ export declare const HOT_MEMORY_HEADER = "[Project memory]";
51
+ /**
52
+ * Render the selected entries into the compact injected block (roadmap
53
+ * §2.3): Actions / Lessons / Recent state. Deterministic for a fixed input.
54
+ */
55
+ export declare function renderHotMemory(selected: MemoirEntry[]): string;
56
+ /** One selection result (diagnostics + injection). */
57
+ export interface HotMemoryResult {
58
+ /** The injected block ('' when nothing was selected). */
59
+ text: string;
60
+ /** Entries that made it into the block. */
61
+ selected: MemoirEntry[];
62
+ /** Total candidates considered (excluding note). */
63
+ total: number;
64
+ /** Estimated tokens of the injected text. */
65
+ estimatedTokens: number;
66
+ }
67
+ /**
68
+ * Truncate one entry's content so the rendered single-entry block fits the
69
+ * hard token ceiling. Binary-searches the largest fitting code-point length
70
+ * (monotone in the token estimate). Used only for the degenerate case of an
71
+ * oversized FIRST candidate — normal selection never exceeds hardMax.
72
+ */
73
+ export declare function truncateEntryToBudget(entry: MemoirEntry, hardMaxTokens: number): MemoirEntry;
74
+ /**
75
+ * Select hot memory under the budget. v0.4.2 quota-based order:
76
+ * 1. newest work entries (Recent-state floor, 1~RECENT_WORK_COUNT)
77
+ * 2. ranked actions
78
+ * 3. ranked lessons
79
+ * 4. remaining work entries, newest first
80
+ * Each candidate is added while the rendered estimate stays below
81
+ * targetTokens; hardMaxTokens is never exceeded (an oversized first
82
+ * candidate is truncated into place via truncateEntryToBudget).
83
+ *
84
+ * @param entries - one project's entries.
85
+ * @param budget - token budget (defaults to DEFAULT_MEMORY_BUDGET).
86
+ * @param now - clock for recency (injectable for deterministic tests).
87
+ */
88
+ export declare function selectHotMemory(entries: MemoirEntry[], budget?: MemoryBudget, now?: number): HotMemoryResult;
89
+ export {};
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Hot memory selector (roadmap §2.3) — picks the highest-value memory for
3
+ * system-prompt injection under a token budget, and renders it compactly.
4
+ *
5
+ * Deterministic: fixed entries + budget always produce the same text (a
6
+ * requirement for stable prompt prefixes). No ids / sessionIds / timestamps /
7
+ * repeated section labels in the injected text — those burn tokens without
8
+ * changing behavior.
9
+ */
10
+ /** Defaults (roadmap §2.3 / config hotMemoryTokens / hotMemoryMaxTokens). */
11
+ export const DEFAULT_MEMORY_BUDGET = { targetTokens: 900, hardMaxTokens: 1200 };
12
+ /** Section weights for the v0.4 scoring (roadmap §2.3). */
13
+ export const SECTION_WEIGHTS = {
14
+ actions: 4.0,
15
+ lessons: 3.5,
16
+ work: 2.0,
17
+ note: 0.5,
18
+ };
19
+ /**
20
+ * Canonical group render order inside the injected text. v0.4.2: work is
21
+ * intentionally NOT a rendered group — work entries appear only in the
22
+ * "Recent state" block, so the same work line is never injected twice.
23
+ */
24
+ export const HOT_SECTION_ORDER = ['actions', 'lessons'];
25
+ /** Recent-work entries shown in the "Recent state" block. */
26
+ export const RECENT_WORK_COUNT = 3;
27
+ /** CJK-ish codepoint ranges counted as ~1 token each. */
28
+ function isCjk(cp) {
29
+ return ((cp >= 0x3000 && cp <= 0x303f) || // CJK punctuation
30
+ (cp >= 0x3040 && cp <= 0x30ff) || // kana
31
+ (cp >= 0x3400 && cp <= 0x9fff) || // CJK ideographs
32
+ (cp >= 0xf900 && cp <= 0xfaff) || // CJK compat
33
+ (cp >= 0xff00 && cp <= 0xffef) || // fullwidth forms
34
+ (cp >= 0xac00 && cp <= 0xd7af) // hangul
35
+ );
36
+ }
37
+ /**
38
+ * Conservative token approximation without a tokenizer library (roadmap
39
+ * §2.3): CJK chars ≈ 1 token each, everything else ≈ 4 chars/token.
40
+ */
41
+ export function estimateTokens(text) {
42
+ let cjk = 0;
43
+ let other = 0;
44
+ for (const ch of text) {
45
+ const cp = ch.codePointAt(0) ?? 0;
46
+ if (isCjk(cp))
47
+ cjk++;
48
+ else
49
+ other++;
50
+ }
51
+ return Math.ceil(cjk + other / 4);
52
+ }
53
+ /** Recency decay: 1.0 now → ~0 toward very old entries. */
54
+ function recencyBoost(time, now) {
55
+ const ageDays = Math.max(0, now - time) / 86_400_000;
56
+ return 1.0 / (1 + ageDays / 30);
57
+ }
58
+ /**
59
+ * Score + order candidates deterministically:
60
+ * section weight + recency decay; ties break by newer time, then id.
61
+ * note entries are excluded by default (roadmap §1.2 B: notes never enter
62
+ * hot memory in v0.4).
63
+ */
64
+ export function rankEntries(entries, now = Date.now()) {
65
+ return entries
66
+ .filter((e) => e.section !== 'note')
67
+ .map((entry) => ({
68
+ entry,
69
+ score: SECTION_WEIGHTS[entry.section] + recencyBoost(entry.time, now),
70
+ }))
71
+ .sort((a, b) => b.score - a.score || b.entry.time - a.entry.time || a.entry.id.localeCompare(b.entry.id));
72
+ }
73
+ /** Compact bullet for one entry: title prefix + content (no ids/timestamps). */
74
+ export function compactLine(entry) {
75
+ const head = entry.title !== undefined && entry.title !== '' ? entry.title + ':' : '';
76
+ return '- ' + head + entry.content.replace(/\s+/g, ' ').trim();
77
+ }
78
+ /** The injected header line. */
79
+ export const HOT_MEMORY_HEADER = '[Project memory]';
80
+ /**
81
+ * Render the selected entries into the compact injected block (roadmap
82
+ * §2.3): Actions / Lessons / Recent state. Deterministic for a fixed input.
83
+ */
84
+ export function renderHotMemory(selected) {
85
+ const lines = [HOT_MEMORY_HEADER];
86
+ for (const section of HOT_SECTION_ORDER) {
87
+ const group = selected.filter((e) => e.section === section);
88
+ if (group.length === 0)
89
+ continue;
90
+ const label = section === 'actions' ? 'Actions:' : 'Lessons:';
91
+ lines.push(label);
92
+ for (const entry of group)
93
+ lines.push(compactLine(entry));
94
+ lines.push('');
95
+ }
96
+ // Recent state: the newest selected work entries (activity context).
97
+ // Work entries render ONLY here (v0.4.2) — no duplicate "Work:" group.
98
+ const recent = [...selected]
99
+ .filter((e) => e.section === 'work')
100
+ .sort((a, b) => b.time - a.time || a.id.localeCompare(b.id))
101
+ .slice(0, RECENT_WORK_COUNT);
102
+ if (recent.length > 0) {
103
+ lines.push('Recent state:');
104
+ for (const entry of recent)
105
+ lines.push(compactLine(entry));
106
+ }
107
+ return lines.join('\n').replace(/\n\n\n+/g, '\n\n').trim();
108
+ }
109
+ /**
110
+ * Truncate one entry's content so the rendered single-entry block fits the
111
+ * hard token ceiling. Binary-searches the largest fitting code-point length
112
+ * (monotone in the token estimate). Used only for the degenerate case of an
113
+ * oversized FIRST candidate — normal selection never exceeds hardMax.
114
+ */
115
+ export function truncateEntryToBudget(entry, hardMaxTokens) {
116
+ if (estimateTokens(renderHotMemory([entry])) <= hardMaxTokens)
117
+ return entry;
118
+ const cps = [...entry.content];
119
+ let lo = 0;
120
+ let hi = cps.length;
121
+ while (lo < hi) {
122
+ const mid = Math.ceil((lo + hi) / 2);
123
+ const probe = { ...entry, content: cps.slice(0, mid).join('') + '…' };
124
+ if (estimateTokens(renderHotMemory([probe])) <= hardMaxTokens)
125
+ lo = mid;
126
+ else
127
+ hi = mid - 1;
128
+ }
129
+ return { ...entry, content: cps.slice(0, lo).join('') + '…' };
130
+ }
131
+ /**
132
+ * Select hot memory under the budget. v0.4.2 quota-based order:
133
+ * 1. newest work entries (Recent-state floor, 1~RECENT_WORK_COUNT)
134
+ * 2. ranked actions
135
+ * 3. ranked lessons
136
+ * 4. remaining work entries, newest first
137
+ * Each candidate is added while the rendered estimate stays below
138
+ * targetTokens; hardMaxTokens is never exceeded (an oversized first
139
+ * candidate is truncated into place via truncateEntryToBudget).
140
+ *
141
+ * @param entries - one project's entries.
142
+ * @param budget - token budget (defaults to DEFAULT_MEMORY_BUDGET).
143
+ * @param now - clock for recency (injectable for deterministic tests).
144
+ */
145
+ export function selectHotMemory(entries, budget = DEFAULT_MEMORY_BUDGET, now = Date.now()) {
146
+ const work = entries
147
+ .filter((e) => e.section === 'work')
148
+ .sort((a, b) => b.time - a.time || a.id.localeCompare(b.id));
149
+ const actions = rankEntries(entries.filter((e) => e.section === 'actions'), now).map((s) => s.entry);
150
+ const lessons = rankEntries(entries.filter((e) => e.section === 'lessons'), now).map((s) => s.entry);
151
+ const candidates = [...work.slice(0, RECENT_WORK_COUNT), ...actions, ...lessons, ...work.slice(RECENT_WORK_COUNT)];
152
+ const selected = [];
153
+ const selectedIds = new Set();
154
+ for (const entry of candidates) {
155
+ if (selectedIds.has(entry.id))
156
+ continue;
157
+ const probe = selected.length === 0 ? [entry] : [...selected, entry];
158
+ const tokens = estimateTokens(renderHotMemory(probe));
159
+ if (tokens > budget.hardMaxTokens) {
160
+ if (selected.length === 0) {
161
+ // A single oversized entry: force it in truncated so output stays
162
+ // bounded and non-empty.
163
+ selected.push(truncateEntryToBudget(entry, budget.hardMaxTokens));
164
+ }
165
+ break;
166
+ }
167
+ selected.push(entry);
168
+ selectedIds.add(entry.id);
169
+ if (tokens >= budget.targetTokens)
170
+ break;
171
+ }
172
+ const text = selected.length === 0 ? '' : renderHotMemory(selected);
173
+ return {
174
+ text,
175
+ selected,
176
+ total: rankEntries(entries, now).length,
177
+ estimatedTokens: estimateTokens(text),
178
+ };
179
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Session memory snapshot manager (roadmap §2.2) — freezes the project
3
+ * memory injected into a session's system prompt so that the prompt prefix
4
+ * stays stable for the whole session. The current session does NOT re-consume
5
+ * memory it just wrote: later assemblies reuse the first snapshot; a NEW
6
+ * session builds a fresh one and sees the new memory.
7
+ *
8
+ * This is what maximizes prompt-prefix cache hits — the goal is stable model
9
+ * input, not just fast reads (the store snapshot cache already covers those).
10
+ *
11
+ * Pure logic, unit-testable without any runtime dependency.
12
+ */
13
+ /** One frozen per-session memory snapshot. */
14
+ export interface SessionSnapshot {
15
+ /** Stable identity of the session (id + workspace). */
16
+ sessionKey: string;
17
+ /** Store revision the snapshot was built from. */
18
+ storeRevision: number;
19
+ /** The injected memory text (frozen until the session ends). */
20
+ text: string;
21
+ /** Truncated SHA-256 of text — the prompt-stability hash. */
22
+ hash: string;
23
+ /** When the snapshot was created. */
24
+ createdAt: number;
25
+ }
26
+ /** Hash a text for prompt-stability comparison (truncated SHA-256). */
27
+ export declare function snapshotHash(text: string): string;
28
+ /**
29
+ * Freezes one session's injected memory; bounded by a simple LRU (oldest
30
+ * snapshot evicted past the cap), so long-running processes never accumulate
31
+ * dead session entries.
32
+ */
33
+ export declare class MemorySnapshotManager {
34
+ /** Live session snapshots in LRU order (most recent last). */
35
+ private readonly snapshots;
36
+ private readonly max;
37
+ /**
38
+ * @param options.max - LRU cap (default 128; config sessionSnapshotMax).
39
+ */
40
+ constructor(options?: {
41
+ max?: number;
42
+ });
43
+ /** Current snapshot count (diagnostics). */
44
+ get size(): number;
45
+ /** The LRU cap this manager was created with. */
46
+ get cap(): number;
47
+ /**
48
+ * Return the session's frozen snapshot, or build one via builder.
49
+ * A later call for the same key ALWAYS returns the first snapshot — even
50
+ * if the store revision moved on (that is the point: stable prompt prefix).
51
+ *
52
+ * @param sessionKey - stable session identity (id + workspace).
53
+ * @param builder - builds { storeRevision, text } when no snapshot exists.
54
+ */
55
+ getOrCreate(sessionKey: string, builder: () => {
56
+ storeRevision: number;
57
+ text: string;
58
+ }): SessionSnapshot;
59
+ /** Peek at a session's snapshot (undefined when not frozen yet). */
60
+ peek(sessionKey: string): SessionSnapshot | undefined;
61
+ /** The most recently created snapshot (diagnostics / inspector). */
62
+ latest(): SessionSnapshot | undefined;
63
+ /** Drop one session's snapshot (disposal hygiene). */
64
+ forget(sessionKey: string): void;
65
+ }
66
+ /**
67
+ * Derive a stable session key from the system-prompt assemble context:
68
+ * prefer the session id, then the agent id; always scoped by the workspace
69
+ * cwd. Returns undefined when no unique identity is known — no freezing then,
70
+ * every assembly builds fresh.
71
+ *
72
+ * v0.4.2: the cwd-only fallback was removed. A key of the form "cwd:<path>"
73
+ * is shared by every session of that workspace, so session A's frozen
74
+ * snapshot would be served to session B, hiding memory session A itself
75
+ * wrote. Without a unique session identity, cache miss beats cache
76
+ * corruption: freeze nothing, rebuild every assembly.
77
+ */
78
+ export declare function sessionKeyOf(context: {
79
+ agent?: {
80
+ id?: string;
81
+ session?: {
82
+ id?: string;
83
+ header?: {
84
+ cwd?: string;
85
+ };
86
+ };
87
+ };
88
+ }): string | undefined;