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.
Files changed (58) hide show
  1. package/README.md +5 -1
  2. package/assets/schema/pal-settings.schema.json +4 -0
  3. package/assets/skills/research-entity/SKILL.md +57 -0
  4. package/assets/templates/hooks.antigravity.json +1 -1
  5. package/assets/templates/hooks.codex.json +11 -1
  6. package/assets/templates/hooks.copilot.json +11 -4
  7. package/assets/templates/hooks.cursor.json +8 -2
  8. package/assets/templates/pal-settings.json +1 -0
  9. package/assets/templates/settings.claude.json +55 -3
  10. package/package.json +1 -1
  11. package/src/cli/content.ts +23 -0
  12. package/src/cli/events.ts +47 -0
  13. package/src/cli/infer.ts +6 -0
  14. package/src/cli/knowledge-research.ts +93 -0
  15. package/src/cli/knowledge.ts +17 -4
  16. package/src/cli/tree.ts +4 -0
  17. package/src/hooks/AttentionEvent.ts +22 -0
  18. package/src/hooks/LoadContext.ts +2 -2
  19. package/src/hooks/PostToolOrchestrator.ts +20 -0
  20. package/src/hooks/PreCompactPersist.ts +4 -2
  21. package/src/hooks/PreserveTranscripts.ts +13 -0
  22. package/src/hooks/SessionClose.ts +5 -0
  23. package/src/hooks/UserPromptOrchestrator.ts +9 -2
  24. package/src/hooks/handlers/entity-extraction.ts +10 -3
  25. package/src/hooks/lib/attention-events.ts +43 -0
  26. package/src/hooks/lib/auto-update.ts +24 -1
  27. package/src/hooks/lib/commitment-events.ts +20 -0
  28. package/src/hooks/lib/discord-events.ts +25 -0
  29. package/src/hooks/lib/entity-events.ts +16 -0
  30. package/src/hooks/lib/entity-extraction.ts +24 -2
  31. package/src/hooks/lib/entity-research.ts +305 -0
  32. package/src/hooks/lib/entity-review.ts +19 -6
  33. package/src/hooks/lib/events.ts +252 -0
  34. package/src/hooks/lib/export.ts +1 -1
  35. package/src/hooks/lib/hook-timing.ts +33 -0
  36. package/src/hooks/lib/inference-events.ts +56 -0
  37. package/src/hooks/lib/inference.ts +51 -6
  38. package/src/hooks/lib/interaction.ts +74 -27
  39. package/src/hooks/lib/opencode-events.ts +59 -0
  40. package/src/hooks/lib/paths.ts +1 -0
  41. package/src/hooks/lib/post-tool.ts +66 -0
  42. package/src/hooks/lib/session-events.ts +211 -0
  43. package/src/hooks/lib/settings.ts +6 -0
  44. package/src/hooks/lib/stop.ts +27 -31
  45. package/src/hooks/lib/token-usage.ts +1 -0
  46. package/src/hooks/lib/tool-events.ts +106 -0
  47. package/src/hooks/lib/transcript-preserve.ts +133 -0
  48. package/src/hooks/lib/turn-extras.ts +78 -0
  49. package/src/hooks/lib/waiting-on-user.ts +75 -0
  50. package/src/hooks/lib/work-tracking.ts +17 -3
  51. package/src/targets/opencode/plugin.ts +33 -1
  52. package/src/tools/agent/interaction.ts +2 -2
  53. package/src/tools/agent/project.ts +4 -0
  54. package/src/tools/control-room/snooze.ts +2 -0
  55. package/src/tools/control-room/writes.ts +2 -0
  56. package/src/tools/knowledge/lib.ts +5 -0
  57. package/src/hooks/LedgerCommit.ts +0 -34
  58. 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
+ }
@@ -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,
@@ -134,6 +135,13 @@ function previewRoute(): RoutePreview {
134
135
  }
135
136
 
136
137
  /** True if any inference path is currently usable (subscription CLI OR API key). */
138
+ /** Routes whose CLI can be limited to web search and fetch alone. */
139
+ function canSearchWeb(): boolean {
140
+ if (isClaude() && hasClaudeBinary()) return true;
141
+ if (isCodex() && hasCodexBinary()) return true;
142
+ return isCopilot() && hasCopilotBinary();
143
+ }
144
+
137
145
  export function canInfer(): boolean {
138
146
  if (isClaude() && hasClaudeBinary()) return true;
139
147
  if (isCodex() && hasCodexBinary()) return true;
@@ -158,6 +166,8 @@ interface InferenceOptions {
158
166
  caller?: string;
159
167
  /** Session ID the call is associated with — appears in debug logs as sessionId=X */
160
168
  sessionId?: string;
169
+ /** Let the model search and read the web, and nothing else; a route that can't fails */
170
+ web?: boolean;
161
171
  }
162
172
 
163
173
  interface InferenceResult {
@@ -179,7 +189,22 @@ function schemaProblem(
179
189
  .join("; ");
180
190
  }
181
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
+
182
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> {
183
208
  const result = await routeInference(opts);
184
209
  if (!opts.jsonSchema || !result.success || !result.output) return result;
185
210
  const problem = schemaProblem(result.output, opts.jsonSchema);
@@ -191,7 +216,7 @@ export async function inference(opts: InferenceOptions): Promise<InferenceResult
191
216
  return {
192
217
  ...result,
193
218
  success: false,
194
- error: `the reply does not match the schema: ${problem}`,
219
+ error: `${SCHEMA_MISMATCH}: ${problem}`,
195
220
  };
196
221
  }
197
222
 
@@ -210,6 +235,10 @@ async function routeInference(opts: InferenceOptions): Promise<InferenceResult>
210
235
  const caller = opts.caller ?? "anonymous";
211
236
  const session = opts.sessionId ?? "-";
212
237
  const tag = `caller=${caller} sessionId=${session}`;
238
+ if (opts.web && !canSearchWeb()) {
239
+ logDebug("inference", `${tag} refuse: no web route agent=${agent}`);
240
+ return { success: false, error: `web research is not available on ${agent} yet` };
241
+ }
213
242
  if (isClaude()) {
214
243
  const bin = getClaudeBinary();
215
244
  if (bin) {
@@ -342,6 +371,15 @@ function hasAntigravityBinary(): boolean {
342
371
  * line. System, user and any JSON-schema instruction all travel together on
343
372
  * stdin instead, the same way every other agent receives them.
344
373
  */
374
+ const CLAUDE_WEB_TOOLS = "WebSearch,WebFetch";
375
+
376
+ /** --tools alone makes the web tools visible; --allowed-tools spares them the prompt. */
377
+ function claudeTools(opts: InferenceOptions): string[] {
378
+ return opts.web
379
+ ? ["--tools", CLAUDE_WEB_TOOLS, "--allowed-tools", CLAUDE_WEB_TOOLS]
380
+ : ["--tools", ""];
381
+ }
382
+
345
383
  export function buildClaudeArgs(
346
384
  opts: InferenceOptions,
347
385
  systemPromptFile?: string
@@ -350,8 +388,7 @@ export function buildClaudeArgs(
350
388
  "--print",
351
389
  "--model",
352
390
  modelFor("claude-spawn", opts),
353
- "--tools",
354
- "",
391
+ ...claudeTools(opts),
355
392
  "--output-format",
356
393
  "text",
357
394
  "--setting-sources",
@@ -447,6 +484,7 @@ export function buildCodexArgs(opts: InferenceOptions): string[] {
447
484
  "--sandbox",
448
485
  "read-only",
449
486
  "--ephemeral",
487
+ ...(opts.web ? ["-c", "web_search=live"] : []),
450
488
  ];
451
489
  }
452
490
 
@@ -584,6 +622,8 @@ async function inferenceViaAntigravitySpawn(
584
622
  * naming an unknown tool hides every tool
585
623
  * --deny-tool=… → backstop: deny rules win over any allow rule
586
624
  * --silent → keeps the tool-filter notices out of stdout
625
+ * web: only web_fetch, pre-approved for every URL; Copilot has no search tool,
626
+ * so it fetches search pages itself
587
627
  *
588
628
  * Copilot has no --system-prompt flag, so system + user + JSON-schema are
589
629
  * concatenated into one prompt delivered on stdin. `-p/--prompt` is deliberately
@@ -591,20 +631,25 @@ async function inferenceViaAntigravitySpawn(
591
631
  * survive cmd.exe when Bun.spawn resolves copilot to its Windows .cmd shim.
592
632
  * Piping stdin keeps copilot non-interactive, so dropping -p costs nothing.
593
633
  */
594
- export function buildCopilotArgs(_opts: InferenceOptions): string[] {
634
+ export function buildCopilotArgs(opts: InferenceOptions): string[] {
595
635
  return [
596
636
  "--no-custom-instructions",
597
637
  "--disable-builtin-mcps",
598
638
  "--no-auto-update",
599
639
  "--no-color",
600
640
  "--silent",
601
- "--available-tools=none",
641
+ ...copilotTools(opts),
602
642
  "--deny-tool=shell",
603
643
  "--deny-tool=write",
604
- "--deny-tool=url",
605
644
  ];
606
645
  }
607
646
 
647
+ function copilotTools(opts: InferenceOptions): string[] {
648
+ return opts.web
649
+ ? ["--available-tools=web_fetch", "--allow-tool=web_fetch", "--allow-all-urls"]
650
+ : ["--available-tools=none", "--deny-tool=url"];
651
+ }
652
+
608
653
  /**
609
654
  * Extract the agent's text reply from opencode --format json NDJSON output.
610
655
  * Concatenates all `type:"text"` event payloads in order. Returns empty
@@ -1,18 +1,13 @@
1
1
  /**
2
- * Measures every user turn in code and logs the features, never the text.
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 eventsDir(): string {
135
+ function legacyTurnsDir(): string {
139
136
  return ensureDir(resolve(paths.signals(), "interaction"));
140
137
  }
141
138
 
142
- function monthFile(date: Date): string {
143
- return resolve(eventsDir(), `${date.toISOString().slice(0, 7)}.jsonl`);
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 appendEvent(event: TurnEvent): void {
147
- const line = { ...currentAttribution(), type: "turn", ...event };
148
- appendFileSync(monthFile(new Date(event.ts)), `${JSON.stringify(line)}\n`);
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 readEvents(file: string): TurnEvent[] {
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
- export function turnsSince(since: Date): TurnEvent[] {
193
+ function legacyTurnsSince(since: Date): TurnEvent[] {
166
194
  const fromMonth = since.toISOString().slice(0, 7);
167
195
  const sinceTs = since.toISOString();
168
- return readdirSync(eventsDir())
196
+ return readdirSync(legacyTurnsDir())
169
197
  .filter((f) => f.endsWith(".jsonl") && f.slice(0, 7) >= fromMonth)
170
198
  .sort()
171
- .flatMap((f) => readEvents(resolve(eventsDir(), 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 [...readEvents(monthFile(lastMonth)), ...readEvents(monthFile(now))].filter(
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: replyShape(reply, now),
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
- appendEvent({
299
- ...event,
300
- mood: mood.key,
301
- hinted: reminder !== null && hearsPromptContext(),
302
- ...(complied !== null && { complied }),
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,