portable-agent-layer 0.93.0 → 0.95.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 +5 -1
- package/assets/schema/pal-settings.schema.json +4 -0
- package/assets/skills/research-entity/SKILL.md +57 -0
- package/assets/templates/hooks.antigravity.json +1 -1
- package/assets/templates/hooks.codex.json +11 -1
- package/assets/templates/hooks.copilot.json +11 -4
- package/assets/templates/hooks.cursor.json +8 -2
- package/assets/templates/pal-settings.json +1 -0
- package/assets/templates/settings.claude.json +55 -3
- package/package.json +1 -1
- package/src/cli/content.ts +23 -0
- package/src/cli/events.ts +47 -0
- package/src/cli/infer.ts +6 -0
- package/src/cli/knowledge-research.ts +93 -0
- package/src/cli/knowledge.ts +17 -4
- package/src/cli/tree.ts +4 -0
- package/src/hooks/AttentionEvent.ts +22 -0
- package/src/hooks/LoadContext.ts +2 -2
- package/src/hooks/PostToolOrchestrator.ts +20 -0
- package/src/hooks/PreCompactPersist.ts +4 -2
- package/src/hooks/PreserveTranscripts.ts +13 -0
- package/src/hooks/SessionClose.ts +5 -0
- package/src/hooks/UserPromptOrchestrator.ts +9 -2
- package/src/hooks/handlers/entity-extraction.ts +10 -3
- package/src/hooks/lib/attention-events.ts +43 -0
- package/src/hooks/lib/auto-update.ts +24 -1
- package/src/hooks/lib/commitment-events.ts +20 -0
- package/src/hooks/lib/discord-events.ts +25 -0
- package/src/hooks/lib/entity-events.ts +16 -0
- package/src/hooks/lib/entity-extraction.ts +24 -2
- package/src/hooks/lib/entity-research.ts +305 -0
- package/src/hooks/lib/entity-review.ts +19 -6
- package/src/hooks/lib/events.ts +252 -0
- package/src/hooks/lib/export.ts +1 -1
- package/src/hooks/lib/hook-timing.ts +33 -0
- package/src/hooks/lib/inference-events.ts +56 -0
- package/src/hooks/lib/inference.ts +51 -6
- package/src/hooks/lib/interaction.ts +74 -27
- package/src/hooks/lib/opencode-events.ts +59 -0
- package/src/hooks/lib/paths.ts +1 -0
- package/src/hooks/lib/post-tool.ts +66 -0
- package/src/hooks/lib/session-events.ts +211 -0
- package/src/hooks/lib/settings.ts +6 -0
- package/src/hooks/lib/stop.ts +27 -31
- package/src/hooks/lib/token-usage.ts +1 -0
- package/src/hooks/lib/tool-events.ts +106 -0
- package/src/hooks/lib/transcript-preserve.ts +133 -0
- package/src/hooks/lib/turn-extras.ts +78 -0
- package/src/hooks/lib/waiting-on-user.ts +75 -0
- package/src/hooks/lib/work-tracking.ts +17 -3
- package/src/targets/opencode/plugin.ts +33 -1
- package/src/tools/agent/interaction.ts +2 -2
- package/src/tools/agent/project.ts +4 -0
- package/src/tools/control-room/snooze.ts +2 -0
- package/src/tools/control-room/writes.ts +2 -0
- package/src/tools/knowledge/lib.ts +5 -0
- package/src/hooks/LedgerCommit.ts +0 -34
- package/src/hooks/LedgerUnapplied.ts +0 -47
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* opencode's bus events as PAL events. The plugin lives as long as opencode,
|
|
3
|
+
* so a permission's tool is remembered from its ask until its reply.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { logCompaction, logSessionStart } from "./session-events";
|
|
7
|
+
import { logToolCall } from "./tool-events";
|
|
8
|
+
|
|
9
|
+
interface OpencodeSession {
|
|
10
|
+
id?: string;
|
|
11
|
+
parentID?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface OpencodePermission {
|
|
15
|
+
id?: string;
|
|
16
|
+
type?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** v1 of opencode's SDK names these permissionID/response, v2 requestID/reply. */
|
|
20
|
+
interface OpencodePermissionReply {
|
|
21
|
+
sessionID?: string;
|
|
22
|
+
permissionID?: string;
|
|
23
|
+
requestID?: string;
|
|
24
|
+
response?: string;
|
|
25
|
+
reply?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const askedTools = new Map<string, string>();
|
|
29
|
+
|
|
30
|
+
export function logOpencodeSessionCreated(session: OpencodeSession | undefined): void {
|
|
31
|
+
if (!session?.id || session.parentID) return;
|
|
32
|
+
logSessionStart({ session_id: session.id, source: "startup" });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function logOpencodeCompaction(sessionID: string | undefined): void {
|
|
36
|
+
logCompaction({ session_id: sessionID });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function rememberAskedTool(permission: OpencodePermission): void {
|
|
40
|
+
if (permission.id && permission.type) askedTools.set(permission.id, permission.type);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function takeAskedTool(reply: OpencodePermissionReply): string | undefined {
|
|
44
|
+
const id = reply.permissionID ?? reply.requestID;
|
|
45
|
+
if (!id) return undefined;
|
|
46
|
+
const tool = askedTools.get(id);
|
|
47
|
+
askedTools.delete(id);
|
|
48
|
+
return tool;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function logOpencodePermissionReply(reply: OpencodePermissionReply): void {
|
|
52
|
+
const tool = takeAskedTool(reply);
|
|
53
|
+
if ((reply.response ?? reply.reply) !== "reject" || !tool) return;
|
|
54
|
+
logToolCall({
|
|
55
|
+
hook_event_name: "PermissionDenied",
|
|
56
|
+
tool_name: tool,
|
|
57
|
+
session_id: reply.sessionID,
|
|
58
|
+
});
|
|
59
|
+
}
|
package/src/hooks/lib/paths.ts
CHANGED
|
@@ -111,6 +111,7 @@ export const paths = {
|
|
|
111
111
|
home("memory", "learning", "reflections", "algorithm-reflections.jsonl"),
|
|
112
112
|
retrievalIndex: () => home("memory", "learning", ".retrieval-index.json"),
|
|
113
113
|
ledger: () => ensureDir(home("memory", "ledger")),
|
|
114
|
+
content: () => ensureDir(home("memory", "content")),
|
|
114
115
|
progress: () => ensureDir(home("memory", "state", "progress")),
|
|
115
116
|
projectHistory: () => ensureDir(home("memory", "projects")),
|
|
116
117
|
unboundHistory: () => ensureDir(home("memory", "state", "unbound-history")),
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything PAL does once a tool call has ended: the tool_call event, and the
|
|
3
|
+
* ledger's second half. A failure or a denial is recorded as unapplied, anything
|
|
4
|
+
* else as applied.
|
|
5
|
+
*
|
|
6
|
+
* Not every attempt that fails to land reaches here. A manual denial at the
|
|
7
|
+
* permission dialog, a `deny` rule and a PreToolUse block fire nothing after
|
|
8
|
+
* PreToolUse, and a schema rejection fires no hook at all; the reaper in
|
|
9
|
+
* ledger.ts claims the snapshots those leave behind.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { reapStalePending } from "./ledger";
|
|
13
|
+
import {
|
|
14
|
+
commitApplied,
|
|
15
|
+
commitUnapplied,
|
|
16
|
+
landedCalls,
|
|
17
|
+
ledgeredCalls,
|
|
18
|
+
type UnappliedVerdict,
|
|
19
|
+
unappliedVerdictOf,
|
|
20
|
+
} from "./ledger-hook";
|
|
21
|
+
import { logDebug } from "./log";
|
|
22
|
+
import { logToolCall } from "./tool-events";
|
|
23
|
+
|
|
24
|
+
type Payload = Record<string, unknown>;
|
|
25
|
+
|
|
26
|
+
function recordApplied(payload: Payload): void {
|
|
27
|
+
for (const call of landedCalls(payload)) {
|
|
28
|
+
const entry = commitApplied(call);
|
|
29
|
+
if (entry) logDebug("post-tool", `applied ${entry.id} ${entry.tool} ${entry.target}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function recordUnapplied(payload: Payload, verdict: UnappliedVerdict): void {
|
|
34
|
+
const calls = ledgeredCalls(payload);
|
|
35
|
+
if (calls.length === 0) return;
|
|
36
|
+
for (const call of calls) {
|
|
37
|
+
const entry = commitUnapplied(call, verdict);
|
|
38
|
+
logDebug("post-tool", `${entry.outcome} ${entry.id} ${entry.target}`);
|
|
39
|
+
}
|
|
40
|
+
reapStalePending();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function recordInLedger(payload: Payload): void {
|
|
44
|
+
const verdict = unappliedVerdictOf(payload);
|
|
45
|
+
if (verdict) recordUnapplied(payload, verdict);
|
|
46
|
+
else recordApplied(payload);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const HANDLERS: Record<string, (payload: Payload) => void> = {
|
|
50
|
+
events: logToolCall,
|
|
51
|
+
ledger: recordInLedger,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Runs every handler, or the one an `--only=<name>` registration asks for:
|
|
56
|
+
* Claude Code runs the ledger sync on edits and the events async on every tool.
|
|
57
|
+
*/
|
|
58
|
+
export function runPostToolHandlers(
|
|
59
|
+
payload: Payload,
|
|
60
|
+
argv: string[] = process.argv
|
|
61
|
+
): void {
|
|
62
|
+
const only = argv.find((a) => a.startsWith("--only="))?.slice("--only=".length);
|
|
63
|
+
for (const [name, handle] of Object.entries(HANDLERS)) {
|
|
64
|
+
if (!only || only === name) handle(payload);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session boundaries as events: when a session opened and why, when and why it
|
|
3
|
+
* closed, and when its context was compacted.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
closeSync,
|
|
8
|
+
existsSync,
|
|
9
|
+
openSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
readSync,
|
|
12
|
+
statSync,
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import { dirname, resolve } from "node:path";
|
|
15
|
+
import { appendEvent, eventsSince, type LoggedEvent, logQuietly } from "./events";
|
|
16
|
+
import { type HookTurnPayload, hookSessionId } from "./hook-turn";
|
|
17
|
+
import { palPkg } from "./paths";
|
|
18
|
+
|
|
19
|
+
const SESSION_LOOKBACK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
20
|
+
const TRANSCRIPT_TAIL_BYTES = 256 * 1024;
|
|
21
|
+
const MAX_FIELD = 64;
|
|
22
|
+
|
|
23
|
+
interface SessionStartPayload extends HookTurnPayload {
|
|
24
|
+
source?: string;
|
|
25
|
+
model?: unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface SessionEndPayload extends HookTurnPayload {
|
|
29
|
+
reason?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface CompactionPayload extends HookTurnPayload {
|
|
33
|
+
trigger?: string;
|
|
34
|
+
custom_instructions?: string;
|
|
35
|
+
customInstructions?: string;
|
|
36
|
+
transcript_path?: string;
|
|
37
|
+
transcriptPath?: string;
|
|
38
|
+
context_tokens?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function palVersion(): string | null {
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(readFileSync(resolve(palPkg(), "package.json"), "utf-8")).version;
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function timezone(now: Date) {
|
|
50
|
+
return {
|
|
51
|
+
tz: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
52
|
+
utcOffsetMin: 0 - now.getTimezoneOffset(),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function modelOf(model: unknown): string | null {
|
|
57
|
+
if (typeof model === "string") return model;
|
|
58
|
+
const id = (model as { id?: unknown } | null)?.id;
|
|
59
|
+
return typeof id === "string" ? id : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function gitDirOf(start: string): string | null {
|
|
63
|
+
let dir = resolve(start);
|
|
64
|
+
while (true) {
|
|
65
|
+
const dotGit = resolve(dir, ".git");
|
|
66
|
+
if (existsSync(dotGit)) {
|
|
67
|
+
if (statSync(dotGit).isDirectory()) return dotGit;
|
|
68
|
+
const pointer = /^gitdir:\s*(.+)$/m.exec(readFileSync(dotGit, "utf-8"))?.[1];
|
|
69
|
+
return pointer ? resolve(dir, pointer.trim()) : null;
|
|
70
|
+
}
|
|
71
|
+
const parent = dirname(dir);
|
|
72
|
+
if (parent === dir) return null;
|
|
73
|
+
dir = parent;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function gitBranchOf(cwd: string): string | null {
|
|
78
|
+
try {
|
|
79
|
+
const gitDir = gitDirOf(cwd);
|
|
80
|
+
if (!gitDir) return null;
|
|
81
|
+
const head = readFileSync(resolve(gitDir, "HEAD"), "utf-8").trim();
|
|
82
|
+
const branch = /^ref: refs\/heads\/(.+)$/.exec(head)?.[1] ?? "detached";
|
|
83
|
+
return branch.slice(0, MAX_FIELD);
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function runtimeSettings() {
|
|
90
|
+
return {
|
|
91
|
+
effort: process.env.CLAUDE_EFFORT || null,
|
|
92
|
+
entrypoint: process.env.CLAUDE_CODE_ENTRYPOINT || null,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function logSessionStart(
|
|
97
|
+
payload: SessionStartPayload | null,
|
|
98
|
+
now: Date = new Date()
|
|
99
|
+
): void {
|
|
100
|
+
logQuietly("session_start", () =>
|
|
101
|
+
appendEvent(
|
|
102
|
+
"session_start",
|
|
103
|
+
{
|
|
104
|
+
source: payload?.source ?? null,
|
|
105
|
+
model: modelOf(payload?.model),
|
|
106
|
+
...runtimeSettings(),
|
|
107
|
+
branch: gitBranchOf(process.cwd()),
|
|
108
|
+
palVersion: palVersion(),
|
|
109
|
+
...timezone(now),
|
|
110
|
+
},
|
|
111
|
+
{ ts: now.toISOString(), session: hookSessionId(payload) ?? null }
|
|
112
|
+
)
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function sessionEvents(session: string, now: Date) {
|
|
117
|
+
const since = new Date(now.getTime() - SESSION_LOOKBACK_MS);
|
|
118
|
+
const ofSession = (e: LoggedEvent) => e.session === session;
|
|
119
|
+
return {
|
|
120
|
+
start: eventsSince("session_start", since).find(ofSession),
|
|
121
|
+
turns: eventsSince("turn", since).filter(ofSession).length,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function sessionSummary(session: string | null, now: Date) {
|
|
126
|
+
if (!session) return { durationSec: null, turns: null };
|
|
127
|
+
const { start, turns } = sessionEvents(session, now);
|
|
128
|
+
const durationSec = start
|
|
129
|
+
? Math.round((now.getTime() - Date.parse(start.ts)) / 1000)
|
|
130
|
+
: null;
|
|
131
|
+
return { durationSec, turns };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function logSessionEnd(
|
|
135
|
+
payload: SessionEndPayload | null,
|
|
136
|
+
now: Date = new Date()
|
|
137
|
+
): void {
|
|
138
|
+
const session = hookSessionId(payload) ?? null;
|
|
139
|
+
logQuietly("session_end", () =>
|
|
140
|
+
appendEvent(
|
|
141
|
+
"session_end",
|
|
142
|
+
{ reason: payload?.reason ?? null, ...sessionSummary(session, now) },
|
|
143
|
+
{ ts: now.toISOString(), session }
|
|
144
|
+
)
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function transcriptTail(path: string): string {
|
|
149
|
+
const size = statSync(path).size;
|
|
150
|
+
const length = Math.min(size, TRANSCRIPT_TAIL_BYTES);
|
|
151
|
+
const buffer = Buffer.alloc(length);
|
|
152
|
+
const fd = openSync(path, "r");
|
|
153
|
+
try {
|
|
154
|
+
readSync(fd, buffer, 0, length, size - length);
|
|
155
|
+
} finally {
|
|
156
|
+
closeSync(fd);
|
|
157
|
+
}
|
|
158
|
+
return buffer.toString("utf-8");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
interface ContextUsage {
|
|
162
|
+
input_tokens?: number;
|
|
163
|
+
cache_read_input_tokens?: number;
|
|
164
|
+
cache_creation_input_tokens?: number;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function usageOfLine(line: string): ContextUsage | null {
|
|
168
|
+
try {
|
|
169
|
+
return JSON.parse(line)?.message?.usage ?? null;
|
|
170
|
+
} catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function contextTokensOf(transcriptPath: string | undefined): number | null {
|
|
176
|
+
if (!transcriptPath || !existsSync(transcriptPath)) return null;
|
|
177
|
+
const lines = transcriptTail(transcriptPath).split("\n").reverse();
|
|
178
|
+
const usage = lines.map(usageOfLine).find((u) => u !== null);
|
|
179
|
+
if (!usage) return null;
|
|
180
|
+
return (
|
|
181
|
+
(usage.input_tokens ?? 0) +
|
|
182
|
+
(usage.cache_read_input_tokens ?? 0) +
|
|
183
|
+
(usage.cache_creation_input_tokens ?? 0)
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function instructionsOf(payload: CompactionPayload | null): string | undefined {
|
|
188
|
+
return payload?.custom_instructions ?? payload?.customInstructions;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function compactedTokensOf(payload: CompactionPayload | null): number | null {
|
|
192
|
+
if (typeof payload?.context_tokens === "number") return payload.context_tokens;
|
|
193
|
+
return contextTokensOf(payload?.transcript_path ?? payload?.transcriptPath);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function logCompaction(
|
|
197
|
+
payload: CompactionPayload | null,
|
|
198
|
+
now: Date = new Date()
|
|
199
|
+
): void {
|
|
200
|
+
logQuietly("compaction", () =>
|
|
201
|
+
appendEvent(
|
|
202
|
+
"compaction",
|
|
203
|
+
{
|
|
204
|
+
trigger: payload?.trigger ?? null,
|
|
205
|
+
withInstructions: Boolean(instructionsOf(payload)?.trim()),
|
|
206
|
+
contextTokens: compactedTokensOf(payload),
|
|
207
|
+
},
|
|
208
|
+
{ ts: now.toISOString(), session: hookSessionId(payload) ?? null }
|
|
209
|
+
)
|
|
210
|
+
);
|
|
211
|
+
}
|
|
@@ -32,6 +32,8 @@ export interface PalSettingsData {
|
|
|
32
32
|
* paths whose contents are never stored; it cannot shrink it.
|
|
33
33
|
*/
|
|
34
34
|
ledger?: { redactPaths?: string[] };
|
|
35
|
+
/** Local event log (memory/events) and content store (memory/content). On unless disabled. */
|
|
36
|
+
eventLog?: { enabled?: boolean };
|
|
35
37
|
/** Control-room ranking knobs and attention sources — see src/tools/control-room/prefs.ts. */
|
|
36
38
|
controlRoom?: Record<string, unknown>;
|
|
37
39
|
/** Contextual-steering user extension: personal rules + shipped rules to suppress by tag. */
|
|
@@ -120,6 +122,10 @@ export function isOptedIn(key: string): boolean {
|
|
|
120
122
|
return load().dynamicContext?.[key] === true;
|
|
121
123
|
}
|
|
122
124
|
|
|
125
|
+
export function eventLogEnabled(): boolean {
|
|
126
|
+
return load().eventLog?.enabled !== false;
|
|
127
|
+
}
|
|
128
|
+
|
|
123
129
|
/** Get the loadAtStartup file list */
|
|
124
130
|
export function startupFiles(): string[] {
|
|
125
131
|
return load().loadAtStartup?.files ?? [];
|
package/src/hooks/lib/stop.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { updateCounts } from "../handlers/update-counts";
|
|
|
21
21
|
import { captureWorkSession } from "../handlers/work-session";
|
|
22
22
|
import { acknowledgeMentioned } from "./daily-nudge";
|
|
23
23
|
import { spawnDetachedInference } from "./detached-inference";
|
|
24
|
+
import { startHookTiming } from "./hook-timing";
|
|
24
25
|
import {
|
|
25
26
|
fileFinalReply,
|
|
26
27
|
type HookTurnPayload,
|
|
@@ -150,6 +151,7 @@ export async function runStopHandlers(
|
|
|
150
151
|
fileTranscript: string,
|
|
151
152
|
options: RunStopHandlersOptions = {}
|
|
152
153
|
): Promise<void> {
|
|
154
|
+
const timing = startHookTiming("stop");
|
|
153
155
|
const fileMessages = parseMessages(fileTranscript);
|
|
154
156
|
if (fileMessages.length < 2) return;
|
|
155
157
|
const messages = withFinalReply(fileMessages, options.lastAssistantMessage);
|
|
@@ -167,10 +169,12 @@ export async function runStopHandlers(
|
|
|
167
169
|
// Detach inference-bearing handlers — claude --print cold-start can exceed
|
|
168
170
|
// any in-hook budget. These spawn detached bun subprocesses that run the
|
|
169
171
|
// inference and write results to disk; they don't block this hook.
|
|
170
|
-
await
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
172
|
+
await timing.time("detach-inference", async () => {
|
|
173
|
+
await detachSessionIntelligence(transcript, options.sessionId);
|
|
174
|
+
await detachEntityExtraction(transcript, options.sessionId);
|
|
175
|
+
await detachFailurePrinciple(transcript);
|
|
176
|
+
detachAgenda(options.sessionId);
|
|
177
|
+
});
|
|
174
178
|
// Failure auto-graduation is intentionally NOT wired here: every pattern it
|
|
175
179
|
// ever promoted was a frustration log, not a principle. Wisdom frames are
|
|
176
180
|
// populated by Claude in-conversation (see wisdom.ts header). The handler
|
|
@@ -178,37 +182,29 @@ export async function runStopHandlers(
|
|
|
178
182
|
|
|
179
183
|
// Run remaining (non-inference) handlers concurrently.
|
|
180
184
|
// project-touch only fires when cwd resolves to an active registered project.
|
|
181
|
-
const
|
|
182
|
-
captureWorkSession(transcript, options.sessionId),
|
|
183
|
-
resetTab(),
|
|
184
|
-
updateCounts(),
|
|
185
|
-
autoBackup(),
|
|
186
|
-
checkReflectTrigger(),
|
|
187
|
-
checkSelfModelTrigger(),
|
|
188
|
-
runSynthesis(),
|
|
189
|
-
projectTouch(options.lastAssistantMessage),
|
|
190
|
-
notifyDesktop(options.sessionId),
|
|
191
|
-
Promise.resolve(writeContextDigests()),
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
"update-counts",
|
|
198
|
-
"backup",
|
|
199
|
-
"reflect-trigger",
|
|
200
|
-
"self-model-trigger",
|
|
201
|
-
"synthesis",
|
|
202
|
-
"project-touch",
|
|
203
|
-
"desktop-notify",
|
|
204
|
-
"context-digests",
|
|
205
|
-
];
|
|
185
|
+
const handlers: Record<string, unknown> = {
|
|
186
|
+
"work-session": captureWorkSession(transcript, options.sessionId),
|
|
187
|
+
tab: resetTab(),
|
|
188
|
+
"update-counts": updateCounts(),
|
|
189
|
+
backup: autoBackup(),
|
|
190
|
+
"reflect-trigger": checkReflectTrigger(),
|
|
191
|
+
"self-model-trigger": checkSelfModelTrigger(),
|
|
192
|
+
synthesis: runSynthesis(),
|
|
193
|
+
"project-touch": projectTouch(options.lastAssistantMessage),
|
|
194
|
+
"desktop-notify": notifyDesktop(options.sessionId),
|
|
195
|
+
"context-digests": Promise.resolve(writeContextDigests()),
|
|
196
|
+
};
|
|
197
|
+
const names = Object.keys(handlers);
|
|
198
|
+
const results = await Promise.allSettled(
|
|
199
|
+
names.map((name) => timing.time(name, Promise.resolve(handlers[name])))
|
|
200
|
+
);
|
|
206
201
|
for (let i = 0; i < results.length; i++) {
|
|
207
202
|
const r = results[i];
|
|
208
|
-
if (r
|
|
209
|
-
logError(`runStopHandlers:${
|
|
203
|
+
if (r?.status === "rejected") {
|
|
204
|
+
logError(`runStopHandlers:${names[i]}`, r.reason);
|
|
210
205
|
}
|
|
211
206
|
}
|
|
207
|
+
timing.log(options.sessionId);
|
|
212
208
|
}
|
|
213
209
|
|
|
214
210
|
/**
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One event per tool call, on every agent: which tool, how it ended, and the
|
|
3
|
+
* cheap facts about it. The call's arguments go to the content store.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { basename, extname } from "node:path";
|
|
7
|
+
import { FILE_TARGET_KEYS, normalizeToolUse, type ToolUseRequest } from "./agent";
|
|
8
|
+
import { appendEvent, logQuietly } from "./events";
|
|
9
|
+
import { hookSessionId } from "./hook-turn";
|
|
10
|
+
import { unappliedVerdictOf } from "./ledger-hook";
|
|
11
|
+
import { isPalSpawnedInference } from "./spawn-guard";
|
|
12
|
+
import { permissionWaitSec } from "./waiting-on-user";
|
|
13
|
+
|
|
14
|
+
const RESULT_KEYS = ["tool_response", "toolResult", "tool_output", "result"];
|
|
15
|
+
const DURATION_KEYS = ["duration_ms", "durationMs"];
|
|
16
|
+
const COMMAND_KEYS = ["command", "commandLine", "CommandLine", "script"];
|
|
17
|
+
|
|
18
|
+
function stringIn(input: Record<string, unknown>, keys: string[]): string | null {
|
|
19
|
+
const found = keys.map((k) => input[k]).find((v) => typeof v === "string" && v);
|
|
20
|
+
return (found as string | undefined) ?? null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function numberIn(source: unknown, keys: string[]): number | null {
|
|
24
|
+
if (typeof source !== "object" || source === null) return null;
|
|
25
|
+
const record = source as Record<string, unknown>;
|
|
26
|
+
const found = keys.map((k) => record[k]).find((v) => typeof v === "number");
|
|
27
|
+
return (found as number | undefined) ?? null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function resultOf(payload: Record<string, unknown>): unknown {
|
|
31
|
+
return RESULT_KEYS.map((k) => payload[k]).find((v) => v !== undefined);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function sizeOf(value: unknown): number {
|
|
35
|
+
if (value === undefined || value === null) return 0;
|
|
36
|
+
return typeof value === "string" ? value.length : JSON.stringify(value).length;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function hostOf(url: string | null): string | null {
|
|
40
|
+
if (!url) return null;
|
|
41
|
+
try {
|
|
42
|
+
return new URL(url).hostname;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function mcpServerOf(tool: string): string | null {
|
|
49
|
+
return tool.startsWith("mcp__") ? (tool.split("__")[1] ?? null) : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function programOf(command: string | null): string | null {
|
|
53
|
+
const first = command?.trim().split(/\s+/)[0];
|
|
54
|
+
return first ? basename(first) : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function outcomeOf(payload: Record<string, unknown>): string {
|
|
58
|
+
const verdict = unappliedVerdictOf(payload);
|
|
59
|
+
if (verdict) return verdict.outcome;
|
|
60
|
+
return typeof payload.error === "string" && payload.error ? "failed" : "ok";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function subagentRun(result: unknown) {
|
|
64
|
+
return {
|
|
65
|
+
subagentTokens: numberIn(result, ["totalTokens"]),
|
|
66
|
+
subagentTools: numberIn(result, ["totalToolUseCount"]),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function toolCallFields(payload: Record<string, unknown>, call: ToolUseRequest) {
|
|
71
|
+
const input = call.toolInput;
|
|
72
|
+
const result = resultOf(payload);
|
|
73
|
+
const file = stringIn(input, FILE_TARGET_KEYS);
|
|
74
|
+
return {
|
|
75
|
+
tool: call.toolName,
|
|
76
|
+
outcome: outcomeOf(payload),
|
|
77
|
+
durationMs: numberIn(payload, DURATION_KEYS) ?? numberIn(result, ["totalDurationMs"]),
|
|
78
|
+
inputChars: sizeOf(input),
|
|
79
|
+
outputChars: sizeOf(result),
|
|
80
|
+
fileExt: file ? extname(file) || null : null,
|
|
81
|
+
program: programOf(stringIn(input, COMMAND_KEYS)),
|
|
82
|
+
skill: stringIn(input, ["skill"]),
|
|
83
|
+
subagent: stringIn(input, ["subagent_type"]),
|
|
84
|
+
...subagentRun(result),
|
|
85
|
+
mcpServer: mcpServerOf(call.toolName),
|
|
86
|
+
host: hostOf(stringIn(input, ["url"])),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Logs the call a post-tool, failure or denial payload describes; ignores anything else. */
|
|
91
|
+
export function logToolCall(payload: Record<string, unknown> | null): void {
|
|
92
|
+
if (isPalSpawnedInference()) return;
|
|
93
|
+
logQuietly("tool_call", () => {
|
|
94
|
+
const call = normalizeToolUse(payload);
|
|
95
|
+
if (!payload || !call) return;
|
|
96
|
+
const session = hookSessionId(payload) ?? null;
|
|
97
|
+
const fields = {
|
|
98
|
+
...toolCallFields(payload, call),
|
|
99
|
+
promptWaitSec: permissionWaitSec(session, call.toolName),
|
|
100
|
+
};
|
|
101
|
+
appendEvent("tool_call", fields, {
|
|
102
|
+
session,
|
|
103
|
+
content: { tool: call.toolName, input: call.toolInput },
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
}
|