@multileaf/ai-workflow 0.7.0 → 0.9.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,190 @@
1
+ import { createHash } from "node:crypto";
2
+ const zeroTiers = () => ({
3
+ "cache-read": 0,
4
+ "fresh-input": 0,
5
+ "cache-write": 0,
6
+ output: 0,
7
+ });
8
+ const tiersOf = (values) => ({
9
+ ...zeroTiers(),
10
+ ...Object.fromEntries(Object.entries(values).filter(([, value]) => value > 0)),
11
+ });
12
+ function event(source, identity, tiers, extra = {}) {
13
+ if (!USAGE_TIERS_NONZERO(tiers))
14
+ return undefined;
15
+ const hash = createHash("sha256").update(identity).digest("hex").slice(0, 12);
16
+ const imported = {
17
+ command: `import.${source}.e${hash}`,
18
+ label: "measured",
19
+ tiers,
20
+ harness: source === "gemini" ? "gemini-cli" : source,
21
+ ...(extra.model ? { model: extra.model } : {}),
22
+ ...(extra.provider ? { provider: extra.provider } : {}),
23
+ ...(extra.harnessVersion ? { harnessVersion: extra.harnessVersion } : {}),
24
+ };
25
+ return imported;
26
+ }
27
+ function USAGE_TIERS_NONZERO(tiers) {
28
+ return Object.values(tiers).some((value) => value > 0);
29
+ }
30
+ function safeParse(line) {
31
+ try {
32
+ const parsed = JSON.parse(line);
33
+ return parsed && typeof parsed === "object" ? parsed : undefined;
34
+ }
35
+ catch {
36
+ return undefined;
37
+ }
38
+ }
39
+ /** Claude Code: one JSONL transcript per session; dedupe assistant chunks by message id. */
40
+ export function parseClaudeCodeTranscript(content) {
41
+ const events = [];
42
+ const seen = new Set();
43
+ for (const line of content.split("\n")) {
44
+ if (!line.includes('"usage"'))
45
+ continue;
46
+ const entry = safeParse(line);
47
+ if (!entry || entry.type !== "assistant")
48
+ continue;
49
+ const message = entry.message;
50
+ const usage = message?.usage;
51
+ if (!message || !usage)
52
+ continue;
53
+ const messageId = String(message.id ?? "");
54
+ if (!messageId || seen.has(messageId))
55
+ continue;
56
+ seen.add(messageId);
57
+ const parsed = event("claude-code", `${entry.sessionId ?? ""}:${messageId}`, tiersOf({
58
+ "fresh-input": usage.input_tokens ?? 0,
59
+ "cache-write": usage.cache_creation_input_tokens ?? 0,
60
+ "cache-read": usage.cache_read_input_tokens ?? 0,
61
+ output: usage.output_tokens ?? 0,
62
+ }), {
63
+ model: typeof message.model === "string" ? message.model : undefined,
64
+ harnessVersion: typeof entry.version === "string" ? entry.version : undefined,
65
+ });
66
+ if (parsed)
67
+ events.push(parsed);
68
+ }
69
+ return events;
70
+ }
71
+ /** Codex: rollout JSONL; returns undefined when the session belongs to another project. */
72
+ export function parseCodexSession(content, projectRoot) {
73
+ const events = [];
74
+ let sessionCwd;
75
+ let cliVersion;
76
+ let model;
77
+ for (const line of content.split("\n")) {
78
+ const entry = safeParse(line);
79
+ if (!entry)
80
+ continue;
81
+ const payload = (entry.payload ?? {});
82
+ if (entry.type === "session_meta") {
83
+ sessionCwd = typeof payload.cwd === "string" ? payload.cwd : undefined;
84
+ cliVersion = typeof payload.cli_version === "string" ? payload.cli_version : undefined;
85
+ continue;
86
+ }
87
+ if (entry.type === "turn_context" && typeof payload.model === "string")
88
+ model = payload.model;
89
+ if (entry.type !== "token_usage_record")
90
+ continue;
91
+ const usage = payload.usage;
92
+ if (!usage)
93
+ continue;
94
+ const cached = usage.cached_input_tokens ?? 0;
95
+ const cacheWrite = usage.cache_write_input_tokens ?? 0;
96
+ const input = usage.input_tokens ?? 0;
97
+ const parsed = event("codex", `${payload.response_id ?? createHash("sha256").update(line).digest("hex").slice(0, 16)}:${payload.turn_id ?? ""}:${entry.ordinal ?? ""}`, tiersOf({
98
+ "cache-read": cached,
99
+ "cache-write": cacheWrite,
100
+ "fresh-input": Math.max(0, input - cached - cacheWrite),
101
+ output: usage.output_tokens ?? 0,
102
+ }), { model, harnessVersion: cliVersion });
103
+ if (parsed)
104
+ events.push(parsed);
105
+ }
106
+ if (sessionCwd === undefined)
107
+ return undefined;
108
+ if (sessionCwd.replace(/\/+$/, "") !== projectRoot.replace(/\/+$/, ""))
109
+ return undefined;
110
+ return events;
111
+ }
112
+ /** Gemini (antigravity-cli): transcript_full JSONL; PLANNER_RESPONSE steps carry token counts. */
113
+ export function parseGeminiTranscript(content) {
114
+ const events = [];
115
+ for (const line of content.split("\n")) {
116
+ if (!line.includes("input_tokens"))
117
+ continue;
118
+ const entry = safeParse(line);
119
+ if (!entry || entry.type !== "PLANNER_RESPONSE")
120
+ continue;
121
+ const num = (value) => (typeof value === "number" ? value : 0);
122
+ const parsed = event("gemini", `${entry.created_at ?? ""}:${entry.step_index ?? ""}:${createHash("sha256").update(line).digest("hex").slice(0, 16)}`, tiersOf({
123
+ "fresh-input": num(entry.input_tokens),
124
+ "cache-read": num(entry.cache_read_tokens),
125
+ output: num(entry.output_tokens),
126
+ }));
127
+ if (parsed)
128
+ events.push(parsed);
129
+ }
130
+ return events;
131
+ }
132
+ /** Pi (pi.dev): session JSONL; assistant messages and dedicated usage entries both carry per-message tiers and provider. */
133
+ export function parsePiSession(content) {
134
+ const events = [];
135
+ for (const line of content.split("\n")) {
136
+ if (!line.includes('"usage"'))
137
+ continue;
138
+ const entry = safeParse(line);
139
+ if (!entry)
140
+ continue;
141
+ const message = entry.message;
142
+ const usage = (message?.usage ?? entry.usage);
143
+ if (!usage)
144
+ continue;
145
+ if (entry.type === "message" && message?.role !== "assistant")
146
+ continue;
147
+ const parsed = event("pi", String(entry.id ?? createHash("sha256").update(line).digest("hex").slice(0, 16)), tiersOf({
148
+ "fresh-input": usage.input ?? 0,
149
+ "cache-read": usage.cacheRead ?? 0,
150
+ "cache-write": usage.cacheWrite ?? 0,
151
+ output: usage.output ?? 0,
152
+ }), {
153
+ model: typeof message?.model === "string"
154
+ ? message.model
155
+ : typeof entry.model === "string"
156
+ ? entry.model
157
+ : undefined,
158
+ provider: typeof message?.provider === "string"
159
+ ? message.provider
160
+ : typeof entry.provider === "string"
161
+ ? entry.provider
162
+ : undefined,
163
+ });
164
+ if (parsed)
165
+ events.push(parsed);
166
+ }
167
+ return events;
168
+ }
169
+ /** Merge imported events into a ledger idempotently (already-present command ids are skipped). */
170
+ export function mergeImportedEvents(ledger, events) {
171
+ const existing = new Set(ledger.map((entry) => entry.command));
172
+ const fresh = events.filter((candidate) => !existing.has(candidate.command));
173
+ return {
174
+ ledger: [
175
+ ...ledger,
176
+ ...fresh.map((candidate, index) => ({ ...candidate, seq: ledger.length + index + 1 })),
177
+ ],
178
+ imported: fresh.length,
179
+ skipped: events.length - fresh.length,
180
+ };
181
+ }
182
+ export function summarizeImport(result) {
183
+ return [
184
+ `source: ${result.source}`,
185
+ `files scanned: ${result.files}`,
186
+ `usage events found: ${result.events}`,
187
+ `imported: ${result.imported} (label: measured)`,
188
+ `skipped (already in ledger): ${result.skipped}`,
189
+ ].join("\n");
190
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Usage sidecar (switchboard pattern): per-run token records with billing
3
+ * tiers, measurement labels, and harness attribution, stored alongside
4
+ * workflow checkpoints.
5
+ *
6
+ * Honest-measurement rules (docs/token-efficiency-study-notes.md):
7
+ * - every entry carries `measured` or `estimated`; aggregates propagate the
8
+ * label pessimistically (any estimated component makes the total estimated);
9
+ * - no timestamps: entries are ordered by a monotonic sequence number, so
10
+ * unchanged input produces byte-identical output;
11
+ * - naive `tokens x price` accounting overstates savings on cache-heavy
12
+ * agents, so tiers are kept separate and cache hit rate is a first-class
13
+ * metric. Cost conversion is deliberately not built in: provider prices
14
+ * rot, and unlabeled dollars would be dishonest by default.
15
+ */
16
+ export declare const USAGE_SIDECAR_SCHEMA = 1;
17
+ export declare const USAGE_SIDECAR_PATH = "checkpoints/usage-sidecar.yml";
18
+ export declare const USAGE_SIDECAR_CAP = 500;
19
+ export declare const USAGE_TIERS: readonly ["cache-read", "fresh-input", "cache-write", "output"];
20
+ export type UsageTier = (typeof USAGE_TIERS)[number];
21
+ export type UsageLabel = "measured" | "estimated";
22
+ export type UsageSidecarEntry = {
23
+ seq: number;
24
+ command: string;
25
+ label: UsageLabel;
26
+ tiers: Record<UsageTier, number>;
27
+ model?: string;
28
+ provider?: string;
29
+ harness?: string;
30
+ harnessVersion?: string;
31
+ requirement?: string;
32
+ /** Correlates entries with one SDD task run (`aiw task start --id=` or ticket), start to merged PR. */
33
+ workflow?: string;
34
+ };
35
+ export type UsageSidecarSummary = {
36
+ entries: number;
37
+ label: UsageLabel;
38
+ tiers: Record<UsageTier, number>;
39
+ cacheHitRate?: number;
40
+ byModel: {
41
+ model: string;
42
+ tokens: number;
43
+ label: UsageLabel;
44
+ }[];
45
+ byHarness: {
46
+ harness: string;
47
+ tokens: number;
48
+ label: UsageLabel;
49
+ }[];
50
+ byProvider: {
51
+ provider: string;
52
+ tokens: number;
53
+ label: UsageLabel;
54
+ }[];
55
+ };
56
+ export declare function validateUsageSidecarEntry(entry: UsageSidecarEntry): UsageSidecarEntry;
57
+ export declare function parseUsageSidecar(content: string): UsageSidecarEntry[];
58
+ export declare function serializeUsageSidecar(entries: UsageSidecarEntry[]): string;
59
+ /** Append an entry, assigning the next sequence number. Drops nothing: the ledger is capped by the caller. */
60
+ export declare function appendUsageSidecarEntry(entries: UsageSidecarEntry[], entry: Omit<UsageSidecarEntry, "seq">): UsageSidecarEntry[];
61
+ export declare function summarizeUsageSidecar(entries: UsageSidecarEntry[]): UsageSidecarSummary;
62
+ export declare function serializeUsageSidecarSummary(summary: UsageSidecarSummary): string;
@@ -0,0 +1,200 @@
1
+ import { isSeq, parseDocument } from "yaml";
2
+ /**
3
+ * Usage sidecar (switchboard pattern): per-run token records with billing
4
+ * tiers, measurement labels, and harness attribution, stored alongside
5
+ * workflow checkpoints.
6
+ *
7
+ * Honest-measurement rules (docs/token-efficiency-study-notes.md):
8
+ * - every entry carries `measured` or `estimated`; aggregates propagate the
9
+ * label pessimistically (any estimated component makes the total estimated);
10
+ * - no timestamps: entries are ordered by a monotonic sequence number, so
11
+ * unchanged input produces byte-identical output;
12
+ * - naive `tokens x price` accounting overstates savings on cache-heavy
13
+ * agents, so tiers are kept separate and cache hit rate is a first-class
14
+ * metric. Cost conversion is deliberately not built in: provider prices
15
+ * rot, and unlabeled dollars would be dishonest by default.
16
+ */
17
+ export const USAGE_SIDECAR_SCHEMA = 1;
18
+ export const USAGE_SIDECAR_PATH = "checkpoints/usage-sidecar.yml";
19
+ export const USAGE_SIDECAR_CAP = 500;
20
+ export const USAGE_TIERS = ["cache-read", "fresh-input", "cache-write", "output"];
21
+ export function validateUsageSidecarEntry(entry) {
22
+ if (!Number.isInteger(entry.seq) || entry.seq < 1)
23
+ throw new Error("Usage sidecar seq must be a positive integer.");
24
+ if (!entry.command.trim())
25
+ throw new Error("Usage sidecar command must not be empty.");
26
+ for (const tier of USAGE_TIERS) {
27
+ const value = entry.tiers[tier];
28
+ if (!Number.isInteger(value) || value < 0)
29
+ throw new Error(`Usage sidecar tier ${tier} must be a non-negative integer.`);
30
+ }
31
+ if (!USAGE_TIERS.some((tier) => entry.tiers[tier] > 0))
32
+ throw new Error("Usage sidecar entry needs at least one non-zero tier.");
33
+ if (entry.model !== undefined && !entry.model.trim())
34
+ throw new Error("Usage sidecar model must be a non-empty string when present.");
35
+ if (entry.provider !== undefined && !entry.provider.trim())
36
+ throw new Error("Usage sidecar provider must be a non-empty string when present.");
37
+ if (entry.requirement !== undefined && !/^REQ-\d+$/.test(entry.requirement))
38
+ throw new Error("Usage sidecar requirement must use the REQ-### identifier format.");
39
+ if (entry.workflow !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(entry.workflow))
40
+ throw new Error("Usage sidecar workflow must be a workflow id or ticket identifier without spaces.");
41
+ for (const [name, value] of [
42
+ ["harness", entry.harness],
43
+ ["harnessVersion", entry.harnessVersion],
44
+ ])
45
+ if (value !== undefined && !value.trim())
46
+ throw new Error(`Usage sidecar ${name} must be a non-empty string when present.`);
47
+ return entry;
48
+ }
49
+ export function parseUsageSidecar(content) {
50
+ if (!content.trim())
51
+ return [];
52
+ const document = parseDocument(content);
53
+ if (document.errors.length)
54
+ throw new Error("Usage sidecar is not valid YAML.");
55
+ const node = document.get("entries");
56
+ if (node === null || node === undefined)
57
+ throw new Error("Usage sidecar is missing entries.");
58
+ if (!isSeq(node))
59
+ throw new Error("Usage sidecar entries must be a list.");
60
+ const schema = document.get("schema");
61
+ if (schema !== USAGE_SIDECAR_SCHEMA)
62
+ throw new Error(`Usage sidecar schema must be version ${USAGE_SIDECAR_SCHEMA}.`);
63
+ const entries = node.items.map((raw) => {
64
+ const pick = (key) => typeof raw.get === "function"
65
+ ? raw.get(key)
66
+ : raw[key];
67
+ const tiers = {};
68
+ const rawTiers = pick("tiers");
69
+ if (rawTiers === null || typeof rawTiers !== "object")
70
+ throw new Error("Usage sidecar entry is missing tiers.");
71
+ const readTier = (tier) => typeof rawTiers.get === "function"
72
+ ? rawTiers.get(tier)
73
+ : rawTiers[tier];
74
+ for (const tier of USAGE_TIERS) {
75
+ const value = readTier(tier);
76
+ if (value !== null && value !== undefined)
77
+ tiers[tier] = value;
78
+ }
79
+ const optional = (key) => {
80
+ const value = pick(key);
81
+ return value === null || value === undefined ? undefined : String(value);
82
+ };
83
+ return validateUsageSidecarEntry({
84
+ seq: pick("seq"),
85
+ command: String(pick("command")),
86
+ label: pick("label"),
87
+ tiers: Object.fromEntries(USAGE_TIERS.map((tier) => [tier, tiers[tier] ?? 0])),
88
+ ...(optional("model") === undefined ? {} : { model: optional("model") }),
89
+ ...(optional("provider") === undefined ? {} : { provider: optional("provider") }),
90
+ ...(optional("harness") === undefined ? {} : { harness: optional("harness") }),
91
+ ...(optional("harness_version") === undefined
92
+ ? {}
93
+ : { harnessVersion: optional("harness_version") }),
94
+ ...(optional("requirement") === undefined ? {} : { requirement: optional("requirement") }),
95
+ ...(optional("workflow") === undefined ? {} : { workflow: optional("workflow") }),
96
+ });
97
+ });
98
+ const sequences = entries.map((entry) => entry.seq);
99
+ if (new Set(sequences).size !== sequences.length)
100
+ throw new Error("Usage sidecar sequence numbers must be unique.");
101
+ return entries;
102
+ }
103
+ export function serializeUsageSidecar(entries) {
104
+ const lines = [`schema: ${USAGE_SIDECAR_SCHEMA}`, "entries:"];
105
+ for (const entry of entries) {
106
+ lines.push(` - seq: ${entry.seq}`, ` command: ${entry.command}`, ` label: ${entry.label}`, " tiers:", ...USAGE_TIERS.map((tier) => ` ${tier}: ${entry.tiers[tier]}`), ...(entry.model === undefined ? [] : [` model: ${entry.model}`]), ...(entry.provider === undefined ? [] : [` provider: ${entry.provider}`]), ...(entry.harness === undefined ? [] : [` harness: ${entry.harness}`]), ...(entry.harnessVersion === undefined
107
+ ? []
108
+ : [` harness_version: ${entry.harnessVersion}`]), ...(entry.requirement === undefined ? [] : [` requirement: ${entry.requirement}`]), ...(entry.workflow === undefined ? [] : [` workflow: ${entry.workflow}`]));
109
+ }
110
+ return lines.join("\n") + "\n";
111
+ }
112
+ /** Append an entry, assigning the next sequence number. Drops nothing: the ledger is capped by the caller. */
113
+ export function appendUsageSidecarEntry(entries, entry) {
114
+ validateUsageSidecarEntry({ ...entry, seq: 1 });
115
+ const nextSeq = entries.reduce((max, existing) => Math.max(max, existing.seq), 0) + 1;
116
+ const recorded = [...entries, { ...entry, seq: nextSeq }];
117
+ return recorded.length > USAGE_SIDECAR_CAP
118
+ ? recorded.slice(recorded.length - USAGE_SIDECAR_CAP)
119
+ : recorded;
120
+ }
121
+ const totalTokens = (entry) => USAGE_TIERS.reduce((total, tier) => total + entry.tiers[tier], 0);
122
+ export function summarizeUsageSidecar(entries) {
123
+ if (!entries.length)
124
+ return {
125
+ entries: 0,
126
+ label: "measured",
127
+ tiers: Object.fromEntries(USAGE_TIERS.map((tier) => [tier, 0])),
128
+ cacheHitRate: undefined,
129
+ byModel: [],
130
+ byProvider: [],
131
+ byHarness: [],
132
+ };
133
+ const tiers = Object.fromEntries(USAGE_TIERS.map((tier) => [
134
+ tier,
135
+ entries.reduce((total, entry) => total + entry.tiers[tier], 0),
136
+ ]));
137
+ const billableInput = tiers["cache-read"] + tiers["fresh-input"];
138
+ const group = (key) => {
139
+ const map = new Map();
140
+ for (const entry of entries) {
141
+ const name = entry[key] ?? "unattributed";
142
+ const bucket = map.get(name) ?? { tokens: 0, measured: true };
143
+ bucket.tokens += totalTokens(entry);
144
+ bucket.measured = bucket.measured && entry.label === "measured";
145
+ map.set(name, bucket);
146
+ }
147
+ return [...map]
148
+ .map(([name, { tokens, measured }]) => ({
149
+ name,
150
+ tokens,
151
+ label: measured ? "measured" : "estimated",
152
+ }))
153
+ .sort((a, b) => b.tokens - a.tokens || a.name.localeCompare(b.name));
154
+ };
155
+ return {
156
+ entries: entries.length,
157
+ label: entries.every((entry) => entry.label === "measured") ? "measured" : "estimated",
158
+ tiers,
159
+ ...(billableInput > 0 ? { cacheHitRate: tiers["cache-read"] / billableInput } : {}),
160
+ byModel: group("model").map(({ name, tokens, label }) => ({ model: name, tokens, label })),
161
+ byProvider: group("provider")
162
+ .filter(({ name }) => name !== "unattributed")
163
+ .map(({ name, tokens, label }) => ({ provider: name, tokens, label })),
164
+ byHarness: group("harness").map(({ name, tokens, label }) => ({
165
+ harness: name,
166
+ tokens,
167
+ label,
168
+ })),
169
+ };
170
+ }
171
+ export function serializeUsageSidecarSummary(summary) {
172
+ if (!summary.entries)
173
+ return "No usage sidecar entries recorded.";
174
+ const percent = summary.cacheHitRate === undefined ? undefined : `${Math.round(summary.cacheHitRate * 100)}%`;
175
+ return [
176
+ `entries: ${summary.entries} (label: ${summary.label})`,
177
+ ...USAGE_TIERS.map((tier) => ` ${tier}: ${summary.tiers[tier].toLocaleString("en-US")} tokens`),
178
+ ...(percent === undefined
179
+ ? []
180
+ : [` cache_hit_rate: ${percent} (cache-read / billable input)`]),
181
+ ...(summary.byModel.length
182
+ ? [
183
+ "by model:",
184
+ ...summary.byModel.map(({ model, tokens, label }) => ` - ${model}: ${tokens.toLocaleString("en-US")} (${label})`),
185
+ ]
186
+ : []),
187
+ ...(summary.byHarness.length
188
+ ? [
189
+ "by harness:",
190
+ ...summary.byHarness.map(({ harness, tokens, label }) => ` - ${harness}: ${tokens.toLocaleString("en-US")} (${label})`),
191
+ ]
192
+ : []),
193
+ ...(summary.byProvider.length
194
+ ? [
195
+ "by provider:",
196
+ ...summary.byProvider.map(({ provider, tokens, label }) => ` - ${provider}: ${tokens.toLocaleString("en-US")} (${label})`),
197
+ ]
198
+ : []),
199
+ ].join("\n");
200
+ }