wendkeep 0.85.0 → 0.86.0

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 (31) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.en.md +2 -1
  3. package/README.md +2 -1
  4. package/docs/en/commands/evidence-embeddings.md +243 -0
  5. package/docs/en/commands/mcp.md +67 -7
  6. package/docs/pt-BR/commands/evidence-embeddings.md +244 -0
  7. package/docs/pt-BR/commands/mcp.md +66 -7
  8. package/hooks/evidence-context.mjs +41 -7
  9. package/hooks/evidence-recall.mjs +10 -0
  10. package/package.json +1 -1
  11. package/packages/mcp/src/effects.mjs +3 -2
  12. package/packages/mcp/src/evidence-recall.mjs +130 -0
  13. package/packages/mcp/src/executor.mjs +4 -0
  14. package/packages/mcp/src/server.mjs +31 -1
  15. package/packages/vault/src/evidence-embedding-plugin.mjs +531 -0
  16. package/packages/vault/src/evidence-index-store.mjs +360 -0
  17. package/packages/vault/src/evidence-recall-page.mjs +381 -0
  18. package/packages/vault/src/evidence-search-index.mjs +917 -0
  19. package/packages/vault/src/index.mjs +12 -1
  20. package/packages/vault/src/memory-ledger-view-base.mjs +545 -0
  21. package/packages/vault/src/memory-ledger-view.mjs +41 -0
  22. package/packages/vault/src/memory-rotation-store.mjs +967 -0
  23. package/packages/vault/src/memory-segment-store.mjs +820 -0
  24. package/packages/vault/src/memory-snapshot-store.mjs +1105 -0
  25. package/packages/vault/src/memory-store-base.mjs +1161 -0
  26. package/packages/vault/src/memory-store-core.mjs +2 -0
  27. package/packages/vault/src/memory-store.mjs +46 -1161
  28. package/src/doctor.mjs +41 -5
  29. package/src/evidence-search-health.mjs +221 -0
  30. package/src/memory-scale-health.mjs +210 -0
  31. package/src/observer-snapshot.mjs +87 -1
@@ -0,0 +1,381 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { normalizeRecallText, recallEvidence } from './evidence-recall.mjs';
3
+
4
+ export const EVIDENCE_RECALL_CURSOR_VERSION = 1;
5
+ export const EVIDENCE_RECALL_DEFAULT_LIMIT = 5;
6
+ export const EVIDENCE_RECALL_MAX_LIMIT = 100;
7
+ export const EVIDENCE_RECALL_DEFAULT_MAX_BYTES = 64 * 1024;
8
+ export const EVIDENCE_RECALL_MAX_BYTES = 16 * 1024 * 1024;
9
+
10
+ const CURSOR_MAX_CHARS = 8 * 1024;
11
+ const HASH_PATTERN = /^[0-9a-f]{64}$/;
12
+ const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/;
13
+ const EXACT_FILTER_FIELDS = [
14
+ 'authority',
15
+ 'validity',
16
+ 'entity_type',
17
+ 'project_id',
18
+ 'change_slug',
19
+ 'session_id',
20
+ 'work_session_id',
21
+ 'logical_path',
22
+ ];
23
+ const FILTER_FIELDS = new Set([...EXACT_FILTER_FIELDS, 'logical_path_prefix']);
24
+
25
+ export class EvidenceRecallCursorError extends Error {
26
+ constructor(message) {
27
+ super(message);
28
+ this.name = 'EvidenceRecallCursorError';
29
+ this.code = 'EVIDENCE_RECALL_CURSOR_INVALID';
30
+ }
31
+ }
32
+
33
+ export class EvidenceRecallBudgetError extends Error {
34
+ constructor(message, { requiredBytes = null, maxBytes = null } = {}) {
35
+ super(message);
36
+ this.name = 'EvidenceRecallBudgetError';
37
+ this.code = 'EVIDENCE_RECALL_BUDGET_TOO_SMALL';
38
+ this.required_bytes = requiredBytes;
39
+ this.max_bytes = maxBytes;
40
+ }
41
+ }
42
+
43
+ function canonicalize(value) {
44
+ if (Array.isArray(value)) return value.map((item) => canonicalize(item));
45
+ if (!value || typeof value !== 'object') return value;
46
+ const out = {};
47
+ for (const key of Object.keys(value).sort()) {
48
+ if (value[key] !== undefined) out[key] = canonicalize(value[key]);
49
+ }
50
+ return out;
51
+ }
52
+
53
+ function stableJson(value) {
54
+ return JSON.stringify(canonicalize(value));
55
+ }
56
+
57
+ function compareText(left, right) {
58
+ const a = String(left ?? '');
59
+ const b = String(right ?? '');
60
+ return a < b ? -1 : a > b ? 1 : 0;
61
+ }
62
+
63
+ function sha256(value) {
64
+ return createHash('sha256').update(String(value ?? '')).digest('hex');
65
+ }
66
+
67
+ function normalizeStringSet(value, field) {
68
+ if (value === undefined || value === null || value === '') return [];
69
+ const values = Array.isArray(value) ? value : [value];
70
+ const normalized = [];
71
+ for (const item of values) {
72
+ if (typeof item !== 'string') {
73
+ throw new TypeError(`evidence recall filter ${field} must be a string or string array`);
74
+ }
75
+ const text = item.trim();
76
+ if (text) normalized.push(text);
77
+ }
78
+ return [...new Set(normalized)].sort(compareText);
79
+ }
80
+
81
+ export function normalizeEvidenceRecallFilters(filters = {}) {
82
+ if (filters === undefined || filters === null) return {};
83
+ if (typeof filters !== 'object' || Array.isArray(filters)) {
84
+ throw new TypeError('evidence recall filters must be an object');
85
+ }
86
+ for (const field of Object.keys(filters)) {
87
+ if (!FILTER_FIELDS.has(field)) {
88
+ throw new TypeError(`unsupported evidence recall filter: ${field}`);
89
+ }
90
+ }
91
+ const normalized = {};
92
+ for (const field of EXACT_FILTER_FIELDS) {
93
+ const values = normalizeStringSet(filters[field], field);
94
+ if (values.length) normalized[field] = values;
95
+ }
96
+ if (normalized.logical_path) {
97
+ normalized.logical_path = [...new Set(normalized.logical_path
98
+ .map((path) => path.replaceAll('\\', '/')))].sort(compareText);
99
+ }
100
+ const prefixes = [...new Set(
101
+ normalizeStringSet(filters.logical_path_prefix, 'logical_path_prefix')
102
+ .map((prefix) => prefix.replaceAll('\\', '/')),
103
+ )].sort(compareText);
104
+ if (prefixes.length) normalized.logical_path_prefix = prefixes;
105
+ return normalized;
106
+ }
107
+
108
+ export function filterEvidenceRecallRows(rows, filters = {}) {
109
+ const normalized = normalizeEvidenceRecallFilters(filters);
110
+ const docs = Array.isArray(rows) ? rows : [];
111
+ return docs.filter((row) => {
112
+ for (const field of EXACT_FILTER_FIELDS) {
113
+ const expected = normalized[field];
114
+ if (expected?.length && !expected.includes(String(row?.[field] ?? ''))) return false;
115
+ }
116
+ const prefixes = normalized.logical_path_prefix;
117
+ if (prefixes?.length) {
118
+ const path = String(row?.logical_path ?? '').replaceAll('\\', '/');
119
+ if (!prefixes.some((prefix) => path.startsWith(prefix))) return false;
120
+ }
121
+ return true;
122
+ });
123
+ }
124
+
125
+ function indexDescriptor(row) {
126
+ return {
127
+ index_version: row?.index_version ?? null,
128
+ project_id: String(row?.project_id ?? ''),
129
+ logical_path: String(row?.logical_path ?? ''),
130
+ title: String(row?.title ?? ''),
131
+ heading: String(row?.heading ?? ''),
132
+ change_slug: String(row?.change_slug ?? ''),
133
+ session_id: String(row?.session_id ?? ''),
134
+ work_session_id: String(row?.work_session_id ?? ''),
135
+ observed_at: String(row?.observed_at ?? ''),
136
+ chunk_id: String(row?.chunk_id ?? ''),
137
+ entity_type: String(row?.entity_type ?? ''),
138
+ authority: String(row?.authority ?? ''),
139
+ validity: String(row?.validity ?? ''),
140
+ ordinal: Number(row?.ordinal ?? 0),
141
+ content_hash: String(row?.content_hash ?? ''),
142
+ };
143
+ }
144
+
145
+ function canonicalRows(rows) {
146
+ return (Array.isArray(rows) ? rows : [])
147
+ .map((row) => ({ row, descriptor: stableJson(indexDescriptor(row)) }))
148
+ .sort((left, right) => compareText(left.row?.logical_path, right.row?.logical_path)
149
+ || Number(left.row?.ordinal ?? 0) - Number(right.row?.ordinal ?? 0)
150
+ || compareText(left.row?.chunk_id, right.row?.chunk_id)
151
+ || compareText(left.row?.content_hash, right.row?.content_hash)
152
+ || compareText(left.descriptor, right.descriptor))
153
+ .map(({ row }) => row);
154
+ }
155
+
156
+ function evidenceIndexHash(rows) {
157
+ return sha256(canonicalRows(rows).map((row) => stableJson(indexDescriptor(row))).join('\n'));
158
+ }
159
+
160
+ function evidenceRequestHash(query, filters) {
161
+ return sha256(stableJson({
162
+ query: normalizeRecallText(query),
163
+ filters,
164
+ }));
165
+ }
166
+
167
+ function invalidCursor(message) {
168
+ throw new EvidenceRecallCursorError(message);
169
+ }
170
+
171
+ function validateCursorPayload(payload) {
172
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
173
+ invalidCursor('evidence recall cursor payload must be an object');
174
+ }
175
+ const keys = Object.keys(payload).sort();
176
+ const expectedKeys = ['as_of', 'index_hash', 'offset', 'request_hash', 'version'];
177
+ if (stableJson(keys) !== stableJson(expectedKeys)) {
178
+ invalidCursor('evidence recall cursor payload has unexpected fields');
179
+ }
180
+ if (payload.version !== EVIDENCE_RECALL_CURSOR_VERSION) {
181
+ invalidCursor('evidence recall cursor version is unsupported');
182
+ }
183
+ if (!HASH_PATTERN.test(String(payload.index_hash || ''))
184
+ || !HASH_PATTERN.test(String(payload.request_hash || ''))) {
185
+ invalidCursor('evidence recall cursor hashes are invalid');
186
+ }
187
+ if (!Number.isSafeInteger(payload.offset) || payload.offset < 0) {
188
+ invalidCursor('evidence recall cursor offset is invalid');
189
+ }
190
+ if (!Number.isSafeInteger(payload.as_of) || Number.isNaN(new Date(payload.as_of).getTime())) {
191
+ invalidCursor('evidence recall cursor as_of is invalid');
192
+ }
193
+ return payload;
194
+ }
195
+
196
+ function encodeCursor(payload) {
197
+ const envelope = {
198
+ payload,
199
+ checksum: sha256(stableJson(payload)),
200
+ };
201
+ return Buffer.from(stableJson(envelope), 'utf8').toString('base64url');
202
+ }
203
+
204
+ function decodeCursor(cursor) {
205
+ if (typeof cursor !== 'string' || !cursor || cursor.length > CURSOR_MAX_CHARS
206
+ || !BASE64URL_PATTERN.test(cursor)) {
207
+ invalidCursor('evidence recall cursor encoding is invalid');
208
+ }
209
+ let decoded = '';
210
+ try {
211
+ const bytes = Buffer.from(cursor, 'base64url');
212
+ if (bytes.toString('base64url') !== cursor) invalidCursor('evidence recall cursor encoding is not canonical');
213
+ decoded = bytes.toString('utf8');
214
+ } catch (error) {
215
+ if (error instanceof EvidenceRecallCursorError) throw error;
216
+ invalidCursor('evidence recall cursor encoding is invalid');
217
+ }
218
+ let envelope = null;
219
+ try {
220
+ envelope = JSON.parse(decoded);
221
+ } catch {
222
+ invalidCursor('evidence recall cursor JSON is invalid');
223
+ }
224
+ if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)
225
+ || stableJson(Object.keys(envelope).sort()) !== stableJson(['checksum', 'payload'])) {
226
+ invalidCursor('evidence recall cursor envelope is invalid');
227
+ }
228
+ const payload = validateCursorPayload(envelope.payload);
229
+ if (!HASH_PATTERN.test(String(envelope.checksum || ''))
230
+ || envelope.checksum !== sha256(stableJson(payload))) {
231
+ invalidCursor('evidence recall cursor checksum is invalid');
232
+ }
233
+ return payload;
234
+ }
235
+
236
+ function normalizeLimit(value) {
237
+ const limit = Number(value ?? EVIDENCE_RECALL_DEFAULT_LIMIT);
238
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > EVIDENCE_RECALL_MAX_LIMIT) {
239
+ throw new RangeError(`evidence recall limit must be an integer between 1 and ${EVIDENCE_RECALL_MAX_LIMIT}`);
240
+ }
241
+ return limit;
242
+ }
243
+
244
+ function normalizeMaxBytes(value) {
245
+ const maxBytes = Number(value ?? EVIDENCE_RECALL_DEFAULT_MAX_BYTES);
246
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 2 || maxBytes > EVIDENCE_RECALL_MAX_BYTES) {
247
+ throw new RangeError(`evidence recall maxBytes must be an integer between 2 and ${EVIDENCE_RECALL_MAX_BYTES}`);
248
+ }
249
+ return maxBytes;
250
+ }
251
+
252
+ function normalizeNow(value) {
253
+ const now = Number(value ?? Date.now());
254
+ if (!Number.isSafeInteger(now) || Number.isNaN(new Date(now).getTime())) {
255
+ throw new RangeError('evidence recall now must be a valid integer timestamp');
256
+ }
257
+ return now;
258
+ }
259
+
260
+ function compactResult(row) {
261
+ const { content, ...rest } = row || {};
262
+ return {
263
+ ...rest,
264
+ excerpt: String(rest.excerpt ?? ''),
265
+ content_bytes: Buffer.byteLength(String(content ?? ''), 'utf8'),
266
+ content_omitted: Object.prototype.hasOwnProperty.call(row || {}, 'content'),
267
+ };
268
+ }
269
+
270
+ function serializedBytes(results) {
271
+ return Buffer.byteLength(JSON.stringify(results), 'utf8');
272
+ }
273
+
274
+ function withTruncatedExcerpt(candidate, existing, maxBytes) {
275
+ const characters = Array.from(String(candidate.excerpt ?? ''));
276
+ if (!characters.length) return null;
277
+ const makeCandidate = (count) => ({
278
+ ...candidate,
279
+ excerpt: count > 0 ? `${characters.slice(0, count).join('')}…` : '',
280
+ excerpt_truncated: true,
281
+ });
282
+ const minimum = makeCandidate(0);
283
+ if (serializedBytes([...existing, minimum]) > maxBytes) return null;
284
+ let low = 0;
285
+ let high = characters.length - 1;
286
+ let best = minimum;
287
+ while (low <= high) {
288
+ const middle = Math.floor((low + high) / 2);
289
+ const current = makeCandidate(middle);
290
+ if (serializedBytes([...existing, current]) <= maxBytes) {
291
+ best = current;
292
+ low = middle + 1;
293
+ } else {
294
+ high = middle - 1;
295
+ }
296
+ }
297
+ return best;
298
+ }
299
+
300
+ export function recallEvidencePage(rows, query, {
301
+ cursor = null,
302
+ filters = {},
303
+ limit: requestedLimit,
304
+ topK,
305
+ maxBytes: requestedMaxBytes,
306
+ now,
307
+ } = {}) {
308
+ const limit = normalizeLimit(requestedLimit ?? topK);
309
+ const maxBytes = normalizeMaxBytes(requestedMaxBytes);
310
+ const normalizedFilters = normalizeEvidenceRecallFilters(filters);
311
+ const docs = canonicalRows(rows);
312
+ const indexHash = evidenceIndexHash(docs);
313
+ const requestHash = evidenceRequestHash(query, normalizedFilters);
314
+ const cursorPayload = cursor ? decodeCursor(cursor) : null;
315
+ if (cursorPayload?.index_hash !== undefined && cursorPayload.index_hash !== indexHash) {
316
+ invalidCursor('evidence recall cursor does not match the current index');
317
+ }
318
+ if (cursorPayload?.request_hash !== undefined && cursorPayload.request_hash !== requestHash) {
319
+ invalidCursor('evidence recall cursor does not match the current query and filters');
320
+ }
321
+ const asOf = cursorPayload ? cursorPayload.as_of : normalizeNow(now);
322
+ const offset = cursorPayload?.offset ?? 0;
323
+ const scoped = filterEvidenceRecallRows(docs, normalizedFilters);
324
+ const ranked = recallEvidence(scoped, query, {
325
+ topK: Math.max(1, scoped.length),
326
+ now: asOf,
327
+ });
328
+ if (offset > ranked.length) {
329
+ invalidCursor('evidence recall cursor offset exceeds the result set');
330
+ }
331
+
332
+ const results = [];
333
+ let position = offset;
334
+ while (position < ranked.length && results.length < limit) {
335
+ const candidate = compactResult(ranked[position]);
336
+ if (serializedBytes([...results, candidate]) <= maxBytes) {
337
+ results.push(candidate);
338
+ position += 1;
339
+ continue;
340
+ }
341
+ const truncated = withTruncatedExcerpt(candidate, results, maxBytes);
342
+ if (truncated) {
343
+ results.push(truncated);
344
+ position += 1;
345
+ continue;
346
+ }
347
+ if (!results.length) {
348
+ const requiredBytes = serializedBytes([{
349
+ ...candidate,
350
+ excerpt: '',
351
+ excerpt_truncated: true,
352
+ }]);
353
+ throw new EvidenceRecallBudgetError(
354
+ 'evidence recall byte budget cannot fit the next result metadata',
355
+ { requiredBytes, maxBytes },
356
+ );
357
+ }
358
+ break;
359
+ }
360
+
361
+ const hasMore = position < ranked.length;
362
+ const nextCursor = hasMore ? encodeCursor({
363
+ version: EVIDENCE_RECALL_CURSOR_VERSION,
364
+ index_hash: indexHash,
365
+ request_hash: requestHash,
366
+ offset: position,
367
+ as_of: asOf,
368
+ }) : null;
369
+ return {
370
+ results,
371
+ next_cursor: nextCursor,
372
+ has_more: hasMore,
373
+ matched_count: ranked.length,
374
+ returned_count: results.length,
375
+ returned_bytes: serializedBytes(results),
376
+ offset,
377
+ limit,
378
+ max_bytes: maxBytes,
379
+ as_of: new Date(asOf).toISOString(),
380
+ };
381
+ }