hippo-memory 1.66.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 (53) 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/auth.d.ts +3 -1
  5. package/dist/auth.js +6 -2
  6. package/dist/capture/compact.d.ts +2 -0
  7. package/dist/capture/compact.js +12 -6
  8. package/dist/capture/copilot-transcript.d.ts +15 -0
  9. package/dist/capture/copilot-transcript.js +40 -0
  10. package/dist/capture/failure-reading.js +5 -2
  11. package/dist/capture/transcript.js +7 -1
  12. package/dist/capture/working-state.js +14 -3
  13. package/dist/capture-contract.d.ts +4 -2
  14. package/dist/capture-contract.js +3 -3
  15. package/dist/cli/context.js +11 -8
  16. package/dist/cli/hook-blocks.d.ts +2 -0
  17. package/dist/cli/hook-blocks.js +6 -0
  18. package/dist/cli/session-hooks.d.ts +1 -1
  19. package/dist/cli/session-hooks.js +28 -16
  20. package/dist/cli/setup.js +66 -7
  21. package/dist/cli/shared.d.ts +5 -0
  22. package/dist/cli/shared.js +22 -3
  23. package/dist/cli/usage.js +20 -9
  24. package/dist/db/sqlite-blocked.js +1 -1
  25. package/dist/delivery-recorder.d.ts +3 -1
  26. package/dist/delivery-recorder.js +1 -1
  27. package/dist/hooks/copilot.d.ts +45 -0
  28. package/dist/hooks/copilot.js +187 -0
  29. package/dist/hooks/json-hooks.d.ts +1 -1
  30. package/dist/hooks/json-hooks.js +41 -2
  31. package/dist/hooks/shared.d.ts +5 -1
  32. package/dist/hooks/shared.js +9 -0
  33. package/dist/mcp/request.d.ts +0 -2
  34. package/dist/mcp/request.js +16 -11
  35. package/dist/prompt-hook.d.ts +6 -0
  36. package/dist/prompt-hook.js +15 -7
  37. package/dist/search/vector.js +2 -5
  38. package/dist/server/routes/admin.js +1 -1
  39. package/dist/server/types.d.ts +5 -4
  40. package/dist/server.d.ts +5 -5
  41. package/dist/server.js +13 -14
  42. package/dist/stdin.d.ts +5 -0
  43. package/dist/stdin.js +62 -0
  44. package/dist/store/key-audit.d.ts +6 -0
  45. package/dist/store/key-audit.js +25 -0
  46. package/dist/store-port.d.ts +29 -8
  47. package/dist/store-port.js +28 -1
  48. package/dist/version.d.ts +1 -1
  49. package/dist/version.js +1 -1
  50. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  51. package/extensions/openclaw-plugin/package.json +1 -1
  52. package/openclaw.plugin.json +1 -1
  53. 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);
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
@@ -4,6 +4,7 @@ import * as fs from 'fs';
4
4
  import { spawn } from 'child_process';
5
5
  import { resolveCodexSessionTranscript } from '../hooks/codex-session.js';
6
6
  import { resolveCodexWrapperPaths } from '../hooks/codex-wrapper.js';
7
+ import { resolveJsonHookPaths } from '../hooks/json-hooks.js';
7
8
  import { isInitialized } from '../store/open.js';
8
9
  import { loadActiveTaskSnapshot, loadFreshActiveTaskSnapshot, closeTaskSnapshotsForSession, listSessionEvents, } from '../store/sessions.js';
9
10
  import { writeSessionEndHandoff } from '../store/handoffs.js';
@@ -20,16 +21,17 @@ import { cmdPreCompact, cmdPostCompact } from '../capture/compact.js';
20
21
  import { transcriptWorkingState } from '../capture/working-state.js';
21
22
  import { collectHandoffEvidence } from '../handoff-evidence.js';
22
23
  import { resolveLastSessionTranscript } from '../capture/transcript.js';
24
+ import { copilotTranscriptFor } from '../capture/copilot-transcript.js';
23
25
  import { truncateCodePointSafe } from '../transcript-tail.js';
24
26
  import { COMPACTION_DB_WAIT_MS } from '../compaction-record.js';
25
27
  import { COMPACT_RESUME_EVENT_CONTENT_CAP, COMPACT_RESUME_MAX_AGE_MS, compactResumeText } from '../context-render.js';
26
- import { readStdinBounded } from '../stdin.js';
28
+ import { readHookStdin } from '../stdin.js';
27
29
  import { resolveTenantId } from '../tenant.js';
28
30
  import { errorMessage, log } from '../log.js';
29
31
  import { withLedgerDb } from '../ledger-db.js';
30
32
  import { printError } from './output.js';
31
33
  import { cmdLastSleep } from './last-sleep.js';
32
- import { logSessionEndImport, appendSessionEndCloseLog, resetHookInjection, hookStoreRoot, runHookWithStores, inPilotHoldout, } from './shared.js';
34
+ import { logSessionEndImport, appendSessionEndCloseLog, resetHookInjection, hookStoreRoot, hookRuntime, payloadCwdRoot, runHookWithStores, inPilotHoldout, } from './shared.js';
33
35
  /**
34
36
  * SessionStart(compact) injector. Prints the active task snapshot + recent
35
37
  * session trail so working state that would otherwise be lost to
@@ -140,10 +142,14 @@ function restoreCompactSnapshot(hippoRoot, payloadSessionId) {
140
142
  * the next SessionStart reads the log via `hippo last-sleep`.
141
143
  */
142
144
  export async function cmdSessionEnd(hippoRoot, flags) {
143
- const logFile = typeof flags['log-file'] === 'string' ? flags['log-file'] : null;
145
+ const runtime = hookRuntime(flags);
146
+ // Copilot's hook command carries no path, since one quoted into it would need escaping for each shell; the log goes where the hook table used to point.
147
+ const logFile = typeof flags['log-file'] === 'string' ? flags['log-file'] : runtime === 'copilot' ? resolveJsonHookPaths('copilot').logFile : null;
144
148
  // Bounded read: extracts transcript_path + session_id for the detached worker's argv.
145
149
  let sessionId = null;
146
- const { text: stdinText } = await readStdinBounded();
150
+ const { text: stdinText } = await readHookStdin();
151
+ // Before the spawn, since the worker finds its store from the folder it inherits.
152
+ const root = payloadCwdRoot(hippoRoot, stdinText, runtime);
147
153
  try {
148
154
  if (stdinText && stdinText.trim().startsWith('{')) {
149
155
  const payload = JSON.parse(stdinText);
@@ -156,8 +162,9 @@ export async function cmdSessionEnd(hippoRoot, flags) {
156
162
  // No stdin, not JSON, or read failure: the snapshot close below will no-op.
157
163
  }
158
164
  // Resolved here because only this process saw the payload; the worker captures just the path it is handed.
159
- // Always a hook, so never scan: an empty stdin here is not a manual run.
160
- const transcriptPath = resolveLastSessionTranscript(undefined, stdinText, { mayScan: false });
165
+ // Always a hook, so never scan: an empty stdin here is not a manual run. The Copilot CLI's sessionEnd names no transcript, so its log is found by id.
166
+ const transcriptPath = resolveLastSessionTranscript(undefined, stdinText, { mayScan: false })
167
+ ?? (runtime === 'copilot' && sessionId ? copilotTranscriptFor(sessionId) : null);
161
168
  const workerArgs = [process.argv[1], '__session-end-worker'];
162
169
  if (logFile)
163
170
  workerArgs.push('--log-file', logFile);
@@ -177,11 +184,13 @@ export async function cmdSessionEnd(hippoRoot, flags) {
177
184
  }
178
185
  catch {
179
186
  // If spawn fails, run inline as a last resort, handed what the child's argv would have carried.
187
+ if (logFile)
188
+ flags['log-file'] = logFile;
180
189
  if (transcriptPath)
181
190
  flags['transcript'] = transcriptPath;
182
191
  if (sessionId)
183
192
  flags['session-id'] = sessionId;
184
- await cmdSessionEndWorker(hippoRoot, flags);
193
+ await cmdSessionEndWorker(root, flags);
185
194
  return;
186
195
  }
187
196
  }
@@ -493,19 +502,22 @@ export async function cmdCodexSessionEndWorker(hippoRoot, flags) {
493
502
  }
494
503
  export async function handlePreCompact({ hippoRoot, flags }) {
495
504
  // Bounded wait, not a TTY guard: an idle non-TTY pipe must not hang.
496
- const { text: stdinText, timedOut: stdinTimedOut } = await readStdinBounded();
505
+ const { text: stdinText, timedOut: stdinTimedOut } = await readHookStdin();
506
+ const runtime = hookRuntime(flags);
507
+ const root = payloadCwdRoot(hippoRoot, stdinText, runtime);
497
508
  await runHookWithStores(async () => {
498
- resetHookInjection(hippoRoot, stdinText, null);
499
- await cmdPreCompact(hookStoreRoot(hippoRoot), {
509
+ resetHookInjection(root, stdinText, null);
510
+ await cmdPreCompact(hookStoreRoot(root), {
500
511
  stdinText,
501
512
  stdinTimedOut,
502
513
  logFile: typeof flags['log-file'] === 'string' ? flags['log-file'] : undefined,
514
+ runtime,
503
515
  });
504
516
  });
505
517
  }
506
518
  export async function handlePostCompact({ hippoRoot, flags }) {
507
519
  // PostCompact hook: saves the compaction summary and its memories, then prints one plain line, because Claude Code shows this hook's stdout as-is. Always exits 0.
508
- const { text } = await readStdinBounded();
520
+ const { text } = await readHookStdin();
509
521
  const logFlag = flags['log-file'];
510
522
  const store = hookStoreRoot(hippoRoot);
511
523
  const line = await runHookWithStores(() => cmdPostCompact(store, {
@@ -524,12 +536,12 @@ export async function handlePostCompact({ hippoRoot, flags }) {
524
536
  if (line !== null && line !== undefined)
525
537
  console.log(line);
526
538
  }
527
- export async function handleCaptureError({ hippoRoot }) {
539
+ export async function handleCaptureError({ hippoRoot, flags }) {
528
540
  // PostToolUseFailure hook: every path exits 0, and nothing is created
529
541
  // when no store exists (the hook fires in every directory).
530
- const { text } = await readStdinBounded();
542
+ const { text } = await readHookStdin();
531
543
  try {
532
- const root = hookStoreRoot(hippoRoot);
544
+ const root = hookStoreRoot(payloadCwdRoot(hippoRoot, text, hookRuntime(flags)));
533
545
  const payload = (text ?? '').trim();
534
546
  if (isInitialized(root) && payload) {
535
547
  // SAFETY: JSON.parse returns a JSON value by definition.
@@ -542,7 +554,7 @@ export async function handleCaptureError({ hippoRoot }) {
542
554
  }
543
555
  }
544
556
  export async function handleCompactResume({ hippoRoot }) {
545
- const { text: stdinText, timedOut: stdinTimedOut } = await readStdinBounded();
557
+ const { text: stdinText, timedOut: stdinTimedOut } = await readHookStdin();
546
558
  await runHookWithStores(() => {
547
559
  resetHookInjection(hippoRoot, stdinText, 'compact');
548
560
  cmdCompactResume(hookStoreRoot(hippoRoot), stdinText, stdinTimedOut);
@@ -574,7 +586,7 @@ export async function handleCapture({ hippoRoot, flags }) {
574
586
  // Bounded, and only when last-session has no explicit path: the
575
587
  // --stdin source keeps its own blocking read in capture.ts by design.
576
588
  const bounded = captureSource === 'last-session' && !transcriptPath
577
- ? await readStdinBounded()
589
+ ? await readHookStdin()
578
590
  : { text: undefined, timedOut: false };
579
591
  cmdCapture(hippoRoot, {
580
592
  source: captureSource,