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.
Files changed (47) hide show
  1. package/README.md +2 -0
  2. package/assets/templates/hooks.antigravity.json +1 -1
  3. package/assets/templates/hooks.codex.json +11 -1
  4. package/assets/templates/hooks.copilot.json +11 -4
  5. package/assets/templates/hooks.cursor.json +8 -2
  6. package/assets/templates/settings.claude.json +55 -3
  7. package/package.json +1 -1
  8. package/src/cli/content.ts +23 -0
  9. package/src/cli/events.ts +47 -0
  10. package/src/cli/tree.ts +4 -0
  11. package/src/hooks/AttentionEvent.ts +22 -0
  12. package/src/hooks/LoadContext.ts +2 -2
  13. package/src/hooks/PostToolOrchestrator.ts +20 -0
  14. package/src/hooks/PreCompactPersist.ts +4 -2
  15. package/src/hooks/PreserveTranscripts.ts +13 -0
  16. package/src/hooks/SessionClose.ts +5 -0
  17. package/src/hooks/UserPromptOrchestrator.ts +9 -2
  18. package/src/hooks/lib/attention-events.ts +43 -0
  19. package/src/hooks/lib/auto-update.ts +24 -1
  20. package/src/hooks/lib/commitment-events.ts +20 -0
  21. package/src/hooks/lib/discord-events.ts +25 -0
  22. package/src/hooks/lib/entity-events.ts +16 -0
  23. package/src/hooks/lib/events.ts +252 -0
  24. package/src/hooks/lib/export.ts +1 -1
  25. package/src/hooks/lib/hook-timing.ts +33 -0
  26. package/src/hooks/lib/inference-events.ts +56 -0
  27. package/src/hooks/lib/inference.ts +17 -1
  28. package/src/hooks/lib/interaction.ts +74 -27
  29. package/src/hooks/lib/opencode-events.ts +59 -0
  30. package/src/hooks/lib/paths.ts +1 -0
  31. package/src/hooks/lib/post-tool.ts +66 -0
  32. package/src/hooks/lib/session-events.ts +211 -0
  33. package/src/hooks/lib/settings.ts +6 -0
  34. package/src/hooks/lib/stop.ts +27 -31
  35. package/src/hooks/lib/tool-events.ts +106 -0
  36. package/src/hooks/lib/transcript-preserve.ts +133 -0
  37. package/src/hooks/lib/turn-extras.ts +78 -0
  38. package/src/hooks/lib/waiting-on-user.ts +75 -0
  39. package/src/hooks/lib/work-tracking.ts +17 -3
  40. package/src/targets/opencode/plugin.ts +33 -1
  41. package/src/tools/agent/interaction.ts +2 -2
  42. package/src/tools/agent/project.ts +4 -0
  43. package/src/tools/control-room/snooze.ts +2 -0
  44. package/src/tools/control-room/writes.ts +2 -0
  45. package/src/tools/knowledge/lib.ts +5 -0
  46. package/src/hooks/LedgerCommit.ts +0 -34
  47. package/src/hooks/LedgerUnapplied.ts +0 -47
@@ -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 ?? [];
@@ -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 detachSessionIntelligence(transcript, options.sessionId);
171
- await detachEntityExtraction(transcript, options.sessionId);
172
- await detachFailurePrinciple(transcript);
173
- detachAgenda(options.sessionId);
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 results = await Promise.allSettled([
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 handlerNames = [
195
- "work-session",
196
- "tab",
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.status === "rejected") {
209
- logError(`runStopHandlers:${handlerNames[i]}`, r.reason);
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
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * PAL's own gzipped copy of every Claude Code transcript, taken before Claude
3
+ * Code's cleanupPeriodDays (30 days by default) deletes the originals.
4
+ */
5
+
6
+ import { spawn } from "node:child_process";
7
+ import {
8
+ closeSync,
9
+ existsSync,
10
+ openSync,
11
+ readdirSync,
12
+ readFileSync,
13
+ rmSync,
14
+ statSync,
15
+ writeFileSync,
16
+ } from "node:fs";
17
+ import { dirname, relative, resolve } from "node:path";
18
+ import { logDebug, logError } from "./log";
19
+ import { assets, ensureDir, paths, platform } from "./paths";
20
+
21
+ const STALE_LOCK_MS = 30 * 60 * 1000;
22
+
23
+ export interface PreserveResult {
24
+ copied: number;
25
+ unchanged: number;
26
+ failed: number;
27
+ }
28
+
29
+ interface PreserveLocations {
30
+ source: string;
31
+ target: string;
32
+ manifest: string;
33
+ }
34
+
35
+ type Manifest = Record<string, string>;
36
+
37
+ function defaultLocations(): PreserveLocations {
38
+ return {
39
+ source: resolve(platform.claudeDir(), "projects"),
40
+ target: resolve(paths.content(), "claude"),
41
+ manifest: resolve(paths.state(), "transcript-copies.json"),
42
+ };
43
+ }
44
+
45
+ function readManifest(path: string): Manifest {
46
+ try {
47
+ return JSON.parse(readFileSync(path, "utf-8")) as Manifest;
48
+ } catch {
49
+ return {};
50
+ }
51
+ }
52
+
53
+ function filesUnder(dir: string): string[] {
54
+ if (!existsSync(dir)) return [];
55
+ return readdirSync(dir, { recursive: true, withFileTypes: true })
56
+ .filter((entry) => entry.isFile())
57
+ .map((entry) => resolve(entry.parentPath, entry.name));
58
+ }
59
+
60
+ function versionOf(file: string): string {
61
+ const stat = statSync(file);
62
+ return `${stat.size}:${Math.trunc(stat.mtimeMs)}`;
63
+ }
64
+
65
+ function gzipCopy(file: string, destination: string): void {
66
+ ensureDir(dirname(destination));
67
+ writeFileSync(destination, Bun.gzipSync(readFileSync(file)));
68
+ }
69
+
70
+ export function preserveTranscripts(at: PreserveLocations): PreserveResult {
71
+ const manifest = readManifest(at.manifest);
72
+ const result: PreserveResult = { copied: 0, unchanged: 0, failed: 0 };
73
+ for (const file of filesUnder(at.source)) {
74
+ const key = relative(at.source, file).replaceAll("\\", "/");
75
+ try {
76
+ const version = versionOf(file);
77
+ if (manifest[key] === version) {
78
+ result.unchanged++;
79
+ continue;
80
+ }
81
+ gzipCopy(file, resolve(at.target, `${key}.gz`));
82
+ manifest[key] = version;
83
+ result.copied++;
84
+ } catch (err) {
85
+ result.failed++;
86
+ logError("transcript-preserve", err);
87
+ }
88
+ }
89
+ ensureDir(dirname(at.manifest));
90
+ writeFileSync(at.manifest, JSON.stringify(manifest), "utf-8");
91
+ return result;
92
+ }
93
+
94
+ function lockPath(): string {
95
+ return resolve(paths.state(), "transcript-preserve.lock");
96
+ }
97
+
98
+ function takeLock(path: string, now = Date.now()): boolean {
99
+ if (existsSync(path) && now - statSync(path).mtimeMs < STALE_LOCK_MS) return false;
100
+ rmSync(path, { force: true });
101
+ try {
102
+ closeSync(openSync(path, "wx"));
103
+ return true;
104
+ } catch {
105
+ return false;
106
+ }
107
+ }
108
+
109
+ export function preserveTranscriptsOnce(
110
+ at: PreserveLocations = defaultLocations()
111
+ ): PreserveResult | null {
112
+ const lock = lockPath();
113
+ if (!takeLock(lock)) return null;
114
+ try {
115
+ return preserveTranscripts(at);
116
+ } finally {
117
+ rmSync(lock, { force: true });
118
+ }
119
+ }
120
+
121
+ export function spawnTranscriptPreserve(): void {
122
+ try {
123
+ const child = spawn("bun", [resolve(assets.hooks(), "PreserveTranscripts.ts")], {
124
+ detached: true,
125
+ stdio: "ignore",
126
+ windowsHide: true,
127
+ });
128
+ child.unref();
129
+ logDebug("transcript-preserve", "detached copy spawned");
130
+ } catch (err) {
131
+ logError("transcript-preserve:spawn", err);
132
+ }
133
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Cheap facts about a prompt and a reply, measured in code: no inference, no text kept.
3
+ */
4
+
5
+ const STOPWORDS: Record<string, readonly string[]> = {
6
+ en: [
7
+ "the",
8
+ "and",
9
+ "is",
10
+ "to",
11
+ "of",
12
+ "it",
13
+ "that",
14
+ "this",
15
+ "with",
16
+ "for",
17
+ "you",
18
+ "what",
19
+ ],
20
+ hu: ["és", "hogy", "nem", "egy", "az", "de", "meg", "van", "is", "mit", "ez", "csak"],
21
+ };
22
+
23
+ const MIN_STOPWORD_HITS = 2;
24
+ const PASTED_RE = /<pasted_content\b[^>]*>([\s\S]*?)<\/pasted_content[^>]*>/gi;
25
+ const IMAGE_RE = /\[Image #\d+\]/g;
26
+ const COMMAND_TAG_RE = /<command-name>\s*\/?([\w:-]+)\s*<\/command-name>/;
27
+ const SLASH_RE = /^\/([\w:-]+)(?:\s|$)/;
28
+ const MODE_RE = /═+\s*PAL(?:\s*\|\s*([A-Z]+))?\s*═+/;
29
+
30
+ function tokens(text: string): string[] {
31
+ return text.toLowerCase().match(/[\p{L}']+/gu) ?? [];
32
+ }
33
+
34
+ export function languageOf(text: string): string | null {
35
+ const words = new Set(tokens(text));
36
+ let best: string | null = null;
37
+ let bestHits = MIN_STOPWORD_HITS - 1;
38
+ for (const [language, stopwords] of Object.entries(STOPWORDS)) {
39
+ const hits = stopwords.filter((w) => words.has(w)).length;
40
+ if (hits > bestHits) {
41
+ best = language;
42
+ bestHits = hits;
43
+ }
44
+ }
45
+ return best;
46
+ }
47
+
48
+ export function pastedChars(prompt: string): number {
49
+ return Array.from(prompt.matchAll(PASTED_RE), (m) => m[1]?.length ?? 0).reduce(
50
+ (a, b) => a + b,
51
+ 0
52
+ );
53
+ }
54
+
55
+ export function imageCount(prompt: string): number {
56
+ return prompt.match(IMAGE_RE)?.length ?? 0;
57
+ }
58
+
59
+ export function slashCommandOf(prompt: string): string | null {
60
+ const tagged = COMMAND_TAG_RE.exec(prompt)?.[1];
61
+ if (tagged) return tagged;
62
+ return SLASH_RE.exec(prompt.trim())?.[1] ?? null;
63
+ }
64
+
65
+ export function turnExtras(prompt: string, text: string) {
66
+ return {
67
+ language: languageOf(text),
68
+ pastedChars: pastedChars(prompt),
69
+ images: imageCount(prompt),
70
+ command: slashCommandOf(prompt),
71
+ };
72
+ }
73
+
74
+ export function responseModeOf(reply: string): string | null {
75
+ const match = MODE_RE.exec(reply.slice(0, 400));
76
+ if (!match) return null;
77
+ return match[1] ?? "MINIMAL";
78
+ }