portable-agent-layer 0.66.1 → 0.68.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/assets/skills/pal-analyze/SKILL.md +1 -1
- package/assets/skills/pal-reflect/SKILL.md +1 -1
- package/assets/skills/projects/SKILL.md +1 -1
- package/assets/skills/telos/SKILL.md +1 -1
- package/assets/templates/hooks.codex.json +18 -4
- package/assets/templates/hooks.copilot.json +31 -12
- package/assets/templates/hooks.cursor.json +23 -7
- package/assets/templates/settings.claude.json +50 -8
- package/package.json +3 -2
- package/src/cli/index.ts +7 -3
- package/src/hooks/LedgerCommit.ts +29 -0
- package/src/hooks/LedgerSnapshot.ts +30 -0
- package/src/hooks/LedgerUnapplied.ts +69 -0
- package/src/hooks/LoadContext.ts +4 -2
- package/src/hooks/lib/actor.ts +4 -4
- package/src/hooks/lib/agent.ts +31 -7
- package/src/hooks/lib/ledger-hook.ts +276 -0
- package/src/hooks/lib/ledger.ts +348 -0
- package/src/hooks/lib/paths.ts +1 -0
- package/src/hooks/lib/sensitive-path.ts +117 -0
- package/src/hooks/lib/settings.ts +5 -0
- package/src/targets/lib.ts +37 -7
- package/src/targets/opencode/plugin.ts +22 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the two ledger hooks agree on: which tool calls are worth recording, and
|
|
3
|
+
* how to find the file and the call id in an agent's payload.
|
|
4
|
+
*
|
|
5
|
+
* Both halves must answer these identically — a pre-tool half that snapshots a
|
|
6
|
+
* tool the post-tool half ignores leaves a snapshot nothing ever claims.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
10
|
+
import { isAbsolute, resolve } from "node:path";
|
|
11
|
+
import { normalizeToolUse } from "./agent";
|
|
12
|
+
import {
|
|
13
|
+
claimPending,
|
|
14
|
+
type LedgerEntry,
|
|
15
|
+
type LedgerOutcome,
|
|
16
|
+
reapStalePending,
|
|
17
|
+
recordAction,
|
|
18
|
+
savePending,
|
|
19
|
+
} from "./ledger";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Edits and writes, which carry their own target in the call. A shell command's
|
|
23
|
+
* effect is not derivable from its arguments, so recording one honestly needs a
|
|
24
|
+
* different mechanism than reading the path out of the payload.
|
|
25
|
+
*
|
|
26
|
+
* Reads and searches are excluded because they are queries, not actions, and a
|
|
27
|
+
* ledger that logs them buries the changes among them.
|
|
28
|
+
*
|
|
29
|
+
* The four beyond edit and write are Copilot's own names for the same act — its
|
|
30
|
+
* runtime groups them as its file-editing tools, and it sends the command as
|
|
31
|
+
* the tool name rather than as an argument.
|
|
32
|
+
*/
|
|
33
|
+
const LEDGERED_TOOLS = new Set([
|
|
34
|
+
"edit",
|
|
35
|
+
"write",
|
|
36
|
+
"create",
|
|
37
|
+
"insert",
|
|
38
|
+
"str_replace",
|
|
39
|
+
"str_replace_editor",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Copilot's CLI also writes through a patch, and one call of that edits however
|
|
44
|
+
* many files the patch names. Its targets live in the patch body rather than in
|
|
45
|
+
* an argument, so it is filtered separately and read by patchedTargets.
|
|
46
|
+
*/
|
|
47
|
+
const PATCHING_TOOLS = new Set(["apply_patch", "applypatch"]);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The editor tool that changes a file also reads one, under a command argument.
|
|
51
|
+
* A read recorded as an action would put queries back in a log of changes.
|
|
52
|
+
*/
|
|
53
|
+
const READING_COMMANDS = new Set(["view"]);
|
|
54
|
+
|
|
55
|
+
/** Agents disagree on the spelling; the value is the same file either way. */
|
|
56
|
+
const TARGET_KEYS = ["file_path", "filePath", "path"];
|
|
57
|
+
|
|
58
|
+
/** Every header in the V4A patch format that names a file the patch changes. */
|
|
59
|
+
const PATCHED_FILE_HEADER = /^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm;
|
|
60
|
+
|
|
61
|
+
export interface LedgeredCall {
|
|
62
|
+
toolUseId: string;
|
|
63
|
+
tool: string;
|
|
64
|
+
target: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The one reading of a payload both halves share. Asking the same question in
|
|
69
|
+
* two places is how they drift apart, and a drift here is silent: the pre-tool
|
|
70
|
+
* half parks a snapshot the post-tool half never comes to claim.
|
|
71
|
+
*
|
|
72
|
+
* A list because a patch call changes a set of files, not one.
|
|
73
|
+
*/
|
|
74
|
+
export function ledgeredCalls(payload: Record<string, unknown>): LedgeredCall[] {
|
|
75
|
+
const toolUse = normalizeToolUse(payload);
|
|
76
|
+
if (!toolUse) return [];
|
|
77
|
+
|
|
78
|
+
const patched = patchedTargets(payload, toolUse.toolName);
|
|
79
|
+
if (patched.length > 0) return patchCalls(payload, toolUse.toolName, patched);
|
|
80
|
+
|
|
81
|
+
const target = ledgeredTarget(toolUse.toolName, toolUse.toolInput);
|
|
82
|
+
if (!target) return [];
|
|
83
|
+
|
|
84
|
+
const toolUseId = pairingKeyOf(payload, toolUse.toolName, target);
|
|
85
|
+
return toolUseId ? [{ toolUseId, tool: toolUse.toolName, target }] : [];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A patch changes several files under one tool call, so the call's own id
|
|
90
|
+
* cannot key its snapshots — all of them would collide on it and only one would
|
|
91
|
+
* ever be claimed. Naming the target in the key is what keeps them apart.
|
|
92
|
+
*/
|
|
93
|
+
function patchCalls(
|
|
94
|
+
payload: Record<string, unknown>,
|
|
95
|
+
tool: string,
|
|
96
|
+
targets: string[]
|
|
97
|
+
): LedgeredCall[] {
|
|
98
|
+
const anchor = toolUseIdOf(payload) ?? sessionOf(payload);
|
|
99
|
+
if (!anchor) return [];
|
|
100
|
+
return targets.map((target) => ({
|
|
101
|
+
toolUseId: derivedPairingKey(anchor, tool, target),
|
|
102
|
+
tool,
|
|
103
|
+
target,
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The files a patch call changes, resolved against the directory the agent ran
|
|
109
|
+
* it in — a patch names them the way the agent typed them, which is relative.
|
|
110
|
+
*/
|
|
111
|
+
function patchedTargets(payload: Record<string, unknown>, toolName: string): string[] {
|
|
112
|
+
if (!PATCHING_TOOLS.has(toolName.toLowerCase())) return [];
|
|
113
|
+
|
|
114
|
+
const patch = patchBodyOf(payload);
|
|
115
|
+
if (!patch) return [];
|
|
116
|
+
|
|
117
|
+
const base = typeof payload.cwd === "string" ? payload.cwd : process.cwd();
|
|
118
|
+
return Array.from(patch.matchAll(PATCHED_FILE_HEADER), (match) =>
|
|
119
|
+
absoluteFrom(base, match[1].trim())
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Its arguments are the patch itself, rather than JSON naming a file. */
|
|
124
|
+
function patchBodyOf(payload: Record<string, unknown>): string | null {
|
|
125
|
+
const args = payload.toolArgs ?? payload.tool_input ?? payload.toolInput;
|
|
126
|
+
if (typeof args === "string") return args.length > 0 ? args : null;
|
|
127
|
+
return patchCommandOf(args);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Codex carries the patch under a command key instead of sending it as the arguments. */
|
|
131
|
+
function patchCommandOf(args: unknown): string | null {
|
|
132
|
+
if (typeof args !== "object" || args === null || Array.isArray(args)) return null;
|
|
133
|
+
const command = (args as Record<string, unknown>).command;
|
|
134
|
+
return typeof command === "string" && command.length > 0 ? command : null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function absoluteFrom(base: string, path: string): string {
|
|
138
|
+
return isAbsolute(path) ? path : resolve(base, path);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Copilot's CLI publishes no id for a tool invocation, so its two halves are
|
|
143
|
+
* paired on what both do carry. Session, tool and target identify the call
|
|
144
|
+
* uniquely as long as it does not overlap another write to the same file in
|
|
145
|
+
* the same session, which sequential tool use cannot produce.
|
|
146
|
+
*/
|
|
147
|
+
function derivedPairingKey(anchor: string, tool: string, target: string): string {
|
|
148
|
+
const digest = new Bun.CryptoHasher("sha256")
|
|
149
|
+
.update([anchor, tool, target].join(" "), "utf-8")
|
|
150
|
+
.digest("hex");
|
|
151
|
+
return `derived-${digest.slice(0, 32)}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function sessionOf(payload: Record<string, unknown>): string | null {
|
|
155
|
+
const session = payload.sessionId ?? payload.session_id;
|
|
156
|
+
return typeof session === "string" && session.length > 0 ? session : null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function pairingKeyOf(
|
|
160
|
+
payload: Record<string, unknown>,
|
|
161
|
+
tool: string,
|
|
162
|
+
target: string
|
|
163
|
+
): string | null {
|
|
164
|
+
const explicit = toolUseIdOf(payload);
|
|
165
|
+
if (explicit) return explicit;
|
|
166
|
+
|
|
167
|
+
const session = sessionOf(payload);
|
|
168
|
+
return session ? derivedPairingKey(session, tool, target) : null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* How a call that did not land reports itself. Two events, because the runtime
|
|
173
|
+
* treats the two endings as different things and so does the ledger: a tool
|
|
174
|
+
* that ran and errored is not a call something refused to run.
|
|
175
|
+
*
|
|
176
|
+
* They carry the same fact under different keys, which is the whole reason this
|
|
177
|
+
* mapping is written down in one place rather than read twice.
|
|
178
|
+
*/
|
|
179
|
+
const UNAPPLIED_EVENTS: Record<string, { outcome: LedgerOutcome; reasonKeys: string[] }> =
|
|
180
|
+
{
|
|
181
|
+
PostToolUseFailure: { outcome: "failed", reasonKeys: ["error"] },
|
|
182
|
+
PermissionDenied: { outcome: "denied", reasonKeys: ["reason"] },
|
|
183
|
+
postToolUseFailure: { outcome: "failed", reasonKeys: ["error_message", "error"] },
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
function deniedByFailureType(payload: Record<string, unknown>): boolean {
|
|
187
|
+
return payload.failure_type === "permission_denied";
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export interface UnappliedVerdict {
|
|
191
|
+
outcome: LedgerOutcome;
|
|
192
|
+
reason?: string;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Copilot names the event nowhere in its payload, so its config says so on argv. */
|
|
196
|
+
function eventFromArgv(): string | undefined {
|
|
197
|
+
const flag = process.argv.find((a) => a.startsWith("--event="));
|
|
198
|
+
return flag?.slice("--event=".length) || undefined;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* What became of a call, for the events that mean it did not land — or nothing
|
|
203
|
+
* when the payload is some other event, so one hook can be registered on both
|
|
204
|
+
* without having to be told which one it is being run for.
|
|
205
|
+
*/
|
|
206
|
+
export function unappliedVerdictOf(
|
|
207
|
+
payload: Record<string, unknown>
|
|
208
|
+
): UnappliedVerdict | null {
|
|
209
|
+
const event = payload.hook_event_name ?? payload.hookEventName ?? eventFromArgv();
|
|
210
|
+
if (typeof event !== "string") return null;
|
|
211
|
+
|
|
212
|
+
const mapping = UNAPPLIED_EVENTS[event];
|
|
213
|
+
if (!mapping) return null;
|
|
214
|
+
|
|
215
|
+
const outcome = deniedByFailureType(payload) ? "denied" : mapping.outcome;
|
|
216
|
+
const reason = mapping.reasonKeys
|
|
217
|
+
.map((key) => payload[key])
|
|
218
|
+
.find((value): value is string => typeof value === "string" && value.length > 0);
|
|
219
|
+
return reason ? { outcome, reason } : { outcome };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function contentsOf(path: string): string | null {
|
|
223
|
+
return existsSync(path) ? readFileSync(path, "utf-8") : null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Park the target's current contents, the last moment they still exist. */
|
|
227
|
+
export function snapshotCall(call: LedgeredCall): void {
|
|
228
|
+
savePending({ ...call, before: contentsOf(call.target), ts: new Date().toISOString() });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Pair a parked before-state with the result, or nothing if none was parked. */
|
|
232
|
+
export function commitApplied(call: LedgeredCall): LedgerEntry | null {
|
|
233
|
+
const pending = claimPending(call.toolUseId);
|
|
234
|
+
if (!pending) return null;
|
|
235
|
+
|
|
236
|
+
const entry = recordAction({
|
|
237
|
+
tool: pending.tool,
|
|
238
|
+
target: pending.target,
|
|
239
|
+
outcome: "applied",
|
|
240
|
+
before: pending.before,
|
|
241
|
+
beforeState: pending.beforeState,
|
|
242
|
+
after: contentsOf(call.target),
|
|
243
|
+
});
|
|
244
|
+
reapStalePending();
|
|
245
|
+
return entry;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function toolUseIdOf(payload: Record<string, unknown>): string | null {
|
|
249
|
+
for (const key of ["tool_use_id", "toolUseId", "tool_call_id"]) {
|
|
250
|
+
const value = payload[key];
|
|
251
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
252
|
+
}
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function readsRatherThanChanges(toolInput: Record<string, unknown>): boolean {
|
|
257
|
+
const command = toolInput.command;
|
|
258
|
+
return typeof command === "string" && READING_COMMANDS.has(command);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The absolute path this call will change, or nothing if the call is not one
|
|
263
|
+
* the ledger records.
|
|
264
|
+
*/
|
|
265
|
+
export function ledgeredTarget(
|
|
266
|
+
toolName: string,
|
|
267
|
+
toolInput: Record<string, unknown>
|
|
268
|
+
): string | null {
|
|
269
|
+
if (!LEDGERED_TOOLS.has(toolName.toLowerCase())) return null;
|
|
270
|
+
if (readsRatherThanChanges(toolInput)) return null;
|
|
271
|
+
for (const key of TARGET_KEYS) {
|
|
272
|
+
const value = toolInput[key];
|
|
273
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
274
|
+
}
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Action ledger — an append-only record of what an agent changed, and under
|
|
3
|
+
* whose authority.
|
|
4
|
+
*
|
|
5
|
+
* A transcript says a tool was called. It does not say what the file looked
|
|
6
|
+
* like before, so it cannot answer "what changed" after the fact — the prior
|
|
7
|
+
* contents are gone by the time anything downstream reads it. The ledger is
|
|
8
|
+
* therefore written at the moment of the change, from both sides of it.
|
|
9
|
+
*
|
|
10
|
+
* Scope is deliberately narrow: edits and writes, which carry their own
|
|
11
|
+
* before/after in the call. A shell command's effect is not derivable from its
|
|
12
|
+
* arguments, so recording one honestly would need a different mechanism than
|
|
13
|
+
* this file — see the AGENTS.md rule steering file changes onto the edit tools.
|
|
14
|
+
*
|
|
15
|
+
* Reads and searches are excluded on purpose. They are queries, not actions,
|
|
16
|
+
* and a ledger that logs them buries the changes among them.
|
|
17
|
+
*
|
|
18
|
+
* This module is silent. It runs inside hooks, where stdout is the agent's
|
|
19
|
+
* protocol channel, so it returns what it wrote and leaves reporting to the
|
|
20
|
+
* caller.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
appendFileSync,
|
|
25
|
+
existsSync,
|
|
26
|
+
readdirSync,
|
|
27
|
+
readFileSync,
|
|
28
|
+
renameSync,
|
|
29
|
+
statSync,
|
|
30
|
+
unlinkSync,
|
|
31
|
+
writeFileSync,
|
|
32
|
+
} from "node:fs";
|
|
33
|
+
import { resolve } from "node:path";
|
|
34
|
+
import { calcPatch } from "fast-myers-diff";
|
|
35
|
+
import { currentAttribution, type RecordAttribution } from "./actor";
|
|
36
|
+
import { encodeAnchor } from "./anchor";
|
|
37
|
+
import { ensureDir, paths } from "./paths";
|
|
38
|
+
import { isSensitivePath } from "./sensitive-path";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* What became of the action.
|
|
42
|
+
*
|
|
43
|
+
* `failed` and `denied` are kept apart because they answer different questions.
|
|
44
|
+
* A failure is the agent's own attempt not working — a bad path, a stale match,
|
|
45
|
+
* a permission on disk. A denial is a human refusing it. Collapsing them would
|
|
46
|
+
* lose the only signal in the record that says where the boundary was drawn,
|
|
47
|
+
* and "what did I try that was refused" is a question worth being able to ask
|
|
48
|
+
* separately from "what did I try that broke".
|
|
49
|
+
*/
|
|
50
|
+
export type LedgerOutcome = "applied" | "failed" | "denied";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* One side of a change, identified rather than reproduced. The hash ties the
|
|
54
|
+
* entry to a real file — apply the delta to something matching `before.hash`
|
|
55
|
+
* and you must land on `after.hash` — and the byte count says how big that file
|
|
56
|
+
* was without keeping it.
|
|
57
|
+
*/
|
|
58
|
+
export interface LedgerState {
|
|
59
|
+
hash: string;
|
|
60
|
+
bytes: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* One contiguous replacement. `at` and `remove` index the before-state and are
|
|
65
|
+
* not shifted by earlier hunks in the same delta, which is what the diff
|
|
66
|
+
* produces and what applying them in order with a running offset expects.
|
|
67
|
+
*/
|
|
68
|
+
export interface LedgerHunk {
|
|
69
|
+
at: number;
|
|
70
|
+
remove: number;
|
|
71
|
+
insert: string[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* What changed, at line granularity.
|
|
76
|
+
*
|
|
77
|
+
* Storing this rather than both whole files is what lets the record scale with
|
|
78
|
+
* the size of the change instead of the size of the file. Under the old shape a
|
|
79
|
+
* four-line edit to a large file kept two hashes and nothing else, so the
|
|
80
|
+
* entries that said least were the ones about the biggest files.
|
|
81
|
+
*/
|
|
82
|
+
export interface LedgerDelta {
|
|
83
|
+
hunks: LedgerHunk[];
|
|
84
|
+
/** Set when the change itself was too large to keep. Its absence means the hunks are complete. */
|
|
85
|
+
truncated?: boolean;
|
|
86
|
+
/** Set when the target is one whose contents the ledger never keeps. */
|
|
87
|
+
redacted?: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface LedgerEntry extends RecordAttribution {
|
|
91
|
+
id: string;
|
|
92
|
+
ts: string;
|
|
93
|
+
/** The tool that made the change — Edit or Write today. */
|
|
94
|
+
tool: string;
|
|
95
|
+
/** Project-anchored path, so the entry survives a different mount or machine. */
|
|
96
|
+
target: string;
|
|
97
|
+
outcome: LedgerOutcome;
|
|
98
|
+
/** Null when nothing was there before: a file creation has no prior state. */
|
|
99
|
+
before: LedgerState | null;
|
|
100
|
+
/** Null when nothing landed, which is what a failed or denied action means. */
|
|
101
|
+
after: LedgerState | null;
|
|
102
|
+
/**
|
|
103
|
+
* The change from one side to the other. Absent when nothing landed: an
|
|
104
|
+
* action that was refused did not empty the file, and a delta saying it did
|
|
105
|
+
* would be the ledger stating something that never happened.
|
|
106
|
+
*/
|
|
107
|
+
delta?: LedgerDelta;
|
|
108
|
+
/** Why the action did not land. Absent on an applied one. */
|
|
109
|
+
reason?: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface RecordActionInput {
|
|
113
|
+
tool: string;
|
|
114
|
+
/** Absolute path of the file the action targeted. */
|
|
115
|
+
target: string;
|
|
116
|
+
outcome: LedgerOutcome;
|
|
117
|
+
/** Prior content; null for a file creation, and withheld for a sensitive target. */
|
|
118
|
+
before: string | null;
|
|
119
|
+
/** Identity of the prior content, when the content itself was withheld. */
|
|
120
|
+
beforeState?: LedgerState;
|
|
121
|
+
/** Resulting content; null when nothing landed. */
|
|
122
|
+
after: string | null;
|
|
123
|
+
reason?: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A change larger than this is recorded as having happened without being kept.
|
|
128
|
+
* It caps the delta rather than the files, so what fits is decided by how much
|
|
129
|
+
* an action changed, not by how big the thing it changed happened to be.
|
|
130
|
+
*/
|
|
131
|
+
const MAX_DELTA_BYTES = 4096;
|
|
132
|
+
|
|
133
|
+
/** Size at which the active file is rotated aside. */
|
|
134
|
+
const MAX_LEDGER_BYTES = 4 * 1024 * 1024;
|
|
135
|
+
|
|
136
|
+
const ACTIVE = "actions.jsonl";
|
|
137
|
+
|
|
138
|
+
export function ledgerPath(): string {
|
|
139
|
+
return resolve(paths.ledger(), ACTIVE);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function hash(content: string): string {
|
|
143
|
+
return new Bun.CryptoHasher("sha256").update(content, "utf-8").digest("hex");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function stateOf(content: string | null): LedgerState | null {
|
|
147
|
+
if (content === null) return null;
|
|
148
|
+
return { hash: hash(content), bytes: Buffer.byteLength(content, "utf-8") };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Splitting on newlines keeps the trailing one: "a\n" becomes ["a", ""], and
|
|
153
|
+
* joining puts it back. A file and its line list round-trip exactly, which is
|
|
154
|
+
* what makes a reconstructed after-state hash-identical to the real one.
|
|
155
|
+
*/
|
|
156
|
+
function toLines(content: string | null): string[] {
|
|
157
|
+
return content === null ? [] : content.split("\n");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The change between two states, or nothing when there was no transition to
|
|
162
|
+
* describe. An action that did not land has no delta — see LedgerEntry.delta.
|
|
163
|
+
*/
|
|
164
|
+
const WITHHELD: LedgerDelta = { hunks: [], redacted: true };
|
|
165
|
+
|
|
166
|
+
function deltaFor(input: RecordActionInput): LedgerDelta | undefined {
|
|
167
|
+
if (isSensitivePath(input.target)) return WITHHELD;
|
|
168
|
+
return deltaOf(input.before, input.after);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function deltaOf(before: string | null, after: string | null): LedgerDelta | undefined {
|
|
172
|
+
if (after === null) return undefined;
|
|
173
|
+
|
|
174
|
+
const hunks: LedgerHunk[] = [];
|
|
175
|
+
for (const [at, end, insert] of calcPatch(toLines(before), toLines(after))) {
|
|
176
|
+
hunks.push({ at, remove: end - at, insert: [...insert] });
|
|
177
|
+
}
|
|
178
|
+
if (hunks.length === 0) return undefined;
|
|
179
|
+
|
|
180
|
+
const delta: LedgerDelta = { hunks };
|
|
181
|
+
return Buffer.byteLength(JSON.stringify(delta), "utf-8") > MAX_DELTA_BYTES
|
|
182
|
+
? { hunks: [], truncated: true }
|
|
183
|
+
: delta;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Rebuild the after-state from the before-state and the delta, or nothing when
|
|
188
|
+
* the delta was too large to keep.
|
|
189
|
+
*
|
|
190
|
+
* This is what makes an entry checkable rather than merely plausible: the hash
|
|
191
|
+
* of what this returns must equal the entry's `after.hash`. It is also the read
|
|
192
|
+
* side of the ledger — a stored change is only evidence if it can be replayed.
|
|
193
|
+
*/
|
|
194
|
+
export function applyDelta(before: string | null, delta: LedgerDelta): string | null {
|
|
195
|
+
if (delta.truncated || delta.redacted) return null;
|
|
196
|
+
|
|
197
|
+
const lines = toLines(before);
|
|
198
|
+
const out: string[] = [];
|
|
199
|
+
let cursor = 0;
|
|
200
|
+
for (const hunk of delta.hunks) {
|
|
201
|
+
out.push(...lines.slice(cursor, hunk.at), ...hunk.insert);
|
|
202
|
+
cursor = hunk.at + hunk.remove;
|
|
203
|
+
}
|
|
204
|
+
out.push(...lines.slice(cursor));
|
|
205
|
+
return out.join("\n");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function generateId(): string {
|
|
209
|
+
return Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Move the active file aside once it crosses the size cap, so reads stay cheap
|
|
214
|
+
* without discarding anything. A count-based trim would delete the oldest
|
|
215
|
+
* entries first, which in an audit record is the evidence most worth keeping.
|
|
216
|
+
*/
|
|
217
|
+
function rotateIfFull(file: string): void {
|
|
218
|
+
if (!existsSync(file) || statSync(file).size < MAX_LEDGER_BYTES) return;
|
|
219
|
+
renameSync(file, freeArchivePath(new Date().toISOString().replace(/[:.]/g, "-")));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* A name no archive already holds. Two rotations within the same millisecond
|
|
224
|
+
* agree on a stamp, and renaming onto a taken name destroys that archive
|
|
225
|
+
* without a trace — the one failure mode an append-only record cannot have.
|
|
226
|
+
*/
|
|
227
|
+
function freeArchivePath(stamp: string): string {
|
|
228
|
+
const nth = (n: number) => {
|
|
229
|
+
const suffix = n ? `-${n}` : "";
|
|
230
|
+
return resolve(paths.ledger(), `actions-${stamp}${suffix}.jsonl`);
|
|
231
|
+
};
|
|
232
|
+
let n = 0;
|
|
233
|
+
while (existsSync(nth(n))) n++;
|
|
234
|
+
return nth(n);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Append one action to the ledger and return the entry as written.
|
|
239
|
+
*
|
|
240
|
+
* An action that did not land is recorded like any other: a log that keeps only
|
|
241
|
+
* what succeeded cannot answer what was attempted, which is usually the question
|
|
242
|
+
* being asked of it.
|
|
243
|
+
*/
|
|
244
|
+
export function recordAction(input: RecordActionInput): LedgerEntry {
|
|
245
|
+
const delta = deltaFor(input);
|
|
246
|
+
const entry: LedgerEntry = {
|
|
247
|
+
id: generateId(),
|
|
248
|
+
ts: new Date().toISOString(),
|
|
249
|
+
...currentAttribution(),
|
|
250
|
+
tool: input.tool,
|
|
251
|
+
target: encodeAnchor(input.target),
|
|
252
|
+
outcome: input.outcome,
|
|
253
|
+
before: input.beforeState ?? stateOf(input.before),
|
|
254
|
+
after: stateOf(input.after),
|
|
255
|
+
...(delta ? { delta } : {}),
|
|
256
|
+
...(input.reason ? { reason: input.reason } : {}),
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const file = ledgerPath();
|
|
260
|
+
rotateIfFull(file);
|
|
261
|
+
appendFileSync(file, `${JSON.stringify(entry)}\n`, "utf-8");
|
|
262
|
+
return entry;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* The before-state, held between the two halves of one tool call.
|
|
267
|
+
*
|
|
268
|
+
* A post-tool event alone cannot produce it: by the time the tool has run, the
|
|
269
|
+
* prior contents are gone. So the pre-tool half reads the file and parks it
|
|
270
|
+
* here, and the post-tool half claims it back and pairs it with the result.
|
|
271
|
+
*/
|
|
272
|
+
export interface PendingSnapshot {
|
|
273
|
+
toolUseId: string;
|
|
274
|
+
tool: string;
|
|
275
|
+
target: string;
|
|
276
|
+
before: string | null;
|
|
277
|
+
beforeState?: LedgerState;
|
|
278
|
+
ts: string;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** How long a snapshot waits for a second half that may never come. */
|
|
282
|
+
const PENDING_TTL_MS = 60 * 60 * 1000;
|
|
283
|
+
|
|
284
|
+
function pendingDir(): string {
|
|
285
|
+
return ensureDir(resolve(paths.ledger(), "pending"));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* A tool-call id is an identifier from another system, and it lands here as a
|
|
290
|
+
* filename — so it is reduced to characters that cannot climb out of the
|
|
291
|
+
* directory rather than trusted to be well-formed.
|
|
292
|
+
*/
|
|
293
|
+
function pendingPath(toolUseId: string): string {
|
|
294
|
+
return resolve(pendingDir(), `${toolUseId.replace(/[^A-Za-z0-9_-]/g, "")}.json`);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function withheldWhenSensitive(snapshot: PendingSnapshot): PendingSnapshot {
|
|
298
|
+
if (snapshot.before === null || !isSensitivePath(snapshot.target)) return snapshot;
|
|
299
|
+
const state = stateOf(snapshot.before);
|
|
300
|
+
return { ...snapshot, before: null, ...(state ? { beforeState: state } : {}) };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function savePending(snapshot: PendingSnapshot): void {
|
|
304
|
+
const withheld = withheldWhenSensitive(snapshot);
|
|
305
|
+
writeFileSync(pendingPath(withheld.toolUseId), JSON.stringify(withheld), "utf-8");
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Take the snapshot for this tool call, removing it in the same step. Claiming
|
|
310
|
+
* is one-shot on purpose: a snapshot that stayed put after being read could be
|
|
311
|
+
* paired with a second result and record a change that never happened.
|
|
312
|
+
*/
|
|
313
|
+
export function claimPending(toolUseId: string): PendingSnapshot | null {
|
|
314
|
+
const file = pendingPath(toolUseId);
|
|
315
|
+
if (!existsSync(file)) return null;
|
|
316
|
+
try {
|
|
317
|
+
const snapshot = JSON.parse(readFileSync(file, "utf-8")) as PendingSnapshot;
|
|
318
|
+
unlinkSync(file);
|
|
319
|
+
return snapshot;
|
|
320
|
+
} catch {
|
|
321
|
+
unlinkSync(file);
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Drop snapshots nothing ever claimed.
|
|
328
|
+
*
|
|
329
|
+
* Applying, failing and being denied by auto mode all fire a second half that
|
|
330
|
+
* claims the snapshot. What is left here is the endings that fire nothing after
|
|
331
|
+
* the pre-tool half — a manual denial at the permission dialog, a deny rule, a
|
|
332
|
+
* pre-tool hook's own block — plus anything interrupted mid-call.
|
|
333
|
+
*
|
|
334
|
+
* They are dropped rather than recorded because the ledger would have to invent
|
|
335
|
+
* which of those it was. The attempt is real and currently goes unrecorded; see
|
|
336
|
+
* the manual-denial gap in the project's ISCs.
|
|
337
|
+
*/
|
|
338
|
+
export function reapStalePending(now: number = Date.now()): number {
|
|
339
|
+
const dir = pendingDir();
|
|
340
|
+
let reaped = 0;
|
|
341
|
+
for (const name of readdirSync(dir)) {
|
|
342
|
+
const file = resolve(dir, name);
|
|
343
|
+
if (now - statSync(file).mtimeMs < PENDING_TTL_MS) continue;
|
|
344
|
+
unlinkSync(file);
|
|
345
|
+
reaped++;
|
|
346
|
+
}
|
|
347
|
+
return reaped;
|
|
348
|
+
}
|
package/src/hooks/lib/paths.ts
CHANGED
|
@@ -54,6 +54,7 @@ export const paths = {
|
|
|
54
54
|
reflectionsFile: () =>
|
|
55
55
|
home("memory", "learning", "reflections", "algorithm-reflections.jsonl"),
|
|
56
56
|
retrievalIndex: () => home("memory", "learning", ".retrieval-index.json"),
|
|
57
|
+
ledger: () => ensureDir(home("memory", "ledger")),
|
|
57
58
|
progress: () => ensureDir(home("memory", "state", "progress")),
|
|
58
59
|
projectHistory: () => ensureDir(home("memory", "projects")),
|
|
59
60
|
sessionLearning: () => ensureDir(home("memory", "learning", "session")),
|