hippo-memory 1.68.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 (70) 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/context-select.d.ts +10 -0
  5. package/dist/api/context-select.js +16 -18
  6. package/dist/api/context.js +101 -60
  7. package/dist/api/forget.d.ts +2 -2
  8. package/dist/api/forget.js +11 -0
  9. package/dist/api/outcome.d.ts +4 -4
  10. package/dist/api/outcome.js +29 -6
  11. package/dist/api/promote.d.ts +3 -3
  12. package/dist/api/promote.js +45 -90
  13. package/dist/api/remember.d.ts +3 -2
  14. package/dist/api/remember.js +38 -11
  15. package/dist/capture/compact.js +21 -2
  16. package/dist/capture/copilot-transcript.d.ts +3 -0
  17. package/dist/capture/copilot-transcript.js +8 -1
  18. package/dist/capture/session-worker.d.ts +13 -0
  19. package/dist/capture/session-worker.js +194 -0
  20. package/dist/capture-contract.d.ts +3 -1
  21. package/dist/capture-contract.js +36 -0
  22. package/dist/cli/session-hooks.js +90 -22
  23. package/dist/cli/setup.js +70 -24
  24. package/dist/cli/usage.js +4 -1
  25. package/dist/cli.js +1 -1
  26. package/dist/connectors/github/ingest.js +2 -1
  27. package/dist/connectors/slack/deletion.js +2 -1
  28. package/dist/connectors/slack/ingest.js +2 -1
  29. package/dist/env.d.ts +2 -0
  30. package/dist/env.js +2 -0
  31. package/dist/hooks/copilot.d.ts +50 -8
  32. package/dist/hooks/copilot.js +116 -30
  33. package/dist/hooks/json-hooks.d.ts +2 -0
  34. package/dist/hooks/json-hooks.js +11 -2
  35. package/dist/hooks/shared.d.ts +7 -1
  36. package/dist/hooks/shared.js +18 -3
  37. package/dist/mcp/memory-tools.d.ts +2 -2
  38. package/dist/mcp/memory-tools.js +9 -8
  39. package/dist/mcp/request.js +3 -3
  40. package/dist/memory.d.ts +2 -0
  41. package/dist/memory.js +7 -0
  42. package/dist/prompt-recall.d.ts +5 -0
  43. package/dist/prompt-recall.js +13 -0
  44. package/dist/raw-archive.d.ts +2 -2
  45. package/dist/raw-archive.js +5 -3
  46. package/dist/recall-scope.d.ts +2 -5
  47. package/dist/recall-scope.js +5 -6
  48. package/dist/server/routes/memories.js +9 -6
  49. package/dist/server-detect.js +3 -2
  50. package/dist/server.d.ts +14 -1
  51. package/dist/server.js +16 -6
  52. package/dist/store/audit-event.d.ts +1 -1
  53. package/dist/store/audit-event.js +1 -1
  54. package/dist/store/entry-writes-group.d.ts +9 -0
  55. package/dist/store/entry-writes-group.js +157 -0
  56. package/dist/store/handoffs.d.ts +5 -2
  57. package/dist/store/handoffs.js +35 -5
  58. package/dist/store/open.d.ts +2 -0
  59. package/dist/store/open.js +10 -0
  60. package/dist/store/search-rows.js +3 -9
  61. package/dist/store/sessions.d.ts +5 -0
  62. package/dist/store/sessions.js +4 -1
  63. package/dist/store-port.d.ts +71 -0
  64. package/dist/store-port.js +23 -11
  65. package/dist/version.d.ts +1 -1
  66. package/dist/version.js +1 -1
  67. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  68. package/extensions/openclaw-plugin/package.json +1 -1
  69. package/openclaw.plugin.json +1 -1
  70. package/package.json +1 -1
@@ -1,10 +1,11 @@
1
- // GitHub Copilot: hippo's hooks file, one MCP server key and an instructions block, all under the Copilot home folder.
1
+ // GitHub Copilot: hippo's hooks file, one MCP server key and an instructions block under the Copilot home folder,
2
+ // plus the MCP server and an instructions file in each VS Code User folder.
2
3
  import * as fs from 'fs';
3
4
  import * as path from 'path';
4
5
  import { isDeepStrictEqual } from 'node:util';
5
6
  import { readJsonFile } from '../json.js';
6
7
  import { HOOK_MARKERS, hippoBlock } from '../cli/hook-blocks.js';
7
- import { copilotHomeDir, isJsonObject } from './shared.js';
8
+ import { copilotHomeDir, isJsonObject, vscodeUserDirs } from './shared.js';
8
9
  import { installJsonHooks, resolveJsonHookPaths, uninstallJsonHooks, writeSettingsFile } from './json-hooks.js';
9
10
  const MCP_KEY = 'hippo';
10
11
  /** Copilot drops what a per-prompt hook prints, so per-task recall goes through the MCP tool the block names. */
@@ -19,32 +20,75 @@ reason, a user preference, why something failed), call the \`hippo_remember\`
19
20
  tool right then, while you work, never as a closing step. Set \`error\` to true
20
21
  for a failure. Leave out secrets and personal details.
21
22
 
23
+ The installed hooks load pinned memories when a session starts and capture
24
+ the session as you work, so there is nothing to run before you finish.
25
+ `.trim();
26
+ /** Every Copilot text a release has written, so install upgrades an old block and uninstall still removes it; add the old text when the text changes. */
27
+ const SHIPPED_COPILOT_INSTRUCTIONS = [
28
+ COPILOT_INSTRUCTIONS,
29
+ `
30
+ ## Project Memory (Hippo)
31
+
32
+ At the start of each task, call the \`hippo_recall\` tool with a short query that
33
+ names the task. Read the memories it returns before you write any code.
34
+
35
+ When you learn something that should outlive this session (a decision and its
36
+ reason, a user preference, why something failed), call the \`hippo_remember\`
37
+ tool right then, while you work, never as a closing step. Set \`error\` to true
38
+ for a failure. Leave out secrets and personal details.
39
+
22
40
  The installed hooks load pinned memories when a session starts, store failed
23
41
  tool calls and capture the session when it ends, so there is nothing to run
24
42
  before you finish.
25
- `.trim();
43
+ `.trim(),
44
+ ];
45
+ /** VS Code attaches a User `*.instructions.md` file to every chat request its `applyTo` glob matches. */
46
+ const withApplyToAll = (text) => `---\napplyTo: "**"\n---\n${text}\n`;
47
+ export const VSCODE_INSTRUCTIONS = withApplyToAll(COPILOT_INSTRUCTIONS);
48
+ function isHipposVscodeInstructions(text) {
49
+ return SHIPPED_COPILOT_INSTRUCTIONS.some((shipped) => text === withApplyToAll(shipped));
50
+ }
51
+ function profileDirs(userDir) {
52
+ const dir = path.join(userDir, 'profiles');
53
+ if (!fs.existsSync(dir))
54
+ return [];
55
+ return fs.readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => path.join(dir, e.name)).sort();
56
+ }
26
57
  export function copilotPaths() {
27
58
  const home = copilotHomeDir();
28
59
  return {
29
60
  hooks: resolveJsonHookPaths('copilot').settings,
30
61
  mcpConfig: path.join(home, 'mcp-config.json'),
31
62
  instructions: path.join(home, 'copilot-instructions.md'),
63
+ vscode: vscodeUserDirs().map((userDir) => ({
64
+ userDir,
65
+ mcpConfig: path.join(userDir, 'mcp.json'),
66
+ instructions: path.join(userDir, 'prompts', 'hippo.instructions.md'),
67
+ profiles: profileDirs(userDir),
68
+ })),
32
69
  };
33
70
  }
34
71
  /** Windows gets npm's .cmd shim, as the Codex hooks do, since a PowerShell execution policy can block hippo.ps1. */
35
- function hippoMcpServer() {
36
- return { type: 'local', command: process.platform === 'win32' ? 'hippo.cmd' : 'hippo', args: ['mcp'], env: {}, tools: ['*'] };
72
+ function hippoCommand() {
73
+ return process.platform === 'win32' ? 'hippo.cmd' : 'hippo';
37
74
  }
38
- /** Only a server hippo wrote counts as hippo's: a user's own "hippo" key with another command or args is never touched. */
39
- function isHipposServer(entry) {
40
- return isJsonObject(entry) && (entry.command === 'hippo' || entry.command === 'hippo.cmd') && isDeepStrictEqual(entry.args, ['mcp']);
75
+ /** The Copilot CLI's mcp-config.json keeps servers under "mcpServers", each with an env and a tool list. */
76
+ export const COPILOT_CLI_MCP = { key: 'mcpServers', server: () => ({ type: 'local', command: hippoCommand(), args: ['mcp'], env: {}, tools: ['*'] }) };
77
+ /** VS Code's mcp.json keeps stdio servers under "servers"; it has no "mcpServers" key. */
78
+ export const VSCODE_MCP = { key: 'servers', server: () => ({ type: 'stdio', command: hippoCommand(), args: ['mcp'] }) };
79
+ /** Only a server hippo wrote counts as hippo's: another command, other args, or any key or value setup does not write (an env you added) makes it the user's. */
80
+ function isHipposServer(entry, host) {
81
+ if (!isJsonObject(entry) || !('command' in entry) || !('args' in entry))
82
+ return false;
83
+ const ours = host.server();
84
+ return Object.entries(entry).every(([key, value]) => (key === 'command' ? value === 'hippo' || value === 'hippo.cmd' : isDeepStrictEqual(value, ours[key])));
41
85
  }
42
- /** What to paste under "mcpServers" when hippo cannot edit the file itself. */
43
- export function copilotMcpSnippet() {
44
- return `"${MCP_KEY}": ${JSON.stringify(hippoMcpServer())}`;
86
+ /** What to paste under the host's key when hippo cannot edit the file itself. */
87
+ export function copilotMcpSnippet(host = COPILOT_CLI_MCP) {
88
+ return `"${MCP_KEY}": ${JSON.stringify(host.server())}`;
45
89
  }
46
90
  /** Null for a file with comments, one that is not JSON, or one whose shape a merge would overwrite; a missing file reads as empty. */
47
- function readMcpConfig(file) {
91
+ function readMcpConfig(file, host) {
48
92
  let config = {};
49
93
  if (fs.existsSync(file)) {
50
94
  try {
@@ -52,45 +96,47 @@ function readMcpConfig(file) {
52
96
  }
53
97
  catch (err) {
54
98
  // Only a parse failure means the text is not JSON hippo can merge; EACCES or EISDIR is thrown with its own message.
55
- if (err instanceof SyntaxError)
99
+ if (!(err instanceof SyntaxError))
100
+ throw err;
101
+ // An empty file is a new one with no servers yet, not one with comments.
102
+ if (fs.readFileSync(file, 'utf8').trim() !== '')
56
103
  return null;
57
- throw err;
58
104
  }
59
105
  }
60
106
  if (!isJsonObject(config))
61
107
  return null;
62
- if (config.mcpServers === undefined)
63
- config.mcpServers = {};
64
- const servers = config.mcpServers;
108
+ if (config[host.key] === undefined)
109
+ config[host.key] = {};
110
+ const servers = config[host.key];
65
111
  return isJsonObject(servers) ? { config, servers } : null;
66
112
  }
67
113
  /** Adds hippo's server and keeps every other key; a file hippo cannot parse, or a "hippo" key it did not write, stays as it is. */
68
- export function mergeMcpServer(file) {
69
- const read = readMcpConfig(file);
114
+ export function mergeMcpServer(file, host = COPILOT_CLI_MCP) {
115
+ const read = readMcpConfig(file, host);
70
116
  if (read === null)
71
117
  return 'unreadable';
72
118
  const existing = read.servers[MCP_KEY];
73
119
  if (existing !== undefined)
74
- return isHipposServer(existing) ? 'present' : 'user-owned';
75
- read.servers[MCP_KEY] = hippoMcpServer();
120
+ return isHipposServer(existing, host) ? 'present' : 'user-owned';
121
+ read.servers[MCP_KEY] = host.server();
76
122
  writeSettingsFile(file, read.config);
77
123
  return 'added';
78
124
  }
79
125
  /** Removes the "hippo" key only when hippo wrote it. */
80
- export function removeMcpServer(file) {
126
+ export function removeMcpServer(file, host = COPILOT_CLI_MCP) {
81
127
  if (!fs.existsSync(file))
82
128
  return 'absent';
83
- const read = readMcpConfig(file);
129
+ const read = readMcpConfig(file, host);
84
130
  if (read === null)
85
131
  return 'unreadable';
86
132
  const existing = read.servers[MCP_KEY];
87
133
  if (existing === undefined)
88
134
  return 'absent';
89
- if (!isHipposServer(existing))
135
+ if (!isHipposServer(existing, host))
90
136
  return 'user-owned';
91
137
  delete read.servers[MCP_KEY];
92
138
  if (Object.keys(read.servers).length === 0)
93
- delete read.config.mcpServers;
139
+ delete read.config[host.key];
94
140
  writeSettingsFile(file, read.config);
95
141
  return 'removed';
96
142
  }
@@ -100,7 +146,7 @@ export function isMcpFailure(status) {
100
146
  function hasErrnoCode(err) {
101
147
  return 'code' in err && typeof err.code === 'string';
102
148
  }
103
- /** One mcp-config.json step; a filesystem error comes back as a failure, so the hooks file and the block still install. */
149
+ /** One MCP config step; a filesystem error comes back as a failure, so the hooks file and the instructions still install. */
104
150
  function mcpStep(step) {
105
151
  try {
106
152
  return step();
@@ -111,9 +157,8 @@ function mcpStep(step) {
111
157
  throw err;
112
158
  }
113
159
  }
114
- // A later release adds the hash of each earlier Copilot text here, as SHIPPED_HOOK_HASHES does for the other agents.
115
160
  function isCopilotBlock(inner) {
116
- return inner === COPILOT_INSTRUCTIONS;
161
+ return SHIPPED_COPILOT_INSTRUCTIONS.includes(inner);
117
162
  }
118
163
  /** Replaces only the text between the markers, in the file's own line ending, or appends a block after the user's text. */
119
164
  function withCopilotBlock(text, found) {
@@ -166,13 +211,49 @@ export function removeInstructionsBlock(file) {
166
211
  fs.writeFileSync(file, left, 'utf8');
167
212
  return 'removed';
168
213
  }
214
+ /** The file is hippo's alone, so it is written whole; an earlier hippo text is upgraded, and any other text already there stays. */
215
+ export function ensureVscodeInstructions(file) {
216
+ if (fs.existsSync(file)) {
217
+ const old = fs.readFileSync(file, 'utf8');
218
+ if (old === VSCODE_INSTRUCTIONS)
219
+ return 'present';
220
+ if (!isHipposVscodeInstructions(old))
221
+ return 'kept';
222
+ }
223
+ fs.mkdirSync(path.dirname(file), { recursive: true });
224
+ fs.writeFileSync(file, VSCODE_INSTRUCTIONS, 'utf8');
225
+ return 'written';
226
+ }
227
+ /** Deletes the file only when it still holds exactly what a hippo release wrote. */
228
+ export function removeVscodeInstructions(file) {
229
+ if (!fs.existsSync(file))
230
+ return 'absent';
231
+ if (!isHipposVscodeInstructions(fs.readFileSync(file, 'utf8')))
232
+ return 'kept';
233
+ fs.rmSync(file);
234
+ return 'removed';
235
+ }
236
+ /** The CLI's folder holds more than the hooks folder setup makes for VS Code, so a rerun on a VS Code-only machine still finds no CLI. */
237
+ export function isCopilotCliPresent(home = copilotHomeDir()) {
238
+ if (fs.statSync(home, { throwIfNoEntry: false })?.isDirectory() !== true)
239
+ return false;
240
+ const entries = fs.readdirSync(home);
241
+ return entries.length !== 1 || entries[0] !== 'hooks';
242
+ }
169
243
  export function installCopilot() {
170
244
  const paths = copilotPaths();
245
+ // Read before the hooks install, which makes the folder on a machine with only VS Code.
246
+ const cli = isCopilotCliPresent();
171
247
  return {
172
248
  paths,
173
249
  hooks: installJsonHooks('copilot').installedSessionStart,
174
- mcp: mcpStep(() => mergeMcpServer(paths.mcpConfig)),
175
- instructions: ensureInstructionsBlock(paths.instructions),
250
+ mcp: cli ? mcpStep(() => mergeMcpServer(paths.mcpConfig)) : null,
251
+ instructions: cli ? ensureInstructionsBlock(paths.instructions) : null,
252
+ vscode: paths.vscode.map((dir) => ({
253
+ paths: dir,
254
+ mcp: mcpStep(() => mergeMcpServer(dir.mcpConfig, VSCODE_MCP)),
255
+ instructions: ensureVscodeInstructions(dir.instructions),
256
+ })),
176
257
  };
177
258
  }
178
259
  export function uninstallCopilot() {
@@ -182,6 +263,11 @@ export function uninstallCopilot() {
182
263
  hooks: uninstallJsonHooks('copilot'),
183
264
  mcp: mcpStep(() => removeMcpServer(paths.mcpConfig)),
184
265
  instructions: removeInstructionsBlock(paths.instructions),
266
+ vscode: paths.vscode.map((dir) => ({
267
+ paths: dir,
268
+ mcp: mcpStep(() => removeMcpServer(dir.mcpConfig, VSCODE_MCP)),
269
+ instructions: removeVscodeInstructions(dir.instructions),
270
+ })),
185
271
  };
186
272
  }
187
273
  //# sourceMappingURL=copilot.js.map
@@ -63,6 +63,8 @@ export interface InstallResult {
63
63
  }
64
64
  /** The paths for `target` under the current home directory; reads and writes nothing. */
65
65
  export declare function resolveJsonHookPaths(target: JsonHookTarget): JsonHookPaths;
66
+ /** The events in hippo's Copilot hooks file, in file order, for setup to name. */
67
+ export declare function copilotHookEvents(): string[];
66
68
  /** Adds hippo's hooks to `target`'s settings file and migrates older hippo entries; a file that is not JSON stays untouched. */
67
69
  export declare function installJsonHooks(target: JsonHookTarget): InstallResult;
68
70
  /** Swaps the file in with one rename, so a crash mid-write cannot leave a truncated settings.json that Claude Code cannot parse. */
@@ -35,7 +35,7 @@
35
35
  */
36
36
  import * as fs from 'fs';
37
37
  import * as path from 'path';
38
- import { isJsonObject, HIPPO_SLEEP_MARKER, HIPPO_LAST_SLEEP_MARKER, HIPPO_CAPTURE_MARKER, HIPPO_SESSION_END_MARKER, HIPPO_PINNED_INJECT_MARKER, HIPPO_PINNED_INJECT_COMMAND, HIPPO_PRE_COMPACT_MARKER, HIPPO_COMPACT_RESUME_MARKER, HIPPO_CAPTURE_ERROR_MARKER, HIPPO_POST_COMPACT_MARKER, homeDir, claudeConfigDir, codexHomeDir, copilotHomeDir, defaultPreCompactLogPath } from './shared.js';
38
+ import { isJsonObject, HIPPO_SLEEP_MARKER, HIPPO_LAST_SLEEP_MARKER, HIPPO_CAPTURE_MARKER, HIPPO_SESSION_END_MARKER, HIPPO_PINNED_INJECT_MARKER, HIPPO_PINNED_INJECT_COMMAND, HIPPO_PRE_COMPACT_MARKER, HIPPO_COMPACT_RESUME_MARKER, HIPPO_CAPTURE_ERROR_MARKER, HIPPO_POST_COMPACT_MARKER, homeDir, claudeConfigDir, codexHomeDir, copilotHooksFile, defaultPreCompactLogPath } from './shared.js';
39
39
  import { isJsonString, readJsonFile } from '../json.js';
40
40
  import { escapeRegex } from '../escape.js';
41
41
  import { errorMessage, log } from '../log.js';
@@ -58,7 +58,7 @@ export function resolveJsonHookPaths(target) {
58
58
  };
59
59
  case 'copilot':
60
60
  return {
61
- settings: path.join(copilotHomeDir(), 'hooks', 'hippo.json'),
61
+ settings: copilotHooksFile(),
62
62
  logFile: path.join(logsDir, 'copilot-sleep.log'),
63
63
  display: 'Copilot',
64
64
  };
@@ -214,10 +214,19 @@ function copilotHooksTable() {
214
214
  sessionStart: copilotCommandHook('context --pinned-only --include-recent 5 --format copilot', 10),
215
215
  postToolUseFailure: copilotCommandHook('capture-error --runtime copilot', 10),
216
216
  preCompact: copilotCommandHook('pre-compact --runtime copilot', 30),
217
+ // VS Code maps no camelCase preCompact. The Copilot CLI may run both names, so pre-compact skips a snapshot saved seconds before.
218
+ PreCompact: copilotCommandHook('pre-compact --runtime copilot', 30),
219
+ // VS Code's per-reply Stop. --turn acts on a VS Code payload only, so the Copilot CLI's agentStop does nothing.
220
+ agentStop: copilotCommandHook('session-end --runtime copilot --turn', 30),
217
221
  sessionEnd: copilotCommandHook('session-end --runtime copilot', 30),
218
222
  },
219
223
  };
220
224
  }
225
+ /** The events in hippo's Copilot hooks file, in file order, for setup to name. */
226
+ export function copilotHookEvents() {
227
+ const hooks = copilotHooksTable().hooks;
228
+ return isJsonObject(hooks) ? Object.keys(hooks) : [];
229
+ }
221
230
  /** Hippo owns this whole file, so install writes the full table and a second install changes no byte. */
222
231
  function installCopilotHooks(settingsPath) {
223
232
  const result = nothingInstalled('copilot', settingsPath);
@@ -30,7 +30,13 @@ export declare function copilotHomeDir(home?: string, env?: Readonly<Record<stri
30
30
  export declare function claudeConfigDir(home?: string, env?: Readonly<Record<string, string | undefined>>): string;
31
31
  /** Codex counts as installed only when its config folder exists: Codex itself refuses a CODEX_HOME that is not a folder. */
32
32
  export declare function isCodexPresent(home?: string): boolean;
33
- /** Copilot counts as installed only when its config folder exists, so setup never creates ~/.copilot on a machine without it. */
33
+ /** hippo's own hooks file, in the hooks folder the Copilot CLI reads. */
34
+ export declare function copilotHooksFile(): string;
35
+ /** Where VS Code's agent finds hippo.json: its docs name ~/.copilot/hooks as the user hooks folder, whatever COPILOT_HOME says. */
36
+ export declare function vscodeUserHooksFile(home?: string): string;
37
+ /** The VS Code User folders that exist; Stable and Insiders keep their own, and only an edition the user has run has one. */
38
+ export declare function vscodeUserDirs(env?: Readonly<Record<string, string | undefined>>, home?: string): string[];
39
+ /** Copilot counts as installed only when its config folder or a VS Code User folder exists, so setup never creates ~/.copilot on a machine with neither. */
34
40
  export declare function isCopilotPresent(home?: string): boolean;
35
41
  /** Codex hashes each hook and skips new or changed ones until the user reviews them in `/hooks`, so the reminder says what they would trust. */
36
42
  export declare const CODEX_TRUST_LINE = "The per-prompt hook sends your pinned memories plus up to 5 that match the prompt. Codex runs hippo's hooks only after you trust them once in `/hooks`.";
@@ -2,6 +2,7 @@ import { envHomeDir, processEnv } from '../env.js';
2
2
  import * as fs from 'fs';
3
3
  import * as os from 'os';
4
4
  import * as path from 'path';
5
+ import { vscodeDataFolders } from '../agent-memories/copilot.js';
5
6
  /** JSON-value plain-object check (excludes arrays and null); `constructor` avoids the `typeof` tag banned by anti-slop/no-runtime-typeof. */
6
7
  export function isJsonObject(value) {
7
8
  return value !== undefined && value !== null && !Array.isArray(value) && value.constructor === Object;
@@ -36,9 +37,23 @@ export function claudeConfigDir(home = os.homedir(), env = processEnv()) {
36
37
  export function isCodexPresent(home = homeDir()) {
37
38
  return fs.statSync(codexHomeDir(home), { throwIfNoEntry: false })?.isDirectory() === true;
38
39
  }
39
- /** Copilot counts as installed only when its config folder exists, so setup never creates ~/.copilot on a machine without it. */
40
+ /** hippo's own hooks file, in the hooks folder the Copilot CLI reads. */
41
+ export function copilotHooksFile() {
42
+ return path.join(copilotHomeDir(), 'hooks', 'hippo.json');
43
+ }
44
+ /** Where VS Code's agent finds hippo.json: its docs name ~/.copilot/hooks as the user hooks folder, whatever COPILOT_HOME says. */
45
+ export function vscodeUserHooksFile(home = os.homedir()) {
46
+ return path.join(home, '.copilot', 'hooks', 'hippo.json');
47
+ }
48
+ /** The VS Code User folders that exist; Stable and Insiders keep their own, and only an edition the user has run has one. */
49
+ export function vscodeUserDirs(env = processEnv(), home = os.homedir()) {
50
+ return vscodeDataFolders({ env, home, platform: process.platform })
51
+ .map((dir) => path.join(dir, 'User'))
52
+ .filter((dir) => fs.statSync(dir, { throwIfNoEntry: false })?.isDirectory() === true);
53
+ }
54
+ /** Copilot counts as installed only when its config folder or a VS Code User folder exists, so setup never creates ~/.copilot on a machine with neither. */
40
55
  export function isCopilotPresent(home = os.homedir()) {
41
- return fs.statSync(copilotHomeDir(home), { throwIfNoEntry: false })?.isDirectory() === true;
56
+ return fs.statSync(copilotHomeDir(home), { throwIfNoEntry: false })?.isDirectory() === true || vscodeUserDirs(processEnv(), home).length > 0;
42
57
  }
43
58
  /** Codex hashes each hook and skips new or changed ones until the user reviews them in `/hooks`, so the reminder says what they would trust. */
44
59
  export const CODEX_TRUST_LINE = "The per-prompt hook sends your pinned memories plus up to 5 that match the prompt. Codex runs hippo's hooks only after you trust them once in `/hooks`.";
@@ -76,7 +91,7 @@ export function detectInstalledTools() {
76
91
  { name: 'opencode', configDir: '~/.config/opencode', detected: exists('.config', 'opencode'), kind: 'plugin', notes: 'installs a TS plugin at ~/.config/opencode/plugins/hippo.ts' },
77
92
  { name: 'openclaw', configDir: '~/.openclaw', detected: exists('.openclaw'), kind: 'plugin', notes: 'install via `openclaw plugins install hippo-memory`' },
78
93
  { name: 'codex', configDir: '~/.codex', detected: isCodexPresent(home), kind: 'wrapper', notes: 'memory hooks in hooks.json, and wraps the detected codex launcher for session-end consolidation' },
79
- { name: 'copilot', configDir: copilotHomeDir(), detected: isCopilotPresent(), kind: 'json-hook', notes: 'hooks in hooks/hippo.json, the MCP server in mcp-config.json and a block in copilot-instructions.md' },
94
+ { name: 'copilot', configDir: copilotHomeDir(), detected: isCopilotPresent(), kind: 'json-hook', notes: 'hooks in hooks/hippo.json, the MCP server in mcp-config.json and a block in copilot-instructions.md; for VS Code, the server in each User mcp.json and prompts/hippo.instructions.md' },
80
95
  { name: 'cursor', configDir: '~/.cursor', detected: exists('.cursor'), kind: 'markdown-instruction', notes: 'no hook API - patches AGENTS.md in the project' },
81
96
  { name: 'pi', configDir: '~/.pi', detected: exists('.pi'), kind: 'markdown-instruction', notes: 'no hook API - patches AGENTS.md in the project' },
82
97
  ];
@@ -1,5 +1,5 @@
1
1
  import { type ToolCall } from './protocol.js';
2
- export declare function runRememberTool({ args, ctx, hippoRoot, config, tenantId }: ToolCall): string;
3
- export declare function runOutcomeTool({ args, ctx, hippoRoot, tenantId }: ToolCall): string;
2
+ export declare function runRememberTool({ args, ctx, hippoRoot, config, tenantId }: ToolCall): Promise<string>;
3
+ export declare function runOutcomeTool({ args, ctx, hippoRoot, tenantId }: ToolCall): Promise<string>;
4
4
  export declare function runLearnTool({ args, ctx, hippoRoot, tenantId }: ToolCall): string;
5
5
  //# sourceMappingURL=memory-tools.d.ts.map
@@ -1,6 +1,6 @@
1
1
  // Write-side tool handlers: hippo_remember, hippo_outcome and hippo_learn.
2
2
  import { log } from '../log.js';
3
- import { readEntry } from '../store/entry-reads.js';
3
+ import { storeFor } from '../store-port.js';
4
4
  import { countCreatedSinceLastSleep } from '../store/index-and-stats.js';
5
5
  import { consolidate } from '../consolidate/sleep.js';
6
6
  import { outsideRequestStores, runWithRequestStores, scopedBusyWait } from '../db.js';
@@ -8,7 +8,7 @@ import { resolveTenantId } from '../tenant.js';
8
8
  import { remember as apiRemember, outcome as apiOutcome, learn as apiLearn, MCP_LEARN } from '../api.js';
9
9
  import { mcpActor } from './protocol.js';
10
10
  import { lastRecalledIds, autoSleepInFlight, resolveClientKey } from './session-state.js';
11
- export function runRememberTool({ args, ctx, hippoRoot, config, tenantId }) {
11
+ export async function runRememberTool({ args, ctx, hippoRoot, config, tenantId }) {
12
12
  const text = String(args.text || '');
13
13
  if (!text)
14
14
  return 'No text provided.';
@@ -28,16 +28,17 @@ export function runRememberTool({ args, ctx, hippoRoot, config, tenantId }) {
28
28
  actor: mcpActor(ctx),
29
29
  store: ctx?.store,
30
30
  };
31
- const result = apiRemember(apiCtx, {
31
+ const result = await apiRemember(apiCtx, {
32
32
  content: text,
33
33
  tags,
34
34
  personal: args.personal === true,
35
35
  project: ctx?.project,
36
36
  });
37
- const entry = readEntry(hippoRoot, result.id, tenantId);
37
+ const [entry] = await storeFor(apiCtx).entriesByIds([result.id], tenantId);
38
38
  // Auto-sleep: one run per store at a time, triggered by what arrived since the last one.
39
- // Consolidation is host-wide, so only the host tenant's writes may start it.
40
- if (ctx?.autoSleep !== false &&
39
+ // Consolidation is host-wide, so only the host tenant's writes may start it. It runs on hippo.db, so another store skips it.
40
+ if ((ctx?.store === undefined || ctx.store.kind === 'sqlite') &&
41
+ ctx?.autoSleep !== false &&
41
42
  config.autoSleep.enabled &&
42
43
  tenantId === resolveTenantId({}) &&
43
44
  !autoSleepInFlight.has(hippoRoot) &&
@@ -57,7 +58,7 @@ export function runRememberTool({ args, ctx, hippoRoot, config, tenantId }) {
57
58
  const warnings = (result.warnings ?? []).map((w) => `\nWarning: ${w}`).join('');
58
59
  return `Remembered [${result.id}] (half-life: ${halfLife}d, tags: ${tagStr})${warnings}`;
59
60
  }
60
- export function runOutcomeTool({ args, ctx, hippoRoot, tenantId }) {
61
+ export async function runOutcomeTool({ args, ctx, hippoRoot, tenantId }) {
61
62
  const good = Boolean(args.good);
62
63
  const clientKey = resolveClientKey(ctx);
63
64
  const ids = lastRecalledIds.get(clientKey) ?? [];
@@ -73,7 +74,7 @@ export function runOutcomeTool({ args, ctx, hippoRoot, tenantId }) {
73
74
  actor: mcpActor(ctx),
74
75
  store: ctx?.store,
75
76
  };
76
- const { applied } = apiOutcome(apiCtx, ids, good);
77
+ const { applied } = await apiOutcome(apiCtx, ids, good);
77
78
  return `Applied ${good ? 'positive' : 'negative'} outcome to ${applied} memories`;
78
79
  }
79
80
  export function runLearnTool({ args, ctx, hippoRoot, tenantId }) {
@@ -64,9 +64,9 @@ const TOOL_HANDLERS = new Map([
64
64
  ['hippo_assemble', { handler: runAssembleTool }],
65
65
  ['hippo_drill', { handler: runDrillTool }],
66
66
  ['hippo_predict_baserate', { handler: runPredictBaserateTool }],
67
- ['hippo_remember', { handler: runRememberTool }],
68
- ['hippo_outcome', { handler: runOutcomeTool }],
69
- ['hippo_context', { handler: runContextTool }],
67
+ ['hippo_remember', { handler: runRememberTool, storeReady: 'entryWrites' }],
68
+ ['hippo_outcome', { handler: runOutcomeTool, storeReady: 'entryWrites' }],
69
+ ['hippo_context', { handler: runContextTool, storeReady: 'contextReads' }],
70
70
  ['hippo_status', { handler: runStatusTool }],
71
71
  ['hippo_learn', { handler: runLearnTool }],
72
72
  ['hippo_conflicts', { handler: runConflictsTool }],
package/dist/memory.d.ts CHANGED
@@ -190,6 +190,8 @@ export declare function deriveHalfLife(base: number, entry: Partial<MemoryEntry>
190
190
  * cumulative reward signal, inspired by R-STDP in spiking networks.
191
191
  */
192
192
  export declare function applyOutcome(entry: MemoryEntry, good: boolean): MemoryEntry;
193
+ /** applyOutcome as a served outcome applies it: a good outcome reconfirms the entry, so its churn-stale tag goes. */
194
+ export declare function entryAfterOutcome(entry: MemoryEntry, good: boolean): MemoryEntry;
193
195
  /**
194
196
  * Generate a random memory ID using crypto.randomUUID().
195
197
  */
package/dist/memory.js CHANGED
@@ -277,6 +277,13 @@ export function applyOutcome(entry, good) {
277
277
  updated.strength = calculateStrength(updated);
278
278
  return updated;
279
279
  }
280
+ /** applyOutcome as a served outcome applies it: a good outcome reconfirms the entry, so its churn-stale tag goes. */
281
+ export function entryAfterOutcome(entry, good) {
282
+ const updated = applyOutcome(entry, good);
283
+ if (!good || !updated.tags.includes(CHURN_STALE_TAG))
284
+ return updated;
285
+ return { ...updated, tags: updated.tags.filter((t) => t !== CHURN_STALE_TAG) };
286
+ }
280
287
  /**
281
288
  * Generate a random memory ID using crypto.randomUUID().
282
289
  */
@@ -30,4 +30,9 @@ export declare const RAREST_TERM_COUNT = 8;
30
30
  /** Terms sorted by ascending FTS doc count (rarest first), zero-count terms dropped, ties by term.
31
31
  * Pure: `docCount` (an FTS lookup in the caller) does the only I/O. */
32
32
  export declare function rarestPromptTerms(terms: Iterable<string>, docCount: (term: string) => number, maxTerms?: number): string[];
33
+ /** The pieces FTS5's unicode61 tokenizer indexes a term as, so `journal_mode` is `journal` and `mode`. */
34
+ export declare function ftsTermParts(term: string): string[];
35
+ /** The rarest terms as a space-joined FTS query, given each part's document count; a term counts as its rarest part,
36
+ * an upper bound on rows holding the whole term, and a term with no parts counts 0, so it is dropped. */
37
+ export declare function rarestFtsQuery(terms: readonly string[], docCount: (part: string) => number, maxTerms?: number): string;
33
38
  //# sourceMappingURL=prompt-recall.d.ts.map
@@ -64,4 +64,17 @@ export function rarestPromptTerms(terms, docCount, maxTerms = RAREST_TERM_COUNT)
64
64
  .slice(0, maxTerms)
65
65
  .map((x) => x.t);
66
66
  }
67
+ /** The pieces FTS5's unicode61 tokenizer indexes a term as, so `journal_mode` is `journal` and `mode`. */
68
+ export function ftsTermParts(term) {
69
+ return term.split(/[^\p{L}\p{N}]+/u).filter(Boolean);
70
+ }
71
+ /** The rarest terms as a space-joined FTS query, given each part's document count; a term counts as its rarest part,
72
+ * an upper bound on rows holding the whole term, and a term with no parts counts 0, so it is dropped. */
73
+ export function rarestFtsQuery(terms, docCount, maxTerms = RAREST_TERM_COUNT) {
74
+ const termCount = (t) => {
75
+ const parts = ftsTermParts(t);
76
+ return parts.length === 0 ? 0 : Math.min(...parts.map(docCount));
77
+ };
78
+ return rarestPromptTerms(terms, termCount, maxTerms).join(' ');
79
+ }
67
80
  //# sourceMappingURL=prompt-recall.js.map
@@ -18,7 +18,7 @@ export interface ArchiveOpts {
18
18
  * append-only trigger lets the delete through, then deletes the row. All in one
19
19
  * write scope, which nests inside an outer transaction (e.g. batchWriteAndDelete).
20
20
  *
21
- * Throws if the row does not exist or is not `kind='raw'`.
21
+ * Throws if the row does not exist or is not `kind='raw'`. Returns the archived_at it wrote.
22
22
  */
23
- export declare function archiveRawMemory(db: DatabaseSyncLike, id: string, opts: ArchiveOpts): void;
23
+ export declare function archiveRawMemory(db: DatabaseSyncLike, id: string, opts: ArchiveOpts): string;
24
24
  //# sourceMappingURL=raw-archive.d.ts.map
@@ -34,6 +34,7 @@ function moveRowToArchive(db, id, row, opts) {
34
34
  // Archived text must be unsearchable at commit, so a failed purge fails (and rolls back) the whole archive.
35
35
  if (isFtsAvailable(db))
36
36
  db.prepare(`DELETE FROM memories_fts WHERE id = ?`).run(id);
37
+ return archivedAt;
37
38
  }
38
39
  // Emit the archive_raw audit event inside the SAVEPOINT so the audit row is
39
40
  // committed atomically with the row deletion. Use the row's own tenant_id
@@ -62,14 +63,14 @@ function auditArchive(db, id, row, opts) {
62
63
  * append-only trigger lets the delete through, then deletes the row. All in one
63
64
  * write scope, which nests inside an outer transaction (e.g. batchWriteAndDelete).
64
65
  *
65
- * Throws if the row does not exist or is not `kind='raw'`.
66
+ * Throws if the row does not exist or is not `kind='raw'`. Returns the archived_at it wrote.
66
67
  */
67
68
  export function archiveRawMemory(db, id, opts) {
68
69
  const row = loadRawRow(db, id);
69
70
  // A SAVEPOINT when already inside a transaction (e.g. batchWriteAndDelete), so a throw here
70
71
  // rolls back only this archive.
71
- withWriteScope(db, 'archive_raw', () => {
72
- moveRowToArchive(db, id, row, opts);
72
+ return withWriteScope(db, 'archive_raw', () => {
73
+ const archivedAt = moveRowToArchive(db, id, row, opts);
73
74
  auditArchive(db, id, row, opts);
74
75
  // Archiving a child under a level-2 summary marks the parent dirty, inside the
75
76
  // SAVEPOINT so the dirty-mark commits atomically with the archive.
@@ -84,6 +85,7 @@ export function archiveRawMemory(db, id, opts) {
84
85
  if (opts.afterArchive) {
85
86
  opts.afterArchive(db, id);
86
87
  }
88
+ return archivedAt;
87
89
  });
88
90
  }
89
91
  //# sourceMappingURL=raw-archive.js.map
@@ -60,6 +60,8 @@ export declare function assertClientScope(scope: string | null | undefined): voi
60
60
  export declare function canTouchScope(actor: {
61
61
  owner?: string;
62
62
  }, scope: string | null): boolean;
63
+ /** canTouchScope for a caller known by its own personal scope, as a store method receives it. */
64
+ export declare function ownScopeTouches(ownScope: string | null, scope: string | null): boolean;
63
65
  /**
64
66
  * Recall-side scope filter — the canonical JS half of the recall default-deny
65
67
  * rule (the SQL half lives in `loadSearchRows` via `loadRecallSearchEntries`).
@@ -68,11 +70,6 @@ export declare function canTouchScope(actor: {
68
70
  * - When `requested` is undefined/empty: default-deny on any
69
71
  * `<source>:private:*` scope and on the `RECALL_DEFAULT_DENY_SCOPES`
70
72
  * quarantine buckets. `null` and public scopes pass, and so does `ownScope`, the caller's own personal scope.
71
- *
72
- * @internal Exported for test parity with
73
- * `RECALL_DEFAULT_DENY_SCOPES` (single-source-of-truth verification). NOT part
74
- * of the public API surface; not re-exported from `src/index.ts`. Subject to
75
- * change without semver bump.
76
73
  */
77
74
  export declare function passesScopeFilterForRecall(scope: string | null, requested: string | undefined, ownScope?: string | null): boolean;
78
75
  export interface SqlFragment {
@@ -83,7 +83,11 @@ export function assertClientScope(scope) {
83
83
  }
84
84
  /** True when `actor` may change or delete a row in `scope`: any non-personal scope, or its own personal one. */
85
85
  export function canTouchScope(actor, scope) {
86
- return !isPersonalScope(scope) || scope === personalScopeOf(actor);
86
+ return ownScopeTouches(personalScopeOf(actor), scope);
87
+ }
88
+ /** canTouchScope for a caller known by its own personal scope, as a store method receives it. */
89
+ export function ownScopeTouches(ownScope, scope) {
90
+ return !isPersonalScope(scope) || scope === ownScope;
87
91
  }
88
92
  /**
89
93
  * Recall-side scope filter — the canonical JS half of the recall default-deny
@@ -93,11 +97,6 @@ export function canTouchScope(actor, scope) {
93
97
  * - When `requested` is undefined/empty: default-deny on any
94
98
  * `<source>:private:*` scope and on the `RECALL_DEFAULT_DENY_SCOPES`
95
99
  * quarantine buckets. `null` and public scopes pass, and so does `ownScope`, the caller's own personal scope.
96
- *
97
- * @internal Exported for test parity with
98
- * `RECALL_DEFAULT_DENY_SCOPES` (single-source-of-truth verification). NOT part
99
- * of the public API surface; not re-exported from `src/index.ts`. Subject to
100
- * change without semver bump.
101
100
  */
102
101
  export function passesScopeFilterForRecall(scope, requested, ownScope) {
103
102
  if (requested !== undefined && requested !== '') {
@@ -29,7 +29,7 @@ export async function handleCreateMemory({ req, res, opts }) {
29
29
  if (personalRaw !== undefined && !isJsonBoolean(personalRaw)) {
30
30
  throw new HttpError(400, 'personal must be a boolean');
31
31
  }
32
- const result = remember(ctx, {
32
+ const result = await remember(ctx, {
33
33
  content,
34
34
  kind: kindRaw,
35
35
  scope: getString(body, 'scope'),
@@ -69,7 +69,7 @@ export async function handleArchiveMemory({ req, res, opts }, archiveMatch) {
69
69
  if (!reason) {
70
70
  throw new HttpError(400, 'reason is required');
71
71
  }
72
- const result = archiveRaw(ctx, archiveMatch.id, reason);
72
+ const result = await archiveRaw(ctx, archiveMatch.id, reason);
73
73
  sendJson(res, 200, result);
74
74
  return;
75
75
  }
@@ -81,7 +81,7 @@ export async function handleSupersedeMemory({ req, res, opts }, supersedeMatch)
81
81
  if (!content) {
82
82
  throw new HttpError(400, 'content is required');
83
83
  }
84
- const result = supersede(ctx, supersedeMatch.id, content);
84
+ const result = await supersede(ctx, supersedeMatch.id, content);
85
85
  sendJson(res, 200, result);
86
86
  return;
87
87
  }
@@ -95,7 +95,7 @@ export async function handlePromoteMemory({ req, res, opts }, promoteMatch) {
95
95
  export async function handleForgetMemory({ req, res, opts }, idMatch) {
96
96
  validateIdSegment(idMatch.id, 'memory id');
97
97
  const ctx = await buildContextWithAuth(req, opts);
98
- const result = forget(ctx, idMatch.id);
98
+ const result = await forget(ctx, idMatch.id);
99
99
  sendJson(res, 200, result);
100
100
  return;
101
101
  }
@@ -132,11 +132,14 @@ export async function handleApplyOutcome({ req, res, opts }) {
132
132
  ids = idsRaw;
133
133
  }
134
134
  if (ids !== undefined) {
135
- const { applied } = outcome(ctx, ids, good);
135
+ const { applied } = await outcome(ctx, ids, good);
136
136
  sendJson(res, 200, { applied });
137
137
  }
138
138
  else {
139
- const result = outcomeForLastRecall(ctx, good);
139
+ // The last recall's ids live in hippo.db alone, so a client of another store must send the ids its recall returned.
140
+ if (ctx.store !== undefined && ctx.store.kind !== 'sqlite')
141
+ throw new HttpError(501, 'this store keeps no last recall: send the ids your recall returned');
142
+ const result = await outcomeForLastRecall(ctx, good);
140
143
  sendJson(res, 200, result);
141
144
  }
142
145
  return;