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.
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Local ranked retrieval (roadmap §2.4) — lexical search without embeddings:
3
+ * - tokenizer: Chinese 2-grams (+3-grams), lowercase english words,
4
+ * path/code identifiers split on / \ . _ - and camelCase
5
+ * - in-memory inverted index (term → entryId → tf) rebuilt when the store
6
+ * epoch changes
7
+ * - ranking: BM25(content) + 2.5×BM25(title) + exact-phrase boost +
8
+ * section weight + recency decay
9
+ * - revision/epoch-aware LRU query cache (default 128 entries)
10
+ */
11
+ import type { MemoirEntry, MemoirStore, SectionKey } from './store.js';
12
+ /** BM25 constants (standard defaults). */
13
+ export declare const BM25_K1 = 1.5;
14
+ export declare const BM25_B = 0.75;
15
+ export declare const TITLE_BOOST = 2.5;
16
+ export declare const EXACT_PHRASE_BOOST = 2;
17
+ export declare const SECTION_BOOST_SCALE = 0.1;
18
+ export declare const RECENCY_BOOST_SCALE = 0.5;
19
+ /** Section weights reused for retrieval (relative, scaled down). */
20
+ export declare const SECTION_WEIGHTS: Record<SectionKey, number>;
21
+ /** One side of the inverted index: term → entryId → term frequency. */
22
+ export type Postings = Map<string, Map<string, number>>;
23
+ /** The in-memory inverted index over the store at one epoch. */
24
+ export interface RetrievalIndex {
25
+ /** Store epoch this index was built from (cache invalidation key). */
26
+ epoch: number;
27
+ docs: number;
28
+ /** Body field length normalization (v0.4.2: independent of title). */
29
+ avgBodyLength: number;
30
+ bodyLengths: Map<string, number>;
31
+ /** Title field length normalization (v0.4.2: independent of body). */
32
+ avgTitleLength: number;
33
+ titleLengths: Map<string, number>;
34
+ body: Postings;
35
+ title: Postings;
36
+ }
37
+ /** One ranked search result. */
38
+ export interface RankedEntry {
39
+ entry: MemoirEntry;
40
+ /** Workspace path the entry lives in (for global grouping). */
41
+ projectPath: string;
42
+ score: number;
43
+ }
44
+ /** Time bucket size for the ranking cache (recency is part of the score). */
45
+ export declare const QUERY_CACHE_TIME_BUCKET_MS = 3600000;
46
+ /** Retrieval observability snapshot (diagnostics endpoint, roadmap §6.3). */
47
+ export interface RetrievalDiagnostics {
48
+ /** Inverted-index shape; null before the first build. */
49
+ index: {
50
+ docs: number;
51
+ terms: number;
52
+ epoch: number;
53
+ } | null;
54
+ /** Query LRU counters. */
55
+ cache: {
56
+ hits: number;
57
+ misses: number;
58
+ evictions: number;
59
+ hitRate: number;
60
+ size: number;
61
+ capacity: number;
62
+ };
63
+ /** The last executed search (a cache hit does not re-run the search). */
64
+ lastQuery: {
65
+ query: string;
66
+ latencyMs: number;
67
+ candidates: number;
68
+ returned: number;
69
+ at: number;
70
+ } | null;
71
+ }
72
+ /** A minimal LRU cache (Map insertion order = recency) with hit stats. */
73
+ export declare class LruCache<V> {
74
+ private readonly values;
75
+ private readonly max;
76
+ private hitCount;
77
+ private missCount;
78
+ private evictionCount;
79
+ constructor(max: number);
80
+ get size(): number;
81
+ /** Configured entry cap. */
82
+ get capacity(): number;
83
+ /** Successful lookups since construction. */
84
+ get hits(): number;
85
+ /** Failed lookups since construction. */
86
+ get misses(): number;
87
+ /** Entries evicted past the cap since construction. */
88
+ get evictions(): number;
89
+ /** hits / (hits + misses), in [0, 1]. */
90
+ get hitRate(): number;
91
+ get(key: string): V | undefined;
92
+ set(key: string, value: V): void;
93
+ }
94
+ /**
95
+ * Tokenize one document for indexing: repeats are KEPT so the inverted
96
+ * index preserves true term frequency ("cache cache cache cache" indexes
97
+ * cache ×4, not ×1).
98
+ */
99
+ export declare function tokenizeDocument(text: string): string[];
100
+ /**
101
+ * Tokenize a query: repeats are deduplicated (a query term counts once
102
+ * per document field, standard BM25 query semantics).
103
+ */
104
+ export declare function tokenizeQuery(text: string): string[];
105
+ /** v0.4.1 compat alias — query semantics (deduplicated). */
106
+ export declare function tokenize(text: string): string[];
107
+ /**
108
+ * Ranked local retrieval over the store — rebuilt lazily per store epoch.
109
+ * The query LRU cache keys on epoch + scope + project + section + query +
110
+ * limit + detail, so any store write (or external change) invalidates it.
111
+ */
112
+ export declare class RetrievalEngine {
113
+ private index;
114
+ private readonly entriesById;
115
+ private readonly pathById;
116
+ private readonly store;
117
+ readonly queryCache: LruCache<RankedEntry[]>;
118
+ private lastQuery;
119
+ /**
120
+ * @param store - the structured store (epoch drives rebuilds).
121
+ * @param options.cacheSize - query LRU cap (config queryCacheSize).
122
+ */
123
+ constructor(store: MemoirStore, options?: {
124
+ cacheSize?: number;
125
+ });
126
+ /** Build (or reuse) the inverted index for the current store epoch. */
127
+ ensureIndex(): RetrievalIndex;
128
+ /** Rank all entries matching the section filter for a query. */
129
+ search(query: string, options?: {
130
+ section?: SectionKey;
131
+ cwd?: string;
132
+ now?: number;
133
+ }): RankedEntry[];
134
+ /**
135
+ * Cached search: the key is epoch + cwd + section + normalized query +
136
+ * 1-hour time bucket. v0.4.2: limit/detail are NOT part of the key — they
137
+ * only shape output, never the ranking — so every limit/detail variant
138
+ * shares the same full ranked result, and the tool layer slices from it.
139
+ * The time bucket stops the recency part of the score from freezing for
140
+ * the whole epoch.
141
+ */
142
+ cachedSearch(query: string, options?: {
143
+ section?: SectionKey;
144
+ cwd?: string;
145
+ now?: number;
146
+ limit?: number;
147
+ detail?: string;
148
+ }): RankedEntry[];
149
+ /** Retrieval observability snapshot for the diagnostics endpoint. */
150
+ diagnostics(): RetrievalDiagnostics;
151
+ }
@@ -0,0 +1,363 @@
1
+ /**
2
+ * Local ranked retrieval (roadmap §2.4) — lexical search without embeddings:
3
+ * - tokenizer: Chinese 2-grams (+3-grams), lowercase english words,
4
+ * path/code identifiers split on / \ . _ - and camelCase
5
+ * - in-memory inverted index (term → entryId → tf) rebuilt when the store
6
+ * epoch changes
7
+ * - ranking: BM25(content) + 2.5×BM25(title) + exact-phrase boost +
8
+ * section weight + recency decay
9
+ * - revision/epoch-aware LRU query cache (default 128 entries)
10
+ */
11
+ /** BM25 constants (standard defaults). */
12
+ export const BM25_K1 = 1.5;
13
+ export const BM25_B = 0.75;
14
+ export const TITLE_BOOST = 2.5;
15
+ export const EXACT_PHRASE_BOOST = 2.0;
16
+ export const SECTION_BOOST_SCALE = 0.1;
17
+ export const RECENCY_BOOST_SCALE = 0.5;
18
+ /** Section weights reused for retrieval (relative, scaled down). */
19
+ export const SECTION_WEIGHTS = {
20
+ actions: 4.0,
21
+ lessons: 3.5,
22
+ work: 2.0,
23
+ note: 0.5,
24
+ };
25
+ /** Time bucket size for the ranking cache (recency is part of the score). */
26
+ export const QUERY_CACHE_TIME_BUCKET_MS = 3_600_000;
27
+ /** A minimal LRU cache (Map insertion order = recency) with hit stats. */
28
+ export class LruCache {
29
+ values = new Map();
30
+ max;
31
+ hitCount = 0;
32
+ missCount = 0;
33
+ evictionCount = 0;
34
+ constructor(max) {
35
+ this.max = max;
36
+ }
37
+ get size() {
38
+ return this.values.size;
39
+ }
40
+ /** Configured entry cap. */
41
+ get capacity() {
42
+ return this.max;
43
+ }
44
+ /** Successful lookups since construction. */
45
+ get hits() {
46
+ return this.hitCount;
47
+ }
48
+ /** Failed lookups since construction. */
49
+ get misses() {
50
+ return this.missCount;
51
+ }
52
+ /** Entries evicted past the cap since construction. */
53
+ get evictions() {
54
+ return this.evictionCount;
55
+ }
56
+ /** hits / (hits + misses), in [0, 1]. */
57
+ get hitRate() {
58
+ const total = this.hitCount + this.missCount;
59
+ return total === 0 ? 0 : this.hitCount / total;
60
+ }
61
+ get(key) {
62
+ const value = this.values.get(key);
63
+ if (value === undefined) {
64
+ this.missCount++;
65
+ return undefined;
66
+ }
67
+ this.hitCount++;
68
+ // Refresh recency.
69
+ this.values.delete(key);
70
+ this.values.set(key, value);
71
+ return value;
72
+ }
73
+ set(key, value) {
74
+ this.values.delete(key);
75
+ this.values.set(key, value);
76
+ while (this.values.size > this.max) {
77
+ const oldest = this.values.keys().next().value;
78
+ if (oldest === undefined)
79
+ break;
80
+ this.values.delete(oldest);
81
+ this.evictionCount++;
82
+ }
83
+ }
84
+ }
85
+ /** CJK codepoint test (same ranges as selector.estimateTokens). */
86
+ function isCjk(cp) {
87
+ return ((cp >= 0x3000 && cp <= 0x303f) ||
88
+ (cp >= 0x3040 && cp <= 0x30ff) ||
89
+ (cp >= 0x3400 && cp <= 0x9fff) ||
90
+ (cp >= 0xf900 && cp <= 0xfaff) ||
91
+ (cp >= 0xff00 && cp <= 0xffef) ||
92
+ (cp >= 0xac00 && cp <= 0xd7af));
93
+ }
94
+ /** Split one latin token further on camelCase boundaries. */
95
+ function splitCamel(token) {
96
+ const parts = token.split(/(?<=[a-z0-9])(?=[A-Z])/).map((p) => p.toLowerCase());
97
+ return parts.length > 1 ? [token.toLowerCase(), ...parts] : [token.toLowerCase()];
98
+ }
99
+ /**
100
+ * Tokenize text for indexing/querying (shared n-gram rules so both sides
101
+ * align), optionally deduplicating the token list.
102
+ */
103
+ function tokenizeInternal(text, dedupe) {
104
+ const tokens = [];
105
+ let cjk = '';
106
+ let latin = '';
107
+ const flushLatin = () => {
108
+ if (latin === '')
109
+ return;
110
+ for (const raw of latin.split(/[^A-Za-z0-9_]+/)) {
111
+ if (raw === '')
112
+ continue;
113
+ // Full compound token first (memoir_record, __ModuleLoader__), then
114
+ // underscore sub-tokens; camelCase is split on the ORIGINAL case so
115
+ // boundaries survive lowercasing.
116
+ const lower = raw.toLowerCase();
117
+ tokens.push(lower);
118
+ const subs = lower.split('_');
119
+ if (subs.length > 1)
120
+ tokens.push(...subs.filter((p) => p !== ''));
121
+ const camel = raw.split(/(?<=[a-z0-9])(?=[A-Z])/).map((p) => p.toLowerCase());
122
+ if (camel.length > 1)
123
+ tokens.push(...camel.filter((p) => p !== '' && p !== '_'));
124
+ }
125
+ latin = '';
126
+ };
127
+ const flushCjk = () => {
128
+ if (cjk === '')
129
+ return;
130
+ const chars = [...cjk];
131
+ if (chars.length === 1) {
132
+ tokens.push(chars[0]);
133
+ }
134
+ else {
135
+ for (let i = 0; i + 2 <= chars.length; i++)
136
+ tokens.push(chars[i] + chars[i + 1]);
137
+ for (let i = 0; i + 3 <= chars.length; i++)
138
+ tokens.push(chars[i] + chars[i + 1] + chars[i + 2]);
139
+ }
140
+ cjk = '';
141
+ };
142
+ for (const ch of text) {
143
+ const cp = ch.codePointAt(0) ?? 0;
144
+ if (isCjk(cp)) {
145
+ flushLatin();
146
+ cjk += ch;
147
+ }
148
+ else {
149
+ flushCjk();
150
+ latin += ch;
151
+ }
152
+ }
153
+ flushLatin();
154
+ flushCjk();
155
+ return dedupe ? [...new Set(tokens)] : tokens;
156
+ }
157
+ /**
158
+ * Tokenize one document for indexing: repeats are KEPT so the inverted
159
+ * index preserves true term frequency ("cache cache cache cache" indexes
160
+ * cache ×4, not ×1).
161
+ */
162
+ export function tokenizeDocument(text) {
163
+ return tokenizeInternal(text, false);
164
+ }
165
+ /**
166
+ * Tokenize a query: repeats are deduplicated (a query term counts once
167
+ * per document field, standard BM25 query semantics).
168
+ */
169
+ export function tokenizeQuery(text) {
170
+ return tokenizeInternal(text, true);
171
+ }
172
+ /** v0.4.1 compat alias — query semantics (deduplicated). */
173
+ export function tokenize(text) {
174
+ return tokenizeQuery(text);
175
+ }
176
+ /** BM25 score of one doc field against the query terms. */
177
+ function bm25Field(field, queryTerms, entryId, docLength, avgDocLength, docs) {
178
+ let score = 0;
179
+ for (const term of queryTerms) {
180
+ const postings = field.get(term);
181
+ if (postings === undefined)
182
+ continue;
183
+ const tf = postings.get(entryId);
184
+ if (tf === undefined)
185
+ continue;
186
+ const df = postings.size;
187
+ const idf = Math.log(1 + (docs - df + 0.5) / (df + 0.5));
188
+ const denom = tf + BM25_K1 * (1 - BM25_B + (BM25_B * docLength) / Math.max(1, avgDocLength));
189
+ score += idf * ((tf * (BM25_K1 + 1)) / denom);
190
+ }
191
+ return score;
192
+ }
193
+ /** Normalized text for phrase matching. */
194
+ function normalizedText(entry) {
195
+ return ((entry.title ?? '') + ' ' + entry.content).toLowerCase().replace(/\s+/g, ' ').trim();
196
+ }
197
+ /** Recency decay term (same shape as the selector). */
198
+ function recencyDecay(time, now) {
199
+ const ageDays = Math.max(0, now - time) / 86_400_000;
200
+ return 1 / (1 + ageDays / 30);
201
+ }
202
+ /**
203
+ * Ranked local retrieval over the store — rebuilt lazily per store epoch.
204
+ * The query LRU cache keys on epoch + scope + project + section + query +
205
+ * limit + detail, so any store write (or external change) invalidates it.
206
+ */
207
+ export class RetrievalEngine {
208
+ index = null;
209
+ entriesById = new Map();
210
+ pathById = new Map();
211
+ store;
212
+ queryCache;
213
+ lastQuery = null;
214
+ /**
215
+ * @param store - the structured store (epoch drives rebuilds).
216
+ * @param options.cacheSize - query LRU cap (config queryCacheSize).
217
+ */
218
+ constructor(store, options = {}) {
219
+ this.store = store;
220
+ this.queryCache = new LruCache(options.cacheSize ?? 128);
221
+ }
222
+ /** Build (or reuse) the inverted index for the current store epoch. */
223
+ ensureIndex() {
224
+ const epoch = this.store.stats().epoch;
225
+ if (this.index !== null && this.index.epoch === epoch)
226
+ return this.index;
227
+ const body = new Map();
228
+ const title = new Map();
229
+ const bodyLengths = new Map();
230
+ const titleLengths = new Map();
231
+ let totalBodyLength = 0;
232
+ let totalTitleLength = 0;
233
+ let docs = 0;
234
+ const add = (field, entryId, terms) => {
235
+ for (const term of terms) {
236
+ let postings = field.get(term);
237
+ if (postings === undefined) {
238
+ postings = new Map();
239
+ field.set(term, postings);
240
+ }
241
+ postings.set(entryId, (postings.get(entryId) ?? 0) + 1);
242
+ }
243
+ };
244
+ this.entriesById.clear();
245
+ this.pathById.clear();
246
+ for (const project of Object.values(this.store.load().projects)) {
247
+ for (const entry of project.entries) {
248
+ this.entriesById.set(entry.id, entry);
249
+ this.pathById.set(entry.id, project.path);
250
+ // v0.4.2: documents keep repeated tokens — true term frequency.
251
+ const bodyTerms = tokenizeDocument(entry.content);
252
+ const titleTerms = entry.title !== undefined ? tokenizeDocument(entry.title) : [];
253
+ bodyLengths.set(entry.id, bodyTerms.length);
254
+ titleLengths.set(entry.id, titleTerms.length);
255
+ totalBodyLength += bodyTerms.length;
256
+ totalTitleLength += titleTerms.length;
257
+ docs++;
258
+ add(body, entry.id, bodyTerms);
259
+ add(title, entry.id, titleTerms);
260
+ }
261
+ }
262
+ this.index = {
263
+ epoch,
264
+ docs,
265
+ avgBodyLength: docs === 0 ? 0 : totalBodyLength / docs,
266
+ bodyLengths,
267
+ avgTitleLength: docs === 0 ? 0 : totalTitleLength / docs,
268
+ titleLengths,
269
+ body,
270
+ title,
271
+ };
272
+ return this.index;
273
+ }
274
+ /** Rank all entries matching the section filter for a query. */
275
+ search(query, options = {}) {
276
+ const startedAt = Date.now();
277
+ const index = this.ensureIndex();
278
+ const now = options.now ?? Date.now();
279
+ const queryTerms = tokenizeQuery(query);
280
+ if (queryTerms.length === 0)
281
+ return [];
282
+ const q = query.toLowerCase().replace(/\s+/g, ' ').trim();
283
+ const candidates = [];
284
+ if (options.cwd !== undefined) {
285
+ candidates.push(...this.store.entries(options.cwd));
286
+ }
287
+ else {
288
+ for (const project of Object.values(this.store.load().projects))
289
+ candidates.push(...project.entries);
290
+ }
291
+ const ranked = [];
292
+ for (const entry of candidates) {
293
+ if (options.section !== undefined && entry.section !== options.section)
294
+ continue;
295
+ // v0.4.2: body and title normalize against their own average lengths.
296
+ const bodyLength = index.bodyLengths.get(entry.id) ?? 0;
297
+ const titleLength = index.titleLengths.get(entry.id) ?? 0;
298
+ const bodyScore = bm25Field(index.body, queryTerms, entry.id, bodyLength, index.avgBodyLength, index.docs);
299
+ const titleScore = bm25Field(index.title, queryTerms, entry.id, titleLength, index.avgTitleLength, index.docs);
300
+ let score = bodyScore + TITLE_BOOST * titleScore;
301
+ if (bodyScore === 0 && titleScore === 0)
302
+ continue;
303
+ if (normalizedText(entry).includes(q))
304
+ score += EXACT_PHRASE_BOOST;
305
+ score += SECTION_WEIGHTS[entry.section] * SECTION_BOOST_SCALE;
306
+ score += recencyDecay(entry.time, now) * RECENCY_BOOST_SCALE;
307
+ ranked.push({ entry, projectPath: this.pathById.get(entry.id) ?? '', score });
308
+ }
309
+ ranked.sort((a, b) => b.score - a.score || b.entry.time - a.entry.time || a.entry.id.localeCompare(b.entry.id));
310
+ this.lastQuery = {
311
+ query,
312
+ latencyMs: Date.now() - startedAt,
313
+ candidates: candidates.length,
314
+ returned: ranked.length,
315
+ at: Date.now(),
316
+ };
317
+ return ranked;
318
+ }
319
+ /**
320
+ * Cached search: the key is epoch + cwd + section + normalized query +
321
+ * 1-hour time bucket. v0.4.2: limit/detail are NOT part of the key — they
322
+ * only shape output, never the ranking — so every limit/detail variant
323
+ * shares the same full ranked result, and the tool layer slices from it.
324
+ * The time bucket stops the recency part of the score from freezing for
325
+ * the whole epoch.
326
+ */
327
+ cachedSearch(query, options = {}) {
328
+ const now = options.now ?? Date.now();
329
+ const timeBucket = Math.floor(now / QUERY_CACHE_TIME_BUCKET_MS);
330
+ const epoch = this.ensureIndex().epoch;
331
+ const key = [
332
+ String(epoch),
333
+ options.cwd ?? '',
334
+ options.section ?? '',
335
+ query.toLowerCase().replace(/\s+/g, ' ').trim(),
336
+ String(timeBucket),
337
+ ].join('|');
338
+ const hit = this.queryCache.get(key);
339
+ if (hit !== undefined)
340
+ return hit;
341
+ const result = this.search(query, options);
342
+ this.queryCache.set(key, result);
343
+ return result;
344
+ }
345
+ /** Retrieval observability snapshot for the diagnostics endpoint. */
346
+ diagnostics() {
347
+ const index = this.index;
348
+ return {
349
+ index: index === null
350
+ ? null
351
+ : { docs: index.docs, terms: index.body.size + index.title.size, epoch: index.epoch },
352
+ cache: {
353
+ hits: this.queryCache.hits,
354
+ misses: this.queryCache.misses,
355
+ evictions: this.queryCache.evictions,
356
+ hitRate: this.queryCache.hitRate,
357
+ size: this.queryCache.size,
358
+ capacity: this.queryCache.capacity,
359
+ },
360
+ lastQuery: this.lastQuery,
361
+ };
362
+ }
363
+ }
@@ -0,0 +1,77 @@
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 type { IncomingMessage, ServerResponse } from 'node:http';
12
+ import type { WebRoute } from '@deepseek-ai/dsh-host-webserver';
13
+ import type { CacheStats, MemoirEntry, MemoirStore } from './store.js';
14
+ import type { RetrievalDiagnostics, RetrievalEngine } from './retrieval.js';
15
+ /** Diagnostics payload shape (v0.4 observability, roadmap §4 / §6.3). */
16
+ export interface DiagnosticsValue {
17
+ storeRevision: number;
18
+ snapshotEpoch: number;
19
+ cache: CacheStats;
20
+ snapshotCount: number;
21
+ snapshotMax: number;
22
+ hotMemory: {
23
+ selected: number;
24
+ total: number;
25
+ estimatedTokens: number;
26
+ } | null;
27
+ /** v0.4.2: retrieval index / query cache / last query observability. */
28
+ retrieval: RetrievalDiagnostics;
29
+ /** v0.4.2: the most recently frozen session snapshot, if any. */
30
+ snapshot: {
31
+ hash: string;
32
+ createdAt: number;
33
+ storeRevision: number;
34
+ } | null;
35
+ config: {
36
+ hotMemoryTokens: number;
37
+ hotMemoryMaxTokens: number;
38
+ readDefaultLimit: number;
39
+ readMaxLimit: number;
40
+ sessionSnapshotMax: number;
41
+ queryCacheSize: number;
42
+ };
43
+ }
44
+ /** Supplies the runtime diagnostics snapshot (closed over plugin state). */
45
+ export type DiagnosticsProvider = (path?: string) => DiagnosticsValue;
46
+ /** Hot-memory preview for one workspace (the inspector endpoint). */
47
+ export type HotMemoryProvider = (path: string) => {
48
+ text: string;
49
+ selected: MemoirEntry[];
50
+ total: number;
51
+ estimatedTokens: number;
52
+ } | null;
53
+ export interface Envelope<T = unknown> {
54
+ ok: boolean;
55
+ value?: T;
56
+ error?: {
57
+ code: string;
58
+ message: string;
59
+ };
60
+ }
61
+ /** Write one JSON envelope response. */
62
+ export declare function json(res: ServerResponse, envelope: Envelope<unknown>, status?: number): void;
63
+ /** Read a bounded JSON request body; null when unparseable or oversized. */
64
+ export declare function readJsonBody(req: IncomingMessage, limit?: number): Promise<unknown>;
65
+ /**
66
+ * Build the /api/dsh-memoir prefix route.
67
+ * @param store - the structured MemoirStore.
68
+ * @param diagnostics - optional runtime diagnostics provider.
69
+ * @param retrieval - optional RetrievalEngine (ranked /search endpoint).
70
+ * @param hotMemory - optional hot-memory preview provider (inspector).
71
+ * @param allowedWorkspace - optional write guard: only paths it accepts may
72
+ * be written via the panel API (v0.4.2 host safety, roadmap §3.5).
73
+ * @param touchWorkspace - optional hook recording seen workspace paths
74
+ * (feeds the plugin's active-workspace set for write authorization).
75
+ * @returns route definitions for ctx.webServer.register.
76
+ */
77
+ export declare function makeRoutes(store: MemoirStore, diagnostics?: DiagnosticsProvider, retrieval?: RetrievalEngine, hotMemory?: HotMemoryProvider, allowedWorkspace?: (path: string) => boolean, touchWorkspace?: (path: string) => void): WebRoute[];