hippo-memory 1.67.0 → 1.69.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 (82) hide show
  1. package/README.md +1 -1
  2. package/dist/agent-memories/copilot.d.ts +3 -1
  3. package/dist/agent-memories/copilot.js +2 -2
  4. package/dist/api/auth.d.ts +14 -18
  5. package/dist/api/auth.js +93 -60
  6. package/dist/api/context-select.d.ts +10 -0
  7. package/dist/api/context-select.js +16 -18
  8. package/dist/api/context.js +101 -60
  9. package/dist/api/forget.d.ts +2 -2
  10. package/dist/api/forget.js +11 -0
  11. package/dist/api/outcome.d.ts +4 -4
  12. package/dist/api/outcome.js +29 -6
  13. package/dist/api/promote.d.ts +3 -3
  14. package/dist/api/promote.js +45 -90
  15. package/dist/api/remember.d.ts +3 -2
  16. package/dist/api/remember.js +38 -11
  17. package/dist/api/types.d.ts +9 -0
  18. package/dist/auth.d.ts +19 -0
  19. package/dist/auth.js +14 -6
  20. package/dist/capture/compact.js +21 -2
  21. package/dist/capture/copilot-transcript.d.ts +3 -0
  22. package/dist/capture/copilot-transcript.js +8 -1
  23. package/dist/capture/session-worker.d.ts +13 -0
  24. package/dist/capture/session-worker.js +194 -0
  25. package/dist/capture-contract.d.ts +3 -1
  26. package/dist/capture-contract.js +36 -0
  27. package/dist/cli/session-hooks.js +90 -22
  28. package/dist/cli/setup.js +70 -24
  29. package/dist/cli/usage.js +4 -1
  30. package/dist/cli.js +1 -1
  31. package/dist/connectors/github/ingest.js +2 -1
  32. package/dist/connectors/slack/deletion.js +2 -1
  33. package/dist/connectors/slack/ingest.js +2 -1
  34. package/dist/embeddings.d.ts +10 -10
  35. package/dist/embeddings.js +103 -18
  36. package/dist/env.d.ts +2 -0
  37. package/dist/env.js +2 -0
  38. package/dist/hooks/copilot.d.ts +50 -8
  39. package/dist/hooks/copilot.js +116 -30
  40. package/dist/hooks/json-hooks.d.ts +2 -0
  41. package/dist/hooks/json-hooks.js +11 -2
  42. package/dist/hooks/shared.d.ts +7 -1
  43. package/dist/hooks/shared.js +18 -3
  44. package/dist/mcp/memory-tools.d.ts +2 -2
  45. package/dist/mcp/memory-tools.js +9 -8
  46. package/dist/mcp/request.js +3 -3
  47. package/dist/memory.d.ts +2 -0
  48. package/dist/memory.js +7 -0
  49. package/dist/prompt-recall.d.ts +5 -0
  50. package/dist/prompt-recall.js +13 -0
  51. package/dist/raw-archive.d.ts +2 -2
  52. package/dist/raw-archive.js +5 -3
  53. package/dist/recall-scope.d.ts +2 -5
  54. package/dist/recall-scope.js +5 -6
  55. package/dist/server/routes/admin.js +2 -2
  56. package/dist/server/routes/memories.js +9 -6
  57. package/dist/server-detect.js +3 -2
  58. package/dist/server.d.ts +20 -4
  59. package/dist/server.js +23 -11
  60. package/dist/store/audit-event.d.ts +1 -1
  61. package/dist/store/audit-event.js +1 -1
  62. package/dist/store/entry-writes-group.d.ts +9 -0
  63. package/dist/store/entry-writes-group.js +157 -0
  64. package/dist/store/handoffs.d.ts +5 -2
  65. package/dist/store/handoffs.js +35 -5
  66. package/dist/store/key-writes.d.ts +9 -0
  67. package/dist/store/key-writes.js +30 -0
  68. package/dist/store/open.d.ts +2 -0
  69. package/dist/store/open.js +10 -0
  70. package/dist/store/search-rows.js +3 -9
  71. package/dist/store/sessions.d.ts +5 -0
  72. package/dist/store/sessions.js +4 -1
  73. package/dist/store/vector-writes.d.ts +6 -0
  74. package/dist/store/vector-writes.js +59 -0
  75. package/dist/store-port.d.ts +143 -1
  76. package/dist/store-port.js +48 -12
  77. package/dist/version.d.ts +1 -1
  78. package/dist/version.js +1 -1
  79. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  80. package/extensions/openclaw-plugin/package.json +1 -1
  81. package/openclaw.plugin.json +1 -1
  82. package/package.json +1 -1
@@ -0,0 +1,194 @@
1
+ // One session-end worker per session at a time, plus how far turn capture has read that session's transcript.
2
+ // Two fast replies would otherwise start two workers that both read the dedupe keys before either writes.
3
+ import * as fs from 'fs';
4
+ import * as path from 'path';
5
+ import { createHash } from 'node:crypto';
6
+ import { homeDir } from '../hooks/shared.js';
7
+ import { errorMessage } from '../log.js';
8
+ import { isObjectLike, isStringValue } from '../capture-contract.js';
9
+ import { SESSION_ID_RE } from './copilot-transcript.js';
10
+ /** Null for a missing id or one that could climb out of the sessions folder. */
11
+ function sessionStateFiles(sessionId) {
12
+ if (sessionId === null || !SESSION_ID_RE.test(sessionId))
13
+ return null;
14
+ const base = path.join(homeDir(), '.hippo', 'sessions', sessionId);
15
+ return { lock: `${base}.lock`, queued: `${base}.queued`, cursor: `${base}.cursor.json` };
16
+ }
17
+ function pidAlive(pid) {
18
+ try {
19
+ process.kill(pid, 0);
20
+ return true;
21
+ }
22
+ catch (err) {
23
+ // EPERM means the process exists but belongs to someone else.
24
+ return err instanceof Error && 'code' in err && err.code === 'EPERM';
25
+ }
26
+ }
27
+ /** Windows reuses pids, so past this a lock is stale whatever process it names; a worker rarely waits more than minutes on a busy store. */
28
+ const STALE_LOCK_MS = 15 * 60 * 1000;
29
+ /** A dead holder frees its session at once, and any holder at STALE_LOCK_MS. */
30
+ function holderAlive(lock) {
31
+ let text;
32
+ let mtimeMs;
33
+ try {
34
+ text = fs.readFileSync(lock, 'utf8');
35
+ mtimeMs = fs.statSync(lock).mtimeMs;
36
+ }
37
+ catch (err) {
38
+ // Gone means released; any other read error is taken as held, so two workers never run on a guess.
39
+ return !(err instanceof Error && 'code' in err && err.code === 'ENOENT');
40
+ }
41
+ const held = /^(\d+):(\d+)$/.exec(text.trim());
42
+ if (Date.now() - (held ? Number(held[2]) : mtimeMs) > STALE_LOCK_MS)
43
+ return false;
44
+ // Empty or torn: its taker is between the create and the write, so it is held.
45
+ if (!held)
46
+ return true;
47
+ const pid = Number(held[1]);
48
+ return pid !== process.pid && pidAlive(pid);
49
+ }
50
+ // SHORTCUT: two takers racing one stale-lock cleanup can both run, as every worker did before the lock; an atomic link-based take if that shows up.
51
+ function takeSessionLock(lock) {
52
+ for (let attempt = 0; attempt < 2; attempt++) {
53
+ try {
54
+ fs.writeFileSync(lock, `${process.pid}:${Date.now()}`, { flag: 'wx' });
55
+ return 'taken';
56
+ }
57
+ catch (err) {
58
+ if (!(err instanceof Error && 'code' in err && err.code === 'EEXIST'))
59
+ return 'unusable';
60
+ if (holderAlive(lock))
61
+ return 'held';
62
+ }
63
+ try {
64
+ fs.rmSync(lock, { force: true });
65
+ }
66
+ catch {
67
+ return 'unusable';
68
+ }
69
+ }
70
+ return 'held';
71
+ }
72
+ function releaseSessionLock(lock) {
73
+ try {
74
+ fs.rmSync(lock, { force: true });
75
+ }
76
+ catch {
77
+ // A lock left behind names this pid, which is dead once the worker exits, so the next taker clears it.
78
+ }
79
+ }
80
+ /** False when the sessions folder cannot be written, so the caller runs unlocked as before. */
81
+ function queueRun(files, mode) {
82
+ try {
83
+ fs.mkdirSync(path.dirname(files.queued), { recursive: true });
84
+ // A full close outranks a turn close: it overwrites a queued turn, and a turn never replaces a queued full close.
85
+ if (mode === 'full')
86
+ fs.writeFileSync(files.queued, 'full');
87
+ else
88
+ fs.writeFileSync(files.queued, 'turn', { flag: 'wx' });
89
+ return true;
90
+ }
91
+ catch (err) {
92
+ return err instanceof Error && 'code' in err && err.code === 'EEXIST';
93
+ }
94
+ }
95
+ /** Takes the queued request by renaming it, so one queued while this reads it lands in a fresh file. */
96
+ function takeQueued(queued) {
97
+ const taken = `${queued}.${process.pid}`;
98
+ try {
99
+ fs.renameSync(queued, taken);
100
+ }
101
+ catch {
102
+ // Nothing queued, or a rename refused for a moment; the request stays queued for the next round.
103
+ return null;
104
+ }
105
+ try {
106
+ return fs.readFileSync(taken, 'utf8') === 'full' ? 'full' : 'turn';
107
+ }
108
+ finally {
109
+ fs.rmSync(taken, { force: true });
110
+ }
111
+ }
112
+ /** Runs `work` unless a live worker holds this session, which then runs the request after its own so the last reply is never dropped. */
113
+ export async function runSessionWorker(sessionId, mode, work) {
114
+ const files = sessionStateFiles(sessionId);
115
+ if (files === null || !queueRun(files, mode))
116
+ return work(mode);
117
+ for (let take = takeSessionLock(files.lock); take !== 'held'; take = takeSessionLock(files.lock)) {
118
+ if (take === 'unusable')
119
+ return work(mode);
120
+ let ran = false;
121
+ try {
122
+ for (let next = takeQueued(files.queued); next !== null; next = takeQueued(files.queued)) {
123
+ ran = true;
124
+ await work(next);
125
+ }
126
+ }
127
+ finally {
128
+ releaseSessionLock(files.lock);
129
+ }
130
+ // A request queued after the last take found the lock still held and left, so it runs here.
131
+ if (!ran || !fs.existsSync(files.queued))
132
+ return;
133
+ }
134
+ }
135
+ function turnKey(turn) {
136
+ return createHash('sha256').update(`${turn.role}\n${turn.text}`).digest('hex').slice(0, 16);
137
+ }
138
+ /** The cursor position after `turns`: how many there were and a hash of the last. */
139
+ export function turnPosition(turns) {
140
+ return turns.length === 0 ? '0:' : `${turns.length}:${turnKey(turns[turns.length - 1])}`;
141
+ }
142
+ /** The turns after `position`; all of them when it is null or its last turn is no longer in view. */
143
+ export function turnsAfter(turns, position) {
144
+ if (position === null)
145
+ return turns;
146
+ const [countText, key] = position.split(':');
147
+ const count = Number(countText);
148
+ if (!Number.isInteger(count) || count <= 0)
149
+ return turns;
150
+ if (count <= turns.length && turnKey(turns[count - 1]) === key)
151
+ return turns.slice(count);
152
+ // A transcript too big to read whole is read from its tail, so the count shifts; the last turn's text still marks the place.
153
+ for (let i = turns.length - 1; i >= 0; i--) {
154
+ if (turnKey(turns[i]) === key)
155
+ return turns.slice(i + 1);
156
+ }
157
+ return turns;
158
+ }
159
+ /** The saved position for this session's transcript; null when none was saved, it names another file, or it is unreadable. */
160
+ export function loadTurnPosition(sessionId, transcriptPath) {
161
+ const file = sessionStateFiles(sessionId)?.cursor;
162
+ if (!file || !fs.existsSync(file))
163
+ return null;
164
+ try {
165
+ const cursor = JSON.parse(fs.readFileSync(file, 'utf8'));
166
+ if (!isObjectLike(cursor) || !('source' in cursor) || cursor.source !== transcriptPath)
167
+ return null;
168
+ return 'position' in cursor && isStringValue(cursor.position) ? cursor.position : null;
169
+ }
170
+ catch {
171
+ // A torn cursor means the next capture reads every turn, which the dedupe keys absorb.
172
+ return null;
173
+ }
174
+ }
175
+ // SHORTCUT: one small cursor file per chat is never pruned; prune by age at sleep if the folder grows.
176
+ export function saveTurnPosition(sessionId, transcriptPath, turns, log) {
177
+ const file = sessionStateFiles(sessionId)?.cursor;
178
+ if (!file)
179
+ return;
180
+ const cursor = {
181
+ runtime: 'vscode', sessionId, source: transcriptPath, position: turnPosition(turns), updatedAt: new Date().toISOString(),
182
+ };
183
+ const tmp = `${file}.${process.pid}.tmp`;
184
+ try {
185
+ fs.mkdirSync(path.dirname(file), { recursive: true });
186
+ fs.writeFileSync(tmp, JSON.stringify(cursor));
187
+ fs.renameSync(tmp, file);
188
+ }
189
+ catch (err) {
190
+ // Windows refuses the rename (EPERM) while another process has the cursor open; the next reply re-reads these turns, which the dedupe keys absorb.
191
+ log(`cursor not saved: ${errorMessage(err)}`);
192
+ }
193
+ }
194
+ //# sourceMappingURL=session-worker.js.map
@@ -1,5 +1,5 @@
1
1
  /** A host lifecycle moment hippo can capture from. */
2
- export type CaptureEvent = 'prompt' | 'tool-failure' | 'pre-compact' | 'post-compact' | 'session-start' | 'session-end' | 'reset';
2
+ export type CaptureEvent = 'prompt' | 'tool-failure' | 'pre-compact' | 'post-compact' | 'session-start' | 'session-end' | 'turn-end' | 'reset';
3
3
  /** What hippo read from one host payload, before anything is written. */
4
4
  export interface CaptureInput {
5
5
  readonly runtime: string;
@@ -46,4 +46,6 @@ export declare function isObjectLike<T>(value: T): value is T & object;
46
46
  export type HookRuntime = 'claude-code' | 'copilot';
47
47
  /** Reads a Claude Code PreCompact payload, or a Copilot one after stdin.ts mapped it to the same snake_case keys; only an empty stdin counts as a manual run. */
48
48
  export declare function readClaudeCodePreCompact(stdinText: string | undefined, timedOut: boolean, runtime?: HookRuntime): CaptureReceipt;
49
+ /** Reads VS Code's per-reply Stop payload. Pass the raw stdin: stdin.ts would give a Copilot CLI agentStop the same snake_case keys. */
50
+ export declare function readVscodeStop(stdinText: string | undefined, timedOut: boolean): CaptureReceipt;
49
51
  //# sourceMappingURL=capture-contract.d.ts.map
@@ -46,4 +46,40 @@ export function readClaudeCodePreCompact(stdinText, timedOut, runtime = 'claude-
46
46
  },
47
47
  };
48
48
  }
49
+ /** Reads VS Code's per-reply Stop payload. Pass the raw stdin: stdin.ts would give a Copilot CLI agentStop the same snake_case keys. */
50
+ export function readVscodeStop(stdinText, timedOut) {
51
+ if (!stdinText || stdinText.trim() === '') {
52
+ return timedOut
53
+ ? { status: 'unavailable', reason: 'no Stop payload arrived before the stdin wait window closed' }
54
+ : { status: 'skipped', reason: 'no payload: turn capture runs only from a VS Code Stop hook' };
55
+ }
56
+ let payload;
57
+ try {
58
+ payload = JSON.parse(stdinText.trim());
59
+ }
60
+ catch {
61
+ // Non-JSON stdin leaves payload undefined, which the shape check rejects.
62
+ }
63
+ if (!isObjectLike(payload) || !('hook_event_name' in payload) || payload.hook_event_name !== 'Stop') {
64
+ return { status: 'skipped', reason: 'not a VS Code Stop payload (no snake_case hook_event_name "Stop")' };
65
+ }
66
+ if (!('transcript_path' in payload) || !isStringValue(payload.transcript_path) || !/\.jsonl$/i.test(payload.transcript_path)) {
67
+ return { status: 'skipped', reason: 'Stop payload names no .jsonl transcript_path' };
68
+ }
69
+ if (!('session_id' in payload) || !isStringValue(payload.session_id) || payload.session_id === '') {
70
+ return { status: 'skipped', reason: 'Stop payload has no session_id' };
71
+ }
72
+ return {
73
+ status: 'received',
74
+ input: {
75
+ runtime: 'vscode',
76
+ event: 'turn-end',
77
+ manual: false,
78
+ sessionId: payload.session_id,
79
+ cwd: 'cwd' in payload && isStringValue(payload.cwd) ? payload.cwd : null,
80
+ transcriptPath: payload.transcript_path,
81
+ trigger: null,
82
+ },
83
+ };
84
+ }
49
85
  //# sourceMappingURL=capture-contract.js.map
@@ -21,11 +21,15 @@ import { cmdPreCompact, cmdPostCompact } from '../capture/compact.js';
21
21
  import { transcriptWorkingState } from '../capture/working-state.js';
22
22
  import { collectHandoffEvidence } from '../handoff-evidence.js';
23
23
  import { resolveLastSessionTranscript } from '../capture/transcript.js';
24
- import { copilotTranscriptFor } from '../capture/copilot-transcript.js';
24
+ import { copilotTranscriptFor, SESSION_ID_RE } from '../capture/copilot-transcript.js';
25
+ import { loadTurnPosition, runSessionWorker, saveTurnPosition, turnsAfter } from '../capture/session-worker.js';
26
+ import { isStringValue, readVscodeStop } from '../capture-contract.js';
27
+ import { loadConfig } from '../config.js';
28
+ import { countCreatedSinceLastSleep } from '../store/index-and-stats.js';
25
29
  import { truncateCodePointSafe } from '../transcript-tail.js';
26
30
  import { COMPACTION_DB_WAIT_MS } from '../compaction-record.js';
27
31
  import { COMPACT_RESUME_EVENT_CONTENT_CAP, COMPACT_RESUME_MAX_AGE_MS, compactResumeText } from '../context-render.js';
28
- import { readHookStdin } from '../stdin.js';
32
+ import { normaliseHookPayload, readHookStdin, readStdinBounded } from '../stdin.js';
29
33
  import { resolveTenantId } from '../tenant.js';
30
34
  import { errorMessage, log } from '../log.js';
31
35
  import { withLedgerDb } from '../ledger-db.js';
@@ -143,11 +147,16 @@ function restoreCompactSnapshot(hippoRoot, payloadSessionId) {
143
147
  */
144
148
  export async function cmdSessionEnd(hippoRoot, flags) {
145
149
  const runtime = hookRuntime(flags);
150
+ const turn = flags['turn'] === true;
146
151
  // Copilot's hook command carries no path, since one quoted into it would need escaping for each shell; the log goes where the hook table used to point.
147
152
  const logFile = typeof flags['log-file'] === 'string' ? flags['log-file'] : runtime === 'copilot' ? resolveJsonHookPaths('copilot').logFile : null;
148
153
  // Bounded read: extracts transcript_path + session_id for the detached worker's argv.
149
154
  let sessionId = null;
150
- const { text: stdinText } = await readHookStdin();
155
+ const raw = await readStdinBounded();
156
+ // Read before normalising: the Copilot CLI's agentStop shares this hook line, and camelCase keys would pass as VS Code's.
157
+ if (turn && !isVscodeStopPayload(raw))
158
+ return;
159
+ const stdinText = normaliseHookPayload(raw.text);
151
160
  // Before the spawn, since the worker finds its store from the folder it inherits.
152
161
  const root = payloadCwdRoot(hippoRoot, stdinText, runtime);
153
162
  try {
@@ -172,6 +181,8 @@ export async function cmdSessionEnd(hippoRoot, flags) {
172
181
  workerArgs.push('--transcript', transcriptPath);
173
182
  if (sessionId)
174
183
  workerArgs.push('--session-id', sessionId);
184
+ if (turn)
185
+ workerArgs.push('--turn');
175
186
  try {
176
187
  const child = spawn(process.execPath, workerArgs, {
177
188
  detached: true,
@@ -194,7 +205,16 @@ export async function cmdSessionEnd(hippoRoot, flags) {
194
205
  return;
195
206
  }
196
207
  }
208
+ /** A VS Code Stop payload with a session id that can name the lock file; anything else makes turn mode a silent no-op. */
209
+ function isVscodeStopPayload(raw) {
210
+ const receipt = readVscodeStop(raw.text, raw.timedOut);
211
+ return receipt.status === 'received' && receipt.input.sessionId !== null && SESSION_ID_RE.test(receipt.input.sessionId);
212
+ }
197
213
  export async function cmdSessionEndWorker(hippoRoot, flags) {
214
+ const sessionId = flags['session-id'];
215
+ await runSessionWorker(isStringValue(sessionId) ? sessionId : null, flags['turn'] === true ? 'turn' : 'full', (mode) => sessionEndWork(hippoRoot, flags, mode));
216
+ }
217
+ async function sessionEndWork(hippoRoot, flags, mode) {
198
218
  const transcriptPath = typeof flags['transcript'] === 'string' ? flags['transcript'] : undefined;
199
219
  const closeLogFile = typeof flags['log-file'] === 'string' ? flags['log-file'] : null;
200
220
  const closeSessionId = typeof flags['session-id'] === 'string' ? flags['session-id'] : null;
@@ -211,11 +231,18 @@ export async function cmdSessionEndWorker(hippoRoot, flags) {
211
231
  flushRereadLog();
212
232
  return;
213
233
  }
214
- await sleepProjectStore(hippoRoot, flags, closeLogFile, transcriptPath);
234
+ if (mode === 'turn' && closeSessionId)
235
+ await sleepIfDue(hippoRoot, flags, closeLogFile, transcriptPath, closeSessionId);
236
+ else
237
+ await sleepProjectStore(hippoRoot, flags, closeLogFile, transcriptPath);
215
238
  flushRereadLog();
216
239
  const digestLog = (message) => appendSessionEndCloseLog(closeLogFile, message);
217
240
  const scan = transcriptPath ? readSessionScan(transcriptPath, digestLog) : null;
218
- captureEndedSession(hippoRoot, store, flags, transcriptPath, scan);
241
+ const capture = (turns) => captureEndedSession(hippoRoot, store, flags, transcriptPath, turns);
242
+ if (mode === 'turn')
243
+ captureNewTurns(transcriptPath, closeSessionId, scan, capture, digestLog);
244
+ else
245
+ capture(scan?.turns);
219
246
  recordSessionDigest(hippoRoot, scan, {
220
247
  key: closeSessionId || path.basename(transcriptPath ?? '', '.jsonl'),
221
248
  tenantId: resolveTenantId({}),
@@ -225,7 +252,12 @@ export async function cmdSessionEndWorker(hippoRoot, flags) {
225
252
  // session-end may never fire (crash, kill -9) the freshness bound in loadFreshActiveTaskSnapshot is the backstop.
226
253
  // The handoff is written first, while the snapshot writeSessionEndHandoff reads is still active.
227
254
  if (closeSessionId)
228
- writeEndHandoff(store, closeSessionId, transcriptPath, closeLogFile);
255
+ writeEndHandoff(store, closeSessionId, transcriptPath, closeLogFile, mode === 'turn');
256
+ if (mode === 'turn') {
257
+ // The chat goes on after a reply, so its snapshot stays for the next compaction to restore.
258
+ appendSessionEndCloseLog(closeLogFile, 'skip snapshot close: turn mode');
259
+ return;
260
+ }
229
261
  try {
230
262
  if (closeSessionId) {
231
263
  const closed = closeTaskSnapshotsForSession(store, resolveTenantId({}), closeSessionId);
@@ -255,32 +287,68 @@ async function sleepProjectStore(hippoRoot, flags, closeLogFile, transcriptPath)
255
287
  logSessionEndImport(closeLogFile, transcriptPath);
256
288
  }
257
289
  }
258
- function captureEndedSession(hippoRoot, store, flags, transcriptPath, scan) {
290
+ /** A close after every reply sleeps only at the MCP server's auto-sleep threshold; each line starts the log afresh, as a sleep does. */
291
+ async function sleepIfDue(hippoRoot, flags, closeLogFile, transcriptPath, sessionId) {
292
+ if (!isInitialized(hippoRoot))
293
+ return sleepProjectStore(hippoRoot, flags, closeLogFile, transcriptPath);
294
+ const prefix = `turn close, session ${sessionId}`;
295
+ try {
296
+ const { enabled, threshold } = loadConfig(hippoRoot).autoSleep;
297
+ const count = enabled ? countCreatedSinceLastSleep(hippoRoot, resolveTenantId({})) : 0;
298
+ if (!enabled || count < threshold) {
299
+ const why = enabled ? `${count} new memories, threshold ${threshold}` : 'auto-sleep is off';
300
+ appendSessionEndCloseLog(closeLogFile, `${prefix}: skip sleep, ${why}`, { startFresh: true });
301
+ return;
302
+ }
303
+ await sleepProjectStore(hippoRoot, flags, closeLogFile, transcriptPath);
304
+ appendSessionEndCloseLog(closeLogFile, `${prefix}: ran sleep at ${count} new memories (threshold ${threshold})`);
305
+ }
306
+ catch (err) {
307
+ appendSessionEndCloseLog(closeLogFile, `${prefix}: sleep check failed: ${errorMessage(err)}`, { startFresh: true });
308
+ }
309
+ }
310
+ /** Captures only the turns after this session's cursor, then moves the cursor, so each reply is extracted once. */
311
+ function captureNewTurns(transcriptPath, sessionId, scan, capture, log) {
312
+ if (!transcriptPath || !sessionId || !scan) {
313
+ log('skip capture: no readable transcript for this session');
314
+ return;
315
+ }
316
+ const fresh = turnsAfter(scan.turns, loadTurnPosition(sessionId, transcriptPath));
317
+ if (fresh.length === 0) {
318
+ log('skip capture: no new turns since the last reply');
319
+ return;
320
+ }
321
+ if (capture(fresh))
322
+ saveTurnPosition(sessionId, transcriptPath, scan.turns, log);
323
+ }
324
+ /** True when capture ran to the end, so a turn close may move its cursor past these turns. */
325
+ function captureEndedSession(hippoRoot, store, flags, transcriptPath, turns) {
259
326
  try {
260
327
  const logFile = typeof flags['log-file'] === 'string' ? flags['log-file'] : undefined;
261
328
  // With no stdin of its own, capture would read this as a manual run and scan every project.
262
329
  if (!transcriptPath) {
263
330
  appendSessionEndCloseLog(logFile ?? null, 'skip capture: no transcript for this session');
331
+ return false;
264
332
  }
265
- else {
266
- cmdCapture(store, {
267
- source: 'last-session',
268
- transcriptPath,
269
- logFile,
270
- dryRun: false,
271
- global: false,
272
- tenantId: resolveTenantId({}),
273
- // In the global store, rows would otherwise read as user-global and show up in every project.
274
- originProject: store === hippoRoot ? undefined : resolveProjectIdentity(process.cwd()),
275
- sessionTurns: scan?.turns,
276
- });
277
- }
333
+ cmdCapture(store, {
334
+ source: 'last-session',
335
+ transcriptPath,
336
+ logFile,
337
+ dryRun: false,
338
+ global: false,
339
+ tenantId: resolveTenantId({}),
340
+ // In the global store, rows would otherwise read as user-global and show up in every project.
341
+ originProject: store === hippoRoot ? undefined : resolveProjectIdentity(process.cwd()),
342
+ sessionTurns: turns,
343
+ });
344
+ return true;
278
345
  }
279
346
  catch {
280
347
  // Same treatment — the failure line is already in the log.
348
+ return false;
281
349
  }
282
350
  }
283
- function writeEndHandoff(store, closeSessionId, transcriptPath, closeLogFile) {
351
+ function writeEndHandoff(store, closeSessionId, transcriptPath, closeLogFile, inPlace = false) {
284
352
  try {
285
353
  const tenantId = resolveTenantId({});
286
354
  const ownSnapshot = loadActiveTaskSnapshot(store, tenantId)?.session_id === closeSessionId;
@@ -293,7 +361,7 @@ function writeEndHandoff(store, closeSessionId, transcriptPath, closeLogFile) {
293
361
  }
294
362
  else {
295
363
  const evidence = collectHandoffEvidence(process.cwd(), 'unknown');
296
- const handoff = writeSessionEndHandoff(store, tenantId, closeSessionId, evidence, derived);
364
+ const handoff = writeSessionEndHandoff(store, tenantId, closeSessionId, evidence, derived, undefined, { inPlace });
297
365
  appendSessionEndCloseLog(closeLogFile, handoff ? `wrote handoff for session ${closeSessionId}` : `skip: kept the existing handoff for session ${closeSessionId}`);
298
366
  }
299
367
  }
package/dist/cli/setup.js CHANGED
@@ -2,11 +2,11 @@
2
2
  import * as path from 'path';
3
3
  import * as fs from 'fs';
4
4
  import { execFileSync } from 'child_process';
5
- import { installJsonHooks, uninstallJsonHooks, checkUninstallable, resolveJsonHookPaths } from '../hooks/json-hooks.js';
5
+ import { installJsonHooks, uninstallJsonHooks, checkUninstallable, copilotHookEvents, resolveJsonHookPaths } from '../hooks/json-hooks.js';
6
6
  import { detectInstalledTools } from '../hooks/shared.js';
7
7
  import { ensureCodexWrapperInstalled, installCodexWrapper, detectRealCodexPath, uninstallCodexWrapper, } from '../hooks/codex-wrapper.js';
8
8
  import { installOpencodePlugin, uninstallOpencodePlugin, resolveOpencodePluginPath } from '../hooks/opencode.js';
9
- import { copilotMcpSnippet, copilotPaths, installCopilot, isMcpFailure, uninstallCopilot } from '../hooks/copilot.js';
9
+ import { COPILOT_CLI_MCP, VSCODE_MCP, copilotMcpSnippet, copilotPaths, installCopilot, isCopilotCliPresent, isMcpFailure, uninstallCopilot, } from '../hooks/copilot.js';
10
10
  import { isInitialized } from '../store/open.js';
11
11
  import { currentMachine, importUserMemories } from '../agent-memories/sync.js';
12
12
  import { getGlobalRoot } from '../shared.js';
@@ -42,7 +42,7 @@ function hookList() {
42
42
  for (const [name, hook] of Object.entries(HOOKS)) {
43
43
  console.log(` ${name.padEnd(15)} -> ${hook.file} (${hook.description})`);
44
44
  }
45
- console.log(` ${'copilot'.padEnd(15)} -> $COPILOT_HOME or ~/.copilot (GitHub Copilot: hooks, MCP server and instructions)`);
45
+ console.log(` ${'copilot'.padEnd(15)} -> $COPILOT_HOME or ~/.copilot, and each VS Code User folder (GitHub Copilot: hooks, MCP server and instructions)`);
46
46
  console.log('\nUsage: hippo hook install <name>');
47
47
  console.log(' hippo hook uninstall <name>');
48
48
  }
@@ -155,11 +155,12 @@ function installOpencodeHook() {
155
155
  console.log(`WARNING: opencode.json is unparseable; legacy hooks block could not be auto-removed. Fix the file manually.`);
156
156
  }
157
157
  }
158
+ const addByHand = (host) => `add this under "${host.key}" by hand: ${copilotMcpSnippet(host)}`;
158
159
  const MCP_MERGE_LINES = {
159
160
  added: (file) => `Added the "hippo" MCP server -> ${file}`,
160
161
  present: (file) => `The "hippo" MCP server is already in ${file}`,
161
162
  'user-owned': (file) => `Left the "hippo" MCP server in ${file} as it is: hippo did not write it`,
162
- unreadable: (file) => `WARNING: ${file} has comments or is not a JSON object hippo can merge into, so it was left unchanged; add this under "mcpServers" by hand: ${copilotMcpSnippet()}`,
163
+ unreadable: (file, host) => `WARNING: ${file} has comments or is not a JSON object hippo can merge into, so it was left unchanged; ${addByHand(host)}`,
163
164
  };
164
165
  const mcpFailureLine = (file, failure, fix) => `WARNING: hippo could not use ${file} (${failure.failed}), so it was left unchanged; ${fix}`;
165
166
  const unclosedLine = (file) => `WARNING: ${file} has ${HOOK_MARKERS.start} with no ${HOOK_MARKERS.end}, so hippo left it unchanged; fix the markers by hand`;
@@ -175,32 +176,74 @@ const INSTRUCTIONS_REMOVE_LINES = {
175
176
  kept: (file) => `Left the hippo block in ${file}: it is not the Copilot text hippo wrote. Delete it by hand if no agent needs it.`,
176
177
  unclosed: unclosedLine,
177
178
  };
179
+ const VSCODE_INSTRUCTIONS_INSTALL_LINES = {
180
+ written: (file) => `Wrote the hippo instructions file -> ${file}`,
181
+ present: (file) => `The hippo instructions file is already at ${file}`,
182
+ kept: (file) => `Kept ${file} as it is: it is not the text hippo writes, so it was edited or is someone else's`,
183
+ };
184
+ const VSCODE_INSTRUCTIONS_REMOVE_LINES = {
185
+ removed: (file) => `Removed the hippo instructions file ${file}`,
186
+ absent: () => '',
187
+ kept: (file) => `Left ${file}: it is not the text hippo wrote. Delete it by hand if no agent needs it.`,
188
+ };
189
+ const VSCODE_HOOKS_LINE = 'VS Code: hooks need VS Code 1.109.3 or later with chat.useHooks on (the default); older versions get the MCP server and the instructions file only.';
190
+ function mcpInstallLine(file, mcp, host) {
191
+ return isMcpFailure(mcp) ? mcpFailureLine(file, mcp, addByHand(host)) : MCP_MERGE_LINES[mcp](file, host);
192
+ }
193
+ function printVscodeInstall(result, indent) {
194
+ const { paths } = result;
195
+ console.log(`${indent}${mcpInstallLine(paths.mcpConfig, result.mcp, VSCODE_MCP)}`);
196
+ console.log(`${indent}${VSCODE_INSTRUCTIONS_INSTALL_LINES[result.instructions](paths.instructions)}`);
197
+ if (paths.profiles.length > 0) {
198
+ const names = paths.profiles.map((dir) => path.basename(dir)).join(', ');
199
+ console.log(`${indent}Found VS Code profiles (${names}) under ${path.join(paths.userDir, 'profiles')}: hippo set up the default profile only; copy the "hippo" server and prompts/hippo.instructions.md into a profile to use hippo there.`);
200
+ }
201
+ }
178
202
  function printCopilotInstall(result, indent) {
179
- const { paths, mcp } = result;
180
- const hooks = result.hooks ? "Installed hippo's Copilot hooks (sessionStart, postToolUseFailure, preCompact, sessionEnd) ->" : "hippo's Copilot hooks are already in";
203
+ const { paths } = result;
204
+ const hooks = result.hooks ? `Installed hippo's Copilot hooks (${copilotHookEvents().join(', ')}) ->` : "hippo's Copilot hooks are already in";
181
205
  console.log(`${indent}${hooks} ${paths.hooks}`);
182
- const mcpLine = isMcpFailure(mcp) ? mcpFailureLine(paths.mcpConfig, mcp, `add this under "mcpServers" by hand: ${copilotMcpSnippet()}`) : MCP_MERGE_LINES[mcp](paths.mcpConfig);
183
- console.log(`${indent}${mcpLine}`);
184
- console.log(`${indent}${INSTRUCTIONS_INSTALL_LINES[result.instructions](paths.instructions)}`);
206
+ if (result.mcp === null || result.instructions === null) {
207
+ console.log(`${indent}No Copilot CLI files in ${path.dirname(paths.mcpConfig)}, so hippo skipped mcp-config.json and copilot-instructions.md, which only the Copilot CLI reads`);
208
+ }
209
+ else {
210
+ console.log(`${indent}${mcpInstallLine(paths.mcpConfig, result.mcp, COPILOT_CLI_MCP)}`);
211
+ console.log(`${indent}${INSTRUCTIONS_INSTALL_LINES[result.instructions](paths.instructions)}`);
212
+ }
213
+ for (const vscode of result.vscode)
214
+ printVscodeInstall(vscode, indent);
215
+ if (result.vscode.length > 0)
216
+ console.log(`${indent}${VSCODE_HOOKS_LINE}`);
185
217
  console.log(`${indent}VS Code Copilot runs new hooks in a new chat session.`);
186
218
  }
187
- function printCopilotUninstall(result) {
188
- const { paths, mcp } = result;
189
- if (result.hooks)
190
- console.log(`Removed hippo's Copilot hooks file ${paths.hooks}`);
219
+ /** The lines for one MCP config file; empty when hippo's server was not there. */
220
+ function mcpRemoveLines(file, mcp) {
191
221
  if (isMcpFailure(mcp))
192
- console.log(mcpFailureLine(paths.mcpConfig, mcp, 'remove the "hippo" server by hand if hippo added it'));
222
+ return [mcpFailureLine(file, mcp, 'remove the "hippo" server by hand if hippo added it')];
193
223
  if (mcp === 'removed')
194
- console.log(`Removed the "hippo" MCP server from ${paths.mcpConfig}`);
224
+ return [`Removed the "hippo" MCP server from ${file}`];
195
225
  if (mcp === 'user-owned')
196
- console.log(`Left the "hippo" MCP server in ${paths.mcpConfig}: hippo did not write it`);
226
+ return [`Left the "hippo" MCP server in ${file}: hippo did not write it`];
197
227
  if (mcp === 'unreadable')
198
- console.log(`WARNING: ${paths.mcpConfig} has comments or is not a JSON object hippo can edit, so it was left unchanged; remove the "hippo" server by hand if hippo added it`);
199
- const instructions = INSTRUCTIONS_REMOVE_LINES[result.instructions](paths.instructions);
200
- if (instructions)
201
- console.log(instructions);
202
- if (!result.hooks && mcp === 'absent' && result.instructions === 'absent')
203
- console.log('No hippo Copilot hooks, MCP server or instructions block found.');
228
+ return [`WARNING: ${file} has comments or is not a JSON object hippo can edit, so it was left unchanged; remove the "hippo" server by hand if hippo added it`];
229
+ return [];
230
+ }
231
+ function printCopilotUninstall(result) {
232
+ const { paths, mcp } = result;
233
+ const lines = [];
234
+ if (result.hooks)
235
+ lines.push(`Removed hippo's Copilot hooks file ${paths.hooks}`);
236
+ lines.push(...mcpRemoveLines(paths.mcpConfig, mcp));
237
+ lines.push(INSTRUCTIONS_REMOVE_LINES[result.instructions](paths.instructions));
238
+ for (const vscode of result.vscode) {
239
+ lines.push(...mcpRemoveLines(vscode.paths.mcpConfig, vscode.mcp));
240
+ lines.push(VSCODE_INSTRUCTIONS_REMOVE_LINES[vscode.instructions](vscode.paths.instructions));
241
+ }
242
+ const printed = lines.filter((line) => line !== '');
243
+ for (const line of printed)
244
+ console.log(line);
245
+ if (printed.length === 0)
246
+ console.log('No hippo Copilot hooks, MCP server or instructions found.');
204
247
  }
205
248
  function hookUninstall(target) {
206
249
  if (target === 'copilot')
@@ -330,8 +373,11 @@ function setupCopilot(dryRun) {
330
373
  const indent = ` ${'copilot'.padEnd(14)} `;
331
374
  if (!dryRun)
332
375
  return printCopilotInstall(installCopilot(), indent);
333
- const { hooks, mcpConfig, instructions } = copilotPaths();
334
- console.log(`[dry-run] would install hooks in ${hooks}, the MCP server in ${mcpConfig} and the hippo block in ${instructions}`);
376
+ const { hooks, mcpConfig, instructions, vscode } = copilotPaths();
377
+ const cliFiles = isCopilotCliPresent() ? `, the MCP server in ${mcpConfig} and the hippo block in ${instructions}` : '';
378
+ console.log(`[dry-run] would install hooks in ${hooks}${cliFiles}`);
379
+ for (const dir of vscode)
380
+ console.log(`[dry-run] would add the MCP server to ${dir.mcpConfig} and write ${dir.instructions}`);
335
381
  }
336
382
  function setupJsonTool(tool, dryRun) {
337
383
  if (tool.name === 'copilot')
package/dist/cli/usage.js CHANGED
@@ -258,7 +258,10 @@ export const VERB_USAGE = {
258
258
  --log-file <path> Tee the worker's output to a log file (paired with 'hippo last-sleep')
259
259
  --runtime copilot The payload came from a Copilot hook: use the store of its cwd and find
260
260
  the Copilot CLI session log by session id; with no --log-file, log to
261
- ~/.hippo/logs/copilot-sleep.log`],
261
+ ~/.hippo/logs/copilot-sleep.log
262
+ --turn VS Code Stop hook, after each reply: capture only the new turns, sleep
263
+ only at the auto-sleep threshold, and keep the session's snapshot; does
264
+ nothing for any other payload`],
262
265
  'pre-compact': [`
263
266
  pre-compact PreCompact hook: record the compaction, save a working-state snapshot, and
264
267
  ask the summariser to end with a "Memories for hippo" list
package/dist/cli.js CHANGED
@@ -57,7 +57,7 @@ export const BOOLEAN_FLAGS = new Set([
57
57
  'no-learn', 'no-mmr', 'no-propagate', 'no-schedule', 'no-share', 'no-summarize-older',
58
58
  'observed', 'open', 'physics', 'pin', 'pinned-only', 'reject-loser', 'rerank-utility',
59
59
  'reset-physics', 'save-baseline', 'show-cases', 'stats', 'stdin',
60
- 'strict', 'suite', 'value-aware', 'verified', 'version', 'why',
60
+ 'strict', 'suite', 'turn', 'value-aware', 'verified', 'version', 'why',
61
61
  ]);
62
62
  // Every flag some command reads. Anything else is a typo that no command would act on.
63
63
  export const KNOWN_FLAGS = new Set([
@@ -132,7 +132,8 @@ function markKeySeenWithoutMemory(hippoRoot, idempotencyKey, input) {
132
132
  }
133
133
  function rememberWithEventLog(ctx, input, idempotencyKey, opts) {
134
134
  // No `|| 'connector:github'` fallback (see rememberWithEventLog in slack/ingest.ts for rationale).
135
- const result = remember(ctx, {
135
+ const result = remember({ ...ctx, store: undefined }, // the event log row commits with the memory on hippo.db's own handle, never through a store
136
+ {
136
137
  ...opts,
137
138
  untrusted: true,
138
139
  afterWrite: (innerDb, memoryId) => {
@@ -44,7 +44,8 @@ export function handleMessageDeleted(ctx, input) {
44
44
  // Archive + event-log mark commit together via afterArchive. The hook
45
45
  // receives the same db handle the archive is using, so the INSERT lives
46
46
  // inside the SAVEPOINT.
47
- archiveRaw(ctx, memoryId, `source_deleted:slack:${input.teamId}:${input.channelId}:${input.deletedTs}`, {
47
+ archiveRaw({ ...ctx, store: undefined }, // afterArchive runs on hippo.db's own handle, never through a store
48
+ memoryId, `source_deleted:slack:${input.teamId}:${input.channelId}:${input.deletedTs}`, {
48
49
  afterArchive: (sameDb, archivedId) => {
49
50
  markEventSeen(sameDb, input.eventId, archivedId);
50
51
  },
@@ -63,7 +63,8 @@ export function ingestMessage(ctx, input) {
63
63
  function rememberWithEventLog(ctx, eventId, opts) {
64
64
  // No `|| 'connector:slack'` fallback: the caller always builds ctx with the connector subject, and with
65
65
  // an object-shaped Context.actor an OR-fallback would never fire anyway.
66
- const result = remember(ctx, {
66
+ const result = remember({ ...ctx, store: undefined }, // the event log row commits with the memory on hippo.db's own handle, never through a store
67
+ {
67
68
  ...opts,
68
69
  untrusted: true,
69
70
  afterWrite: (innerDb, memoryId) => {