hippo-memory 1.65.0 → 1.67.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 (71) hide show
  1. package/README.md +2 -0
  2. package/dist/api/auth.d.ts +11 -2
  3. package/dist/api/auth.js +25 -2
  4. package/dist/api/recall-types.d.ts +1 -0
  5. package/dist/api/recall.js +5 -5
  6. package/dist/auth.d.ts +3 -1
  7. package/dist/auth.js +6 -2
  8. package/dist/capture/compact.d.ts +2 -0
  9. package/dist/capture/compact.js +12 -6
  10. package/dist/capture/copilot-transcript.d.ts +15 -0
  11. package/dist/capture/copilot-transcript.js +40 -0
  12. package/dist/capture/failure-reading.js +5 -2
  13. package/dist/capture/transcript.js +7 -1
  14. package/dist/capture/working-state.js +14 -3
  15. package/dist/capture-contract.d.ts +4 -2
  16. package/dist/capture-contract.js +3 -3
  17. package/dist/cli/context.js +11 -8
  18. package/dist/cli/hook-blocks.d.ts +2 -0
  19. package/dist/cli/hook-blocks.js +6 -0
  20. package/dist/cli/session-hooks.d.ts +1 -1
  21. package/dist/cli/session-hooks.js +28 -16
  22. package/dist/cli/setup.js +66 -7
  23. package/dist/cli/shared.d.ts +5 -0
  24. package/dist/cli/shared.js +22 -3
  25. package/dist/cli/usage.js +20 -9
  26. package/dist/db/open.d.ts +5 -1
  27. package/dist/db/open.js +28 -7
  28. package/dist/db/sqlite-blocked.d.ts +11 -1
  29. package/dist/db/sqlite-blocked.js +20 -2
  30. package/dist/db.d.ts +2 -2
  31. package/dist/db.js +2 -2
  32. package/dist/delivery-recorder.d.ts +3 -1
  33. package/dist/delivery-recorder.js +1 -1
  34. package/dist/embeddings.d.ts +11 -2
  35. package/dist/embeddings.js +20 -11
  36. package/dist/hooks/copilot.d.ts +45 -0
  37. package/dist/hooks/copilot.js +187 -0
  38. package/dist/hooks/json-hooks.d.ts +1 -1
  39. package/dist/hooks/json-hooks.js +41 -2
  40. package/dist/hooks/shared.d.ts +5 -1
  41. package/dist/hooks/shared.js +9 -0
  42. package/dist/mcp/request.d.ts +0 -2
  43. package/dist/mcp/request.js +16 -11
  44. package/dist/prompt-hook.d.ts +6 -0
  45. package/dist/prompt-hook.js +15 -7
  46. package/dist/quality-repair.js +3 -0
  47. package/dist/search/hybrid.d.ts +3 -0
  48. package/dist/search/physics-search.d.ts +3 -0
  49. package/dist/search/physics-search.js +18 -19
  50. package/dist/search/vector.d.ts +7 -2
  51. package/dist/search/vector.js +24 -10
  52. package/dist/server/routes/admin.js +1 -1
  53. package/dist/server/routes/recall.js +6 -9
  54. package/dist/server/types.d.ts +5 -4
  55. package/dist/server.d.ts +11 -6
  56. package/dist/server.js +18 -14
  57. package/dist/stdin.d.ts +5 -0
  58. package/dist/stdin.js +62 -0
  59. package/dist/store/key-audit.d.ts +6 -0
  60. package/dist/store/key-audit.js +25 -0
  61. package/dist/store/open.js +2 -1
  62. package/dist/store-port.d.ts +42 -4
  63. package/dist/store-port.js +46 -2
  64. package/dist/vector-store.d.ts +8 -0
  65. package/dist/vector-store.js +31 -19
  66. package/dist/version.d.ts +1 -1
  67. package/dist/version.js +1 -1
  68. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  69. package/extensions/openclaw-plugin/package.json +1 -1
  70. package/openclaw.plugin.json +1 -1
  71. package/package.json +1 -1
@@ -4,6 +4,7 @@ import * as fs from 'fs';
4
4
  import { spawn } from 'child_process';
5
5
  import { resolveCodexSessionTranscript } from '../hooks/codex-session.js';
6
6
  import { resolveCodexWrapperPaths } from '../hooks/codex-wrapper.js';
7
+ import { resolveJsonHookPaths } from '../hooks/json-hooks.js';
7
8
  import { isInitialized } from '../store/open.js';
8
9
  import { loadActiveTaskSnapshot, loadFreshActiveTaskSnapshot, closeTaskSnapshotsForSession, listSessionEvents, } from '../store/sessions.js';
9
10
  import { writeSessionEndHandoff } from '../store/handoffs.js';
@@ -20,16 +21,17 @@ import { cmdPreCompact, cmdPostCompact } from '../capture/compact.js';
20
21
  import { transcriptWorkingState } from '../capture/working-state.js';
21
22
  import { collectHandoffEvidence } from '../handoff-evidence.js';
22
23
  import { resolveLastSessionTranscript } from '../capture/transcript.js';
24
+ import { copilotTranscriptFor } from '../capture/copilot-transcript.js';
23
25
  import { truncateCodePointSafe } from '../transcript-tail.js';
24
26
  import { COMPACTION_DB_WAIT_MS } from '../compaction-record.js';
25
27
  import { COMPACT_RESUME_EVENT_CONTENT_CAP, COMPACT_RESUME_MAX_AGE_MS, compactResumeText } from '../context-render.js';
26
- import { readStdinBounded } from '../stdin.js';
28
+ import { readHookStdin } from '../stdin.js';
27
29
  import { resolveTenantId } from '../tenant.js';
28
30
  import { errorMessage, log } from '../log.js';
29
31
  import { withLedgerDb } from '../ledger-db.js';
30
32
  import { printError } from './output.js';
31
33
  import { cmdLastSleep } from './last-sleep.js';
32
- import { logSessionEndImport, appendSessionEndCloseLog, resetHookInjection, hookStoreRoot, runHookWithStores, inPilotHoldout, } from './shared.js';
34
+ import { logSessionEndImport, appendSessionEndCloseLog, resetHookInjection, hookStoreRoot, hookRuntime, payloadCwdRoot, runHookWithStores, inPilotHoldout, } from './shared.js';
33
35
  /**
34
36
  * SessionStart(compact) injector. Prints the active task snapshot + recent
35
37
  * session trail so working state that would otherwise be lost to
@@ -140,10 +142,14 @@ function restoreCompactSnapshot(hippoRoot, payloadSessionId) {
140
142
  * the next SessionStart reads the log via `hippo last-sleep`.
141
143
  */
142
144
  export async function cmdSessionEnd(hippoRoot, flags) {
143
- const logFile = typeof flags['log-file'] === 'string' ? flags['log-file'] : null;
145
+ const runtime = hookRuntime(flags);
146
+ // 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
+ const logFile = typeof flags['log-file'] === 'string' ? flags['log-file'] : runtime === 'copilot' ? resolveJsonHookPaths('copilot').logFile : null;
144
148
  // Bounded read: extracts transcript_path + session_id for the detached worker's argv.
145
149
  let sessionId = null;
146
- const { text: stdinText } = await readStdinBounded();
150
+ const { text: stdinText } = await readHookStdin();
151
+ // Before the spawn, since the worker finds its store from the folder it inherits.
152
+ const root = payloadCwdRoot(hippoRoot, stdinText, runtime);
147
153
  try {
148
154
  if (stdinText && stdinText.trim().startsWith('{')) {
149
155
  const payload = JSON.parse(stdinText);
@@ -156,8 +162,9 @@ export async function cmdSessionEnd(hippoRoot, flags) {
156
162
  // No stdin, not JSON, or read failure: the snapshot close below will no-op.
157
163
  }
158
164
  // Resolved here because only this process saw the payload; the worker captures just the path it is handed.
159
- // Always a hook, so never scan: an empty stdin here is not a manual run.
160
- const transcriptPath = resolveLastSessionTranscript(undefined, stdinText, { mayScan: false });
165
+ // Always a hook, so never scan: an empty stdin here is not a manual run. The Copilot CLI's sessionEnd names no transcript, so its log is found by id.
166
+ const transcriptPath = resolveLastSessionTranscript(undefined, stdinText, { mayScan: false })
167
+ ?? (runtime === 'copilot' && sessionId ? copilotTranscriptFor(sessionId) : null);
161
168
  const workerArgs = [process.argv[1], '__session-end-worker'];
162
169
  if (logFile)
163
170
  workerArgs.push('--log-file', logFile);
@@ -177,11 +184,13 @@ export async function cmdSessionEnd(hippoRoot, flags) {
177
184
  }
178
185
  catch {
179
186
  // If spawn fails, run inline as a last resort, handed what the child's argv would have carried.
187
+ if (logFile)
188
+ flags['log-file'] = logFile;
180
189
  if (transcriptPath)
181
190
  flags['transcript'] = transcriptPath;
182
191
  if (sessionId)
183
192
  flags['session-id'] = sessionId;
184
- await cmdSessionEndWorker(hippoRoot, flags);
193
+ await cmdSessionEndWorker(root, flags);
185
194
  return;
186
195
  }
187
196
  }
@@ -493,19 +502,22 @@ export async function cmdCodexSessionEndWorker(hippoRoot, flags) {
493
502
  }
494
503
  export async function handlePreCompact({ hippoRoot, flags }) {
495
504
  // Bounded wait, not a TTY guard: an idle non-TTY pipe must not hang.
496
- const { text: stdinText, timedOut: stdinTimedOut } = await readStdinBounded();
505
+ const { text: stdinText, timedOut: stdinTimedOut } = await readHookStdin();
506
+ const runtime = hookRuntime(flags);
507
+ const root = payloadCwdRoot(hippoRoot, stdinText, runtime);
497
508
  await runHookWithStores(async () => {
498
- resetHookInjection(hippoRoot, stdinText, null);
499
- await cmdPreCompact(hookStoreRoot(hippoRoot), {
509
+ resetHookInjection(root, stdinText, null);
510
+ await cmdPreCompact(hookStoreRoot(root), {
500
511
  stdinText,
501
512
  stdinTimedOut,
502
513
  logFile: typeof flags['log-file'] === 'string' ? flags['log-file'] : undefined,
514
+ runtime,
503
515
  });
504
516
  });
505
517
  }
506
518
  export async function handlePostCompact({ hippoRoot, flags }) {
507
519
  // PostCompact hook: saves the compaction summary and its memories, then prints one plain line, because Claude Code shows this hook's stdout as-is. Always exits 0.
508
- const { text } = await readStdinBounded();
520
+ const { text } = await readHookStdin();
509
521
  const logFlag = flags['log-file'];
510
522
  const store = hookStoreRoot(hippoRoot);
511
523
  const line = await runHookWithStores(() => cmdPostCompact(store, {
@@ -524,12 +536,12 @@ export async function handlePostCompact({ hippoRoot, flags }) {
524
536
  if (line !== null && line !== undefined)
525
537
  console.log(line);
526
538
  }
527
- export async function handleCaptureError({ hippoRoot }) {
539
+ export async function handleCaptureError({ hippoRoot, flags }) {
528
540
  // PostToolUseFailure hook: every path exits 0, and nothing is created
529
541
  // when no store exists (the hook fires in every directory).
530
- const { text } = await readStdinBounded();
542
+ const { text } = await readHookStdin();
531
543
  try {
532
- const root = hookStoreRoot(hippoRoot);
544
+ const root = hookStoreRoot(payloadCwdRoot(hippoRoot, text, hookRuntime(flags)));
533
545
  const payload = (text ?? '').trim();
534
546
  if (isInitialized(root) && payload) {
535
547
  // SAFETY: JSON.parse returns a JSON value by definition.
@@ -542,7 +554,7 @@ export async function handleCaptureError({ hippoRoot }) {
542
554
  }
543
555
  }
544
556
  export async function handleCompactResume({ hippoRoot }) {
545
- const { text: stdinText, timedOut: stdinTimedOut } = await readStdinBounded();
557
+ const { text: stdinText, timedOut: stdinTimedOut } = await readHookStdin();
546
558
  await runHookWithStores(() => {
547
559
  resetHookInjection(hippoRoot, stdinText, 'compact');
548
560
  cmdCompactResume(hookStoreRoot(hippoRoot), stdinText, stdinTimedOut);
@@ -574,7 +586,7 @@ export async function handleCapture({ hippoRoot, flags }) {
574
586
  // Bounded, and only when last-session has no explicit path: the
575
587
  // --stdin source keeps its own blocking read in capture.ts by design.
576
588
  const bounded = captureSource === 'last-session' && !transcriptPath
577
- ? await readStdinBounded()
589
+ ? await readHookStdin()
578
590
  : { text: undefined, timedOut: false };
579
591
  cmdCapture(hippoRoot, {
580
592
  source: captureSource,
package/dist/cli/setup.js CHANGED
@@ -6,6 +6,7 @@ import { installJsonHooks, uninstallJsonHooks, checkUninstallable, resolveJsonHo
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
10
  import { isInitialized } from '../store/open.js';
10
11
  import { currentMachine, importUserMemories } from '../agent-memories/sync.js';
11
12
  import { getGlobalRoot } from '../shared.js';
@@ -15,7 +16,7 @@ import { log } from '../log.js';
15
16
  import { printError } from './output.js';
16
17
  import { printAgentImport, installCodexMemoryHooks, setupDailySchedule, warnClaudeSettingsUnusable } from './shared.js';
17
18
  import { repairQualityOnceAt } from './quality-repair-once.js';
18
- import { HOOK_MARKERS, HOOKS, hippoBlock } from './hook-blocks.js';
19
+ import { HOOK_MARKERS, HOOKS, hippoBlock, withoutHookBlock } from './hook-blocks.js';
19
20
  import { escapeRegex } from '../escape.js';
20
21
  // ---------------------------------------------------------------------------
21
22
  // Hook install/uninstall
@@ -41,13 +42,17 @@ function hookList() {
41
42
  for (const [name, hook] of Object.entries(HOOKS)) {
42
43
  console.log(` ${name.padEnd(15)} -> ${hook.file} (${hook.description})`);
43
44
  }
45
+ console.log(` ${'copilot'.padEnd(15)} -> $COPILOT_HOME or ~/.copilot (GitHub Copilot: hooks, MCP server and instructions)`);
44
46
  console.log('\nUsage: hippo hook install <name>');
45
47
  console.log(' hippo hook uninstall <name>');
46
48
  }
47
49
  function hookInstall(target) {
50
+ // Copilot's instructions live in its home folder, not in a project file, so it has no HOOKS entry.
51
+ if (target === 'copilot')
52
+ return printCopilotInstall(installCopilot(), '');
48
53
  if (!target || !HOOKS[target]) {
49
54
  printError(`Unknown hook target: ${target ?? '(none)'}`);
50
- printError(` Available: ${Object.keys(HOOKS).join(', ')}`);
55
+ printError(` Available: ${[...Object.keys(HOOKS), 'copilot'].join(', ')}`);
51
56
  process.exit(1);
52
57
  }
53
58
  const hook = HOOKS[target];
@@ -150,7 +155,56 @@ function installOpencodeHook() {
150
155
  console.log(`WARNING: opencode.json is unparseable; legacy hooks block could not be auto-removed. Fix the file manually.`);
151
156
  }
152
157
  }
158
+ const MCP_MERGE_LINES = {
159
+ added: (file) => `Added the "hippo" MCP server -> ${file}`,
160
+ present: (file) => `The "hippo" MCP server is already in ${file}`,
161
+ '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
+ };
164
+ const mcpFailureLine = (file, failure, fix) => `WARNING: hippo could not use ${file} (${failure.failed}), so it was left unchanged; ${fix}`;
165
+ const unclosedLine = (file) => `WARNING: ${file} has ${HOOK_MARKERS.start} with no ${HOOK_MARKERS.end}, so hippo left it unchanged; fix the markers by hand`;
166
+ const INSTRUCTIONS_INSTALL_LINES = {
167
+ written: (file) => `Wrote the hippo block -> ${file}`,
168
+ present: (file) => `The hippo block is already in ${file}`,
169
+ kept: (file) => `Kept the hippo block in ${file} as it is: it is not the Copilot text hippo wrote, so it was edited or is another agent's`,
170
+ unclosed: unclosedLine,
171
+ };
172
+ const INSTRUCTIONS_REMOVE_LINES = {
173
+ removed: (file) => `Removed the hippo block from ${file}`,
174
+ absent: () => '',
175
+ 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
+ unclosed: unclosedLine,
177
+ };
178
+ 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";
181
+ 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)}`);
185
+ console.log(`${indent}VS Code Copilot runs new hooks in a new chat session.`);
186
+ }
187
+ function printCopilotUninstall(result) {
188
+ const { paths, mcp } = result;
189
+ if (result.hooks)
190
+ console.log(`Removed hippo's Copilot hooks file ${paths.hooks}`);
191
+ if (isMcpFailure(mcp))
192
+ console.log(mcpFailureLine(paths.mcpConfig, mcp, 'remove the "hippo" server by hand if hippo added it'));
193
+ if (mcp === 'removed')
194
+ console.log(`Removed the "hippo" MCP server from ${paths.mcpConfig}`);
195
+ if (mcp === 'user-owned')
196
+ console.log(`Left the "hippo" MCP server in ${paths.mcpConfig}: hippo did not write it`);
197
+ 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.');
204
+ }
153
205
  function hookUninstall(target) {
206
+ if (target === 'copilot')
207
+ return printCopilotUninstall(uninstallCopilot());
154
208
  if (!target || !HOOKS[target]) {
155
209
  printError(`Unknown hook target: ${target ?? '(none)'}`);
156
210
  process.exit(1);
@@ -218,10 +272,6 @@ function removeLegacyCursorRules() {
218
272
  console.log(left ? 'Removed the old Hippo hook from .cursorrules' : 'Deleted .cursorrules, which held only the old Hippo hook');
219
273
  }
220
274
  }
221
- function withoutHookBlock(text) {
222
- const re = new RegExp(`\\n?${escapeRegex(HOOK_MARKERS.start)}[\\s\\S]*?${escapeRegex(HOOK_MARKERS.end)}\\n?`, 'g');
223
- return text.replace(re, '\n').replace(/\n{3,}/g, '\n\n').trim();
224
- }
225
275
  // `hippo setup` -- one-shot configuration for every AI coding tool on the box.
226
276
  // Detection and install logic live in ./hooks.ts.
227
277
  export function cmdSetup(flags) {
@@ -237,7 +287,7 @@ export function cmdSetup(flags) {
237
287
  const markdownTools = tools.filter((t) => t.kind === 'markdown-instruction' && t.detected);
238
288
  const pluginTools = tools.filter((t) => t.kind === 'plugin' && t.detected);
239
289
  if (jsonTools.length === 0 && !forceAll) {
240
- console.log('No JSON-hook-capable tools detected (checked: claude-code).');
290
+ console.log('No JSON-hook-capable tools detected (checked: claude-code, copilot).');
241
291
  console.log('Run with --all to install hooks anyway.');
242
292
  }
243
293
  for (const tool of jsonTools)
@@ -276,7 +326,16 @@ export function cmdSetup(flags) {
276
326
  console.log('');
277
327
  console.log('Done. Restart your AI tool to activate the hooks.');
278
328
  }
329
+ function setupCopilot(dryRun) {
330
+ const indent = ` ${'copilot'.padEnd(14)} `;
331
+ if (!dryRun)
332
+ 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}`);
335
+ }
279
336
  function setupJsonTool(tool, dryRun) {
337
+ if (tool.name === 'copilot')
338
+ return setupCopilot(dryRun);
280
339
  if (dryRun) {
281
340
  // Resolve the real settings path so the filename is right for each tool
282
341
  // (claude-code -> settings.json, opencode -> opencode.json).
@@ -1,5 +1,6 @@
1
1
  import { type InstallResult } from '../hooks/json-hooks.js';
2
2
  import { TaskSnapshot, SessionEvent } from '../store/rows.js';
3
+ import type { HookRuntime } from '../capture-contract.js';
3
4
  import type { SessionHandoff } from '../handoff.js';
4
5
  import type { SearchResult } from '../search/types.js';
5
6
  import { type HippoConfig } from '../config.js';
@@ -113,6 +114,10 @@ export declare function captureConsole(fn: () => void): string;
113
114
  * is looked for in the other.
114
115
  */
115
116
  export declare function hookStoreRoot(hippoRoot: string): string;
117
+ /** `--runtime copilot`, or `--format copilot` on `hippo context`, marks a Copilot hook: the flag decides, never the payload. */
118
+ export declare function hookRuntime(flags: CliFlags): HookRuntime;
119
+ /** A Copilot hook's project root, from the payload's `cwd`, since VS Code runs user-level hooks in the home folder; other runtimes keep `hippoRoot`. */
120
+ export declare function payloadCwdRoot(hippoRoot: string, stdinText: string | undefined, runtime: HookRuntime): string;
116
121
  export declare function learnFromRepo(hippoRoot: string, repoPath: string, days: number, label?: string): {
117
122
  added: number;
118
123
  skipped: number;
@@ -7,13 +7,13 @@ import { execFileSync, execSync } from 'child_process';
7
7
  import { installJsonHooks } from '../hooks/json-hooks.js';
8
8
  import { CODEX_TRUST_LINE } from '../hooks/shared.js';
9
9
  import { confidenceLabel } from '../memory.js';
10
- import { isInitialized } from '../store/open.js';
10
+ import { getHippoRoot, isInitialized } from '../store/open.js';
11
11
  import { explainMatch } from '../search/explain.js';
12
12
  import { isSharedStore } from '../config.js';
13
13
  import { openHippoDb, closeHippoDb, isSqliteBusy, noteStoreBusy, runWithRequestStores, HOOK_DB_WAIT_MS } from '../db.js';
14
14
  import { withLedgerDb } from '../ledger-db.js';
15
15
  import { sessionPilotArm } from '../pilot-arm.js';
16
- import { hookPayloadSessionId, isSubagentPayload, recordTokenUse } from '../token-ledger.js';
16
+ import { hookPayloadSessionId, hookPayloadString, isSubagentPayload, recordTokenUse } from '../token-ledger.js';
17
17
  import { importAtSessionEnd, currentMachine } from '../agent-memories/sync.js';
18
18
  import { summaryLine } from '../agent-memories/report.js';
19
19
  import { detectChurnStale } from '../invalidation.js';
@@ -27,7 +27,7 @@ import { detectServer, removePidfileIfOwned } from '../server-detect.js';
27
27
  import { resolveTenantId } from '../tenant.js';
28
28
  import { adminActor, learn, CLI_LEARN } from '../api.js';
29
29
  import { snapshotText, sessionTrailText, handoffText } from '../context-render.js';
30
- import { log } from '../log.js';
30
+ import { errorMessage, log } from '../log.js';
31
31
  import { printError } from './output.js';
32
32
  export function parseLimitFlag(value) {
33
33
  if (!value)
@@ -441,6 +441,25 @@ export function hookStoreRoot(hippoRoot) {
441
441
  const globalRoot = getGlobalRoot();
442
442
  return isInitialized(globalRoot) ? globalRoot : hippoRoot;
443
443
  }
444
+ /** `--runtime copilot`, or `--format copilot` on `hippo context`, marks a Copilot hook: the flag decides, never the payload. */
445
+ export function hookRuntime(flags) {
446
+ return flags['runtime'] === 'copilot' || flags['format'] === 'copilot' ? 'copilot' : 'claude-code';
447
+ }
448
+ /** A Copilot hook's project root, from the payload's `cwd`, since VS Code runs user-level hooks in the home folder; other runtimes keep `hippoRoot`. */
449
+ export function payloadCwdRoot(hippoRoot, stdinText, runtime) {
450
+ const cwd = runtime === 'copilot' ? hookPayloadString(stdinText, 'cwd') : null;
451
+ if (cwd === null || cwd.trim() === '')
452
+ return hippoRoot;
453
+ try {
454
+ // Moving, not just re-rooting, keeps project identity, scope, handoff evidence and the session-end worker on that folder too.
455
+ process.chdir(cwd);
456
+ }
457
+ catch (err) {
458
+ log.warn(`hippo: payload cwd ${cwd} is not usable, so the hook stays in ${process.cwd()}: ${errorMessage(err)}`);
459
+ return hippoRoot;
460
+ }
461
+ return getHippoRoot(process.cwd());
462
+ }
444
463
  export function learnFromRepo(hippoRoot, repoPath, days, label) {
445
464
  const prefix = label ? `[${label}] ` : '';
446
465
  const ctx = { hippoRoot, tenantId: resolveTenantId({}), actor: adminActor('cli') };
package/dist/cli/usage.js CHANGED
@@ -255,18 +255,24 @@ export const VERB_USAGE = {
255
255
  session-end SessionEnd hook: count this session's re-read tokens, run sleep, then
256
256
  capture from the session's last 20 user and 10 assistant messages,
257
257
  in a detached worker
258
- --log-file <path> Tee the worker's output to a log file (paired with 'hippo last-sleep')`],
258
+ --log-file <path> Tee the worker's output to a log file (paired with 'hippo last-sleep')
259
+ --runtime copilot The payload came from a Copilot hook: use the store of its cwd and find
260
+ the Copilot CLI session log by session id; with no --log-file, log to
261
+ ~/.hippo/logs/copilot-sleep.log`],
259
262
  'pre-compact': [`
260
263
  pre-compact PreCompact hook: record the compaction, save a working-state snapshot, and
261
264
  ask the summariser to end with a "Memories for hippo" list
262
- --log-file <p> Diagnostic log path (default: ~/.hippo/logs/pre-compact.log)`],
265
+ --log-file <p> Diagnostic log path (default: ~/.hippo/logs/pre-compact.log)
266
+ --runtime copilot The payload came from a Copilot hook: use the store of its cwd and save
267
+ the snapshot only (Copilot has no PostCompact hook to close a record)`],
263
268
  'post-compact': [`
264
269
  post-compact PostCompact hook: keep that list as memories (a busy store leaves the save to
265
270
  the next hippo sleep) and print one line saying how many
266
271
  --log-file <p> Same log path as pre-compact (default: ~/.hippo/logs/pre-compact.log)`],
267
272
  'capture-error': [`
268
273
  capture-error Store a failed tool call as an error memory (reads the Claude Code
269
- PostToolUseFailure hook payload on stdin; skips routine failures)`],
274
+ PostToolUseFailure hook payload on stdin; skips routine failures)
275
+ --runtime copilot The payload came from a Copilot hook: use the store of its cwd`],
270
276
  'compact-resume': [`
271
277
  compact-resume SessionStart(compact) hook: re-print the snapshot, if under 15 minutes old`],
272
278
  'codex-run': [`
@@ -544,23 +550,28 @@ export const VERB_USAGE = {
544
550
  --pinned-only Only inject pinned memories (used by UserPromptSubmit hook)
545
551
  --include-recent <n> With --pinned-only, also inject the last N writes regardless of pinning
546
552
  (the hook payload's "prompt" drives prompt recall instead of --include-recent when pinnedInject.promptRecall is on, the default)
547
- --format <fmt> Output format: markdown (default), json, or additional-context (Claude Code hook JSON)
553
+ --format <fmt> Output format: markdown (default), json, additional-context (Claude Code hook JSON),
554
+ or copilot (Copilot sessionStart hook JSON, from the store of the payload's cwd)
548
555
  --framing <mode> Framing: observe (default), suggest, assert`],
549
556
  hook: [`
550
557
  hook <sub> [target] Manage framework integrations
551
558
  hook list Show available hooks
552
- hook install <target> Install hook (claude-code|codex|cursor|openclaw|opencode|pi)
559
+ hook install <target> Install hook (claude-code|codex|copilot|cursor|openclaw|opencode|pi)
553
560
  claude-code adds 7 hooks to $CLAUDE_CONFIG_DIR/settings.json
554
561
  (~/.claude by default); opencode installs a plugin; codex
555
562
  adds 2 hooks to $CODEX_HOME/hooks.json (trust them once in /hooks) and
556
- wraps the detected launcher in place; all but claude-code
557
- also patch an existing AGENTS.md
558
- hook uninstall <target> Remove hook`],
563
+ wraps the detected launcher in place; copilot writes
564
+ hooks/hippo.json, the "hippo" MCP server and an instructions
565
+ block under $COPILOT_HOME (~/.copilot by default); all but
566
+ claude-code and copilot also patch an existing AGENTS.md
567
+ hook uninstall <target> Remove hook; for copilot, only what hippo wrote`],
559
568
  setup: [`
560
569
  setup One-shot: detect installed AI tools and install their hooks:
561
570
  claude-code gets 7 hooks in $CLAUDE_CONFIG_DIR/settings.json
562
571
  (~/.claude by default), opencode a plugin, codex 2 hooks in
563
- its hooks.json plus a launcher wrapper; other tools get a
572
+ its hooks.json plus a launcher wrapper, copilot 4 hooks, the
573
+ MCP server and an instructions block under $COPILOT_HOME
574
+ (~/.copilot by default); other tools get a
564
575
  hint. Then imports each agent's
565
576
  user-level memories into the global store
566
577
  --all Install for every JSON-hook tool, even if not detected
package/dist/db/open.d.ts CHANGED
@@ -18,10 +18,14 @@ export declare const SLEEP_DB_WAIT_MS = 5000;
18
18
  export declare function scopedBusyWait(): number | undefined;
19
19
  /** Runs `fn` so that every hippo.db open inside it, across awaits, throws; otherwise a missed port would create and write a hippo.db nobody reads. */
20
20
  export declare function withSqliteBlocked<T>(storeKind: string, fn: () => T): T;
21
- /** Runs `fn` with hippo.db opens allowed again, for a store whose own methods are backed by hippo.db. */
21
+ /** Runs `fn` with hippo.db opens allowed again, for a store whose own methods are backed by hippo.db, or a copy that reads the old hippo.db. */
22
22
  export declare function withSqliteAllowed<T>(fn: () => T): T;
23
23
  /** First line of a best-effort catch around a hippo.db open: an unported path must fail closed, not fall back silently. */
24
24
  export declare function rethrowIfSqliteBlocked<E>(err: E): void;
25
+ /** store init --db, store copy --db and serve --db write this file in the hippo root; its text is the store kind. */
26
+ export declare const OTHER_STORE_MARKER = "other-store";
27
+ /** Throws where hippo.db must not open; checked on every open, not cached, since the marker can appear while a process runs. */
28
+ export declare function assertSqliteAllowed(hippoRoot: string): void;
25
29
  /** `busyWaitMs` shortens every lock wait of this open, for a hook that must finish inside its own timeout. */
26
30
  export declare function openHippoDb(hippoRoot: string, opts?: {
27
31
  busyWaitMs?: number;
package/dist/db/open.js CHANGED
@@ -1,11 +1,13 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks';
2
- import { log } from '../log.js';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { errorMessage, log } from '../log.js';
3
5
  import { DatabaseSync } from './sqlite.js';
4
6
  import { tableExists } from './tables.js';
5
7
  import { assertBinaryCompatible } from './migrate.js';
6
8
  import { connectHippoDb, getHippoDbPath } from './connect.js';
7
9
  import { currentRequestStores, isScopedHandle, runWithRequestStores } from './request-stores.js';
8
- import { SqliteBlockedError } from './sqlite-blocked.js';
10
+ import { OtherStoreFolderError, SqliteBlockedError } from './sqlite-blocked.js';
9
11
  export { getHippoDbPath };
10
12
  /** Lock wait for hook commands: under the 5 s prompt-hook budget even after a few skipped writes, and far above a normal write's hold. */
11
13
  export const HOOK_DB_WAIT_MS = 1000;
@@ -32,29 +34,48 @@ const sqliteBlockedBy = new AsyncLocalStorage();
32
34
  export function withSqliteBlocked(storeKind, fn) {
33
35
  return sqliteBlockedBy.run(storeKind, fn);
34
36
  }
35
- /** Runs `fn` with hippo.db opens allowed again, for a store whose own methods are backed by hippo.db. */
37
+ /** Runs `fn` with hippo.db opens allowed again, for a store whose own methods are backed by hippo.db, or a copy that reads the old hippo.db. */
36
38
  export function withSqliteAllowed(fn) {
37
- return sqliteBlockedBy.exit(fn);
39
+ return sqliteBlockedBy.exit(() => markerWaived.run(true, fn));
38
40
  }
39
41
  /** First line of a best-effort catch around a hippo.db open: an unported path must fail closed, not fall back silently. */
40
42
  export function rethrowIfSqliteBlocked(err) {
41
43
  if (err instanceof SqliteBlockedError)
42
44
  throw err;
43
45
  }
44
- function assertSqliteAllowed() {
46
+ /** store init --db, store copy --db and serve --db write this file in the hippo root; its text is the store kind. */
47
+ export const OTHER_STORE_MARKER = 'other-store';
48
+ const markerWaived = new AsyncLocalStorage();
49
+ // An empty or unreadable marker still refuses: falling through would write the hippo.db the marker forbids.
50
+ function markerKind(marker) {
51
+ try {
52
+ return readFileSync(marker, 'utf8').trim() || 'unknown';
53
+ }
54
+ catch (err) {
55
+ log.debug(`${marker} is unreadable, so its store kind reads as unknown: ${errorMessage(err)}`);
56
+ return 'unknown';
57
+ }
58
+ }
59
+ /** Throws where hippo.db must not open; checked on every open, not cached, since the marker can appear while a process runs. */
60
+ export function assertSqliteAllowed(hippoRoot) {
45
61
  const storeKind = sqliteBlockedBy.getStore();
46
62
  if (storeKind !== undefined)
47
63
  throw new SqliteBlockedError(storeKind);
64
+ if (markerWaived.getStore())
65
+ return;
66
+ const marker = join(hippoRoot, OTHER_STORE_MARKER);
67
+ if (existsSync(marker))
68
+ throw new OtherStoreFolderError(markerKind(marker), marker);
48
69
  }
49
70
  /** `busyWaitMs` shortens every lock wait of this open, for a hook that must finish inside its own timeout. */
50
71
  export function openHippoDb(hippoRoot, opts) {
51
- assertSqliteAllowed();
72
+ assertSqliteAllowed(hippoRoot);
52
73
  const stores = currentRequestStores();
53
74
  return stores ? stores.get(hippoRoot, opts) : connectHippoDb(hippoRoot, opts?.busyWaitMs);
54
75
  }
55
76
  /** Open an existing store without changing it: no mkdir, WAL switch, migration or mirror cleanup. Throws when hippo.db is missing. */
56
77
  export function openHippoDbReadOnly(hippoRoot) {
57
- assertSqliteAllowed();
78
+ assertSqliteAllowed(hippoRoot);
58
79
  const db = new DatabaseSync(getHippoDbPath(hippoRoot), { readOnly: true });
59
80
  try {
60
81
  db.exec('PRAGMA busy_timeout = 5000');
@@ -1,6 +1,16 @@
1
1
  /** Thrown by a hippo.db open inside a request served from another store: the code path is not ported to the store port yet. */
2
2
  export declare class SqliteBlockedError extends Error {
3
3
  readonly storeKind: string;
4
- constructor(storeKind: string);
4
+ constructor(storeKind: string, message?: string);
5
+ }
6
+ /** A store that lacks a whole group of port methods; it maps to the same 501, and the log names the group instead of hippo.db. */
7
+ export declare class StoreNotPortedError extends SqliteBlockedError {
8
+ readonly group: string;
9
+ constructor(storeKind: string, group: string);
10
+ }
11
+ /** Thrown by a hippo.db open in a folder whose marker file names the store that holds its memories, from the CLI as from a request. */
12
+ export declare class OtherStoreFolderError extends SqliteBlockedError {
13
+ readonly markerPath: string;
14
+ constructor(storeKind: string, markerPath: string);
5
15
  }
6
16
  //# sourceMappingURL=sqlite-blocked.d.ts.map
@@ -2,10 +2,28 @@
2
2
  /** Thrown by a hippo.db open inside a request served from another store: the code path is not ported to the store port yet. */
3
3
  export class SqliteBlockedError extends Error {
4
4
  storeKind;
5
- constructor(storeKind) {
6
- super(`hippo.db is not opened while the '${storeKind}' store serves this request; this code path is not ported to the store yet`);
5
+ constructor(storeKind, message = `hippo.db is not opened while the '${storeKind}' store serves this request; this code path is not ported to the store yet`) {
6
+ super(message);
7
7
  this.storeKind = storeKind;
8
8
  this.name = 'SqliteBlockedError';
9
9
  }
10
10
  }
11
+ /** A store that lacks a whole group of port methods; it maps to the same 501, and the log names the group instead of hippo.db. */
12
+ export class StoreNotPortedError extends SqliteBlockedError {
13
+ group;
14
+ constructor(storeKind, group) {
15
+ super(storeKind, `the '${storeKind}' store has no '${group}' group; this code path is not ported to the store yet`);
16
+ this.group = group;
17
+ this.name = 'StoreNotPortedError';
18
+ }
19
+ }
20
+ /** Thrown by a hippo.db open in a folder whose marker file names the store that holds its memories, from the CLI as from a request. */
21
+ export class OtherStoreFolderError extends SqliteBlockedError {
22
+ markerPath;
23
+ constructor(storeKind, markerPath) {
24
+ super(storeKind, `This folder's memories live in the '${storeKind}' store, so hippo.db is not used here (marker: ${markerPath})`);
25
+ this.markerPath = markerPath;
26
+ this.name = 'OtherStoreFolderError';
27
+ }
28
+ }
11
29
  //# sourceMappingURL=sqlite-blocked.js.map
package/dist/db.d.ts CHANGED
@@ -3,7 +3,7 @@ export { isSqliteBusy, isStoreBusy, StoreBusyError, STORE_BUSY_MESSAGE, execWith
3
3
  export { getSchemaVersion, getMeta, setMeta, isFtsAvailable } from './db/meta.js';
4
4
  export { countTableRows, pruneConsolidationRuns } from './db/tables.js';
5
5
  export { getCurrentSchemaVersion, IncompatibleBinaryError, ftsRowCounts, repairFtsDrift } from './db/migrate.js';
6
- export { getHippoDbPath, HOOK_DB_WAIT_MS, noteStoreBusy, withSharedStoreHandles, SERVER_DB_WAIT_MS, SLEEP_DB_WAIT_MS, scopedBusyWait, withSqliteBlocked, withSqliteAllowed, rethrowIfSqliteBlocked, openHippoDb, openHippoDbReadOnly, closeHippoDb, } from './db/open.js';
6
+ export { getHippoDbPath, HOOK_DB_WAIT_MS, noteStoreBusy, withSharedStoreHandles, SERVER_DB_WAIT_MS, SLEEP_DB_WAIT_MS, scopedBusyWait, withSqliteBlocked, withSqliteAllowed, rethrowIfSqliteBlocked, OTHER_STORE_MARKER, openHippoDb, openHippoDbReadOnly, closeHippoDb, } from './db/open.js';
7
7
  export { RequestStores, runWithRequestStores, currentRequestStores, outsideRequestStores } from './db/request-stores.js';
8
- export { SqliteBlockedError } from './db/sqlite-blocked.js';
8
+ export { OtherStoreFolderError, SqliteBlockedError } from './db/sqlite-blocked.js';
9
9
  //# sourceMappingURL=db.d.ts.map
package/dist/db.js CHANGED
@@ -2,7 +2,7 @@ export { isSqliteBusy, isStoreBusy, StoreBusyError, STORE_BUSY_MESSAGE, execWith
2
2
  export { getSchemaVersion, getMeta, setMeta, isFtsAvailable } from './db/meta.js';
3
3
  export { countTableRows, pruneConsolidationRuns } from './db/tables.js';
4
4
  export { getCurrentSchemaVersion, IncompatibleBinaryError, ftsRowCounts, repairFtsDrift } from './db/migrate.js';
5
- export { getHippoDbPath, HOOK_DB_WAIT_MS, noteStoreBusy, withSharedStoreHandles, SERVER_DB_WAIT_MS, SLEEP_DB_WAIT_MS, scopedBusyWait, withSqliteBlocked, withSqliteAllowed, rethrowIfSqliteBlocked, openHippoDb, openHippoDbReadOnly, closeHippoDb, } from './db/open.js';
5
+ export { getHippoDbPath, HOOK_DB_WAIT_MS, noteStoreBusy, withSharedStoreHandles, SERVER_DB_WAIT_MS, SLEEP_DB_WAIT_MS, scopedBusyWait, withSqliteBlocked, withSqliteAllowed, rethrowIfSqliteBlocked, OTHER_STORE_MARKER, openHippoDb, openHippoDbReadOnly, closeHippoDb, } from './db/open.js';
6
6
  export { RequestStores, runWithRequestStores, currentRequestStores, outsideRequestStores } from './db/request-stores.js';
7
- export { SqliteBlockedError } from './db/sqlite-blocked.js';
7
+ export { OtherStoreFolderError, SqliteBlockedError } from './db/sqlite-blocked.js';
8
8
  //# sourceMappingURL=db.js.map
@@ -1,6 +1,6 @@
1
1
  import type { MemoryEntry } from './memory.js';
2
2
  import { type PromptRecallGate } from './prompt-recall.js';
3
- export type DeliveryRuntime = 'claude-code' | 'codex' | 'unknown';
3
+ export type DeliveryRuntime = 'claude-code' | 'codex' | 'copilot' | 'unknown';
4
4
  export type DeliveryEventType = 'prompt-submit' | 'pinned-manual';
5
5
  export type DeliverySurface = 'hook' | 'context';
6
6
  export type DeliveryWriteStore = 'local' | 'global';
@@ -121,6 +121,8 @@ export interface DeliveryRecorderInit {
121
121
  tenantId: string;
122
122
  stdinText?: string;
123
123
  envSessionId?: string;
124
+ /** Set by the caller's runtime flag; Copilot payloads carry hook_event_name too, so inference would say claude-code. */
125
+ runtime?: DeliveryRuntime;
124
126
  }
125
127
  /** A recorder for one call; every observer method is guarded, and a throw marks it broken instead of escaping. */
126
128
  export declare function createDeliveryRecorder(init: DeliveryRecorderInit): DeliveryRecorder;
@@ -92,7 +92,7 @@ function buildEvent(init, payload, state, ts, startedMs) {
92
92
  return {
93
93
  ts,
94
94
  tenantId: init.tenantId,
95
- runtime: payload.hostTurnId !== null ? 'codex' : payload.hookEvent !== null ? 'claude-code' : 'unknown',
95
+ runtime: init.runtime ?? (payload.hostTurnId !== null ? 'codex' : payload.hookEvent !== null ? 'claude-code' : 'unknown'),
96
96
  eventType: payload.hookEvent === 'UserPromptSubmit' ? 'prompt-submit' : 'pinned-manual',
97
97
  surface: 'hook',
98
98
  storeHash: init.storeHash,
@@ -64,8 +64,19 @@ export declare function embeddingInputText(entry: {
64
64
  * consistent with the compare side in `embeddingModelRequiresReindex`.
65
65
  */
66
66
  export declare function saveStoredEmbeddingModel(hippoRoot: string, model: string): void;
67
+ /** What a store's vector index was built by: the stored identity, and whether any vector exists. */
68
+ export interface EmbeddingIndexState {
69
+ readonly storedModel: string | null;
70
+ readonly hasVectors: boolean;
71
+ }
72
+ /** The model the index was built by: the stored identity, else the default model when vectors exist. */
73
+ export declare function indexedModel(state: EmbeddingIndexState): string | null;
74
+ /** The one rebuild rule every store applies to its indexed model. */
75
+ export declare function indexNeedsRebuild(indexed: string | null, providerId: string): boolean;
67
76
  export declare function resolveIndexedEmbeddingModel(hippoRoot: string, index?: Record<string, number[]>): string | null;
68
77
  export declare function embeddingModelRequiresReindex(hippoRoot: string, model: string, index?: Record<string, number[]>): boolean;
78
+ /** hippo.db's index state, the meta row and the EXISTS on one handle. */
79
+ export declare function embeddingIndexStateAt(hippoRoot: string): EmbeddingIndexState;
69
80
  /**
70
81
  * Cosine similarity between two vectors. Handles unnormalized vectors.
71
82
  * Returns 0 for empty or mismatched vectors.
@@ -75,8 +86,6 @@ export declare function cosineSimilarity(a: number[], b: number[]): number;
75
86
  export declare function loadEmbeddingIndex(hippoRoot: string): Record<string, number[]>;
76
87
  /** Stored vectors for `ids` only. */
77
88
  export declare function loadStoredVectors(hippoRoot: string, ids: readonly string[]): Map<string, number[]>;
78
- /** Whether the store holds any vector at all. */
79
- export declare function hasEmbeddings(hippoRoot: string): boolean;
80
89
  /** Replace every stored vector with `index`; `model` defaults to the stored index identity. */
81
90
  export declare function saveEmbeddingIndex(hippoRoot: string, index: Record<string, number[]>, model?: string): void;
82
91
  /**