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
@@ -4,8 +4,10 @@
4
4
  * Falls back silently if the library is not installed.
5
5
  */
6
6
  import { MemoryEntry } from './memory.js';
7
+ import { type DatabaseSyncLike } from './db.js';
7
8
  import { EMBEDDING_MODEL_META_KEY } from './vector-store.js';
8
9
  import { type EmbeddingProvider } from './embedding-provider.js';
10
+ import type { HippoStore } from './store-port.js';
9
11
  export { EMBEDDING_MODEL_META_KEY };
10
12
  /**
11
13
  * Bump whenever `embeddingInputText`'s composition changes in a way that
@@ -73,10 +75,13 @@ export interface EmbeddingIndexState {
73
75
  export declare function indexedModel(state: EmbeddingIndexState): string | null;
74
76
  /** The one rebuild rule every store applies to its indexed model. */
75
77
  export declare function indexNeedsRebuild(indexed: string | null, providerId: string): boolean;
78
+ /** Whether a vector write under the index identity `model` first drops the stored index, which another model built. */
79
+ export declare function replacesIndex(state: EmbeddingIndexState, model: string): boolean;
76
80
  export declare function resolveIndexedEmbeddingModel(hippoRoot: string, index?: Record<string, number[]>): string | null;
77
81
  export declare function embeddingModelRequiresReindex(hippoRoot: string, model: string, index?: Record<string, number[]>): boolean;
78
82
  /** hippo.db's index state, the meta row and the EXISTS on one handle. */
79
83
  export declare function embeddingIndexStateAt(hippoRoot: string): EmbeddingIndexState;
84
+ export declare function embeddingIndexStateOn(db: DatabaseSyncLike): EmbeddingIndexState;
80
85
  /**
81
86
  * Cosine similarity between two vectors. Handles unnormalized vectors.
82
87
  * Returns 0 for empty or mismatched vectors.
@@ -88,14 +93,9 @@ export declare function loadEmbeddingIndex(hippoRoot: string): Record<string, nu
88
93
  export declare function loadStoredVectors(hippoRoot: string, ids: readonly string[]): Map<string, number[]>;
89
94
  /** Replace every stored vector with `index`; `model` defaults to the stored index identity. */
90
95
  export declare function saveEmbeddingIndex(hippoRoot: string, index: Record<string, number[]>, model?: string): void;
91
- /**
92
- * Embed a single memory entry and cache the result in the embedding index.
93
- */
94
- export declare function embedMemory(hippoRoot: string, entry: MemoryEntry, model?: string): Promise<void>;
95
- /**
96
- * Embed all entries in hippoRoot that don't already have cached vectors.
97
- * Prunes orphaned embeddings for memories that no longer exist.
98
- * Returns the count of newly embedded entries. `provider` defaults to the store's configured one.
99
- */
100
- export declare function embedAll(hippoRoot: string, model?: string, provider?: EmbeddingProvider): Promise<number>;
96
+ /** Embed a single memory entry and cache the result in the embedding index; with `store`, through its vectorWrites, never opening hippo.db. */
97
+ export declare function embedMemory(hippoRoot: string, entry: MemoryEntry, model?: string, store?: HippoStore): Promise<void>;
98
+ /** Embeds every entry with no cached vector, prunes vectors of deleted memories, and returns how many it embedded; `provider` defaults to the
99
+ * configured one. With `store`, every tenant's memories with no vector for the provider's model go through its `vectorWrites`, never hippo.db. */
100
+ export declare function embedAll(hippoRoot: string, model?: string, provider?: EmbeddingProvider, store?: HippoStore): Promise<number>;
101
101
  //# sourceMappingURL=embeddings.d.ts.map
@@ -16,6 +16,7 @@ import { resolveEmbeddingProvider } from './embedding-provider.js';
16
16
  import { DEFAULT_EMBEDDING_MODEL } from './local-embedding.js';
17
17
  import { redactSecretsStrict } from './secret-detect.js';
18
18
  import { log } from './log.js';
19
+ import { StoreNotPortedError } from './db/sqlite-blocked.js';
19
20
  export { EMBEDDING_MODEL_META_KEY };
20
21
  /**
21
22
  * Bump whenever `embeddingInputText`'s composition changes in a way that
@@ -109,6 +110,11 @@ export function indexedModel(state) {
109
110
  export function indexNeedsRebuild(indexed, providerId) {
110
111
  return indexed !== null && indexed !== embeddingIndexIdentity(providerId);
111
112
  }
113
+ /** Whether a vector write under the index identity `model` first drops the stored index, which another model built. */
114
+ export function replacesIndex(state, model) {
115
+ const indexed = indexedModel(state);
116
+ return indexed !== null && indexed !== model;
117
+ }
112
118
  export function resolveIndexedEmbeddingModel(hippoRoot, index) {
113
119
  const storedModel = loadStoredEmbeddingModel(hippoRoot);
114
120
  if (storedModel)
@@ -120,10 +126,13 @@ export function embeddingModelRequiresReindex(hippoRoot, model, index) {
120
126
  }
121
127
  /** hippo.db's index state, the meta row and the EXISTS on one handle. */
122
128
  export function embeddingIndexStateAt(hippoRoot) {
123
- return withVectorDb(hippoRoot, (db) => ({
129
+ return withVectorDb(hippoRoot, embeddingIndexStateOn);
130
+ }
131
+ export function embeddingIndexStateOn(db) {
132
+ return {
124
133
  storedModel: getMeta(db, EMBEDDING_MODEL_META_KEY, '').trim() || null,
125
134
  hasVectors: hasStoredVectors(db),
126
- }));
135
+ };
127
136
  }
128
137
  async function rebuildEmbeddingIndex(entries, provider) {
129
138
  const rebuilt = {};
@@ -273,12 +282,7 @@ async function acquireEmbedFileLock(hippoRoot) {
273
282
  }
274
283
  }
275
284
  async function withEmbedLock(hippoRoot, fn) {
276
- let resolve;
277
- const next = new Promise(r => { resolve = r; });
278
- const prev = _embedWriteLock;
279
- _embedWriteLock = next;
280
- await prev;
281
- try {
285
+ return withProcessEmbedLock(async () => {
282
286
  const release = await acquireEmbedFileLock(hippoRoot);
283
287
  try {
284
288
  return await fn();
@@ -286,6 +290,17 @@ async function withEmbedLock(hippoRoot, fn) {
286
290
  finally {
287
291
  release();
288
292
  }
293
+ });
294
+ }
295
+ // A store keeps each write whole in its own transaction, and a lock file in hippoRoot would not reach a worker on another host.
296
+ async function withProcessEmbedLock(fn) {
297
+ let resolve;
298
+ const next = new Promise(r => { resolve = r; });
299
+ const prev = _embedWriteLock;
300
+ _embedWriteLock = next;
301
+ await prev;
302
+ try {
303
+ return await fn();
289
304
  }
290
305
  finally {
291
306
  resolve();
@@ -301,10 +316,79 @@ function warnEmbedFailureOnce(source, rawMessage) {
301
316
  const message = redactSecretsStrict(rawMessage).replace(/\s+/g, ' ').replace(/\.+$/, '');
302
317
  log.warn(`embedding failed (${source}): ${message}. Memories are stored without embeddings until this is fixed.`);
303
318
  }
304
- /**
305
- * Embed a single memory entry and cache the result in the embedding index.
306
- */
307
- export async function embedMemory(hippoRoot, entry, model) {
319
+ // store-port.ts imports this module, so its requireGroup would close an import cycle.
320
+ function vectorGroups(store) {
321
+ if (store.vectors === undefined)
322
+ throw new StoreNotPortedError(store.kind, 'vectors');
323
+ if (store.vectorWrites === undefined)
324
+ throw new StoreNotPortedError(store.kind, 'vectorWrites');
325
+ return [store.vectors, store.vectorWrites];
326
+ }
327
+ /** Writes one page's vectors, one writeVectors per tenant, and stops at the first refused write; `withParticles` seeds each memory's particle. */
328
+ async function writeVectorPage(writes, write, page, vectors, withParticles) {
329
+ const byTenant = new Map();
330
+ page.forEach((entry, i) => {
331
+ const vector = vectors[i];
332
+ if (!vector || vector.length === 0)
333
+ return noteSkippedEmbedding(entry.id);
334
+ const row = withParticles ? { memoryId: entry.id, vector, particle: initializeParticle(entry, vector) } : { memoryId: entry.id, vector };
335
+ byTenant.set(entry.tenantId, [...(byTenant.get(entry.tenantId) ?? []), row]);
336
+ });
337
+ let written = 0;
338
+ for (const [tenantId, rows] of byTenant) {
339
+ const result = await writes.writeVectors({ ...write, tenantId, rows });
340
+ if (result.modelMismatch)
341
+ return { written, modelMismatch: true };
342
+ written += result.written;
343
+ }
344
+ return { written, modelMismatch: false };
345
+ }
346
+ const STORE_PAGE = 64;
347
+ const OTHER_MODEL_INDEX = "the vector index was built by another embedding model; run 'hippo embed' to rebuild it";
348
+ /** Embeds every memory with no vector under `model`, page by page, so a provider failure keeps the pages written; a rebuild reseeds the particles it drops. */
349
+ async function backfillInStore(writes, provider, model, rebuild) {
350
+ let count = 0;
351
+ let afterId;
352
+ for (;;) {
353
+ const page = await writes.entriesWithoutVector({ model, afterId, limit: STORE_PAGE });
354
+ if (page.length === 0)
355
+ return count;
356
+ afterId = page[page.length - 1].id;
357
+ const vectors = await provider.embed(page.map((e) => embeddingInputText(e)), 'passage');
358
+ const result = await writeVectorPage(writes, { model, replaceIndex: rebuild }, page, vectors, rebuild);
359
+ // Another process rebuilt the index mid-run; every later page would be refused too, after paying the provider for it.
360
+ if (result.modelMismatch)
361
+ throw new Error(OTHER_MODEL_INDEX);
362
+ count += result.written;
363
+ }
364
+ }
365
+ /** embedAll on a store: a model change re-embeds every memory, and the first page written drops the old index. */
366
+ async function embedAllInStore(store, provider) {
367
+ const [reads, writes] = vectorGroups(store);
368
+ const rebuild = indexNeedsRebuild(indexedModel(await reads.embeddingIndexState()), provider.id);
369
+ return backfillInStore(writes, provider, embeddingIndexIdentity(provider.id), rebuild);
370
+ }
371
+ /** embedMemory on a store; like the hippo.db path it resolves whatever fails. Unlike it, it leaves a model change to embedAll, so a stale
372
+ * read here never drops another process's rebuild; the read only saves a paid embed call, as the write refuses another model itself. */
373
+ function embedMemoryInStore(store, provider, entry) {
374
+ return withProcessEmbedLock(async () => {
375
+ const [reads, writes] = vectorGroups(store);
376
+ const model = embeddingIndexIdentity(provider.id);
377
+ try {
378
+ const refused = replacesIndex(await reads.embeddingIndexState(), model)
379
+ || (await writeVectorPage(writes, { model, replaceIndex: false }, [entry], await provider.embed([embeddingInputText(entry)], 'passage'), true)).modelMismatch;
380
+ if (refused)
381
+ warnEmbedFailureOnce('index', OTHER_MODEL_INDEX);
382
+ }
383
+ catch (err) {
384
+ warnEmbedFailureOnce(provider.kind, err instanceof Error ? err.message : String(err));
385
+ }
386
+ }).catch((err) => {
387
+ log.warn(`skipped embedding ${entry.id} (${err instanceof Error ? err.message : String(err)})`);
388
+ });
389
+ }
390
+ /** Embed a single memory entry and cache the result in the embedding index; with `store`, through its vectorWrites, never opening hippo.db. */
391
+ export async function embedMemory(hippoRoot, entry, model, store) {
308
392
  let provider;
309
393
  try {
310
394
  provider = resolveEmbeddingProvider(hippoRoot, { model });
@@ -316,6 +400,8 @@ export async function embedMemory(hippoRoot, entry, model) {
316
400
  }
317
401
  if (!provider.isAvailable())
318
402
  return;
403
+ if (store)
404
+ return embedMemoryInStore(store, provider, entry);
319
405
  return withEmbedLock(hippoRoot, async () => {
320
406
  // embedMemory is best-effort: an embedding failure (API down / bad key / 5xx)
321
407
  // must not reject the caller's write — `getEmbedding` historically swallowed
@@ -418,16 +504,15 @@ async function backfillPending(hippoRoot, provider, pending, model) {
418
504
  }
419
505
  return { count, backfillError };
420
506
  }
421
- /**
422
- * Embed all entries in hippoRoot that don't already have cached vectors.
423
- * Prunes orphaned embeddings for memories that no longer exist.
424
- * Returns the count of newly embedded entries. `provider` defaults to the store's configured one.
425
- */
426
- export async function embedAll(hippoRoot, model, provider = resolveEmbeddingProvider(hippoRoot, { model })) {
507
+ /** Embeds every entry with no cached vector, prunes vectors of deleted memories, and returns how many it embedded; `provider` defaults to the
508
+ * configured one. With `store`, every tenant's memories with no vector for the provider's model go through its `vectorWrites`, never hippo.db. */
509
+ export async function embedAll(hippoRoot, model, provider = resolveEmbeddingProvider(hippoRoot, { model }), store) {
427
510
  if (!provider.isAvailable()) {
428
511
  throwIfProviderKeyMissing(hippoRoot, provider);
429
512
  return 0;
430
513
  }
514
+ if (store)
515
+ return withProcessEmbedLock(() => embedAllInStore(store, provider));
431
516
  return withEmbedLock(hippoRoot, async () => {
432
517
  const identity = provider.id;
433
518
  // Host-wide by design. embedAll backfills vectors for all tenants'
package/dist/env.d.ts CHANGED
@@ -41,6 +41,8 @@ export declare function envRequireSessionScopedFreshTail(): boolean;
41
41
  export declare function envStdinWaitMs(): number | undefined;
42
42
  export declare function envLogLevel(): string;
43
43
  export declare function envPort(): string | undefined;
44
+ /** How long a client waits for a running server's /health before writing to the store directly; server-detect.ts holds the default. */
45
+ export declare function envHealthProbeMs(): number | undefined;
44
46
  export declare function envRequireAuth(): boolean;
45
47
  export declare function envV1Rps(): string | undefined;
46
48
  export declare function envApiKey(): string | undefined;
package/dist/env.js CHANGED
@@ -83,6 +83,8 @@ export function envStdinWaitMs() { return positiveInt('HIPPO_STDIN_WAIT_MS'); }
83
83
  export function envLogLevel() { return raw('HIPPO_LOG')?.trim().toLowerCase() ?? ''; }
84
84
  // Server.
85
85
  export function envPort() { return raw('HIPPO_PORT'); }
86
+ /** How long a client waits for a running server's /health before writing to the store directly; server-detect.ts holds the default. */
87
+ export function envHealthProbeMs() { return positiveInt('HIPPO_HEALTH_PROBE_MS'); }
86
88
  export function envRequireAuth() { return isOne('HIPPO_REQUIRE_AUTH'); }
87
89
  export function envV1Rps() { return raw('HIPPO_V1_RPS'); }
88
90
  export function envApiKey() { return raw('HIPPO_API_KEY'); }
@@ -1,21 +1,41 @@
1
+ import type { JsonObject } from '../working-memory.js';
1
2
  /** Copilot drops what a per-prompt hook prints, so per-task recall goes through the MCP tool the block names. */
2
3
  export declare const COPILOT_INSTRUCTIONS: string;
3
- /** The three files hippo writes for Copilot. */
4
+ export declare const VSCODE_INSTRUCTIONS: string;
5
+ /** The files hippo writes in one VS Code User folder. */
6
+ export interface VscodePaths {
7
+ readonly userDir: string;
8
+ readonly mcpConfig: string;
9
+ readonly instructions: string;
10
+ /** Profile folders, which keep their own mcp.json and prompts; hippo installs into the default profile only. */
11
+ readonly profiles: readonly string[];
12
+ }
13
+ /** The three files hippo writes for Copilot, plus the two it writes in each VS Code User folder. */
4
14
  export interface CopilotPaths {
5
15
  readonly hooks: string;
6
16
  readonly mcpConfig: string;
7
17
  readonly instructions: string;
18
+ readonly vscode: readonly VscodePaths[];
8
19
  }
9
20
  export declare function copilotPaths(): CopilotPaths;
10
- /** What to paste under "mcpServers" when hippo cannot edit the file itself. */
11
- export declare function copilotMcpSnippet(): string;
21
+ /** The app that reads an MCP config: where it keeps its servers, and hippo's entry there. */
22
+ export interface McpHost {
23
+ readonly key: 'mcpServers' | 'servers';
24
+ server(): JsonObject;
25
+ }
26
+ /** The Copilot CLI's mcp-config.json keeps servers under "mcpServers", each with an env and a tool list. */
27
+ export declare const COPILOT_CLI_MCP: McpHost;
28
+ /** VS Code's mcp.json keeps stdio servers under "servers"; it has no "mcpServers" key. */
29
+ export declare const VSCODE_MCP: McpHost;
30
+ /** What to paste under the host's key when hippo cannot edit the file itself. */
31
+ export declare function copilotMcpSnippet(host?: McpHost): string;
12
32
  export type McpMergeStatus = 'added' | 'present' | 'user-owned' | 'unreadable';
13
33
  /** 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. */
14
- export declare function mergeMcpServer(file: string): McpMergeStatus;
34
+ export declare function mergeMcpServer(file: string, host?: McpHost): McpMergeStatus;
15
35
  export type McpRemoveStatus = 'removed' | 'absent' | 'user-owned' | 'unreadable';
16
36
  /** Removes the "hippo" key only when hippo wrote it. */
17
- export declare function removeMcpServer(file: string): McpRemoveStatus;
18
- /** A filesystem error on mcp-config.json (EACCES, EISDIR, EBUSY), kept as its own message so setup can report it and go on. */
37
+ export declare function removeMcpServer(file: string, host?: McpHost): McpRemoveStatus;
38
+ /** A filesystem error on an MCP config file (EACCES, EISDIR, EBUSY), kept as its own message so setup can report it and go on. */
19
39
  export interface McpFailure {
20
40
  readonly failed: string;
21
41
  }
@@ -27,19 +47,41 @@ export declare function ensureInstructionsBlock(file: string): InstructionsInsta
27
47
  export type InstructionsRemoveStatus = 'removed' | 'absent' | 'kept' | 'unclosed';
28
48
  /** Splices out only hippo's own Copilot block and keeps every other byte; a file left with nothing but whitespace goes. */
29
49
  export declare function removeInstructionsBlock(file: string): InstructionsRemoveStatus;
50
+ /** `kept`: the file is there but is not the text hippo writes, so the user edited it or it is someone else's. */
51
+ export type VscodeInstructionsInstallStatus = 'written' | 'present' | 'kept';
52
+ /** The file is hippo's alone, so it is written whole; an earlier hippo text is upgraded, and any other text already there stays. */
53
+ export declare function ensureVscodeInstructions(file: string): VscodeInstructionsInstallStatus;
54
+ export type VscodeInstructionsRemoveStatus = 'removed' | 'absent' | 'kept';
55
+ /** Deletes the file only when it still holds exactly what a hippo release wrote. */
56
+ export declare function removeVscodeInstructions(file: string): VscodeInstructionsRemoveStatus;
57
+ export interface VscodeInstallResult {
58
+ readonly paths: VscodePaths;
59
+ readonly mcp: McpMergeStatus | McpFailure;
60
+ readonly instructions: VscodeInstructionsInstallStatus;
61
+ }
30
62
  export interface CopilotInstallResult {
31
63
  readonly paths: CopilotPaths;
32
64
  /** True when the hooks file was written; false when it already held hippo's current table. */
33
65
  readonly hooks: boolean;
34
- readonly mcp: McpMergeStatus | McpFailure;
35
- readonly instructions: InstructionsInstallStatus;
66
+ /** Null, like `instructions`, when the Copilot CLI is not installed, since only it reads these two files. */
67
+ readonly mcp: McpMergeStatus | McpFailure | null;
68
+ readonly instructions: InstructionsInstallStatus | null;
69
+ readonly vscode: readonly VscodeInstallResult[];
36
70
  }
71
+ /** 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. */
72
+ export declare function isCopilotCliPresent(home?: string): boolean;
37
73
  export declare function installCopilot(): CopilotInstallResult;
74
+ export interface VscodeUninstallResult {
75
+ readonly paths: VscodePaths;
76
+ readonly mcp: McpRemoveStatus | McpFailure;
77
+ readonly instructions: VscodeInstructionsRemoveStatus;
78
+ }
38
79
  export interface CopilotUninstallResult {
39
80
  readonly paths: CopilotPaths;
40
81
  readonly hooks: boolean;
41
82
  readonly mcp: McpRemoveStatus | McpFailure;
42
83
  readonly instructions: InstructionsRemoveStatus;
84
+ readonly vscode: readonly VscodeUninstallResult[];
43
85
  }
44
86
  export declare function uninstallCopilot(): CopilotUninstallResult;
45
87
  //# sourceMappingURL=copilot.d.ts.map
@@ -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`.";