portable-agent-layer 0.94.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 +2 -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/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/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/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/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 +17 -1
- 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/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,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The local event log: one append-only metadata stream every pattern, report
|
|
3
|
+
* and dashboard is derived from. Events hold counts, durations, ids and enums;
|
|
4
|
+
* any text goes to the content store, which never leaves the machine.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { appendFileSync, existsSync, readdirSync, readFileSync } from "node:fs";
|
|
8
|
+
import { resolve } from "node:path";
|
|
9
|
+
import { currentAttribution } from "./actor";
|
|
10
|
+
import { logError } from "./log";
|
|
11
|
+
import { ensureDir, paths } from "./paths";
|
|
12
|
+
import { readAllProjects, resolveProjectFromCwd } from "./projects";
|
|
13
|
+
import { eventLogEnabled } from "./settings";
|
|
14
|
+
|
|
15
|
+
export const EVENT_SCHEMA_VERSION = 1;
|
|
16
|
+
const MAX_STRING = 64;
|
|
17
|
+
const MAX_ARRAY = 50;
|
|
18
|
+
|
|
19
|
+
export const EVENT_FIELDS = {
|
|
20
|
+
turn: [
|
|
21
|
+
"hour",
|
|
22
|
+
"weekday",
|
|
23
|
+
"words",
|
|
24
|
+
"gapSec",
|
|
25
|
+
"afterBreak",
|
|
26
|
+
"skimmed",
|
|
27
|
+
"interrupted",
|
|
28
|
+
"repeated",
|
|
29
|
+
"corrected",
|
|
30
|
+
"reply",
|
|
31
|
+
"reaction",
|
|
32
|
+
"reactionRules",
|
|
33
|
+
"mood",
|
|
34
|
+
"hinted",
|
|
35
|
+
"complied",
|
|
36
|
+
"language",
|
|
37
|
+
"pastedChars",
|
|
38
|
+
"images",
|
|
39
|
+
"command",
|
|
40
|
+
"backAfterSec",
|
|
41
|
+
],
|
|
42
|
+
reply: ["words", "listItems", "headings", "asked", "mode", "latencySec"],
|
|
43
|
+
session_start: [
|
|
44
|
+
"source",
|
|
45
|
+
"model",
|
|
46
|
+
"effort",
|
|
47
|
+
"entrypoint",
|
|
48
|
+
"branch",
|
|
49
|
+
"palVersion",
|
|
50
|
+
"tz",
|
|
51
|
+
"utcOffsetMin",
|
|
52
|
+
],
|
|
53
|
+
session_end: ["reason", "durationSec", "turns"],
|
|
54
|
+
compaction: ["trigger", "withInstructions", "contextTokens"],
|
|
55
|
+
hook_timing: ["hook", "totalMs", "handlers"],
|
|
56
|
+
inference_call: [
|
|
57
|
+
"caller",
|
|
58
|
+
"route",
|
|
59
|
+
"model",
|
|
60
|
+
"tier",
|
|
61
|
+
"web",
|
|
62
|
+
"latencyMs",
|
|
63
|
+
"failure",
|
|
64
|
+
"inputTokens",
|
|
65
|
+
"outputTokens",
|
|
66
|
+
],
|
|
67
|
+
pal_update: ["outcome", "from", "to", "durationSec", "skipReason"],
|
|
68
|
+
commitment: ["iscProject", "isc", "outcome", "surface", "snoozeDays"],
|
|
69
|
+
entity_first_seen: ["domain", "entityType"],
|
|
70
|
+
discord_nag: ["stage", "kind", "responseSec"],
|
|
71
|
+
permission_prompt: ["tool"],
|
|
72
|
+
notification: ["kind"],
|
|
73
|
+
tool_call: [
|
|
74
|
+
"tool",
|
|
75
|
+
"outcome",
|
|
76
|
+
"promptWaitSec",
|
|
77
|
+
"durationMs",
|
|
78
|
+
"inputChars",
|
|
79
|
+
"outputChars",
|
|
80
|
+
"fileExt",
|
|
81
|
+
"program",
|
|
82
|
+
"skill",
|
|
83
|
+
"subagent",
|
|
84
|
+
"subagentTokens",
|
|
85
|
+
"subagentTools",
|
|
86
|
+
"mcpServer",
|
|
87
|
+
"host",
|
|
88
|
+
],
|
|
89
|
+
} as const satisfies Record<string, readonly string[]>;
|
|
90
|
+
|
|
91
|
+
export type EventType = keyof typeof EVENT_FIELDS;
|
|
92
|
+
type EventValue =
|
|
93
|
+
| string
|
|
94
|
+
| number
|
|
95
|
+
| boolean
|
|
96
|
+
| null
|
|
97
|
+
| EventValue[]
|
|
98
|
+
| { [k: string]: EventValue };
|
|
99
|
+
|
|
100
|
+
interface EventEnvelope {
|
|
101
|
+
v: number;
|
|
102
|
+
id: string;
|
|
103
|
+
ts: string;
|
|
104
|
+
type: EventType;
|
|
105
|
+
session: string | null;
|
|
106
|
+
runtime: string;
|
|
107
|
+
machine: string;
|
|
108
|
+
actor: string;
|
|
109
|
+
authority: string;
|
|
110
|
+
channel: string | null;
|
|
111
|
+
project: string | null;
|
|
112
|
+
cwd: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export type LoggedEvent = EventEnvelope & Record<string, unknown>;
|
|
116
|
+
|
|
117
|
+
export interface AppendOptions {
|
|
118
|
+
id?: string;
|
|
119
|
+
ts?: string;
|
|
120
|
+
session?: string | null;
|
|
121
|
+
channel?: string | null;
|
|
122
|
+
cwd?: string;
|
|
123
|
+
content?: Record<string, unknown>;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function isEventValue(value: unknown, depth = 0): value is EventValue {
|
|
127
|
+
if (value === null || typeof value === "boolean") return true;
|
|
128
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
129
|
+
if (typeof value === "string") return value.length <= MAX_STRING;
|
|
130
|
+
if (depth > 1) return false;
|
|
131
|
+
if (Array.isArray(value))
|
|
132
|
+
return value.length <= MAX_ARRAY && value.every((v) => isEventValue(v, depth + 1));
|
|
133
|
+
if (typeof value === "object")
|
|
134
|
+
return Object.values(value).every((v) => isEventValue(v, depth + 1));
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function admittedFields(
|
|
139
|
+
type: EventType,
|
|
140
|
+
fields: Record<string, unknown>
|
|
141
|
+
): Record<string, EventValue> {
|
|
142
|
+
const allowed: readonly string[] = EVENT_FIELDS[type];
|
|
143
|
+
const kept: Record<string, EventValue> = {};
|
|
144
|
+
for (const [name, value] of Object.entries(fields)) {
|
|
145
|
+
if (allowed.includes(name) && isEventValue(value)) kept[name] = value;
|
|
146
|
+
}
|
|
147
|
+
return kept;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const projectByCwd = new Map<string, string | null>();
|
|
151
|
+
|
|
152
|
+
function projectOf(cwd: string): string | null {
|
|
153
|
+
if (!projectByCwd.has(cwd)) {
|
|
154
|
+
projectByCwd.set(cwd, resolveProjectFromCwd(cwd, readAllProjects())?.name ?? null);
|
|
155
|
+
}
|
|
156
|
+
return projectByCwd.get(cwd) ?? null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function monthOf(ts: string): string {
|
|
160
|
+
return ts.slice(0, 7);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function eventsDir(): string {
|
|
164
|
+
return ensureDir(resolve(paths.memory(), "events"));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function contentDir(): string {
|
|
168
|
+
return ensureDir(resolve(paths.content(), "events"));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function envelope(type: EventType, options: AppendOptions): EventEnvelope {
|
|
172
|
+
const cwd = options.cwd ?? process.cwd();
|
|
173
|
+
return {
|
|
174
|
+
v: EVENT_SCHEMA_VERSION,
|
|
175
|
+
id: options.id ?? crypto.randomUUID(),
|
|
176
|
+
ts: options.ts ?? new Date().toISOString(),
|
|
177
|
+
type,
|
|
178
|
+
session: options.session ?? null,
|
|
179
|
+
...currentAttribution(),
|
|
180
|
+
channel:
|
|
181
|
+
options.channel !== undefined ? options.channel : process.env.PAL_CHANNEL || null,
|
|
182
|
+
project: projectOf(cwd),
|
|
183
|
+
cwd,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Appends one event and returns its id, or null when the log is off or the write failed. */
|
|
188
|
+
export function appendEvent(
|
|
189
|
+
type: EventType,
|
|
190
|
+
fields: Record<string, unknown>,
|
|
191
|
+
options: AppendOptions = {}
|
|
192
|
+
): string | null {
|
|
193
|
+
if (!eventLogEnabled()) return null;
|
|
194
|
+
try {
|
|
195
|
+
const head = envelope(type, options);
|
|
196
|
+
const line = { ...head, ...admittedFields(type, fields) };
|
|
197
|
+
appendFileSync(
|
|
198
|
+
resolve(eventsDir(), `${monthOf(head.ts)}.jsonl`),
|
|
199
|
+
`${JSON.stringify(line)}\n`
|
|
200
|
+
);
|
|
201
|
+
if (options.content) {
|
|
202
|
+
const stored = { id: head.id, ts: head.ts, type, ...options.content };
|
|
203
|
+
appendFileSync(
|
|
204
|
+
resolve(contentDir(), `${monthOf(head.ts)}.jsonl`),
|
|
205
|
+
`${JSON.stringify(stored)}\n`
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
return head.id;
|
|
209
|
+
} catch (err) {
|
|
210
|
+
logError("events:append", err);
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Runs a logging step so that nothing it does can fail the hook around it. */
|
|
216
|
+
export function logQuietly(label: string, write: () => void): void {
|
|
217
|
+
try {
|
|
218
|
+
write();
|
|
219
|
+
} catch (err) {
|
|
220
|
+
logError(`events:${label}`, err);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function parseLines(file: string): LoggedEvent[] {
|
|
225
|
+
if (!existsSync(file)) return [];
|
|
226
|
+
return readFileSync(file, "utf-8")
|
|
227
|
+
.split("\n")
|
|
228
|
+
.filter(Boolean)
|
|
229
|
+
.flatMap((line) => {
|
|
230
|
+
try {
|
|
231
|
+
return [JSON.parse(line) as LoggedEvent];
|
|
232
|
+
} catch {
|
|
233
|
+
return [];
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function eventsInMonth(type: EventType, month: Date): LoggedEvent[] {
|
|
239
|
+
const file = resolve(eventsDir(), `${monthOf(month.toISOString())}.jsonl`);
|
|
240
|
+
return parseLines(file).filter((e) => e.type === type);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function eventsSince(type: EventType, since: Date): LoggedEvent[] {
|
|
244
|
+
const dir = eventsDir();
|
|
245
|
+
const sinceTs = since.toISOString();
|
|
246
|
+
const fromMonth = monthOf(sinceTs);
|
|
247
|
+
return readdirSync(dir)
|
|
248
|
+
.filter((f) => f.endsWith(".jsonl") && f.slice(0, 7) >= fromMonth)
|
|
249
|
+
.sort()
|
|
250
|
+
.flatMap((f) => parseLines(resolve(dir, f)))
|
|
251
|
+
.filter((e) => e.type === type && e.ts >= sinceTs);
|
|
252
|
+
}
|
package/src/hooks/lib/export.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { palHome } from "./paths";
|
|
|
20
20
|
const EXPORT_DIRS = ["telos", "memory", "skills", "agents"];
|
|
21
21
|
|
|
22
22
|
/** Subdirectories/files to skip during export. */
|
|
23
|
-
const SKIP_PATTERNS = ["memory/downloads"];
|
|
23
|
+
const SKIP_PATTERNS = ["memory/downloads", "memory/content"];
|
|
24
24
|
|
|
25
25
|
function shouldSkip(relPath: string): boolean {
|
|
26
26
|
const normalized = relPath.replaceAll("\\", "/");
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How long a hook and each of its handlers took, logged as one event per run.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { appendEvent, logQuietly } from "./events";
|
|
6
|
+
|
|
7
|
+
function elapsedMs(since: number): number {
|
|
8
|
+
return Math.round((performance.now() - since) * 10) / 10;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function startHookTiming(hook: string) {
|
|
12
|
+
const started = performance.now();
|
|
13
|
+
const handlers: Record<string, number> = {};
|
|
14
|
+
return {
|
|
15
|
+
async time<T>(name: string, work: Promise<T> | (() => Promise<T> | T)): Promise<T> {
|
|
16
|
+
const begun = performance.now();
|
|
17
|
+
try {
|
|
18
|
+
return await (typeof work === "function" ? work() : work);
|
|
19
|
+
} finally {
|
|
20
|
+
handlers[name] = elapsedMs(begun);
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
log(session: string | null | undefined): void {
|
|
24
|
+
logQuietly("hook_timing", () =>
|
|
25
|
+
appendEvent(
|
|
26
|
+
"hook_timing",
|
|
27
|
+
{ hook, totalMs: elapsedMs(started), handlers },
|
|
28
|
+
{ session: session ?? null }
|
|
29
|
+
)
|
|
30
|
+
);
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One event per inference PAL makes for itself: who asked, which route and
|
|
3
|
+
* model answered, how long it took and how it ended.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { appendEvent, logQuietly } from "./events";
|
|
7
|
+
|
|
8
|
+
interface InferenceCall {
|
|
9
|
+
caller?: string;
|
|
10
|
+
sessionId?: string;
|
|
11
|
+
tier?: string;
|
|
12
|
+
web?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface InferenceOutcome {
|
|
16
|
+
success: boolean;
|
|
17
|
+
output?: string;
|
|
18
|
+
error?: string;
|
|
19
|
+
usage?: { inputTokens: number; outputTokens: number };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const SCHEMA_MISMATCH = "the reply does not match the schema";
|
|
23
|
+
const NO_ROUTE = ["none", "disabled"];
|
|
24
|
+
|
|
25
|
+
function failureOf(outcome: InferenceOutcome, route: string): string | null {
|
|
26
|
+
if (outcome.success) return null;
|
|
27
|
+
if (NO_ROUTE.includes(route)) return "unavailable";
|
|
28
|
+
if (outcome.error?.startsWith(SCHEMA_MISMATCH)) return "schema";
|
|
29
|
+
if (outcome.output) return "unparsable";
|
|
30
|
+
return outcome.error ? "error" : "no-reply";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function logInferenceCall(
|
|
34
|
+
call: InferenceCall,
|
|
35
|
+
planned: { route: string; model: string | null },
|
|
36
|
+
outcome: InferenceOutcome,
|
|
37
|
+
latencyMs: number
|
|
38
|
+
): void {
|
|
39
|
+
logQuietly("inference_call", () =>
|
|
40
|
+
appendEvent(
|
|
41
|
+
"inference_call",
|
|
42
|
+
{
|
|
43
|
+
caller: call.caller ?? "anonymous",
|
|
44
|
+
route: planned.route,
|
|
45
|
+
model: planned.model,
|
|
46
|
+
tier: call.tier ?? "small",
|
|
47
|
+
web: Boolean(call.web),
|
|
48
|
+
latencyMs: Math.round(latencyMs),
|
|
49
|
+
failure: failureOf(outcome, planned.route),
|
|
50
|
+
inputTokens: outcome.usage?.inputTokens ?? null,
|
|
51
|
+
outputTokens: outcome.usage?.outputTokens ?? null,
|
|
52
|
+
},
|
|
53
|
+
{ session: call.sessionId ?? null }
|
|
54
|
+
)
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
streamJsonPrompt,
|
|
41
41
|
writeInferenceAgent,
|
|
42
42
|
} from "./antigravity-inference";
|
|
43
|
+
import { logInferenceCall, SCHEMA_MISMATCH } from "./inference-events";
|
|
43
44
|
import { logDebug, logError } from "./log";
|
|
44
45
|
import {
|
|
45
46
|
type FixedModelRoute,
|
|
@@ -188,7 +189,22 @@ function schemaProblem(
|
|
|
188
189
|
.join("; ");
|
|
189
190
|
}
|
|
190
191
|
|
|
192
|
+
function plannedRoute(opts: InferenceOptions): { route: string; model: string | null } {
|
|
193
|
+
const { route } = previewRoute();
|
|
194
|
+
if (route === "opencode-spawn")
|
|
195
|
+
return { route, model: opencodeTierModel(opts.tier ?? "small") };
|
|
196
|
+
if (isFixedModelRoute(route)) return { route, model: modelFor(route, opts) };
|
|
197
|
+
return { route, model: null };
|
|
198
|
+
}
|
|
199
|
+
|
|
191
200
|
export async function inference(opts: InferenceOptions): Promise<InferenceResult> {
|
|
201
|
+
const started = performance.now();
|
|
202
|
+
const result = await checkedInference(opts);
|
|
203
|
+
logInferenceCall(opts, plannedRoute(opts), result, performance.now() - started);
|
|
204
|
+
return result;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function checkedInference(opts: InferenceOptions): Promise<InferenceResult> {
|
|
192
208
|
const result = await routeInference(opts);
|
|
193
209
|
if (!opts.jsonSchema || !result.success || !result.output) return result;
|
|
194
210
|
const problem = schemaProblem(result.output, opts.jsonSchema);
|
|
@@ -200,7 +216,7 @@ export async function inference(opts: InferenceOptions): Promise<InferenceResult
|
|
|
200
216
|
return {
|
|
201
217
|
...result,
|
|
202
218
|
success: false,
|
|
203
|
-
error:
|
|
219
|
+
error: `${SCHEMA_MISMATCH}: ${problem}`,
|
|
204
220
|
};
|
|
205
221
|
}
|
|
206
222
|
|
|
@@ -1,18 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Measures every user turn in code and logs the features
|
|
2
|
+
* Measures every user turn in code and logs the features as turn events; the
|
|
3
|
+
* prompt text goes only to the content store, which never leaves the machine.
|
|
3
4
|
* Per-session tracking lives in state and is discarded with the session.
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
|
-
import {
|
|
7
|
-
appendFileSync,
|
|
8
|
-
existsSync,
|
|
9
|
-
readdirSync,
|
|
10
|
-
readFileSync,
|
|
11
|
-
writeFileSync,
|
|
12
|
-
} from "node:fs";
|
|
7
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
8
|
import { resolve } from "node:path";
|
|
14
|
-
import { currentAttribution } from "./actor";
|
|
15
9
|
import { hearsPromptContext } from "./agent";
|
|
10
|
+
import { appendEvent, eventsInMonth, eventsSince, logQuietly } from "./events";
|
|
16
11
|
import { asksForLess, followedHint, ignoredHintReminder } from "./interaction-hint-check";
|
|
17
12
|
import {
|
|
18
13
|
type Baseline,
|
|
@@ -34,6 +29,8 @@ import { keepSample, replyEnd } from "./interaction-samples";
|
|
|
34
29
|
import { ensureDir, paths } from "./paths";
|
|
35
30
|
import { isSystemText, stripInjectedTags } from "./prompt-text";
|
|
36
31
|
import { isEnabled } from "./settings";
|
|
32
|
+
import { responseModeOf, turnExtras } from "./turn-extras";
|
|
33
|
+
import { backAfterNotificationSec } from "./waiting-on-user";
|
|
37
34
|
import { localClock } from "./wall-clock";
|
|
38
35
|
|
|
39
36
|
interface ReplyShape {
|
|
@@ -135,20 +132,51 @@ function measureTurn(
|
|
|
135
132
|
};
|
|
136
133
|
}
|
|
137
134
|
|
|
138
|
-
function
|
|
135
|
+
function legacyTurnsDir(): string {
|
|
139
136
|
return ensureDir(resolve(paths.signals(), "interaction"));
|
|
140
137
|
}
|
|
141
138
|
|
|
142
|
-
function
|
|
143
|
-
|
|
139
|
+
function logTurn(event: TurnEvent, prompt: string, text: string): void {
|
|
140
|
+
const { ts, session, channel, ...fields } = event;
|
|
141
|
+
logQuietly("turn", () =>
|
|
142
|
+
appendEvent(
|
|
143
|
+
"turn",
|
|
144
|
+
{
|
|
145
|
+
...fields,
|
|
146
|
+
...turnExtras(prompt, text),
|
|
147
|
+
backAfterSec: backAfterNotificationSec(session, new Date(ts)),
|
|
148
|
+
},
|
|
149
|
+
{ ts, session, channel, content: { prompt: text } }
|
|
150
|
+
)
|
|
151
|
+
);
|
|
144
152
|
}
|
|
145
153
|
|
|
146
|
-
function
|
|
147
|
-
|
|
148
|
-
|
|
154
|
+
function secondsBetween(from: string | undefined, to: Date): number | null {
|
|
155
|
+
if (!from) return null;
|
|
156
|
+
return Math.round((to.getTime() - new Date(from).getTime()) / 100) / 10;
|
|
149
157
|
}
|
|
150
158
|
|
|
151
|
-
function
|
|
159
|
+
function logReply(
|
|
160
|
+
session: string,
|
|
161
|
+
reply: string,
|
|
162
|
+
shape: ReplyShape,
|
|
163
|
+
track: SessionTrack
|
|
164
|
+
) {
|
|
165
|
+
const { at, ...features } = shape;
|
|
166
|
+
logQuietly("reply", () =>
|
|
167
|
+
appendEvent(
|
|
168
|
+
"reply",
|
|
169
|
+
{
|
|
170
|
+
...features,
|
|
171
|
+
mode: responseModeOf(reply),
|
|
172
|
+
latencySec: secondsBetween(track.lastPromptAt, new Date(at)),
|
|
173
|
+
},
|
|
174
|
+
{ ts: at, session, content: { reply } }
|
|
175
|
+
)
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function readLegacyTurns(file: string): TurnEvent[] {
|
|
152
180
|
if (!existsSync(file)) return [];
|
|
153
181
|
return readFileSync(file, "utf-8")
|
|
154
182
|
.split("\n")
|
|
@@ -162,20 +190,33 @@ function readEvents(file: string): TurnEvent[] {
|
|
|
162
190
|
});
|
|
163
191
|
}
|
|
164
192
|
|
|
165
|
-
|
|
193
|
+
function legacyTurnsSince(since: Date): TurnEvent[] {
|
|
166
194
|
const fromMonth = since.toISOString().slice(0, 7);
|
|
167
195
|
const sinceTs = since.toISOString();
|
|
168
|
-
return readdirSync(
|
|
196
|
+
return readdirSync(legacyTurnsDir())
|
|
169
197
|
.filter((f) => f.endsWith(".jsonl") && f.slice(0, 7) >= fromMonth)
|
|
170
198
|
.sort()
|
|
171
|
-
.flatMap((f) =>
|
|
199
|
+
.flatMap((f) => readLegacyTurns(resolve(legacyTurnsDir(), f)))
|
|
172
200
|
.filter((e) => e.ts >= sinceTs);
|
|
173
201
|
}
|
|
174
202
|
|
|
203
|
+
export function turnsSince(since: Date): TurnEvent[] {
|
|
204
|
+
const streamed = eventsSince("turn", since) as unknown as TurnEvent[];
|
|
205
|
+
return [...legacyTurnsSince(since), ...streamed].sort((a, b) =>
|
|
206
|
+
a.ts.localeCompare(b.ts)
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function turnsInMonth(month: Date): TurnEvent[] {
|
|
211
|
+
const file = `${month.toISOString().slice(0, 7)}.jsonl`;
|
|
212
|
+
const streamed = eventsInMonth("turn", month) as unknown as TurnEvent[];
|
|
213
|
+
return [...readLegacyTurns(resolve(legacyTurnsDir(), file)), ...streamed];
|
|
214
|
+
}
|
|
215
|
+
|
|
175
216
|
/** This month and last, outside the session being judged. */
|
|
176
217
|
function historyOutside(session: string, now: Date): TurnEvent[] {
|
|
177
218
|
const lastMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1));
|
|
178
|
-
return [...
|
|
219
|
+
return [...turnsInMonth(lastMonth), ...turnsInMonth(now)].filter(
|
|
179
220
|
(e) => e.session !== session
|
|
180
221
|
);
|
|
181
222
|
}
|
|
@@ -234,10 +275,12 @@ export function recordReply(
|
|
|
234
275
|
): void {
|
|
235
276
|
if (!session || !reply.trim() || !isEnabled("interactionAwareness")) return;
|
|
236
277
|
const track = trackOf(session);
|
|
278
|
+
const shape = replyShape(reply, now);
|
|
279
|
+
logReply(session, reply, shape, track);
|
|
237
280
|
writeTrack(session, {
|
|
238
281
|
...track,
|
|
239
282
|
updated: now.toISOString(),
|
|
240
|
-
reply:
|
|
283
|
+
reply: shape,
|
|
241
284
|
replyEnd: replyEnd(reply),
|
|
242
285
|
});
|
|
243
286
|
}
|
|
@@ -295,12 +338,16 @@ export function observeTurn(
|
|
|
295
338
|
moodReminder(mood, track.mood ?? ""),
|
|
296
339
|
reminderForIgnoredHint(complied, mood.key, replyWords, usualReply, history)
|
|
297
340
|
);
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
341
|
+
logTurn(
|
|
342
|
+
{
|
|
343
|
+
...event,
|
|
344
|
+
mood: mood.key,
|
|
345
|
+
hinted: reminder !== null && hearsPromptContext(),
|
|
346
|
+
...(complied !== null && { complied }),
|
|
347
|
+
},
|
|
348
|
+
prompt,
|
|
349
|
+
text
|
|
350
|
+
);
|
|
304
351
|
sampleReaction(event, text, track);
|
|
305
352
|
writeTrack(session, {
|
|
306
353
|
...track,
|
|
@@ -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
|
+
}
|