@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.
- package/README.md +15 -1
- package/package.json +2 -2
- package/src/claude-hook.mjs +20 -0
- package/src/client-registry.mjs +9 -2
- package/src/host-config.mjs +3 -2
- package/src/host-hook.mjs +37 -3
- package/src/host-roots.mjs +13 -0
- package/src/install.mjs +71 -0
- package/src/installer-cli.mjs +305 -22
- package/src/runtime.mjs +14 -9
- package/src/session.mjs +10 -6
- package/src/skills-sync.mjs +266 -0
- package/src/transcript-drivers/claude.mjs +33 -0
- package/src/transcript-drivers/codex.mjs +188 -0
- package/src/transcript-drivers/index.mjs +18 -0
- package/src/transcript-drivers/kimi.mjs +180 -0
- package/src/transcript-drivers/shared.mjs +104 -0
- package/src/transport.mjs +7 -0
- package/src/version.mjs +3 -3
|
@@ -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
|
+
};
|