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
package/README.md CHANGED
@@ -844,6 +844,8 @@ For Codex, it adds two hooks to `$CODEX_HOME/hooks.json` (else `~/.codex/hooks.j
844
844
 
845
845
  **Codex runs a new or changed hook only after you trust it, so open `/hooks` in Codex once and trust both;** `hippo doctor` reminds you. The per-prompt hook was checked against a real Codex request; the compaction hook follows Codex's documented `compact` start source and has not been watched end to end in Codex. Each hook also carries a `commandWindows` form (`hippo.cmd ...`), because Codex runs hooks through PowerShell on Windows, where the execution policy can block npm's `hippo.ps1`. hippo only ever appends these two entries and never rewrites one, since Codex treats a changed command as a new hook to trust. To remove: `hippo hook uninstall codex`, which takes out only hippo's exact commands and leaves every other hook, including one of yours that runs hippo.
846
846
 
847
+ For GitHub Copilot, once `$COPILOT_HOME` (else `~/.copilot`) exists, `hippo setup` writes hippo's own `hooks/hippo.json`, adds a `hippo` server to `mcp-config.json` and puts a block in `copilot-instructions.md` that asks the agent to call `hippo_recall` and `hippo_remember`. The Copilot CLI, including its sessions inside VS Code, uses all four hooks, the server and the block. VS Code's Local agent runs only the session-start hook and reads neither of the other files, so it gets no pre-compact snapshot or end-of-session capture yet, and its recall tools need hippo added to VS Code's own `mcp.json`. The hooks, that `mcp.json` recipe and how to remove it all: [integrations/copilot.md](integrations/copilot.md).
848
+
847
849
  ### What the hook adds (Claude Code example)
848
850
 
849
851
  ````markdown
@@ -1,5 +1,6 @@
1
1
  import { type ApiKeyListItem, type ApiKeyListRow } from '../auth.js';
2
2
  import type { KeysetPosition } from '../keyset.js';
3
+ import { type HippoStore } from '../store-port.js';
3
4
  import type { Context } from './types.js';
4
5
  export interface AuthCreateOpts {
5
6
  label?: string;
@@ -64,9 +65,17 @@ export interface AuthRevokeResult {
64
65
  ok: true;
65
66
  revokedAt: string;
66
67
  }
68
+ /** A promise when `ctx` carries a store, since every store method is async; today's plain result when it carries none.
69
+ * hippoRoot keeps the second test off TypeScript's weak-type rule, which would fail a ctx that has no store key at all. */
70
+ export type AuthRevokeReply<C extends Context> = C extends {
71
+ readonly store: HippoStore;
72
+ } ? Promise<AuthRevokeResult> : C extends {
73
+ readonly hippoRoot: string;
74
+ readonly store?: undefined;
75
+ } ? AuthRevokeResult : AuthRevokeResult | Promise<AuthRevokeResult>;
67
76
  /** Revoke a key in the caller's tenant: a member API key may revoke only itself, a resolver member only the keys it minted.
68
- * The auth_revoke row carries the KEY ROW's tenant, as cmdAuthRevoke does, and is skipped for an already-revoked key. */
69
- export declare function authRevoke(ctx: Context, keyId: string): AuthRevokeResult;
77
+ * With `ctx.store`, its keyAudit group revokes and writes the auth_revoke row; hippo.db is opened only when there is no store. */
78
+ export declare function authRevoke<C extends Context>(ctx: C, keyId: string): AuthRevokeReply<C>;
70
79
  /**
71
80
  * The tenant that owns `keyId`, or undefined for an unknown key. Host admin only: it reads across tenants,
72
81
  * so the local CLI can run revoke and grant in the key's own tenant.
package/dist/api/auth.js CHANGED
@@ -2,9 +2,10 @@
2
2
  import { openHippoDb, closeHippoDb } from '../db.js';
3
3
  import { BadRequestError, ConflictError, ForbiddenError, NotFoundError } from '../api-errors.js';
4
4
  import { appendAuditEvent, reportAuditWriteFailure } from '../audit.js';
5
- import { createApiKey, listApiKeyRows, listLiveOwnedKeyIds, revokeApiKey, grantScope, ungrantScope } from '../auth.js';
5
+ import { createApiKey, forgetVerifiedKey, listApiKeyRows, listLiveOwnedKeyIds, revokeApiKey, grantScope, ungrantScope, } from '../auth.js';
6
6
  import { DAY_MS } from '../dashboard-snapshot.js';
7
7
  import { isRestrictedScope } from '../recall-scope.js';
8
+ import { requireGroup } from '../store-port.js';
8
9
  import { selectApiKeyOwner } from '../store/tenant-lookup.js';
9
10
  const API_KEY_SUBJECT = 'api_key:';
10
11
  /** The key id an API-key actor's subject names, or null for any other actor. */
@@ -168,11 +169,33 @@ function assertMayRevoke(ctx, keyId, row) {
168
169
  }
169
170
  }
170
171
  /** Revoke a key in the caller's tenant: a member API key may revoke only itself, a resolver member only the keys it minted.
171
- * The auth_revoke row carries the KEY ROW's tenant, as cmdAuthRevoke does, and is skipped for an already-revoked key. */
172
+ * With `ctx.store`, its keyAudit group revokes and writes the auth_revoke row; hippo.db is opened only when there is no store. */
172
173
  export function authRevoke(ctx, keyId) {
174
+ const reply = ctx.store ? revokeThroughStore(ctx, ctx.store, keyId) : revokeOnHippoDb(ctx, keyId);
175
+ // SAFETY: a C typed with a store gets the promise its path returns; a C whose type hides a runtime store (a Pick of
176
+ // Context) is typed as the union, which a caller has to await anyway.
177
+ return reply;
178
+ }
179
+ function assertMemberKeyRevokesSelf(ctx, keyId) {
173
180
  if (ctx.actor.role !== 'admin' && !ctx.actor.viaAuthResolver && keyIdOfSubject(ctx.actor.subject) !== keyId) {
174
181
  throw new ForbiddenError('A member key can revoke only itself');
175
182
  }
183
+ }
184
+ function keyOwnerOf(record) {
185
+ return record ? { tenantId: record.tenantId, revokedAt: record.revokedAt, role: record.role, ownerSubject: record.ownerSubject ?? null } : undefined;
186
+ }
187
+ /** The store keeps no handle on this process's verified-key cache, so the key leaves it here once the revoke commits. */
188
+ async function revokeThroughStore(ctx, store, keyId) {
189
+ const keyAudit = requireGroup(store, 'keyAudit');
190
+ assertMemberKeyRevokesSelf(ctx, keyId);
191
+ assertMayRevoke(ctx, keyId, keyOwnerOf(await store.findApiKey(keyId)));
192
+ const revokedAt = await keyAudit.revokeApiKey({ tenantId: ctx.tenantId, keyId, actor: ctx.actor.subject, at: new Date().toISOString() });
193
+ forgetVerifiedKey(keyId);
194
+ return { ok: true, revokedAt };
195
+ }
196
+ /** The auth_revoke row carries the KEY ROW's tenant, as cmdAuthRevoke does, and is skipped for an already-revoked key. */
197
+ function revokeOnHippoDb(ctx, keyId) {
198
+ assertMemberKeyRevokesSelf(ctx, keyId);
176
199
  const db = openHippoDb(ctx.hippoRoot);
177
200
  try {
178
201
  const row = selectApiKeyOwner(db, keyId);
@@ -152,6 +152,7 @@ export interface RecallOpts {
152
152
  /** Set only by the MCP recall tool, which ranks with its own scorer and drops copies from its own final list: this call
153
153
  * then keeps a memory that a merged row in the same result holds word for word. Other callers leave it unset. */
154
154
  keepHeldCopies?: boolean;
155
+ leadingAudit?: readonly AppendAuditOpts[];
155
156
  /** MCP recall only: `retrieve` ranks the whole scoped store and strengthens and traces (pipeline 'mcp') just the ids this returns; `results` stays the window band. */
156
157
  showRanked?: (ranking: StoreRanking, result: RecallResult) => ShownRanking;
157
158
  }
@@ -62,7 +62,7 @@ export function recall(ctx, opts) {
62
62
  const all = loadRecallSearchEntries(ctx.hippoRoot, opts.query, windowSize, ctx.tenantId, opts.scope, 'exact', false, recallOrigin(opts), own);
63
63
  const plan = planRecall(ctx, opts, all, own);
64
64
  const { result, writes } = composeRecall(ctx, opts, windowSize, all, plan, readRecallSync(ctx, opts, plan));
65
- finishRecallAt(ctx.hippoRoot, writes);
65
+ finishRecallAt(ctx.hippoRoot, { ...writes, audit: [...(opts.leadingAudit ?? []), ...writes.audit] });
66
66
  return result;
67
67
  }
68
68
  function recallOrigin(opts) {
@@ -82,7 +82,7 @@ export async function retrieve(ctx, opts) {
82
82
  const store = storeFor(ctx);
83
83
  let candidates = await store.searchRecallEntries(opts.query, recallSearchArgs(ctx, opts, windowSize, own));
84
84
  if (opts.mode === 'hybrid' || opts.mode === 'physics') {
85
- const searchOpts = { budget: Infinity, hippoRoot: ctx.hippoRoot, scope: opts.scope ?? null, vectorCandidates: recallVectorSpec(ctx, opts, own) };
85
+ const searchOpts = { budget: Infinity, hippoRoot: ctx.hippoRoot, scope: opts.scope ?? null, vectorCandidates: recallVectorSpec(ctx, opts, own), store };
86
86
  const ranked = opts.mode === 'physics'
87
87
  ? await physicsSearch(opts.query, candidates, { ...searchOpts, physicsConfig: loadConfig(ctx.hippoRoot).physics })
88
88
  : await hybridSearch(opts.query, candidates, searchOpts);
@@ -91,7 +91,7 @@ export async function retrieve(ctx, opts) {
91
91
  }
92
92
  const plan = planRecall(ctx, opts, candidates, own);
93
93
  const { result, writes } = composeRecall(ctx, opts, windowSize, candidates, plan, await readRecall(store, ctx, opts, plan));
94
- await store.finishRecall({ ...writes, strengthen: strengthenOf(ctx, result.results.map((r) => r.id)) });
94
+ await store.finishRecall({ ...writes, audit: [...(opts.leadingAudit ?? []), ...writes.audit], strengthen: strengthenOf(ctx, result.results.map((r) => r.id)) });
95
95
  return result;
96
96
  }
97
97
  /** The recall load's arguments: exact scope, current rows only, the caller's project and personal scope. */
@@ -127,7 +127,7 @@ async function retrieveFromStore(ctx, opts, windowSize, show, own) {
127
127
  const loaded = await store.searchRecallEntries(opts.query, recallSearchArgs(ctx, opts, Math.max(windowSize, SHOW_RANKED_LEXICAL_WINDOW), own));
128
128
  const pool = loaded.filter((e) => passesScopeFilterForRecall(e.scope ?? null, opts.scope, own));
129
129
  // No scope option: the scope boost follows HIPPO_SCOPE and the skill env, as MCP recall always ranked.
130
- const searchOpts = { budget: Infinity, hippoRoot: ctx.hippoRoot, vectorCandidates: recallVectorSpec(ctx, opts, own) };
130
+ const searchOpts = { budget: Infinity, hippoRoot: ctx.hippoRoot, vectorCandidates: recallVectorSpec(ctx, opts, own), store };
131
131
  let ranked = opts.mode === 'physics'
132
132
  ? await physicsSearch(opts.query, pool, { ...searchOpts, physicsConfig: loadConfig(ctx.hippoRoot).physics })
133
133
  : await hybridSearch(opts.query, pool, searchOpts);
@@ -151,7 +151,7 @@ async function retrieveFromStore(ctx, opts, windowSize, show, own) {
151
151
  const shownRow = recallAuditRow(callerOf(ctx), 'recall', undefined, recallAuditMetadata(opts.query, shown.ids.length));
152
152
  await store.finishRecall({
153
153
  goalLog: [...rankedLog, ...writes.goalLog],
154
- audit: [...writes.audit, shownRow, ...shown.audit],
154
+ audit: [...(opts.leadingAudit ?? []), ...writes.audit, shownRow, ...shown.audit],
155
155
  trace: opts.suppressRecallTrace ? undefined : {
156
156
  tenantId: ctx.tenantId,
157
157
  sessionId: opts.sessionId ?? null,
package/dist/auth.d.ts CHANGED
@@ -84,7 +84,9 @@ export declare class VerifiedKeyCache {
84
84
  }
85
85
  /** Verify a bearer API key against `store`, served at `hippoRoot`; a cache hit skips both scrypt and the store. Null when invalid. A token whose cache entry lapsed re-reads its record without scrypt; a throw from `beforeScrypt` refuses before scrypt runs. */
86
86
  export declare function verifyApiKeyCached(hippoRoot: string, plaintext: string, store: HippoStore, beforeScrypt?: () => void): Promise<VerifiedApiKey | null>;
87
- export declare function revokeApiKey(db: DatabaseSyncLike, keyId: string): void;
87
+ export declare function revokeApiKey(db: DatabaseSyncLike, keyId: string, at?: string): void;
88
+ /** A store other than hippo.db revokes without this process's cache, so its caller drops the key here once the revoke commits. */
89
+ export declare function forgetVerifiedKey(keyId: string): void;
88
90
  /** Grant `keyId` read access to one restricted `scope`. Idempotent. */
89
91
  export declare function grantScope(db: DatabaseSyncLike, keyId: string, scope: string): void;
90
92
  /** Revoke `keyId`'s grant on `scope`. Not an error when no such grant exists. */
package/dist/auth.js CHANGED
@@ -187,9 +187,13 @@ export async function verifyApiKeyCached(hippoRoot, plaintext, store, beforeScry
187
187
  verifiedKeys.set(hippoRoot, keyId, plaintext, found, Date.now());
188
188
  return found?.key ?? null;
189
189
  }
190
- export function revokeApiKey(db, keyId) {
190
+ export function revokeApiKey(db, keyId, at = new Date().toISOString()) {
191
191
  db.prepare(`UPDATE api_keys SET revoked_at = ? WHERE key_id = ? AND revoked_at IS NULL`)
192
- .run(new Date().toISOString(), keyId);
192
+ .run(at, keyId);
193
+ verifiedKeys.delete(keyId);
194
+ }
195
+ /** A store other than hippo.db revokes without this process's cache, so its caller drops the key here once the revoke commits. */
196
+ export function forgetVerifiedKey(keyId) {
193
197
  verifiedKeys.delete(keyId);
194
198
  }
195
199
  /** Grant `keyId` read access to one restricted `scope`. Idempotent. */
@@ -1,3 +1,4 @@
1
+ import { type HookRuntime } from '../capture-contract.js';
1
2
  /**
2
3
  * Log-forgery guard: messages here interpolate payload-controlled values
3
4
  * (transcript paths, session ids). Strip C0 control chars — newlines above
@@ -11,6 +12,7 @@ export interface PreCompactOptions {
11
12
  stdinText?: string;
12
13
  stdinTimedOut?: boolean;
13
14
  logFile?: string;
15
+ runtime?: HookRuntime;
14
16
  }
15
17
  /**
16
18
  * PreCompact hook entry point. Exit code 2 on PreCompact BLOCKS compaction,
@@ -59,7 +59,8 @@ function printPreCompactInstruction(logFile) {
59
59
  }
60
60
  }
61
61
  /** Runs the PreCompact producer: records the compaction, asks the summariser for memories, saves a working-state snapshot. Never extracts memories itself; SessionEnd capture owns that. */
62
- function runPreCompact(hippoRoot, stdinText, stdinTimedOut, logFile) {
62
+ function runPreCompact(hippoRoot, options, logFile) {
63
+ const { stdinText, stdinTimedOut = false, runtime = 'claude-code' } = options;
63
64
  // The PreCompact hook fires in every Claude Code project, including
64
65
  // ones that never ran `hippo init`, so gate before any store-opening call
65
66
  // (saveActiveTaskSnapshot etc. call initStore, which would create one).
@@ -67,19 +68,20 @@ function runPreCompact(hippoRoot, stdinText, stdinTimedOut, logFile) {
67
68
  appendPreCompactLog(logFile, 'skip: store not initialized');
68
69
  return;
69
70
  }
70
- const receipt = readClaudeCodePreCompact(stdinText, stdinTimedOut);
71
+ const receipt = readClaudeCodePreCompact(stdinText, stdinTimedOut, runtime);
71
72
  if (receipt.status !== 'received') {
72
73
  appendPreCompactLog(logFile, `skip: ${receipt.reason}`);
73
74
  return;
74
75
  }
75
76
  const { sessionId, transcriptPath: payloadTranscriptPath, cwd: payloadCwd, trigger: payloadTrigger } = receipt.input;
76
77
  // The record is the "something saved before every compaction", so it lands even when no snapshot is derivable below.
78
+ // Copilot has no PostCompact hook to close a record, so its compactions get the snapshot alone.
77
79
  let recordId = null;
78
- if (sessionId !== null && sessionId !== '') {
80
+ if (runtime === 'claude-code' && sessionId !== null && sessionId !== '') {
79
81
  recordId = recordCompactionStart(hippoRoot, { sessionId, trigger: payloadTrigger, cwd: payloadCwd, transcriptPath: payloadTranscriptPath }, (message) => appendPreCompactLog(logFile, message));
80
82
  printPreCompactInstruction(logFile);
81
83
  }
82
- const transcriptPath = resolvePreCompactTranscript(payloadTranscriptPath, stdinText, logFile);
84
+ const transcriptPath = resolvePreCompactTranscript(payloadTranscriptPath, stdinText, logFile, runtime);
83
85
  if (!transcriptPath)
84
86
  return;
85
87
  // Nothing derivable skips the write, so a user-authored active snapshot is never clobbered with junk.
@@ -89,7 +91,7 @@ function runPreCompact(hippoRoot, stdinText, stdinTimedOut, logFile) {
89
91
  saveDerivedSnapshot(hippoRoot, logFile, sessionId, recordId, derived);
90
92
  }
91
93
  /** The transcript to snapshot, or null after logging why there is none. */
92
- function resolvePreCompactTranscript(payloadTranscriptPath, stdinText, logFile) {
94
+ function resolvePreCompactTranscript(payloadTranscriptPath, stdinText, logFile, runtime) {
93
95
  // A payload transcript_path is EXCLUSIVE: auto-discovery would snapshot a DIFFERENT session's
94
96
  // transcript under THIS payload's session_id, so it runs only on a manual invocation (no payload).
95
97
  let transcriptPath;
@@ -102,6 +104,10 @@ function resolvePreCompactTranscript(payloadTranscriptPath, stdinText, logFile)
102
104
  return null;
103
105
  }
104
106
  }
107
+ else if (runtime === 'copilot') {
108
+ // The scan only knows Claude Code's folders, so a manual Copilot run would snapshot a Claude session.
109
+ transcriptPath = null;
110
+ }
105
111
  else {
106
112
  transcriptPath = resolveLastSessionTranscript(undefined, stdinText, { mayScan: true });
107
113
  }
@@ -147,7 +153,7 @@ function saveDerivedSnapshot(hippoRoot, logFile, sessionId, recordId, derived) {
147
153
  export async function cmdPreCompact(hippoRoot, options) {
148
154
  const logFile = options.logFile ?? defaultPreCompactLogPath();
149
155
  try {
150
- runPreCompact(hippoRoot, options.stdinText, options.stdinTimedOut ?? false, logFile);
156
+ runPreCompact(hippoRoot, options, logFile);
151
157
  }
152
158
  catch (err) {
153
159
  appendPreCompactLog(logFile, `pre-compact failed: ${errorMessage(err)}`);
@@ -0,0 +1,15 @@
1
+ /** The envelope fields a Copilot event-log line is read by; any may be absent. */
2
+ export interface CopilotEventLine {
3
+ type?: unknown;
4
+ data?: unknown;
5
+ agentId?: unknown;
6
+ }
7
+ export interface CopilotTurn {
8
+ role: 'user' | 'assistant';
9
+ text: string;
10
+ }
11
+ /** The Copilot CLI log for a session, `<copilotHome>/session-state/<id>/events.jsonl`; null for an unsafe id or a missing file. */
12
+ export declare function copilotTranscriptFor(sessionId: string): string | null;
13
+ /** The turn one Copilot event-log line carries: the text of a human user.message or of an assistant.message; null for any other line, a sub-agent's included. */
14
+ export declare function copilotTurn(line: CopilotEventLine): CopilotTurn | null;
15
+ //# sourceMappingURL=copilot-transcript.d.ts.map
@@ -0,0 +1,40 @@
1
+ // The Copilot event log: one `{type, data, id, timestamp, parentId}` object per line, written by the Copilot CLI and VS Code Local alike.
2
+ // Matched line by line, since the tail readers start mid-file and never see the session.start line.
3
+ import * as fs from 'fs';
4
+ import * as path from 'path';
5
+ import { copilotHomeDir } from '../hooks/shared.js';
6
+ import { isObjectLike, isStringValue } from '../capture-contract.js';
7
+ // A session id becomes a folder name, so anything beyond word characters and hyphens could climb out of session-state.
8
+ const SESSION_ID_RE = /^[\w-]+$/;
9
+ // user.message sources that mark text hippo did not get from the human: a hidden skill injection or another agent's prompt.
10
+ const INJECTED_SOURCE_PREFIXES = ['skill-', 'agent-'];
11
+ /** The Copilot CLI log for a session, `<copilotHome>/session-state/<id>/events.jsonl`; null for an unsafe id or a missing file. */
12
+ export function copilotTranscriptFor(sessionId) {
13
+ if (!SESSION_ID_RE.test(sessionId))
14
+ return null;
15
+ const file = path.join(copilotHomeDir(), 'session-state', sessionId, 'events.jsonl');
16
+ return fs.existsSync(file) ? file : null;
17
+ }
18
+ function isSubagentLine(line, data) {
19
+ if (isStringValue(line.agentId) && line.agentId !== '')
20
+ return true;
21
+ return isStringValue(data.parentToolCallId) && data.parentToolCallId !== '';
22
+ }
23
+ function isInjectedUserMessage(data) {
24
+ if (data.isAutopilotContinuation === true)
25
+ return true;
26
+ const source = data.source;
27
+ return isStringValue(source) && INJECTED_SOURCE_PREFIXES.some((prefix) => source.startsWith(prefix));
28
+ }
29
+ /** The turn one Copilot event-log line carries: the text of a human user.message or of an assistant.message; null for any other line, a sub-agent's included. */
30
+ export function copilotTurn(line) {
31
+ const role = line.type === 'user.message' ? 'user' : line.type === 'assistant.message' ? 'assistant' : null;
32
+ const data = isObjectLike(line.data) ? line.data : null;
33
+ if (role === null || data === null || isSubagentLine(line, data))
34
+ return null;
35
+ if (role === 'user' && isInjectedUserMessage(data))
36
+ return null;
37
+ const text = isStringValue(data.content) ? data.content.trim() : '';
38
+ return text ? { role, text } : null;
39
+ }
40
+ //# sourceMappingURL=copilot-transcript.js.map
@@ -14,6 +14,9 @@ const LEADING_CD = /^\s*(?:(?:cd|pushd)\b[^;&|]*(?:&&|\|\||;)\s*)+/;
14
14
  const NO_MATCH = /\bno (?:matches|files|results) found\b/i;
15
15
  /** Shell commands whose exit code 1 means "nothing found" or "differs", not an error. */
16
16
  const QUIET_EXIT_1 = /^\s*(?:grep|rg|egrep|fgrep|find|test|\[|diff|cmp|git diff|git grep)\b/;
17
+ // Claude Code's tool names, then the Copilot CLI's and VS Code's; tests/copilot-failure-reading.test.ts cites where each comes from.
18
+ const SEARCH_TOOLS = new Set(['Grep', 'Glob', 'grep', 'glob', 'rg', 'grep_search', 'file_search', 'semantic_search']);
19
+ const SHELL_TOOLS = new Set(['Bash', 'bash', 'powershell', 'run_in_terminal']);
17
20
  function isString(v) {
18
21
  return v !== undefined && v !== null && v.constructor === String;
19
22
  }
@@ -58,9 +61,9 @@ export function lessonFromFailure(payload, scrub = (text) => text) {
58
61
  return routine('os-permission');
59
62
  if (NO_MATCH.test(error))
60
63
  return routine('no-match');
61
- if (tool === 'Grep' || tool === 'Glob')
64
+ if (SEARCH_TOOLS.has(tool))
62
65
  return routine('search-tool');
63
- if (tool === 'Bash' && QUIET_EXIT_1.test(command) && /exit code 1\b/i.test(error))
66
+ if (SHELL_TOOLS.has(tool) && QUIET_EXIT_1.test(command) && /exit code 1\b/i.test(error))
64
67
  return routine('quiet-exit');
65
68
  return { text, detail };
66
69
  }
@@ -3,8 +3,9 @@ import { claudeConfigDir } from '../hooks/shared.js';
3
3
  import * as fs from 'fs';
4
4
  import * as path from 'path';
5
5
  import { isObjectLike, isStringValue } from '../capture-contract.js';
6
+ import { copilotTurn } from './copilot-transcript.js';
6
7
  /**
7
- * Build a compact text summary from a Claude Code / OpenCode JSONL transcript.
8
+ * Build a compact text summary from a Claude Code / OpenCode / Copilot JSONL transcript.
8
9
  * Keeps plain user messages and the final chunk of assistant text, drops
9
10
  * thinking blocks, tool_use, and tool_result noise. Capture reads the same
10
11
  * turns through `sessionTail`, one text per turn.
@@ -53,6 +54,11 @@ export function collectSessionTurns(jsonl, visit) {
53
54
  if (!isObjectLike(entry) || !('type' in entry))
54
55
  continue;
55
56
  visit?.(entry);
57
+ const copilot = copilotTurn(entry);
58
+ if (copilot !== null) {
59
+ turns.push(copilot);
60
+ continue;
61
+ }
56
62
  if (entry.type === 'user' || entry.type === 'assistant') {
57
63
  const message = 'message' in entry && isObjectLike(entry.message) ? entry.message : undefined;
58
64
  if (!message)
@@ -5,6 +5,7 @@ import { isObjectLike, isStringValue } from '../capture-contract.js';
5
5
  import { errorMessage } from '../log.js';
6
6
  import { PRE_COMPACT_TAIL_BYTES, readTranscriptTail, truncateCodePointSafe } from '../transcript-tail.js';
7
7
  import { humanUserText, summariseTranscript } from './transcript.js';
8
+ import { copilotTurn } from './copilot-transcript.js';
8
9
  export const PRE_COMPACT_TASK_CAP = 200;
9
10
  export const PRE_COMPACT_SUMMARY_CAP = 2000;
10
11
  export const PRE_COMPACT_NEXT_STEP_CAP = 500;
@@ -48,7 +49,7 @@ export function truncateKeepNewest(text, maxChars) {
48
49
  start = nl + 1;
49
50
  return TRIM_MARKER + text.slice(start);
50
51
  }
51
- /** Most recent plain-text user message in a JSONL tail. Claude Code transcript shape only (PreCompact is claude-code-only). */
52
+ /** Most recent plain-text user message in a JSONL tail, from a Claude Code or a Copilot transcript. */
52
53
  function lastPlainUserMessage(jsonl) {
53
54
  const lines = jsonl.split('\n').filter((l) => l.trim());
54
55
  for (let i = lines.length - 1; i >= 0; i--) {
@@ -61,7 +62,12 @@ function lastPlainUserMessage(jsonl) {
61
62
  }
62
63
  if (!isObjectLike(entry))
63
64
  continue;
64
- if (!('type' in entry) || entry.type !== 'user')
65
+ if (!('type' in entry))
66
+ continue;
67
+ const copilot = copilotTurn(entry);
68
+ if (copilot?.role === 'user')
69
+ return copilot.text;
70
+ if (entry.type !== 'user')
65
71
  continue;
66
72
  // An isMeta user line holds an earlier compact summary and an isSidechain one is a sub-agent turn; neither is the human's task.
67
73
  if (('isMeta' in entry && entry.isMeta === true) || ('isSidechain' in entry && entry.isSidechain === true))
@@ -88,7 +94,12 @@ function lastAssistantTextBlock(jsonl) {
88
94
  }
89
95
  if (!isObjectLike(entry))
90
96
  continue;
91
- if (!('type' in entry) || entry.type !== 'assistant')
97
+ if (!('type' in entry))
98
+ continue;
99
+ const copilot = copilotTurn(entry);
100
+ if (copilot?.role === 'assistant')
101
+ return copilot.text;
102
+ if (entry.type !== 'assistant')
92
103
  continue;
93
104
  // Same meta/sidechain guard as lastPlainUserMessage: sub-agent turns
94
105
  // (isSidechain) are not this session's next step.
@@ -42,6 +42,8 @@ export interface ProgressCursor {
42
42
  export declare function isStringValue<T>(value: T): value is T & string;
43
43
  /** An object guard that avoids `typeof`; arrays count as objects, as they do for `typeof`. */
44
44
  export declare function isObjectLike<T>(value: T): value is T & object;
45
- /** Reads a Claude Code PreCompact payload; only an empty stdin counts as a manual run. */
46
- export declare function readClaudeCodePreCompact(stdinText: string | undefined, timedOut: boolean): CaptureReceipt;
45
+ /** The hook runtimes a `--runtime` flag names; absent means Claude Code. */
46
+ export type HookRuntime = 'claude-code' | 'copilot';
47
+ /** Reads a Claude Code PreCompact payload, or a Copilot one after stdin.ts mapped it to the same snake_case keys; only an empty stdin counts as a manual run. */
48
+ export declare function readClaudeCodePreCompact(stdinText: string | undefined, timedOut: boolean, runtime?: HookRuntime): CaptureReceipt;
47
49
  //# sourceMappingURL=capture-contract.d.ts.map
@@ -8,13 +8,13 @@ export function isStringValue(value) {
8
8
  export function isObjectLike(value) {
9
9
  return value !== null && value instanceof Object;
10
10
  }
11
- /** Reads a Claude Code PreCompact payload; only an empty stdin counts as a manual run. */
12
- export function readClaudeCodePreCompact(stdinText, timedOut) {
11
+ /** Reads a Claude Code PreCompact payload, or a Copilot one after stdin.ts mapped it to the same snake_case keys; only an empty stdin counts as a manual run. */
12
+ export function readClaudeCodePreCompact(stdinText, timedOut, runtime = 'claude-code') {
13
13
  const empty = !stdinText || stdinText.trim() === '';
14
14
  if (timedOut && empty) {
15
15
  return { status: 'unavailable', reason: 'no PreCompact payload arrived before the stdin wait window closed' };
16
16
  }
17
- const base = { runtime: 'claude-code', event: 'pre-compact' };
17
+ const base = { runtime, event: 'pre-compact' };
18
18
  if (empty) {
19
19
  return { status: 'received', input: { ...base, manual: true, sessionId: null, cwd: null, transcriptPath: null, trigger: null } };
20
20
  }
@@ -7,14 +7,14 @@ import { isGlobalStoreRoot } from '../project-identity.js';
7
7
  import { autoDetectContext } from '../context-auto.js';
8
8
  import { detectScope } from '../scope.js';
9
9
  import { ledgerRoot, withLedgerDb } from '../ledger-db.js';
10
- import { readStdinBounded } from '../stdin.js';
10
+ import { readHookStdin } from '../stdin.js';
11
11
  import * as api from '../api.js';
12
12
  import { resolveTenantId } from '../tenant.js';
13
13
  import { renderAmbientSummary } from '../ambient.js';
14
14
  import { contextBlockLines, contextCost, crossProjectLines, settleTokens } from '../context-render.js';
15
- import { additionalContextOutput, flushDeliveryRecorder, hasContextData, toRenderItems, } from '../prompt-hook.js';
15
+ import { additionalContextOutput, flushDeliveryRecorder, hasContextData, sessionStartEnvelope, toRenderItems, } from '../prompt-hook.js';
16
16
  import { printError } from './output.js';
17
- import { parseLimitFlag, parseCountFlag, parseBudgetFlag, requireInit, printActiveTaskSnapshot, printSessionEvents, printHandoff, hostSessionId, captureConsole, hookStoreRoot, runHookWithStores, inPilotHoldout, } from './shared.js';
17
+ import { parseLimitFlag, parseCountFlag, parseBudgetFlag, requireInit, printActiveTaskSnapshot, printSessionEvents, printHandoff, hostSessionId, captureConsole, hookStoreRoot, hookRuntime, payloadCwdRoot, runHookWithStores, inPilotHoldout, } from './shared.js';
18
18
  export async function cmdContext(hippoRoot, args, flags, stdinText) {
19
19
  const rec = startDeliveryRecorder(hippoRoot, flags, stdinText);
20
20
  // No try/finally: a render throw keeps its own exit code and writes no event.
@@ -37,6 +37,7 @@ function startDeliveryRecorder(hippoRoot, flags, stdinText) {
37
37
  tenantId: resolveTenantId({}),
38
38
  stdinText,
39
39
  envSessionId: hostSessionId(),
40
+ runtime: hookRuntime(flags) === 'copilot' ? 'copilot' : undefined,
40
41
  });
41
42
  }
42
43
  catch (error) {
@@ -102,11 +103,12 @@ async function renderContext(hippoRoot, args, flags, stdinText, rec) {
102
103
  rec?.delivered({ state: 'empty' });
103
104
  return;
104
105
  }
105
- const view = { hippoRoot, tenantId: ctx.tenantId, ledgerSessionId, payloadSessionId, pinnedOnly, framing, rec, result };
106
+ const envelope = format === 'copilot' ? sessionStartEnvelope(stdinText) : undefined;
107
+ const view = { hippoRoot, tenantId: ctx.tenantId, ledgerSessionId, payloadSessionId, pinnedOnly, framing, rec, result, envelope };
106
108
  if (format === 'json') {
107
109
  renderContextJson(view, query);
108
110
  }
109
- else if (format === 'additional-context') {
111
+ else if (format === 'additional-context' || format === 'copilot') {
110
112
  const stdout = additionalContextOutput(view);
111
113
  if (stdout)
112
114
  process.stdout.write(stdout);
@@ -145,7 +147,7 @@ function buildContextOpts(flags, input) {
145
147
  currentSessionId: input.session.currentSessionId,
146
148
  prompt: input.session.prompt,
147
149
  // JSON is budgeted as the markdown it stands for, so one budget picks the same memories in every format.
148
- cost: contextCost(input.format === 'additional-context' ? 'additional-context' : 'markdown', input.framing),
150
+ cost: contextCost(input.format === 'additional-context' || input.format === 'copilot' ? 'additional-context' : 'markdown', input.framing),
149
151
  deliveryObserver: input.rec ?? undefined,
150
152
  };
151
153
  }
@@ -225,7 +227,8 @@ export function printContextMarkdown(items, totalTokens, framing = 'observe', op
225
227
  }
226
228
  export async function handleContext({ hippoRoot, args, flags }) {
227
229
  // Bounded, not a TTY guard: the hot stdin path and a manual run share this one command.
228
- const { text: stdinText } = await readStdinBounded();
229
- await runHookWithStores(() => cmdContext(hookStoreRoot(hippoRoot), args, flags, stdinText));
230
+ const { text: stdinText } = await readHookStdin();
231
+ const root = payloadCwdRoot(hippoRoot, stdinText, hookRuntime(flags));
232
+ await runHookWithStores(() => cmdContext(hookStoreRoot(root), args, flags, stdinText));
230
233
  }
231
234
  //# sourceMappingURL=context.js.map
@@ -15,4 +15,6 @@ export declare function hippoBlock(text: string): {
15
15
  inner: string;
16
16
  owner?: string;
17
17
  } | null;
18
+ /** `text` with every hippo block and its markers removed, trimmed. */
19
+ export declare function withoutHookBlock(text: string): string;
18
20
  //# sourceMappingURL=hook-blocks.d.ts.map
@@ -1,5 +1,6 @@
1
1
  // The hippo block each agent's instruction file carries, and how init recognises one it wrote earlier.
2
2
  import { createHash } from 'node:crypto';
3
+ import { escapeRegex } from '../escape.js';
3
4
  export const HOOK_MARKERS = {
4
5
  start: '<!-- hippo:start -->',
5
6
  end: '<!-- hippo:end -->',
@@ -230,4 +231,9 @@ export function hippoBlock(text) {
230
231
  const owner = Object.keys(HOOKS).find((k) => HOOKS[k].content === inner) ?? SHIPPED_HOOK_HASHES.get(createHash('sha256').update(inner).digest('hex'));
231
232
  return { start, end, eol: raw.includes('\r\n') ? '\r\n' : '\n', inner, owner };
232
233
  }
234
+ /** `text` with every hippo block and its markers removed, trimmed. */
235
+ export function withoutHookBlock(text) {
236
+ const re = new RegExp(`\\n?${escapeRegex(HOOK_MARKERS.start)}[\\s\\S]*?${escapeRegex(HOOK_MARKERS.end)}\\n?`, 'g');
237
+ return text.replace(re, '\n').replace(/\n{3,}/g, '\n\n').trim();
238
+ }
233
239
  //# sourceMappingURL=hook-blocks.js.map
@@ -20,7 +20,7 @@ export declare function cmdCodexRun(hippoRoot: string, args: string[]): void;
20
20
  export declare function cmdCodexSessionEndWorker(hippoRoot: string, flags: Record<string, string | boolean | string[]>): Promise<void>;
21
21
  export declare function handlePreCompact({ hippoRoot, flags }: CommandContext): Promise<void>;
22
22
  export declare function handlePostCompact({ hippoRoot, flags }: CommandContext): Promise<void>;
23
- export declare function handleCaptureError({ hippoRoot }: CommandContext): Promise<void>;
23
+ export declare function handleCaptureError({ hippoRoot, flags }: CommandContext): Promise<void>;
24
24
  export declare function handleCompactResume({ hippoRoot }: CommandContext): Promise<void>;
25
25
  export declare function handleCapture({ hippoRoot, flags }: CommandContext): Promise<void>;
26
26
  //# sourceMappingURL=session-hooks.d.ts.map