@halofy/agent-connect 0.5.1 → 0.7.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.
@@ -0,0 +1,266 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, readFile, rename, stat } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { basename, dirname, join } from "node:path";
5
+ import { ensurePrivateDirectory, readJson, writePrivateFile } from "./storage.mjs";
6
+
7
+ /**
8
+ * Badge-delivered skills (CONTRACTS-S16-SKILL-TRACKING D37–D39 on the signed
9
+ * badge runtime). The badge resolves, server-side, every approved and vetted
10
+ * skill on its team + org chain; this module mirrors that set into the host's
11
+ * own skills folder and keeps it there.
12
+ *
13
+ * Every managed copy is written as `<skillsRoot>/<skillKey>/SKILL.md`, the
14
+ * layout Claude Code and Codex both read. A manifest under the runtime home
15
+ * records what was written (id, sha, file digest) so the next check-in can
16
+ * report it, drift can be repaired, and a withdrawn skill can be quarantined
17
+ * — moved, never deleted (D7 on the client mirror).
18
+ *
19
+ * A folder this runtime did not create is never touched: an employee's own
20
+ * skill with a colliding name is reported as a conflict and left alone.
21
+ */
22
+
23
+ export const STALE_AFTER_MS = 7 * 24 * 60 * 60 * 1000;
24
+ const SKILL_KEY = /^[a-z0-9][a-z0-9._-]{0,199}$/i;
25
+ const MANAGED_MARKER = "content_sha256:";
26
+
27
+ /**
28
+ * The user-level Agent Skills folder each host scans, `<dir>/<name>/SKILL.md`.
29
+ * Every host also reads a workspace folder and most read the generic
30
+ * `~/.agents/skills`; the badge writes the host's own brand folder so one
31
+ * badge maps to one host and nothing leaks into a host the badge was not
32
+ * issued for. Verified 2026-09-04: Claude Code + Codex by install; Gemini CLI
33
+ * 0.58 (`GEMINI_CLI_HOME` replaces the HOME directory, so the folder is
34
+ * `<home>/.gemini/skills`) and Kimi Code 0.40 (`KIMI_CODE_HOME || ~/.kimi-code`;
35
+ * `~/.kimi/skills` is its legacy tree) from the installed binaries; Cursor,
36
+ * VS Code Copilot, and Cline from their published docs.
37
+ */
38
+ export function managedSkillsDirectory(clientKind, { home = homedir(), env = process.env } = {}) {
39
+ switch (clientKind) {
40
+ case "claude-code": return join(env.CLAUDE_CONFIG_DIR || join(home, ".claude"), "skills");
41
+ case "codex": return join(env.CODEX_HOME || join(home, ".codex"), "skills");
42
+ case "cursor": return join(home, ".cursor", "skills");
43
+ case "gemini-cli": return join(env.GEMINI_CLI_HOME || home, ".gemini", "skills");
44
+ case "kimi-cli": return join(env.KIMI_CODE_HOME || join(home, ".kimi-code"), "skills");
45
+ case "vscode": return join(home, ".copilot", "skills");
46
+ case "cline": return join(home, ".cline", "skills");
47
+ default: return null;
48
+ }
49
+ }
50
+
51
+ export function skillsManifestPath(root, installationId) {
52
+ return join(root, `skills-${installationId}.json`);
53
+ }
54
+
55
+ function digest(text) {
56
+ return createHash("sha256").update(text).digest("hex");
57
+ }
58
+
59
+ function safeKey(skillKey) {
60
+ return typeof skillKey === "string" && SKILL_KEY.test(skillKey) && !skillKey.includes("..");
61
+ }
62
+
63
+ async function pathState(path) {
64
+ try {
65
+ return await stat(path);
66
+ } catch (error) {
67
+ if (error?.code === "ENOENT") return null;
68
+ throw error;
69
+ }
70
+ }
71
+
72
+ async function readText(path) {
73
+ try {
74
+ return await readFile(path, "utf8");
75
+ } catch (error) {
76
+ if (error?.code === "ENOENT") return null;
77
+ throw error;
78
+ }
79
+ }
80
+
81
+ /** Move a managed copy aside with its bytes intact; never `rm`. */
82
+ async function quarantine(skillsRoot, quarantineRoot, skillKey, now) {
83
+ const from = join(skillsRoot, skillKey);
84
+ if (!(await pathState(from))) return null;
85
+ await ensurePrivateDirectory(quarantineRoot);
86
+ const to = join(quarantineRoot, `${skillKey}-${now.toISOString().replace(/[:.]/g, "-")}`);
87
+ await rename(from, to);
88
+ return to;
89
+ }
90
+
91
+ /**
92
+ * A folder is "ours" when the manifest lists it, or when its SKILL.md carries
93
+ * the kernel export frontmatter. Anything else is the employee's own skill.
94
+ */
95
+ async function isManaged(skillsRoot, skillKey, manifestEntry) {
96
+ if (manifestEntry) return true;
97
+ const body = await readText(join(skillsRoot, skillKey, "SKILL.md"));
98
+ return typeof body === "string" && body.startsWith("---\n") && body.includes(`\n${MANAGED_MARKER}`);
99
+ }
100
+
101
+ export async function loadSkillsManifest(root, installationId) {
102
+ const manifest = await readJson(skillsManifestPath(root, installationId), null);
103
+ if (!manifest || manifest.version !== 1 || !Array.isArray(manifest.installs)) {
104
+ return { version: 1, installationId, installs: [], lastCheckinAt: null };
105
+ }
106
+ return manifest;
107
+ }
108
+
109
+ /**
110
+ * One sync pass. Never throws for server or filesystem trouble on a single
111
+ * skill; the summary names what happened so callers (installer, SessionStart
112
+ * hooks) can report it. A failed check-in leaves installed copies usable
113
+ * until the 7-day staleness allowance runs out, then quarantines them (D37).
114
+ */
115
+ export async function syncManagedSkills({
116
+ connection,
117
+ transport,
118
+ root,
119
+ home = homedir(),
120
+ env = process.env,
121
+ now = () => new Date(),
122
+ }) {
123
+ const clientKind = connection.clientKind;
124
+ const skillsRoot = managedSkillsDirectory(clientKind, { home, env });
125
+ const summary = {
126
+ supported: skillsRoot !== null,
127
+ skillsRoot,
128
+ checkedIn: false,
129
+ installed: [],
130
+ updated: [],
131
+ quarantined: [],
132
+ conflicts: [],
133
+ unchanged: [],
134
+ errors: [],
135
+ };
136
+ if (!skillsRoot) return summary;
137
+
138
+ const manifestPath = skillsManifestPath(root, connection.installationId);
139
+ const quarantineRoot = join(root, "skills-quarantine", connection.installationId);
140
+ const manifest = await loadSkillsManifest(root, connection.installationId);
141
+ const byId = new Map(manifest.installs.map((entry) => [entry.skillId, entry]));
142
+ const current = now();
143
+
144
+ let response;
145
+ try {
146
+ response = await transport.skillsCheckin(manifest.installs.map(({ skillId, sha }) => ({ skillId, sha })));
147
+ summary.checkedIn = true;
148
+ } catch (error) {
149
+ summary.errors.push({ stage: "checkin", code: error?.code || "runtime_unavailable" });
150
+ const last = manifest.lastCheckinAt ? Date.parse(manifest.lastCheckinAt) : NaN;
151
+ if (Number.isFinite(last) && current.getTime() - last > STALE_AFTER_MS) {
152
+ for (const entry of manifest.installs) {
153
+ try {
154
+ const moved = await quarantine(skillsRoot, quarantineRoot, entry.skillKey, current);
155
+ if (moved) summary.quarantined.push({ skillKey: entry.skillKey, reason: "stale_checkin", to: moved });
156
+ } catch (fsError) {
157
+ summary.errors.push({ stage: "quarantine", skillKey: entry.skillKey, code: fsError?.code || "fs_error" });
158
+ }
159
+ }
160
+ manifest.installs = [];
161
+ await writePrivateFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
162
+ }
163
+ return summary;
164
+ }
165
+
166
+ const wanted = new Map();
167
+ for (const skill of Array.isArray(response?.skills) ? response.skills : []) {
168
+ if (safeKey(skill.skillKey)) wanted.set(skill.skillId, skill);
169
+ }
170
+
171
+ // 1. Withdrawn copies: revoked / unknown verdicts, and anything the manifest
172
+ // lists that the badge no longer resolves.
173
+ const verdicts = new Map((Array.isArray(response?.items) ? response.items : []).map((item) => [item.skillId, item]));
174
+ const survivors = [];
175
+ for (const entry of manifest.installs) {
176
+ const verdict = verdicts.get(entry.skillId);
177
+ const status = verdict?.status;
178
+ const withdrawn = status === "revoked" || status === "unknown" || (!wanted.has(entry.skillId) && status !== "stale");
179
+ if (!withdrawn) { survivors.push(entry); continue; }
180
+ try {
181
+ const moved = await quarantine(skillsRoot, quarantineRoot, entry.skillKey, current);
182
+ summary.quarantined.push({ skillKey: entry.skillKey, reason: status || "not_resolved", to: moved });
183
+ } catch (error) {
184
+ summary.errors.push({ stage: "quarantine", skillKey: entry.skillKey, code: error?.code || "fs_error" });
185
+ survivors.push(entry);
186
+ }
187
+ }
188
+ manifest.installs = survivors;
189
+
190
+ // 2. Missing, stale, or drifted copies: fetch through the governed export
191
+ // gate and write. A stale entry keyed on a superseded row is re-keyed
192
+ // to the row that replaced it.
193
+ for (const entry of manifest.installs) {
194
+ const verdict = verdicts.get(entry.skillId);
195
+ if (verdict?.status === "stale" && verdict.currentSkillId && !wanted.has(entry.skillId)) {
196
+ entry.skillId = verdict.currentSkillId;
197
+ }
198
+ }
199
+ const next = [];
200
+ for (const skill of wanted.values()) {
201
+ const entry = manifest.installs.find((candidate) => candidate.skillId === skill.skillId);
202
+ const dir = join(skillsRoot, skill.skillKey);
203
+ const file = join(dir, "SKILL.md");
204
+ const onDisk = await readText(file);
205
+ const intact = entry && typeof onDisk === "string" && digest(onDisk) === entry.fileSha256;
206
+ if (entry && entry.sha === skill.sha && intact) {
207
+ summary.unchanged.push(skill.skillKey);
208
+ next.push({ ...entry, lastCheckinAt: current.toISOString() });
209
+ continue;
210
+ }
211
+ if (!(await isManaged(skillsRoot, skill.skillKey, entry)) && (await pathState(dir))) {
212
+ summary.conflicts.push({ skillKey: skill.skillKey, reason: "unmanaged_folder" });
213
+ continue;
214
+ }
215
+ try {
216
+ const exported = await transport.skillDownload(skill.skillId);
217
+ const content = typeof exported?.content === "string" ? exported.content : null;
218
+ if (!content) throw Object.assign(new Error("skill export was empty"), { code: "empty_export" });
219
+ await mkdir(dir, { recursive: true });
220
+ await writePrivateFile(file, content);
221
+ const record = {
222
+ skillId: skill.skillId,
223
+ skillKey: skill.skillKey,
224
+ sha: skill.sha,
225
+ fileSha256: digest(content),
226
+ namespace: skill.namespace,
227
+ installedAt: entry?.installedAt || current.toISOString(),
228
+ lastCheckinAt: current.toISOString(),
229
+ };
230
+ next.push(record);
231
+ (entry ? summary.updated : summary.installed).push(skill.skillKey);
232
+ } catch (error) {
233
+ summary.errors.push({ stage: "download", skillKey: skill.skillKey, code: error?.code || "runtime_unavailable" });
234
+ if (entry) next.push(entry);
235
+ }
236
+ }
237
+ manifest.installs = next;
238
+ manifest.lastCheckinAt = current.toISOString();
239
+ manifest.clientKind = clientKind;
240
+ await writePrivateFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
241
+
242
+ // 3. Acknowledge: a second beacon carrying what is now on disk so the
243
+ // console's "installed on N badges" reflects this pass, not the last.
244
+ if (summary.installed.length > 0 || summary.updated.length > 0 || summary.quarantined.length > 0) {
245
+ try {
246
+ await transport.skillsCheckin(manifest.installs.map(({ skillId, sha }) => ({ skillId, sha })));
247
+ } catch (error) {
248
+ summary.errors.push({ stage: "ack", code: error?.code || "runtime_unavailable" });
249
+ }
250
+ }
251
+ return summary;
252
+ }
253
+
254
+ /** One-line, content-free summary for installer output and hook stderr. */
255
+ export function describeSkillSync(summary) {
256
+ if (!summary.supported) return "skills: not managed for this host";
257
+ const parts = [];
258
+ if (summary.installed.length) parts.push(`installed ${summary.installed.length}`);
259
+ if (summary.updated.length) parts.push(`updated ${summary.updated.length}`);
260
+ if (summary.unchanged.length) parts.push(`unchanged ${summary.unchanged.length}`);
261
+ if (summary.quarantined.length) parts.push(`quarantined ${summary.quarantined.length}`);
262
+ if (summary.conflicts.length) parts.push(`skipped ${summary.conflicts.length} unmanaged`);
263
+ if (summary.errors.length) parts.push(`${summary.errors.length} error${summary.errors.length === 1 ? "" : "s"}`);
264
+ if (!summary.checkedIn) parts.unshift("check-in unavailable");
265
+ return `skills (${basename(dirname(summary.skillsRoot))}/${basename(summary.skillsRoot)}): ${parts.join(", ") || "nothing to do"}`;
266
+ }
@@ -0,0 +1,33 @@
1
+ import {
2
+ buildClaudeMetadataPayload,
3
+ claudeMetadataEvent,
4
+ readClaudeTranscriptSuffix,
5
+ } from "../session.mjs";
6
+
7
+ /**
8
+ * Thin delegation over the reviewed Claude Code adapter. The claude path is
9
+ * frozen — its eventKeys are pinned by the golden fixture test and must stay
10
+ * byte-identical for server-side dedupe history. Claude Code hooks pass the
11
+ * transcript path directly, so this driver has no locate step.
12
+ */
13
+ export const claudeTranscriptDriver = {
14
+ clientKind: "claude-code",
15
+ eventKeyNamespace: "claude",
16
+ usageGapPrefix: "claude:usage-gap:",
17
+
18
+ async locate() {
19
+ return null;
20
+ },
21
+
22
+ readSuffix(path, cursor, sessionHash) {
23
+ return readClaudeTranscriptSuffix(path, cursor, sessionHash);
24
+ },
25
+
26
+ buildMetadataPayload(metadata, context) {
27
+ return buildClaudeMetadataPayload(metadata, context);
28
+ },
29
+
30
+ metadataEvent(payload) {
31
+ return claudeMetadataEvent(payload);
32
+ },
33
+ };
@@ -0,0 +1,188 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import {
4
+ buildClaudeMetadataPayload,
5
+ hostMetadataEvent,
6
+ normalizedEvent,
7
+ usageInt,
8
+ USAGE_MODEL_PATTERN,
9
+ } from "../session.mjs";
10
+ import { codexHome } from "../host-roots.mjs";
11
+ import {
12
+ boundedHostSessionId,
13
+ boundedHostState,
14
+ boundedMetadataText,
15
+ completeJsonlLines,
16
+ containedPath,
17
+ drainedEndOffset,
18
+ MAX_DRIVER_SUFFIX_BYTES,
19
+ readSuffixWindow,
20
+ } from "./shared.mjs";
21
+
22
+ const NAMESPACE = "codex";
23
+ const PROVIDER_PATTERN = /^[a-z0-9-]{1,64}$/;
24
+
25
+ /**
26
+ * Codex rollouts live at
27
+ * `<codexHome>/sessions/YYYY/MM/DD/rollout-<ts>-<session-id>.jsonl` — the
28
+ * session id is the filename suffix. The hook payload is never trusted for a
29
+ * path; only the validated session id is interpolated, and every result is
30
+ * containment-checked under the codex root.
31
+ */
32
+ async function locateRollout(root, sessionId) {
33
+ const sessionsRoot = join(root, "sessions");
34
+ let names;
35
+ try {
36
+ names = await readdir(sessionsRoot, { recursive: true });
37
+ } catch {
38
+ return null;
39
+ }
40
+ const suffix = `-${sessionId}.jsonl`;
41
+ const matches = names.filter((name) => {
42
+ const base = String(name).split(/[\\/]/).pop();
43
+ return base.startsWith("rollout-") && base.endsWith(suffix);
44
+ }).sort();
45
+ if (matches.length === 0) return null;
46
+ // Timestamped names sort chronologically; take the newest.
47
+ return containedPath(sessionsRoot, matches[matches.length - 1]);
48
+ }
49
+
50
+ function usageEventFromTokenCount(info, { model, provider, byteOffset, endOffset, occurredAt }) {
51
+ const usage = info?.last_token_usage;
52
+ const input = usageInt(usage?.input_tokens);
53
+ const output = usageInt(usage?.output_tokens);
54
+ if (usage === null || typeof usage !== "object" || (input === null && output === null)) {
55
+ return normalizedEvent({
56
+ eventKey: `${NAMESPACE}:usage-gap:${byteOffset}`,
57
+ type: "usage",
58
+ occurredAt,
59
+ payload: {
60
+ role: "assistant", contentFormat: "json", captureStatus: "complete",
61
+ gap: true, reason: "usage_unavailable",
62
+ },
63
+ sourceEndOffset: endOffset,
64
+ });
65
+ }
66
+ if (model === null) {
67
+ return normalizedEvent({
68
+ eventKey: `${NAMESPACE}:usage-gap:${byteOffset}`,
69
+ type: "usage",
70
+ occurredAt,
71
+ payload: {
72
+ role: "assistant", contentFormat: "json", captureStatus: "complete",
73
+ gap: true, reason: "model_unknown",
74
+ },
75
+ sourceEndOffset: endOffset,
76
+ });
77
+ }
78
+ const cached = usageInt(usage.cached_input_tokens) ?? 0;
79
+ const cacheWrite = usageInt(usage.cache_write_input_tokens) ?? 0;
80
+ // Verified against real rollouts: input_tokens is inclusive of BOTH the
81
+ // cached-read and cache-write tokens (total_tokens = input + output). The
82
+ // server prices input, cache-read, and cache-write as disjoint buckets, so
83
+ // the uncached-and-unwritten remainder is what belongs in inputTokens;
84
+ // clamp for malformed hosts.
85
+ const inputTokens = input === null ? null : Math.max(0, input - cached - cacheWrite);
86
+ return normalizedEvent({
87
+ eventKey: `${NAMESPACE}:usage:${byteOffset}`,
88
+ type: "usage",
89
+ occurredAt,
90
+ payload: {
91
+ role: "assistant",
92
+ contentFormat: "json",
93
+ captureStatus: "complete",
94
+ provider,
95
+ model,
96
+ providerRequestId: null,
97
+ messageId: null,
98
+ stopReason: null,
99
+ serviceTier: null,
100
+ effort: null,
101
+ sidechain: false,
102
+ latencyMs: null,
103
+ inputTokens,
104
+ outputTokens: output,
105
+ cacheReadTokens: usageInt(usage.cached_input_tokens),
106
+ cacheWriteTokens: usageInt(usage.cache_write_input_tokens),
107
+ cacheWrite1hTokens: null,
108
+ cacheWrite5mTokens: null,
109
+ reasoningTokens: usageInt(usage.reasoning_output_tokens),
110
+ },
111
+ sourceEndOffset: endOffset,
112
+ });
113
+ }
114
+
115
+ export const codexTranscriptDriver = {
116
+ clientKind: "codex",
117
+ eventKeyNamespace: NAMESPACE,
118
+ usageGapPrefix: `${NAMESPACE}:usage-gap:`,
119
+
120
+ async locate(hostSessionId, { env = process.env } = {}) {
121
+ const sessionId = boundedHostSessionId(hostSessionId);
122
+ if (sessionId === null) return null;
123
+ const path = await locateRollout(codexHome(env), sessionId);
124
+ return path === null ? null : { path };
125
+ },
126
+
127
+ async readSuffix(path, cursor, _sessionHash) {
128
+ const { buffer, start } = await readSuffixWindow(path, cursor?.byteOffset ?? 0);
129
+ const priorState = cursor?.hostState !== null && typeof cursor?.hostState === "object"
130
+ ? cursor.hostState : {};
131
+ let model = typeof priorState.model === "string" && USAGE_MODEL_PATTERN.test(priorState.model)
132
+ ? priorState.model : null;
133
+ let provider = typeof priorState.provider === "string" && PROVIDER_PATTERN.test(priorState.provider)
134
+ ? priorState.provider : "openai";
135
+ const metadata = {};
136
+ const events = [];
137
+ let lastEnd = start;
138
+ for (const line of completeJsonlLines(buffer, start)) {
139
+ lastEnd = line.endOffset;
140
+ const entry = line.entry;
141
+ if (entry === null) continue;
142
+ const payload = entry.payload !== null && typeof entry.payload === "object" ? entry.payload : {};
143
+ const occurredAt = typeof entry.timestamp === "string" ? entry.timestamp : new Date().toISOString();
144
+ if (entry.type === "session_meta") {
145
+ if (typeof payload.model_provider === "string" && PROVIDER_PATTERN.test(payload.model_provider)) {
146
+ provider = payload.model_provider;
147
+ }
148
+ const hostVersion = boundedMetadataText(payload.cli_version, 64);
149
+ if (hostVersion) metadata.hostVersion = hostVersion;
150
+ const entrypoint = boundedMetadataText(payload.originator, 64);
151
+ if (entrypoint) metadata.entrypoint = entrypoint;
152
+ const metaCwd = boundedMetadataText(payload.cwd, 2048);
153
+ if (metaCwd) metadata.cwd = metaCwd;
154
+ continue;
155
+ }
156
+ if (entry.type === "turn_context") {
157
+ if (typeof payload.model === "string" && USAGE_MODEL_PATTERN.test(payload.model)) {
158
+ model = payload.model;
159
+ }
160
+ const turnCwd = boundedMetadataText(payload.cwd, 2048);
161
+ if (turnCwd) metadata.cwd = turnCwd;
162
+ continue;
163
+ }
164
+ if (entry.type === "event_msg" && payload.type === "token_count") {
165
+ events.push(usageEventFromTokenCount(payload.info, {
166
+ model, provider,
167
+ byteOffset: line.byteOffset,
168
+ endOffset: line.endOffset,
169
+ occurredAt,
170
+ }));
171
+ }
172
+ }
173
+ return {
174
+ events,
175
+ observedEndOffset: drainedEndOffset(lastEnd, start, buffer.length, MAX_DRIVER_SUFFIX_BYTES),
176
+ metadata,
177
+ hostState: boundedHostState({ ...priorState, model, provider }),
178
+ };
179
+ },
180
+
181
+ buildMetadataPayload(metadata, context) {
182
+ return buildClaudeMetadataPayload(metadata, context);
183
+ },
184
+
185
+ metadataEvent(payload) {
186
+ return hostMetadataEvent(NAMESPACE, payload);
187
+ },
188
+ };
@@ -0,0 +1,18 @@
1
+ import { claudeTranscriptDriver } from "./claude.mjs";
2
+ import { codexTranscriptDriver } from "./codex.mjs";
3
+ import { kimiTranscriptDriver } from "./kimi.mjs";
4
+
5
+ const DRIVERS = Object.freeze({
6
+ "claude-code": claudeTranscriptDriver,
7
+ codex: codexTranscriptDriver,
8
+ "kimi-cli": kimiTranscriptDriver,
9
+ });
10
+
11
+ /**
12
+ * The reviewed transcript driver for a client kind, or null. Hosts without a
13
+ * reviewed driver keep hooks-only capture — absence is a supported state,
14
+ * never an error.
15
+ */
16
+ export function transcriptDriverFor(clientKind) {
17
+ return DRIVERS[String(clientKind ?? "")] ?? null;
18
+ }
@@ -0,0 +1,180 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import {
4
+ buildClaudeMetadataPayload,
5
+ hostMetadataEvent,
6
+ normalizedEvent,
7
+ usageInt,
8
+ USAGE_MODEL_PATTERN,
9
+ } from "../session.mjs";
10
+ import { kimiHome } from "../host-roots.mjs";
11
+ import {
12
+ boundedHostSessionId,
13
+ boundedHostState,
14
+ boundedMetadataText,
15
+ completeJsonlLines,
16
+ containedPath,
17
+ drainedEndOffset,
18
+ MAX_DRIVER_SUFFIX_BYTES,
19
+ readSuffixWindow,
20
+ } from "./shared.mjs";
21
+
22
+ const NAMESPACE = "kimi-cli";
23
+ const MAX_INDEX_BYTES = 8 * 1024 * 1024;
24
+
25
+ async function readableFile(root, candidate) {
26
+ const contained = await containedPath(root, candidate);
27
+ if (contained === null) return null;
28
+ try {
29
+ return (await stat(contained)).isFile() ? contained : null;
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Kimi's `session_index.jsonl` maps sessionId -> sessionDir. The index is
37
+ * host-written but its paths are still treated as untrusted: every sessionDir
38
+ * is containment-checked under the kimi root before any file inside it is
39
+ * opened. The primary wire is `<sessionDir>/agents/main/wire.jsonl`; older
40
+ * layouts kept `<sessionDir>/wire.jsonl`.
41
+ */
42
+ async function locateWire(root, sessionId) {
43
+ let indexText;
44
+ try {
45
+ indexText = await readFile(join(root, "session_index.jsonl"), "utf8");
46
+ } catch {
47
+ return null;
48
+ }
49
+ if (Buffer.byteLength(indexText, "utf8") > MAX_INDEX_BYTES) return null;
50
+ const wanted = new Set([sessionId, `session_${sessionId}`]);
51
+ let sessionDir = null;
52
+ let workDir = null;
53
+ for (const line of indexText.split("\n")) {
54
+ if (!line) continue;
55
+ let entry;
56
+ try { entry = JSON.parse(line); } catch { continue; }
57
+ if (entry !== null && typeof entry === "object" && wanted.has(entry.sessionId) &&
58
+ typeof entry.sessionDir === "string") {
59
+ sessionDir = entry.sessionDir; // keep last — the index is append-ordered
60
+ workDir = typeof entry.workDir === "string" ? entry.workDir : null;
61
+ }
62
+ }
63
+ if (sessionDir === null) return null;
64
+ const containedDir = await containedPath(root, sessionDir);
65
+ if (containedDir === null) return null;
66
+ const path = await readableFile(root, join(containedDir, "agents", "main", "wire.jsonl")) ||
67
+ await readableFile(root, join(containedDir, "wire.jsonl"));
68
+ if (path === null) return null;
69
+ const statePath = await readableFile(root, join(containedDir, "state.json"));
70
+ return { path, statePath, workDir };
71
+ }
72
+
73
+ function usageEventFromRecord(entry, { byteOffset, endOffset }) {
74
+ const usage = entry.usage !== null && typeof entry.usage === "object" ? entry.usage : null;
75
+ const model = typeof entry.model === "string" && USAGE_MODEL_PATTERN.test(entry.model)
76
+ ? entry.model : null;
77
+ const occurredAt = Number.isSafeInteger(entry.time) && entry.time > 0
78
+ ? new Date(entry.time).toISOString() : new Date().toISOString();
79
+ const input = usageInt(usage?.inputOther);
80
+ const output = usageInt(usage?.output);
81
+ if (usage === null || model === null || (input === null && output === null)) {
82
+ return normalizedEvent({
83
+ eventKey: `${NAMESPACE}:usage-gap:${byteOffset}`,
84
+ type: "usage",
85
+ occurredAt,
86
+ payload: {
87
+ role: "assistant", contentFormat: "json", captureStatus: "complete",
88
+ gap: true, reason: model === null && usage !== null ? "model_unknown" : "usage_unavailable",
89
+ },
90
+ sourceEndOffset: endOffset,
91
+ });
92
+ }
93
+ return normalizedEvent({
94
+ eventKey: `${NAMESPACE}:usage:${byteOffset}`,
95
+ type: "usage",
96
+ occurredAt,
97
+ payload: {
98
+ role: "assistant",
99
+ contentFormat: "json",
100
+ captureStatus: "complete",
101
+ provider: "moonshot",
102
+ model,
103
+ providerRequestId: null,
104
+ messageId: null,
105
+ stopReason: null,
106
+ serviceTier: null,
107
+ effort: null,
108
+ sidechain: false,
109
+ latencyMs: null,
110
+ // usage.record buckets are named as disjoint shares of the prompt.
111
+ inputTokens: input,
112
+ outputTokens: output,
113
+ cacheReadTokens: usageInt(usage.inputCacheRead),
114
+ cacheWriteTokens: usageInt(usage.inputCacheCreation),
115
+ cacheWrite1hTokens: null,
116
+ cacheWrite5mTokens: null,
117
+ reasoningTokens: null,
118
+ },
119
+ sourceEndOffset: endOffset,
120
+ });
121
+ }
122
+
123
+ export const kimiTranscriptDriver = {
124
+ clientKind: "kimi-cli",
125
+ eventKeyNamespace: NAMESPACE,
126
+ usageGapPrefix: `${NAMESPACE}:usage-gap:`,
127
+
128
+ async locate(hostSessionId, { env = process.env } = {}) {
129
+ const raw = String(hostSessionId ?? "");
130
+ const sessionId = boundedHostSessionId(raw.startsWith("session_") ? raw.slice("session_".length) : raw);
131
+ if (sessionId === null) return null;
132
+ const located = await locateWire(kimiHome(env), sessionId);
133
+ if (located === null) return null;
134
+ const sessionFacts = {};
135
+ const cwd = boundedMetadataText(located.workDir, 2048);
136
+ if (cwd) sessionFacts.cwd = cwd;
137
+ if (located.statePath !== null) {
138
+ try {
139
+ const state = JSON.parse(await readFile(located.statePath, "utf8"));
140
+ const title = boundedMetadataText(state?.title, 512);
141
+ if (title) sessionFacts.title = title;
142
+ } catch {
143
+ // state.json is optional evidence, never a failure.
144
+ }
145
+ }
146
+ return { path: located.path, sessionFacts };
147
+ },
148
+
149
+ async readSuffix(path, cursor, _sessionHash) {
150
+ const { buffer, start } = await readSuffixWindow(path, cursor?.byteOffset ?? 0);
151
+ const metadata = {};
152
+ const events = [];
153
+ let lastEnd = start;
154
+ for (const line of completeJsonlLines(buffer, start)) {
155
+ lastEnd = line.endOffset;
156
+ const entry = line.entry;
157
+ if (entry === null) continue;
158
+ if (entry.type === "usage.record") {
159
+ events.push(usageEventFromRecord(entry, {
160
+ byteOffset: line.byteOffset,
161
+ endOffset: line.endOffset,
162
+ }));
163
+ }
164
+ }
165
+ return {
166
+ events,
167
+ observedEndOffset: drainedEndOffset(lastEnd, start, buffer.length, MAX_DRIVER_SUFFIX_BYTES),
168
+ metadata,
169
+ hostState: boundedHostState(cursor?.hostState),
170
+ };
171
+ },
172
+
173
+ buildMetadataPayload(metadata, context) {
174
+ return buildClaudeMetadataPayload(metadata, context);
175
+ },
176
+
177
+ metadataEvent(payload) {
178
+ return hostMetadataEvent(NAMESPACE, payload);
179
+ },
180
+ };