hippo-memory 1.52.7 → 1.52.9

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 (84) hide show
  1. package/README.md +159 -99
  2. package/dist/api.d.ts +52 -18
  3. package/dist/api.js +155 -86
  4. package/dist/audit.d.ts +2 -1
  5. package/dist/audit.js +63 -0
  6. package/dist/autolearn.js +3 -2
  7. package/dist/capture.d.ts +40 -0
  8. package/dist/capture.js +141 -119
  9. package/dist/churn-git.js +2 -2
  10. package/dist/cli.d.ts +1 -4
  11. package/dist/cli.js +695 -702
  12. package/dist/codex-patch.d.ts +12 -0
  13. package/dist/codex-patch.js +71 -0
  14. package/dist/config.d.ts +0 -1
  15. package/dist/config.js +0 -4
  16. package/dist/connectors/slack/types.d.ts +0 -1
  17. package/dist/consolidate.js +85 -32
  18. package/dist/context-render.d.ts +36 -0
  19. package/dist/context-render.js +154 -0
  20. package/dist/dag.js +3 -2
  21. package/dist/dashboard.js +4 -0
  22. package/dist/db.js +6 -6
  23. package/dist/dedupe.d.ts +6 -6
  24. package/dist/dedupe.js +10 -9
  25. package/dist/doctor.d.ts +1 -1
  26. package/dist/doctor.js +35 -2
  27. package/dist/dormant.d.ts +4 -0
  28. package/dist/dormant.js +17 -2
  29. package/dist/embedding-provider.d.ts +2 -1
  30. package/dist/embedding-provider.js +2 -1
  31. package/dist/embeddings.js +23 -3
  32. package/dist/extensions/openclaw-plugin/index.js +1 -0
  33. package/dist/extract.js +5 -1
  34. package/dist/forward-claim-detector.d.ts +1 -1
  35. package/dist/forward-claim-detector.js +1 -1
  36. package/dist/graph-recall.d.ts +3 -1
  37. package/dist/graph-recall.js +13 -10
  38. package/dist/handoff.d.ts +2 -0
  39. package/dist/hooks.d.ts +19 -5
  40. package/dist/hooks.js +131 -30
  41. package/dist/importers.js +5 -12
  42. package/dist/judgment.d.ts +30 -0
  43. package/dist/judgment.js +122 -0
  44. package/dist/mcp/server.js +174 -213
  45. package/dist/merged-row.d.ts +6 -0
  46. package/dist/merged-row.js +35 -0
  47. package/dist/multihop.d.ts +2 -1
  48. package/dist/multihop.js +7 -4
  49. package/dist/physics-state.d.ts +0 -4
  50. package/dist/physics-state.js +0 -6
  51. package/dist/predictions.d.ts +2 -17
  52. package/dist/predictions.js +2 -15
  53. package/dist/reject-flow.d.ts +7 -5
  54. package/dist/reject-flow.js +41 -12
  55. package/dist/salience.js +12 -5
  56. package/dist/same-text.d.ts +17 -0
  57. package/dist/same-text.js +38 -0
  58. package/dist/scheduler.d.ts +4 -0
  59. package/dist/scheduler.js +8 -0
  60. package/dist/search.d.ts +7 -0
  61. package/dist/search.js +16 -32
  62. package/dist/secret-detect.d.ts +2 -0
  63. package/dist/secret-detect.js +9 -3
  64. package/dist/server-detect.js +9 -33
  65. package/dist/server.js +6 -62
  66. package/dist/session-digest.d.ts +79 -0
  67. package/dist/session-digest.js +528 -0
  68. package/dist/shared.d.ts +11 -3
  69. package/dist/shared.js +41 -31
  70. package/dist/store.d.ts +4 -5
  71. package/dist/store.js +26 -13
  72. package/dist/token-ledger.d.ts +46 -8
  73. package/dist/token-ledger.js +140 -21
  74. package/dist/version.d.ts +1 -1
  75. package/dist/version.js +1 -1
  76. package/dist-ui/assets/index-BhT8RvO6.js +61 -0
  77. package/dist-ui/index.html +1 -1
  78. package/extensions/openclaw-plugin/README.md +4 -4
  79. package/extensions/openclaw-plugin/index.ts +1 -0
  80. package/extensions/openclaw-plugin/openclaw.plugin.json +2 -2
  81. package/extensions/openclaw-plugin/package.json +1 -1
  82. package/openclaw.plugin.json +2 -2
  83. package/package.json +2 -2
  84. package/dist-ui/assets/index-BgmA7Hwe.js +0 -61
@@ -0,0 +1,528 @@
1
+ // One memory per ended session, built without a model call from the agent's final message and the files it changed.
2
+ import { createHash } from 'crypto';
3
+ import * as fs from 'fs';
4
+ import * as path from 'path';
5
+ import { collectSessionTurns, errorMessage, isObjectLike, isStringValue, readTranscriptTail, splitSentences, } from './capture.js';
6
+ import { PATCH_SUCCESS_LINE, patchPaths, shellPatch } from './codex-patch.js';
7
+ import { loadConfig } from './config.js';
8
+ import { createMemory, Layer } from './memory.js';
9
+ import { RejectedValueError } from './rejection.js';
10
+ import { maskEmails, redactSecretsStrict } from './secret-detect.js';
11
+ import { SNAPSHOT_AMBIENT_MAX_AGE_MS, isInitialized, loadAllEntries, loadLatestHandoff, writeEntry } from './store.js';
12
+ import { isSyntheticMessage } from './token-ledger.js';
13
+ export const SESSION_DIGEST_TAG = 'session-digest';
14
+ /** Five-word runs are stock phrases; six in a row is a copied clause. */
15
+ export const ECHO_WINDOW = 6;
16
+ /** Five sentences of about 150 chars leave room for the Changed line inside the total cap. */
17
+ export const MAX_SENTENCES = 5;
18
+ /** A longer "sentence" in agent text is an unsplit list or pasted output. */
19
+ export const MAX_SENTENCE_CHARS = 300;
20
+ /** At about 20 chars per repo path this keeps the Changed line near 50 tokens. */
21
+ export const MAX_FILES = 10;
22
+ /** 300 tokens at 4 chars each: 20% of the 1500-token default budget of both context surfaces. */
23
+ export const MAX_DIGEST_CHARS = 1200;
24
+ /** Bounds a detached worker's memory; the final message sits at the end, so a tail read loses nothing. */
25
+ export const READ_CAP_BYTES = 64 * 1024 * 1024;
26
+ /** The pinned hook shows the newest 5 memories, so older digests were never injected. */
27
+ const RECENT_DIGESTS = 5;
28
+ const CLAUDE_EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
29
+ function transcriptItem(value) {
30
+ return isObjectLike(value) && 'type' in value ? value : null;
31
+ }
32
+ function itemsOf(content) {
33
+ return Array.isArray(content) ? content.map(transcriptItem).filter((b) => b !== null) : [];
34
+ }
35
+ function messageItems(record) {
36
+ const message = record.message;
37
+ return isObjectLike(message) && 'content' in message ? itemsOf(message.content) : [];
38
+ }
39
+ function noteCwd(state, cwd) {
40
+ state.cwd = cwd;
41
+ state.firstCwd ??= cwd;
42
+ }
43
+ function editTarget(block) {
44
+ const input = block.input;
45
+ if (!isObjectLike(input))
46
+ return null;
47
+ if (block.name === 'NotebookEdit')
48
+ return 'notebook_path' in input && isStringValue(input.notebook_path) ? input.notebook_path : null;
49
+ return 'file_path' in input && isStringValue(input.file_path) ? input.file_path : null;
50
+ }
51
+ function readClaudeAssistant(record, state) {
52
+ const blocks = messageItems(record);
53
+ for (const block of blocks) {
54
+ if (block.type !== 'tool_use' || !isStringValue(block.name) || !CLAUDE_EDIT_TOOLS.has(block.name) || !isStringValue(block.id))
55
+ continue;
56
+ const target = editTarget(block);
57
+ if (target)
58
+ state.pending.push({ callId: block.id, filePath: target, base: state.cwd });
59
+ }
60
+ // Sidechain edits changed files, but a sub-agent's reply is not this session's closing message, nor is Claude Code's own notice.
61
+ if (record.isMeta === true || record.isSidechain === true || isSynthetic(record))
62
+ return;
63
+ for (let i = blocks.length - 1; i >= 0; i--) {
64
+ const text = blocks[i].type === 'text' ? blocks[i].text : undefined;
65
+ if (isStringValue(text) && text.trim()) {
66
+ state.claudeFinal = text.trim();
67
+ return;
68
+ }
69
+ }
70
+ }
71
+ function isSynthetic(record) {
72
+ const message = record.message;
73
+ return isObjectLike(message) && isSyntheticMessage(message);
74
+ }
75
+ function readClaudeResults(record, state) {
76
+ for (const block of messageItems(record)) {
77
+ if (block.type === 'tool_result' && isStringValue(block.tool_use_id) && block.is_error !== true)
78
+ state.landed.add(block.tool_use_id);
79
+ }
80
+ }
81
+ function readCodexMessage(item, state) {
82
+ if (item.role !== 'assistant')
83
+ return;
84
+ const text = itemsOf(item.content)
85
+ .map((b) => (b.type === 'output_text' && isStringValue(b.text) ? b.text.trim() : ''))
86
+ .filter(Boolean)
87
+ .join('\n');
88
+ if (!text)
89
+ return;
90
+ if (item.phase === 'final_answer')
91
+ state.codexFinal = text;
92
+ else if (item.phase === undefined || item.phase === null)
93
+ state.codexUnphased = text;
94
+ }
95
+ function isAbsoluteAnywhere(p) {
96
+ return /^(?:[A-Za-z]:[\\/]|[\\/])/.test(p);
97
+ }
98
+ /** Absolute native form: drops a \\?\ prefix and maps Git Bash /c/... to C:\... on Windows. */
99
+ function nativePath(p) {
100
+ let s = p.replace(/^\\\\\?\\/, '');
101
+ if (process.platform === 'win32') {
102
+ const drive = /^\/([A-Za-z])(?=\/|$)/.exec(s);
103
+ if (drive)
104
+ s = `${drive[1].toUpperCase()}:${s.slice(2) || '/'}`;
105
+ }
106
+ return path.resolve(s);
107
+ }
108
+ /** p resolved against base; null for a relative path with no base to resolve it against. */
109
+ function resolveFrom(base, p) {
110
+ if (isNetworkPath(p))
111
+ return null;
112
+ if (isAbsoluteAnywhere(p))
113
+ return nativePath(p);
114
+ return base === null || isNetworkPath(base) ? null : path.resolve(nativePath(base), p);
115
+ }
116
+ function addPatch(state, callId, body, base) {
117
+ for (const filePath of patchPaths(body))
118
+ state.pending.push({ callId, filePath, base });
119
+ }
120
+ function commandOf(value) {
121
+ if (isStringValue(value))
122
+ return value;
123
+ return Array.isArray(value) && value.every((a) => isStringValue(a)) ? value : null;
124
+ }
125
+ function addShellPatch(state, callId, command, workdir) {
126
+ const argv = commandOf(command);
127
+ const patch = argv === null ? null : shellPatch(argv);
128
+ if (!patch)
129
+ return;
130
+ const callDir = workdir === null ? state.cwd : resolveFrom(state.cwd, workdir);
131
+ addPatch(state, callId, patch.body, patch.cd === null ? callDir : resolveFrom(callDir, patch.cd));
132
+ }
133
+ function readCodexFunctionCall(item, state) {
134
+ if (!isStringValue(item.call_id) || !isStringValue(item.arguments))
135
+ return;
136
+ let args;
137
+ try {
138
+ args = JSON.parse(item.arguments);
139
+ }
140
+ catch {
141
+ return; // Codex stores the model's raw arguments; a malformed call ran nothing.
142
+ }
143
+ if (!isObjectLike(args))
144
+ return;
145
+ if (item.name === 'apply_patch') {
146
+ if ('input' in args && isStringValue(args.input))
147
+ addPatch(state, item.call_id, args.input, state.cwd);
148
+ return;
149
+ }
150
+ const command = 'command' in args ? args.command : 'cmd' in args ? args.cmd : undefined;
151
+ const workdir = 'workdir' in args && isStringValue(args.workdir) ? args.workdir : null;
152
+ addShellPatch(state, item.call_id, command, workdir);
153
+ }
154
+ function outputText(output) {
155
+ return isStringValue(output) ? output : (JSON.stringify(output) ?? '');
156
+ }
157
+ function readCodexItem(item, state) {
158
+ switch (item.type) {
159
+ case 'message':
160
+ readCodexMessage(item, state);
161
+ return;
162
+ case 'custom_tool_call':
163
+ if (item.name === 'apply_patch' && isStringValue(item.call_id) && isStringValue(item.input))
164
+ addPatch(state, item.call_id, item.input, state.cwd);
165
+ return;
166
+ case 'function_call':
167
+ readCodexFunctionCall(item, state);
168
+ return;
169
+ case 'local_shell_call': {
170
+ const action = item.action;
171
+ if (!isStringValue(item.call_id) || !isObjectLike(action) || !('command' in action))
172
+ return;
173
+ const workdir = 'working_directory' in action && isStringValue(action.working_directory) ? action.working_directory : null;
174
+ addShellPatch(state, item.call_id, action.command, workdir);
175
+ return;
176
+ }
177
+ case 'function_call_output':
178
+ case 'custom_tool_call_output':
179
+ if (isStringValue(item.call_id) && outputText(item.output).includes(PATCH_SUCCESS_LINE))
180
+ state.landed.add(item.call_id);
181
+ return;
182
+ }
183
+ }
184
+ function visitRecord(record, state) {
185
+ if (isStringValue(record.cwd))
186
+ noteCwd(state, record.cwd);
187
+ if (record.type === 'assistant')
188
+ return readClaudeAssistant(record, state);
189
+ if (record.type === 'user')
190
+ return readClaudeResults(record, state);
191
+ const meta = record.payload;
192
+ // Codex's session_meta and turn_context payloads carry no type of their own.
193
+ if (record.type === 'session_meta' || record.type === 'turn_context') {
194
+ if (isObjectLike(meta) && 'cwd' in meta && isStringValue(meta.cwd))
195
+ noteCwd(state, meta.cwd);
196
+ return;
197
+ }
198
+ const payload = transcriptItem(meta);
199
+ if (!payload)
200
+ return;
201
+ if (record.type === 'response_item') {
202
+ readCodexItem(payload, state);
203
+ }
204
+ else if (record.type === 'event_msg' && payload.type === 'patch_apply_end' && payload.success === true && isStringValue(payload.call_id)) {
205
+ state.landed.add(payload.call_id);
206
+ }
207
+ }
208
+ /** One pass over a Claude Code or Codex transcript: the turns capture reads, the closing message, and the edits that landed. */
209
+ export function scanSessionTranscript(jsonl) {
210
+ const state = {
211
+ cwd: null, firstCwd: null, pending: [], landed: new Set(), claudeFinal: '', codexFinal: '', codexUnphased: '',
212
+ };
213
+ const turns = collectSessionTurns(jsonl, (record) => visitRecord(record, state));
214
+ return {
215
+ turns,
216
+ finalText: state.claudeFinal || state.codexFinal || state.codexUnphased,
217
+ cwd: state.firstCwd,
218
+ // An unanswered call may never have run, so an edit counts only once its result says it applied.
219
+ edits: state.pending
220
+ .filter((e) => state.landed.has(e.callId))
221
+ .map(({ filePath, base }) => ({ filePath, base })),
222
+ };
223
+ }
224
+ export function readSessionTranscript(transcriptPath, log) {
225
+ const size = fs.statSync(transcriptPath).size;
226
+ if (size <= READ_CAP_BYTES)
227
+ return fs.readFileSync(transcriptPath, 'utf8');
228
+ log(`digest: transcript is ${size} bytes, reading its last ${READ_CAP_BYTES}`);
229
+ return readTranscriptTail(transcriptPath, READ_CAP_BYTES);
230
+ }
231
+ /** The transcript, read once and scanned, or null with the reason logged. */
232
+ export function readSessionScan(transcriptPath, log) {
233
+ try {
234
+ return scanSessionTranscript(readSessionTranscript(transcriptPath, log));
235
+ }
236
+ catch (err) {
237
+ log(`digest: could not read the transcript: ${errorMessage(err)}`);
238
+ return null;
239
+ }
240
+ }
241
+ let existsProbe = fs.existsSync;
242
+ /** Test-only seam, the scheduler's pattern: lets a test prove a network path never reaches the probe. Null restores it. */
243
+ export function __setDigestExistsProbe(probe) {
244
+ existsProbe = probe ?? fs.existsSync;
245
+ }
246
+ /** A \\host or //host path would make existsSync open an SMB connection to a host named in transcript text. */
247
+ function isNetworkPath(p) {
248
+ return /^[\\/]{2}(?:(?![?.][\\/])|[?.][\\/]UNC[\\/])/i.test(p);
249
+ }
250
+ /** The store root is realpath'd; transcript paths are not, and a deleted file has no realpath of its own. Null for a network path. */
251
+ export function realFsPath(p) {
252
+ if (isNetworkPath(p))
253
+ return null;
254
+ const abs = nativePath(p);
255
+ if (isNetworkPath(abs))
256
+ return null;
257
+ const rest = [];
258
+ let head = abs;
259
+ while (!existsProbe(head)) {
260
+ const parent = path.dirname(head);
261
+ if (parent === head)
262
+ return abs;
263
+ rest.unshift(path.basename(head));
264
+ head = parent;
265
+ }
266
+ return path.join(fs.realpathSync.native(head), ...rest);
267
+ }
268
+ /** Repo-relative with forward slashes, '' for the root itself, null outside the repo or when the path cannot be resolved. */
269
+ export function repoRelative(p, repoRoot) {
270
+ let rel;
271
+ try {
272
+ const real = realFsPath(p);
273
+ if (real === null)
274
+ return null;
275
+ rel = path.relative(repoRoot, real);
276
+ }
277
+ catch {
278
+ // SHORTCUT: an unresolvable path counts as outside the repo; only that path is lost, never the digest.
279
+ return null;
280
+ }
281
+ if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel))
282
+ return null;
283
+ return rel.split(path.sep).join('/');
284
+ }
285
+ function changedFiles(edits, repoRoot) {
286
+ const seen = new Set();
287
+ const files = [];
288
+ for (const edit of edits) {
289
+ const abs = resolveFrom(edit.base, edit.filePath);
290
+ const rel = abs === null ? null : repoRelative(abs, repoRoot);
291
+ if (!rel)
292
+ continue;
293
+ const key = process.platform === 'win32' ? rel.toLowerCase() : rel;
294
+ if (seen.has(key))
295
+ continue;
296
+ seen.add(key);
297
+ files.push(rel);
298
+ }
299
+ return files;
300
+ }
301
+ // A home-directory path names its user and the store may be shared, so such a sentence is dropped.
302
+ export const USER_SEGMENT = [
303
+ /[A-Za-z]:[\\/]+(?:Users|Documents and Settings)[\\/]+[^\\/\s]+/i,
304
+ /(?<![\w.])(?:\/mnt)?\/[A-Za-z]\/Users\/[^/\s]+/i,
305
+ /\\\\\?\\/,
306
+ /(?<![\w.~-])(?:\/var)?\/home\/[^/\s]+/,
307
+ /(?<![\w.~-])(?:\/var)?\/root(?![\w.-])/,
308
+ /(?<![\w.~-])\/Users\/[^/\s]+/,
309
+ /[\\/][A-Z0-9_$]{1,6}~\d{1,6}(?:\.[A-Z0-9]{1,3})?(?![\w~])|\b[A-Z0-9_$]{1,6}~\d{1,6}(?:\.[A-Z0-9]{1,3})?[\\/]/,
310
+ ];
311
+ const ABSOLUTE_PATH = /(?<![\w.~/\\:-])(?:\\\\\?\\)?(?:[A-Za-z]:[\\/]|\/)[^\s`'"<>|*?()[\]{},;]+/g;
312
+ function rewriteRepoPaths(sentence, repoRoot) {
313
+ return sentence.replace(ABSOLUTE_PATH, (token) => {
314
+ const trail = /[.:!]+$/.exec(token)?.[0] ?? '';
315
+ const rel = repoRelative(token.slice(0, token.length - trail.length), repoRoot);
316
+ return rel === null ? token : (rel || '.') + trail;
317
+ });
318
+ }
319
+ /** Each opener points back to something named before the sentence; There, I, We and You do not. */
320
+ const OPENERS = new Set([
321
+ 'this', 'that', 'these', 'those', 'it', 'its', 'they', 'them', 'their', 'he', 'she', 'his', 'her', 'here',
322
+ 'both', 'each', 'either', 'neither', 'such', 'which',
323
+ ]);
324
+ const COUNTED_OPENER = /^(?:all|the|these|those|both)\s+\d/i;
325
+ /** Each anchor names a place in the code a later task can search for. */
326
+ const ANCHORS = [
327
+ /`[^`]+`/,
328
+ /\S[\\/]\S/,
329
+ /\b[\w-]+\.[A-Za-z][A-Za-z0-9]{0,4}\b/,
330
+ /\b[a-z]+[A-Z][A-Za-z0-9]*\b/,
331
+ /\b[A-Za-z0-9]+_[A-Za-z0-9_]+\b/,
332
+ /\b[A-Za-z_]\w*\(\)/,
333
+ /(?:^|\s)--[a-z][\w-]*/,
334
+ /(?:^|\s)#\d+\b/,
335
+ ];
336
+ /** Each marker states a cause or a choice, the part of a lesson that transfers. */
337
+ const MARKERS = /\b(?:because|caused by|due to|root cause|the (?:bug|issue|problem) was|fail(?:s|ed) when|so that|otherwise|instead of|rather than|switched to|decided|chose|must|never|always|do not)\b/i;
338
+ const FENCE = /^\s*(`{3,}|~{3,})/;
339
+ const STRUCTURE_LINE = /^\s*(?:#{1,6}\s|\||>|([-*_])(?:\s*\1){2,}\s*$)/;
340
+ const PROTECTED = /`[^`\n]+`|\b(?:e\.g|i\.e)\./gi;
341
+ /** Prose lines of a markdown reply: code, headings, tables, quotes and rules are not sentences. */
342
+ function proseText(text) {
343
+ const kept = [];
344
+ let fence = null;
345
+ for (const line of text.split(/\r?\n/)) {
346
+ const mark = FENCE.exec(line)?.[1];
347
+ if (fence !== null) {
348
+ if (mark && mark[0] === fence[0] && mark.length >= fence.length)
349
+ fence = null;
350
+ continue;
351
+ }
352
+ if (mark) {
353
+ fence = mark;
354
+ continue;
355
+ }
356
+ if (STRUCTURE_LINE.test(line))
357
+ continue;
358
+ kept.push(line.replace(/^\s*(?:[-*+]|\d+[.)])\s+/, '').replace(/\*\*(.+?)\*\*/g, '$1'));
359
+ }
360
+ return kept.join('\n');
361
+ }
362
+ /** Sentences of the reply; code spans and "e.g." are held out of the split so their dots never end a sentence. */
363
+ export function digestSentences(text) {
364
+ const held = [];
365
+ const masked = proseText(text).replace(PROTECTED, (m) => `\uE000${held.push(m) - 1}\uE001`);
366
+ return splitSentences(masked)
367
+ .map((s) => s.replace(/\uE000(\d+)\uE001/g, (_m, i) => held[Number(i)]).trim())
368
+ .filter(Boolean);
369
+ }
370
+ function echoTokens(text) {
371
+ return text.normalize('NFKC').toLowerCase().match(/[\p{L}\p{N}_]+/gu) ?? [];
372
+ }
373
+ function echoWindows(texts) {
374
+ const windows = new Set();
375
+ for (const text of texts) {
376
+ const tokens = echoTokens(text);
377
+ for (let i = 0; i + ECHO_WINDOW <= tokens.length; i++)
378
+ windows.add(tokens.slice(i, i + ECHO_WINDOW).join(' '));
379
+ }
380
+ return windows;
381
+ }
382
+ function echoes(sentence, windows) {
383
+ const tokens = echoTokens(sentence);
384
+ for (let i = 0; i + ECHO_WINDOW <= tokens.length; i++) {
385
+ if (windows.has(tokens.slice(i, i + ECHO_WINDOW).join(' ')))
386
+ return true;
387
+ }
388
+ return false;
389
+ }
390
+ function opensOnReferent(sentence) {
391
+ const head = sentence.replace(/^[^\p{L}\p{N}]+/u, '');
392
+ const word = /^\p{L}+/u.exec(head)?.[0].toLowerCase() ?? '';
393
+ return OPENERS.has(word) || COUNTED_OPENER.test(head);
394
+ }
395
+ export function sentenceScore(sentence) {
396
+ const plain = sentence.replace(/\b(?:e\.g|i\.e)\./gi, '');
397
+ return (ANCHORS.some((re) => re.test(plain)) ? 1 : 0) + (MARKERS.test(plain) ? 1 : 0);
398
+ }
399
+ /** Surviving sentences, best first: anchored or reasoned ones rank above the rest, earlier wins a tie. */
400
+ function rankedSentences(finalText, windows, repoRoot) {
401
+ const kept = [];
402
+ // Redact the whole reply before splitting and capping, so a secret is never cut into pieces that no pattern matches.
403
+ digestSentences(maskEmails(redactSecretsStrict(finalText))).forEach((sentence, index) => {
404
+ if (/[?:]$/.test(sentence) || opensOnReferent(sentence) || echoes(sentence, windows))
405
+ return;
406
+ const text = rewriteRepoPaths(sentence, repoRoot);
407
+ if (text.length > MAX_SENTENCE_CHARS || USER_SEGMENT.some((re) => re.test(text)))
408
+ return;
409
+ kept.push({ index, text, score: sentenceScore(text) });
410
+ });
411
+ return kept.sort((a, b) => b.score - a.score || a.index - b.index);
412
+ }
413
+ function changedLine(files) {
414
+ for (let shown = Math.min(files.length, MAX_FILES); shown > 0; shown--) {
415
+ const more = files.length - shown;
416
+ const line = maskEmails(redactSecretsStrict(`Changed: ${files.slice(0, shown).join(', ')}${more > 0 ? ` (+${more} more)` : ''}`));
417
+ if (line.length <= MAX_DIGEST_CHARS)
418
+ return line;
419
+ }
420
+ return '';
421
+ }
422
+ /** The digest text, or null when nothing survives the filters. Sentences print in reply order. */
423
+ export function buildSessionDigest(sources) {
424
+ const ranked = rankedSentences(sources.finalText, echoWindows(sources.echoTexts), sources.repoRoot);
425
+ const files = changedFiles(sources.edits, sources.repoRoot);
426
+ const changed = changedLine(files);
427
+ // The cap drops whole sentences, lowest-ranked first, and keeps the Changed line.
428
+ for (let kept = ranked.slice(0, MAX_SENTENCES);; kept = kept.slice(0, -1)) {
429
+ const lines = [...kept].sort((a, b) => a.index - b.index).map((c) => c.text);
430
+ const content = [...lines, changed].filter(Boolean).join('\n');
431
+ if (content.length > MAX_DIGEST_CHARS && kept.length > 0)
432
+ continue;
433
+ return content ? { content, sentences: kept.length, files: files.length } : null;
434
+ }
435
+ }
436
+ /** Facts extracted from a digest and DAG summaries over one inherit its tag, but they are not the digest itself. */
437
+ export function isSessionDigestRow(entry) {
438
+ return entry.source === SESSION_DIGEST_TAG && entry.tags.includes(SESSION_DIGEST_TAG) && !entry.extracted_from;
439
+ }
440
+ /** Same session, same row: a second worker for one session updates the digest instead of adding one. */
441
+ export function sessionDigestId(tenantId, key) {
442
+ return `mem_${createHash('sha256').update(`${SESSION_DIGEST_TAG}\n${tenantId}\n${key}`).digest('hex').slice(0, 12)}`;
443
+ }
444
+ /** What hippo could have injected into the session: recent digests and the ambient handoff, never this session's own. */
445
+ function injectedTexts(hippoRoot, opts) {
446
+ const digests = loadAllEntries(hippoRoot, opts.tenantId)
447
+ .filter((e) => isSessionDigestRow(e) && !e.superseded_by && e.source_session_id !== opts.key)
448
+ .sort((a, b) => (a.created < b.created ? 1 : a.created > b.created ? -1 : 0))
449
+ .slice(0, RECENT_DIGESTS)
450
+ .map((e) => e.content);
451
+ const handoff = loadLatestHandoff(hippoRoot, opts.tenantId, undefined, {
452
+ unfinishedOnly: true,
453
+ maxAgeMs: SNAPSHOT_AMBIENT_MAX_AGE_MS,
454
+ scopeFilter: 'default-deny',
455
+ excludeSessionId: opts.key,
456
+ });
457
+ if (!handoff)
458
+ return digests;
459
+ return [...digests, [handoff.taskId, handoff.summary, handoff.nextAction].filter(Boolean).join('\n')];
460
+ }
461
+ function skipped(reason) {
462
+ return { written: false, reason, sentences: 0, files: 0 };
463
+ }
464
+ export function writeSessionDigest(hippoRoot, scan, opts) {
465
+ const prompts = scan.turns.filter((t) => t.role === 'user').map((t) => t.text);
466
+ if (prompts.length === 0)
467
+ return skipped('no human prompt');
468
+ if (!scan.finalText.trim() && scan.edits.length === 0)
469
+ return skipped('no final message and no edits');
470
+ if (!scan.cwd)
471
+ return skipped('the transcript names no working directory');
472
+ const repoRoot = realFsPath(path.dirname(hippoRoot));
473
+ if (repoRoot === null)
474
+ return skipped('the repo is on a network path');
475
+ if (repoRelative(scan.cwd, repoRoot) === null)
476
+ return skipped('the session ran outside this repo');
477
+ const draft = buildSessionDigest({
478
+ finalText: scan.finalText,
479
+ echoTexts: [...prompts, ...injectedTexts(hippoRoot, opts)],
480
+ edits: scan.edits,
481
+ repoRoot,
482
+ });
483
+ if (!draft)
484
+ return skipped('nothing left after the filters');
485
+ const entry = {
486
+ ...createMemory(draft.content, {
487
+ layer: Layer.Episodic,
488
+ tags: [SESSION_DIGEST_TAG],
489
+ source: SESSION_DIGEST_TAG,
490
+ confidence: 'observed',
491
+ source_session_id: opts.key,
492
+ tenantId: opts.tenantId,
493
+ baseHalfLifeDays: loadConfig(hippoRoot).defaultHalfLifeDays,
494
+ }),
495
+ id: sessionDigestId(opts.tenantId, opts.key),
496
+ };
497
+ try {
498
+ writeEntry(hippoRoot, entry);
499
+ }
500
+ catch (err) {
501
+ if (err instanceof RejectedValueError)
502
+ return skipped('it matches a rejected value');
503
+ throw err;
504
+ }
505
+ return { written: true, reason: '', sentences: draft.sentences, files: draft.files };
506
+ }
507
+ /** Never throws: a digest failure must not block the handoff or the snapshot close. Logs carry no digest text. */
508
+ export function recordSessionDigest(hippoRoot, scan, opts) {
509
+ // The digest names this repo's files, so it goes only to the repo's own store, never the global one.
510
+ if (!isInitialized(hippoRoot)) {
511
+ opts.log('digest: skip: this folder has no store of its own');
512
+ return;
513
+ }
514
+ if (!scan) {
515
+ opts.log('digest: skip: no transcript to read');
516
+ return;
517
+ }
518
+ try {
519
+ const outcome = writeSessionDigest(hippoRoot, scan, opts);
520
+ opts.log(outcome.written
521
+ ? `digest: wrote ${outcome.sentences} sentence(s), ${outcome.files} file(s) for ${opts.key}`
522
+ : `digest: skip: ${outcome.reason}`);
523
+ }
524
+ catch (err) {
525
+ opts.log(`digest failed: ${errorMessage(err)}`);
526
+ }
527
+ }
528
+ //# sourceMappingURL=session-digest.js.map
package/dist/shared.d.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * Local .hippo/ stores are per-project.
6
6
  */
7
7
  import { MemoryEntry } from './memory.js';
8
- import { SearchResult } from './search.js';
8
+ import { SearchResult, type ResultCost } from './search.js';
9
9
  /**
10
10
  * Returns the path to the global Hippo store.
11
11
  * Resolution order: $HIPPO_HOME > $XDG_DATA_HOME/hippo > ~/.hippo/
@@ -51,6 +51,8 @@ export interface HybridSearchOptions extends SearchOptions {
51
51
  includeSuperseded?: boolean;
52
52
  /** Filter to memories current at this ISO date string. */
53
53
  asOf?: string;
54
+ /** Budget cost per result, spent the same way in each store and in the merged list. */
55
+ cost?: ResultCost;
54
56
  /** v0.30 / E4 — propagated to underlying hybridSearch calls.
55
57
  * Per-call > env HIPPO_SUMMARY_DEBOOST > 0.85 default. */
56
58
  summaryDeboost?: number;
@@ -89,6 +91,11 @@ export interface HybridSearchOptions extends SearchOptions {
89
91
  * Async version of searchBoth that calls hybridSearch instead of search.
90
92
  */
91
93
  export declare function searchBothHybrid(query: string, localRoot: string, globalRoot: string, options?: HybridSearchOptions): Promise<SearchResult[]>;
94
+ /** Tags whose rows only a hand-run share or promote may copy to the global store; derived rows inherit them. */
95
+ export declare const NEVER_AUTO_SHARE_TAGS: ReadonlySet<string>;
96
+ export declare function neverAutoShareTags(sources: readonly MemoryEntry[]): string[];
97
+ /** Tags whose rows sleep keeps as written: never merged, never sent to LLM extraction. Conflict detection keeps its own list. */
98
+ export declare const NO_MERGE_TAGS: ReadonlySet<string>;
92
99
  /**
93
100
  * Estimate how well a memory would transfer to other projects.
94
101
  * Returns 0..1 where >0.5 = good candidate for sharing.
@@ -121,7 +128,7 @@ export declare function listPeers(globalRoot?: string, tenantId?: string): Array
121
128
  latest: string;
122
129
  }>;
123
130
  /**
124
- * Auto-share: find local memories with high transfer scores that aren't already global.
131
+ * Auto-share: local memories with high transfer scores, not already global, no NEVER_AUTO_SHARE_TAGS tag.
125
132
  * Returns the list of shared entries.
126
133
  *
127
134
  * L9: `options.tenantId` is opt-in. When provided, the LOCAL-entries read is
@@ -153,11 +160,12 @@ export declare function autoShare(localRoot: string, options?: {
153
160
  stats?: {
154
161
  secretSkipped: number;
155
162
  rejectedSkipped?: number;
163
+ neverAutoShareSkipped?: number;
156
164
  };
157
165
  }): MemoryEntry[];
158
166
  /**
159
167
  * Copy all global memories into the local store.
160
- * Skips entries that already exist locally (by ID or by near-identical content).
168
+ * Skips entries that already exist locally, by ID or by text (promote and share copy under a new ID).
161
169
  * Returns the count of newly copied entries.
162
170
  */
163
171
  export declare function syncGlobalToLocal(localRoot: string, globalRoot: string, opts?: {