pi-jev-wiki 0.2.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/CHANGELOG.md +52 -0
- package/LICENSE +21 -0
- package/README.md +152 -0
- package/package.json +63 -0
- package/skills/llm-wiki/SKILL.md +143 -0
- package/skills/llm-wiki/references/decision.md +32 -0
- package/skills/llm-wiki/references/flow.md +32 -0
- package/skills/llm-wiki/references/gotcha.md +16 -0
- package/skills/llm-wiki/references/invariant.md +20 -0
- package/skills/llm-wiki/references/module.md +30 -0
- package/src/config.ts +168 -0
- package/src/doctor.ts +171 -0
- package/src/extension.ts +1674 -0
- package/src/git.ts +69 -0
- package/src/grounding.ts +46 -0
- package/src/jev.ts +207 -0
- package/src/ledger.ts +85 -0
- package/src/lint.ts +407 -0
- package/src/metrics.ts +61 -0
- package/src/pipeline/adjudicate.ts +416 -0
- package/src/pipeline/capture.ts +109 -0
- package/src/pipeline/extract.ts +150 -0
- package/src/pipeline/write.ts +263 -0
- package/src/provenance.ts +137 -0
- package/src/redact.ts +46 -0
- package/src/review.ts +146 -0
- package/src/sessionlog.ts +107 -0
- package/src/structure.ts +215 -0
- package/src/sync.ts +295 -0
- package/src/wiki/frontmatter.ts +164 -0
- package/src/wiki/layout.ts +126 -0
- package/src/wiki/links.ts +17 -0
- package/src/wiki/lock.ts +86 -0
- package/src/wiki/search.ts +263 -0
- package/src/wiki/toc.ts +198 -0
package/src/git.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal git helpers for change-driven wiki invalidation.
|
|
3
|
+
* Uses child_process directly so the logic is testable without the pi runtime.
|
|
4
|
+
*/
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
|
|
8
|
+
const run = promisify(execFile);
|
|
9
|
+
|
|
10
|
+
export interface GitResult {
|
|
11
|
+
code: number;
|
|
12
|
+
stdout: string;
|
|
13
|
+
stderr: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function git(cwd: string, args: string[]): Promise<GitResult> {
|
|
17
|
+
try {
|
|
18
|
+
const { stdout, stderr } = await run("git", ["-C", cwd, ...args], { maxBuffer: 20 * 1024 * 1024 });
|
|
19
|
+
return { code: 0, stdout, stderr };
|
|
20
|
+
} catch (error) {
|
|
21
|
+
const failure = error as { code?: number; stdout?: string; stderr?: string; message?: string };
|
|
22
|
+
return {
|
|
23
|
+
code: typeof failure.code === "number" ? failure.code : 1,
|
|
24
|
+
stdout: failure.stdout ?? "",
|
|
25
|
+
stderr: failure.stderr ?? failure.message ?? String(error),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function isGitRepo(cwd: string): Promise<boolean> {
|
|
31
|
+
const result = await git(cwd, ["rev-parse", "--is-inside-work-tree"]);
|
|
32
|
+
return result.code === 0 && result.stdout.trim() === "true";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function headCommit(cwd: string): Promise<string | undefined> {
|
|
36
|
+
const result = await git(cwd, ["rev-parse", "HEAD"]);
|
|
37
|
+
return result.code === 0 ? result.stdout.trim() : undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function changedFiles(cwd: string, from: string, to = "HEAD"): Promise<string[]> {
|
|
41
|
+
const result = await git(cwd, ["diff", "--name-only", "--diff-filter=ACMR", `${from}..${to}`]);
|
|
42
|
+
if (result.code !== 0) return [];
|
|
43
|
+
return result.stdout
|
|
44
|
+
.split(/\r?\n/)
|
|
45
|
+
.map((line) => line.trim())
|
|
46
|
+
.filter(Boolean)
|
|
47
|
+
.map((line) => line.split("\\").join("/"));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function diffForFiles(cwd: string, from: string, to: string, files: string[], maxChars: number): Promise<string> {
|
|
51
|
+
if (files.length === 0) return "";
|
|
52
|
+
const result = await git(cwd, ["diff", "--no-color", "--unified=3", `${from}..${to}`, "--", ...files]);
|
|
53
|
+
if (result.code !== 0) return "";
|
|
54
|
+
return result.stdout.length > maxChars ? `${result.stdout.slice(0, maxChars)}\n[... diff truncated ...]` : result.stdout;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Exact path, directory prefix, or a simple `*` glob. */
|
|
58
|
+
export function fileMatches(changedPath: string, pattern: string): boolean {
|
|
59
|
+
const changed = changedPath.split("\\").join("/");
|
|
60
|
+
const candidate = pattern.split("\\").join("/").replace(/^\.\//, "");
|
|
61
|
+
if (!candidate) return false;
|
|
62
|
+
if (candidate.includes("*")) {
|
|
63
|
+
const regex = new RegExp(`^${candidate.split("*").map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`);
|
|
64
|
+
return regex.test(changed);
|
|
65
|
+
}
|
|
66
|
+
if (changed === candidate) return true;
|
|
67
|
+
const prefix = candidate.endsWith("/") ? candidate : `${candidate}/`;
|
|
68
|
+
return changed.startsWith(prefix);
|
|
69
|
+
}
|
package/src/grounding.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writer grounding: extract load-bearing literals from generated page bodies
|
|
3
|
+
* (numbers, URLs, version strings) and check them against the evidence the
|
|
4
|
+
* writer was given. Anything absent is flagged for review — this catches the
|
|
5
|
+
* characteristic failure of generative writing: plausible invented specifics.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface LiteralCheck {
|
|
9
|
+
literals: string[];
|
|
10
|
+
missing: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const LITERAL_PATTERNS: RegExp[] = [
|
|
14
|
+
/\b\d+(?:[.,]\d+)*(?:\s?(?:ms|s|m|h|kb|mb|gb|tok|tokens|%)|(?:\s?(?:million|billion)))?\b/g,
|
|
15
|
+
/https?:\/\/[^\s)"'\]]+/g,
|
|
16
|
+
/\bv?\d+\.\d+(?:\.\d+)?\b/g,
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
function normalizeNumber(value: string): string {
|
|
20
|
+
return value.replace(/[,\s]/g, "").toLowerCase();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Extract literals worth verifying; single digits and years are ignored as noise. */
|
|
24
|
+
export function extractLiterals(body: string): string[] {
|
|
25
|
+
const withoutCode = body.replace(/```[\s\S]*?```/g, " ");
|
|
26
|
+
const found = new Set<string>();
|
|
27
|
+
for (const pattern of LITERAL_PATTERNS) {
|
|
28
|
+
for (const match of withoutCode.matchAll(pattern)) {
|
|
29
|
+
const value = match[0].trim();
|
|
30
|
+
if (/^\d$/.test(value)) continue;
|
|
31
|
+
if (/^(19|20)\d{2}$/.test(value)) continue;
|
|
32
|
+
found.add(value);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return [...found];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function checkLiterals(body: string, evidence: string): LiteralCheck {
|
|
39
|
+
const literals = extractLiterals(body);
|
|
40
|
+
const haystack = evidence.toLowerCase();
|
|
41
|
+
const missing = literals.filter((literal) => {
|
|
42
|
+
const candidates = [literal.toLowerCase(), normalizeNumber(literal)];
|
|
43
|
+
return !candidates.some((candidate) => haystack.includes(candidate));
|
|
44
|
+
});
|
|
45
|
+
return { literals, missing };
|
|
46
|
+
}
|
package/src/jev.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal TypeSafe / Jev client.
|
|
3
|
+
*
|
|
4
|
+
* Endpoint contract (native schema):
|
|
5
|
+
* POST {baseUrl} Authorization: Bearer <key>
|
|
6
|
+
* { model, state, questions: { key: Question } }
|
|
7
|
+
* -> { model, answers: { key: Answer }, usage: { input_tokens, output_tokens }, meta? }
|
|
8
|
+
*
|
|
9
|
+
* Chat completions do NOT work with decision models. This client is provider-agnostic:
|
|
10
|
+
* TypeSafe direct, OpenRouter `/api/alpha/decisions`, and AI/ML API all accept this schema.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type JevQuestion =
|
|
14
|
+
| { type: "noul"; instructions: string; criteria?: { true: string; false: string } }
|
|
15
|
+
| { type: "choice"; instructions: string; criteria: Record<string, string | null> }
|
|
16
|
+
| { type: "score"; instructions: string; criteria: string[] };
|
|
17
|
+
|
|
18
|
+
export interface NoulAnswer {
|
|
19
|
+
type: "noul";
|
|
20
|
+
noul: number;
|
|
21
|
+
}
|
|
22
|
+
export interface ChoiceAnswer {
|
|
23
|
+
type: "choice";
|
|
24
|
+
choice: string;
|
|
25
|
+
confidence: number;
|
|
26
|
+
probabilities: Record<string, number>;
|
|
27
|
+
}
|
|
28
|
+
export interface ScoreAnswer {
|
|
29
|
+
type: "score";
|
|
30
|
+
score: number;
|
|
31
|
+
confidence: number;
|
|
32
|
+
legend: Record<string, string>;
|
|
33
|
+
probabilities: Record<string, number>;
|
|
34
|
+
}
|
|
35
|
+
export type JevAnswer = NoulAnswer | ChoiceAnswer | ScoreAnswer;
|
|
36
|
+
|
|
37
|
+
export interface JevUsage {
|
|
38
|
+
input_tokens: number;
|
|
39
|
+
output_tokens: number;
|
|
40
|
+
cost?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface JevResponse {
|
|
44
|
+
model: string;
|
|
45
|
+
provider?: string;
|
|
46
|
+
answers: Record<string, JevAnswer>;
|
|
47
|
+
usage: JevUsage;
|
|
48
|
+
meta?: unknown;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface JevClientOptions {
|
|
52
|
+
baseUrl: string;
|
|
53
|
+
apiKey: string;
|
|
54
|
+
model: string;
|
|
55
|
+
timeoutMs?: number;
|
|
56
|
+
maxRetries?: number;
|
|
57
|
+
fetchImpl?: typeof fetch;
|
|
58
|
+
userAgent?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Returns the number of retry attempts left, or null when the response is final.
|
|
63
|
+
* 402 (no credits) is intentionally non-retryable: retrying cannot fix billing.
|
|
64
|
+
*/
|
|
65
|
+
function retryableStatus(status: number): boolean {
|
|
66
|
+
return status === 429 || status === 529 || status >= 500;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class JevError extends Error {
|
|
70
|
+
readonly status: number;
|
|
71
|
+
readonly body: unknown;
|
|
72
|
+
|
|
73
|
+
constructor(message: string, status: number, body: unknown) {
|
|
74
|
+
super(message);
|
|
75
|
+
this.name = "JevError";
|
|
76
|
+
this.status = status;
|
|
77
|
+
this.body = body;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function noul(instructions: string, criteria?: { true: string; false: string }): JevQuestion {
|
|
82
|
+
return criteria ? { type: "noul", instructions, criteria } : { type: "noul", instructions };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function choice(instructions: string, criteria: Record<string, string | null>): JevQuestion {
|
|
86
|
+
return { type: "choice", instructions, criteria };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function score(instructions: string, criteria: string[]): JevQuestion {
|
|
90
|
+
return { type: "score", instructions, criteria };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function chunk<T>(items: T[], size: number): T[][] {
|
|
94
|
+
const out: T[][] = [];
|
|
95
|
+
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function mapLimit<T, R>(items: T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]> {
|
|
100
|
+
const results = new Array<R>(items.length);
|
|
101
|
+
let cursor = 0;
|
|
102
|
+
const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
|
|
103
|
+
while (cursor < items.length) {
|
|
104
|
+
const index = cursor++;
|
|
105
|
+
results[index] = await fn(items[index], index);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
await Promise.all(workers);
|
|
109
|
+
return results;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
113
|
+
|
|
114
|
+
export class JevClient {
|
|
115
|
+
private readonly baseUrl: string;
|
|
116
|
+
private readonly apiKey: string;
|
|
117
|
+
private readonly model: string;
|
|
118
|
+
private readonly timeoutMs: number;
|
|
119
|
+
private readonly maxRetries: number;
|
|
120
|
+
private readonly fetchImpl: typeof fetch;
|
|
121
|
+
private readonly userAgent: string;
|
|
122
|
+
readonly totals: JevUsage = { input_tokens: 0, output_tokens: 0, cost: 0 };
|
|
123
|
+
|
|
124
|
+
constructor(options: JevClientOptions) {
|
|
125
|
+
this.baseUrl = options.baseUrl;
|
|
126
|
+
this.apiKey = options.apiKey;
|
|
127
|
+
this.model = options.model;
|
|
128
|
+
this.timeoutMs = options.timeoutMs ?? 60_000;
|
|
129
|
+
this.maxRetries = options.maxRetries ?? 3;
|
|
130
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
131
|
+
this.userAgent = options.userAgent ?? "jev-wiki/0.1";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async systemOne(
|
|
135
|
+
state: unknown,
|
|
136
|
+
questions: Record<string, JevQuestion>,
|
|
137
|
+
options?: { signal?: AbortSignal; model?: string; timeoutMs?: number },
|
|
138
|
+
): Promise<JevResponse> {
|
|
139
|
+
const body = JSON.stringify({ model: options?.model ?? this.model, state, questions });
|
|
140
|
+
let lastError: unknown;
|
|
141
|
+
|
|
142
|
+
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
143
|
+
const timeout = AbortSignal.timeout(options?.timeoutMs ?? this.timeoutMs);
|
|
144
|
+
const signal = options?.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
|
|
145
|
+
let response: Response;
|
|
146
|
+
try {
|
|
147
|
+
response = await this.fetchImpl(this.baseUrl, {
|
|
148
|
+
method: "POST",
|
|
149
|
+
headers: {
|
|
150
|
+
"content-type": "application/json",
|
|
151
|
+
authorization: `Bearer ${this.apiKey}`,
|
|
152
|
+
"user-agent": this.userAgent,
|
|
153
|
+
},
|
|
154
|
+
body,
|
|
155
|
+
signal,
|
|
156
|
+
});
|
|
157
|
+
} catch (error) {
|
|
158
|
+
lastError = error;
|
|
159
|
+
if (attempt < this.maxRetries) {
|
|
160
|
+
await sleep(500 * 2 ** attempt);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
throw new JevError(`Jev request failed: ${String((error as Error)?.message ?? error)}`, 0, undefined);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (response.ok) {
|
|
167
|
+
const payload = (await response.json()) as JevResponse;
|
|
168
|
+
this.totals.input_tokens += payload.usage?.input_tokens ?? 0;
|
|
169
|
+
this.totals.output_tokens += payload.usage?.output_tokens ?? 0;
|
|
170
|
+
this.totals.cost = (this.totals.cost ?? 0) + (payload.usage?.cost ?? 0);
|
|
171
|
+
return payload;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const retryable = retryableStatus(response.status);
|
|
175
|
+
const text = await response.text().catch(() => "");
|
|
176
|
+
let parsed: unknown = text;
|
|
177
|
+
try {
|
|
178
|
+
parsed = JSON.parse(text);
|
|
179
|
+
} catch {
|
|
180
|
+
/* keep text */
|
|
181
|
+
}
|
|
182
|
+
lastError = new JevError(`Jev HTTP ${response.status}: ${text.slice(0, 300)}`, response.status, parsed);
|
|
183
|
+
if (!retryable || attempt === this.maxRetries) throw lastError;
|
|
184
|
+
|
|
185
|
+
const retryAfter = Number(response.headers.get("retry-after"));
|
|
186
|
+
const delay = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 500 * 2 ** attempt;
|
|
187
|
+
await sleep(delay);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
throw lastError instanceof Error ? lastError : new JevError("Jev request failed", 0, undefined);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Convenience: build a client from a resolved config. */
|
|
195
|
+
export function createJevClient(config: { baseUrl: string; model: string }, apiKey: string, options?: Partial<JevClientOptions>): JevClient {
|
|
196
|
+
return new JevClient({ baseUrl: config.baseUrl, model: config.model, apiKey, ...options });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function isNoul(answer: JevAnswer | undefined): answer is NoulAnswer {
|
|
200
|
+
return answer?.type === "noul";
|
|
201
|
+
}
|
|
202
|
+
export function isChoice(answer: JevAnswer | undefined): answer is ChoiceAnswer {
|
|
203
|
+
return answer?.type === "choice";
|
|
204
|
+
}
|
|
205
|
+
export function isScore(answer: JevAnswer | undefined): answer is ScoreAnswer {
|
|
206
|
+
return answer?.type === "score";
|
|
207
|
+
}
|
package/src/ledger.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decision ledger: every agent, Jev, and code decision with thresholds and outcomes.
|
|
3
|
+
* Newline-delimited JSON at `<wikiRoot>/.jev-wiki/decisions.jsonl`.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { appendFile, readFile } from "node:fs/promises";
|
|
7
|
+
import { mkdir } from "node:fs/promises";
|
|
8
|
+
import { dirname } from "node:path";
|
|
9
|
+
import type { WikiLayout } from "./wiki/layout.ts";
|
|
10
|
+
|
|
11
|
+
export type LedgerActor = "jev" | "agent" | "code";
|
|
12
|
+
|
|
13
|
+
export interface LedgerEntry {
|
|
14
|
+
ts: string;
|
|
15
|
+
actor: LedgerActor;
|
|
16
|
+
op: string;
|
|
17
|
+
subject?: string;
|
|
18
|
+
verdict?: unknown;
|
|
19
|
+
thresholds?: unknown;
|
|
20
|
+
action?: string;
|
|
21
|
+
reason?: string;
|
|
22
|
+
evidence?: string[];
|
|
23
|
+
outcome?: string;
|
|
24
|
+
usage?: { input_tokens: number; output_tokens: number; cost?: number };
|
|
25
|
+
[key: string]: unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface LedgerInput {
|
|
29
|
+
actor: LedgerActor;
|
|
30
|
+
op: string;
|
|
31
|
+
ts?: string;
|
|
32
|
+
subject?: string;
|
|
33
|
+
verdict?: unknown;
|
|
34
|
+
thresholds?: unknown;
|
|
35
|
+
action?: string;
|
|
36
|
+
reason?: string;
|
|
37
|
+
evidence?: string[];
|
|
38
|
+
outcome?: string;
|
|
39
|
+
usage?: { input_tokens: number; output_tokens: number; cost?: number };
|
|
40
|
+
[key: string]: unknown;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function appendLedger(layout: WikiLayout, entry: LedgerInput): Promise<void> {
|
|
44
|
+
await mkdir(dirname(layout.ledgerPath), { recursive: true });
|
|
45
|
+
const record: LedgerEntry = { ts: entry.ts ?? new Date().toISOString(), ...entry };
|
|
46
|
+
await appendFile(layout.ledgerPath, `${JSON.stringify(record)}\n`, "utf8");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function readLedger(layout: WikiLayout): Promise<LedgerEntry[]> {
|
|
50
|
+
if (!existsSync(layout.ledgerPath)) return [];
|
|
51
|
+
const text = await readFile(layout.ledgerPath, "utf8");
|
|
52
|
+
return text
|
|
53
|
+
.split(/\r?\n/)
|
|
54
|
+
.filter(Boolean)
|
|
55
|
+
.map((line) => {
|
|
56
|
+
try {
|
|
57
|
+
return JSON.parse(line) as LedgerEntry;
|
|
58
|
+
} catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
.filter((entry): entry is LedgerEntry => Boolean(entry));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function summarizeLedger(entries: LedgerEntry[]): {
|
|
66
|
+
total: number;
|
|
67
|
+
byActor: Record<string, number>;
|
|
68
|
+
jevTokensIn: number;
|
|
69
|
+
jevTokensOut: number;
|
|
70
|
+
jevCost: number;
|
|
71
|
+
} {
|
|
72
|
+
const byActor: Record<string, number> = {};
|
|
73
|
+
let jevTokensIn = 0;
|
|
74
|
+
let jevTokensOut = 0;
|
|
75
|
+
let jevCost = 0;
|
|
76
|
+
for (const entry of entries) {
|
|
77
|
+
byActor[entry.actor] = (byActor[entry.actor] ?? 0) + 1;
|
|
78
|
+
if (entry.usage) {
|
|
79
|
+
jevTokensIn += entry.usage.input_tokens ?? 0;
|
|
80
|
+
jevTokensOut += entry.usage.output_tokens ?? 0;
|
|
81
|
+
jevCost += entry.usage.cost ?? 0;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { total: entries.length, byActor, jevTokensIn, jevTokensOut, jevCost };
|
|
85
|
+
}
|