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.
- package/README.md +2 -0
- package/dist/api/auth.d.ts +11 -2
- package/dist/api/auth.js +25 -2
- package/dist/api/recall-types.d.ts +1 -0
- package/dist/api/recall.js +5 -5
- package/dist/auth.d.ts +3 -1
- package/dist/auth.js +6 -2
- package/dist/capture/compact.d.ts +2 -0
- package/dist/capture/compact.js +12 -6
- package/dist/capture/copilot-transcript.d.ts +15 -0
- package/dist/capture/copilot-transcript.js +40 -0
- package/dist/capture/failure-reading.js +5 -2
- package/dist/capture/transcript.js +7 -1
- package/dist/capture/working-state.js +14 -3
- package/dist/capture-contract.d.ts +4 -2
- package/dist/capture-contract.js +3 -3
- package/dist/cli/context.js +11 -8
- package/dist/cli/hook-blocks.d.ts +2 -0
- package/dist/cli/hook-blocks.js +6 -0
- package/dist/cli/session-hooks.d.ts +1 -1
- package/dist/cli/session-hooks.js +28 -16
- package/dist/cli/setup.js +66 -7
- package/dist/cli/shared.d.ts +5 -0
- package/dist/cli/shared.js +22 -3
- package/dist/cli/usage.js +20 -9
- package/dist/db/open.d.ts +5 -1
- package/dist/db/open.js +28 -7
- package/dist/db/sqlite-blocked.d.ts +11 -1
- package/dist/db/sqlite-blocked.js +20 -2
- package/dist/db.d.ts +2 -2
- package/dist/db.js +2 -2
- package/dist/delivery-recorder.d.ts +3 -1
- package/dist/delivery-recorder.js +1 -1
- package/dist/embeddings.d.ts +11 -2
- package/dist/embeddings.js +20 -11
- package/dist/hooks/copilot.d.ts +45 -0
- package/dist/hooks/copilot.js +187 -0
- package/dist/hooks/json-hooks.d.ts +1 -1
- package/dist/hooks/json-hooks.js +41 -2
- package/dist/hooks/shared.d.ts +5 -1
- package/dist/hooks/shared.js +9 -0
- package/dist/mcp/request.d.ts +0 -2
- package/dist/mcp/request.js +16 -11
- package/dist/prompt-hook.d.ts +6 -0
- package/dist/prompt-hook.js +15 -7
- package/dist/quality-repair.js +3 -0
- package/dist/search/hybrid.d.ts +3 -0
- package/dist/search/physics-search.d.ts +3 -0
- package/dist/search/physics-search.js +18 -19
- package/dist/search/vector.d.ts +7 -2
- package/dist/search/vector.js +24 -10
- package/dist/server/routes/admin.js +1 -1
- package/dist/server/routes/recall.js +6 -9
- package/dist/server/types.d.ts +5 -4
- package/dist/server.d.ts +11 -6
- package/dist/server.js +18 -14
- package/dist/stdin.d.ts +5 -0
- package/dist/stdin.js +62 -0
- package/dist/store/key-audit.d.ts +6 -0
- package/dist/store/key-audit.js +25 -0
- package/dist/store/open.js +2 -1
- package/dist/store-port.d.ts +42 -4
- package/dist/store-port.js +46 -2
- package/dist/vector-store.d.ts +8 -0
- package/dist/vector-store.js +31 -19
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
- package/extensions/openclaw-plugin/package.json +1 -1
- package/openclaw.plugin.json +1 -1
- 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
|
package/dist/api/auth.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
69
|
-
export declare function authRevoke(ctx:
|
|
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
|
-
*
|
|
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
|
}
|
package/dist/api/recall.js
CHANGED
|
@@ -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(
|
|
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,
|
package/dist/capture/compact.js
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
64
|
+
if (SEARCH_TOOLS.has(tool))
|
|
62
65
|
return routine('search-tool');
|
|
63
|
-
if (tool
|
|
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
|
|
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)
|
|
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)
|
|
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
|
-
/**
|
|
46
|
-
export
|
|
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
|
package/dist/capture-contract.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/dist/cli/context.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
229
|
-
|
|
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
|
package/dist/cli/hook-blocks.js
CHANGED
|
@@ -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
|