@mafdet/cli 0.4.1 → 0.4.2
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 +34 -0
- package/README.md +61 -0
- package/README.zh-CN.md +52 -0
- package/dist/agent/loop.d.ts +14 -0
- package/dist/agent/loop.d.ts.map +1 -1
- package/dist/agent/loop.js +16 -3
- package/dist/agent/session.d.ts +5 -0
- package/dist/agent/session.d.ts.map +1 -1
- package/dist/agent/session.js +1 -1
- package/dist/agent/tools/read-only.d.ts.map +1 -1
- package/dist/agent/tools/read-only.js +6 -0
- package/dist/api/account.d.ts +43 -0
- package/dist/api/account.d.ts.map +1 -1
- package/dist/api/account.js +40 -0
- package/dist/commands/sessions.d.ts +5 -0
- package/dist/commands/sessions.d.ts.map +1 -0
- package/dist/commands/sessions.js +379 -0
- package/dist/config/paths.d.ts +7 -0
- package/dist/config/paths.d.ts.map +1 -1
- package/dist/config/paths.js +24 -0
- package/dist/filesystem/protected-dirs.d.ts +26 -0
- package/dist/filesystem/protected-dirs.d.ts.map +1 -0
- package/dist/filesystem/protected-dirs.js +78 -0
- package/dist/filesystem/workspace.d.ts +12 -1
- package/dist/filesystem/workspace.d.ts.map +1 -1
- package/dist/filesystem/workspace.js +24 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/security/redact.d.ts +26 -3
- package/dist/security/redact.d.ts.map +1 -1
- package/dist/security/redact.js +48 -3
- package/dist/sessions/index-file.d.ts +38 -0
- package/dist/sessions/index-file.d.ts.map +1 -0
- package/dist/sessions/index-file.js +174 -0
- package/dist/sessions/lock.d.ts +39 -0
- package/dist/sessions/lock.d.ts.map +1 -0
- package/dist/sessions/lock.js +134 -0
- package/dist/sessions/open.d.ts +45 -0
- package/dist/sessions/open.d.ts.map +1 -0
- package/dist/sessions/open.js +96 -0
- package/dist/sessions/policy.d.ts +32 -0
- package/dist/sessions/policy.d.ts.map +1 -0
- package/dist/sessions/policy.js +57 -0
- package/dist/sessions/reconcile.d.ts +49 -0
- package/dist/sessions/reconcile.d.ts.map +1 -0
- package/dist/sessions/reconcile.js +115 -0
- package/dist/sessions/recorder.d.ts +42 -0
- package/dist/sessions/recorder.d.ts.map +1 -0
- package/dist/sessions/recorder.js +147 -0
- package/dist/sessions/resume.d.ts +67 -0
- package/dist/sessions/resume.d.ts.map +1 -0
- package/dist/sessions/resume.js +100 -0
- package/dist/sessions/store.d.ts +141 -0
- package/dist/sessions/store.d.ts.map +1 -0
- package/dist/sessions/store.js +272 -0
- package/dist/sessions/usage.d.ts +35 -0
- package/dist/sessions/usage.d.ts.map +1 -0
- package/dist/sessions/usage.js +157 -0
- package/dist/tui/agent-surface.d.ts +3 -0
- package/dist/tui/agent-surface.d.ts.map +1 -1
- package/dist/tui/agent-surface.js +16 -1
- package/dist/tui/app.d.ts.map +1 -1
- package/dist/tui/app.js +18 -1
- package/dist/tui/route.d.ts.map +1 -1
- package/dist/tui/route.js +19 -2
- package/dist/tui/session-centre.d.ts +15 -0
- package/dist/tui/session-centre.d.ts.map +1 -0
- package/dist/tui/session-centre.js +59 -0
- package/dist/tui/start-agent.d.ts +9 -0
- package/dist/tui/start-agent.d.ts.map +1 -1
- package/dist/tui/start-agent.js +122 -0
- package/package.json +1 -1
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// CLI-024 · Turning a saved transcript back into model context.
|
|
2
|
+
//
|
|
3
|
+
// Plan §20.6. The rule that shapes this file: a resumed session inherits the
|
|
4
|
+
// conversation and nothing else. Historical tool calls are context, never
|
|
5
|
+
// instructions to re-run; every approval expired when the process that granted
|
|
6
|
+
// it exited; and `--allow-command` has to be declared again by this run.
|
|
7
|
+
//
|
|
8
|
+
// So the rebuild deliberately drops the `tool_calls` structure and keeps only
|
|
9
|
+
// what was said. A replayed `tool_calls` block would invite the model to
|
|
10
|
+
// continue a plan the user approved yesterday, in a workspace that has since
|
|
11
|
+
// changed, with permissions this run may not have.
|
|
12
|
+
const DEFAULT_MAX_MESSAGES = 40;
|
|
13
|
+
/**
|
|
14
|
+
* Rebuild the messages to send, from what was stored.
|
|
15
|
+
*
|
|
16
|
+
* Tool activity becomes a short note in the conversation rather than a
|
|
17
|
+
* `tool_calls` block: the model learns that a file was read without being
|
|
18
|
+
* handed a half-finished plan to resume, and the API cannot be asked to
|
|
19
|
+
* reference a tool call whose result no longer exists.
|
|
20
|
+
*/
|
|
21
|
+
export function rebuildContext(input) {
|
|
22
|
+
const limit = input.maxMessages ?? DEFAULT_MAX_MESSAGES;
|
|
23
|
+
const turns = [];
|
|
24
|
+
let toolRunsSummarised = 0;
|
|
25
|
+
const pendingTools = [];
|
|
26
|
+
const flushTools = () => {
|
|
27
|
+
if (pendingTools.length === 0)
|
|
28
|
+
return;
|
|
29
|
+
turns.push({
|
|
30
|
+
role: "assistant",
|
|
31
|
+
content: `(earlier in this session I used: ${pendingTools.join(", ")})`,
|
|
32
|
+
});
|
|
33
|
+
pendingTools.length = 0;
|
|
34
|
+
};
|
|
35
|
+
for (const event of input.events) {
|
|
36
|
+
if (event.type === "user_message") {
|
|
37
|
+
flushTools();
|
|
38
|
+
turns.push({ role: "user", content: String(event.content ?? "") });
|
|
39
|
+
}
|
|
40
|
+
else if (event.type === "assistant_message") {
|
|
41
|
+
flushTools();
|
|
42
|
+
turns.push({ role: "assistant", content: String(event.content ?? "") });
|
|
43
|
+
}
|
|
44
|
+
else if (event.type === "tool_call") {
|
|
45
|
+
toolRunsSummarised += 1;
|
|
46
|
+
pendingTools.push(String(event.tool ?? "a tool"));
|
|
47
|
+
}
|
|
48
|
+
// tool_result and usage carry nothing the next turn needs: the result is
|
|
49
|
+
// already reflected in whatever the assistant said afterwards.
|
|
50
|
+
}
|
|
51
|
+
flushTools();
|
|
52
|
+
// Drop from the front, keeping the most recent exchange intact.
|
|
53
|
+
const omitted = Math.max(turns.length - limit, 0);
|
|
54
|
+
const kept = omitted > 0 ? turns.slice(omitted) : turns;
|
|
55
|
+
return {
|
|
56
|
+
messages: [{ role: "system", content: input.systemPrompt }, ...kept],
|
|
57
|
+
omitted,
|
|
58
|
+
toolRunsSummarised,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* What stands in the way of continuing, checked before anything is sent.
|
|
63
|
+
*
|
|
64
|
+
* Everything here is re-derived from the *current* key: a session recorded
|
|
65
|
+
* with a key that could call a model says nothing about whether this key can.
|
|
66
|
+
*/
|
|
67
|
+
export function resumeBlockers(input) {
|
|
68
|
+
const blockers = [];
|
|
69
|
+
if (!input.callable.includes(input.modelId)) {
|
|
70
|
+
blockers.push({ kind: "model-unavailable", modelId: input.modelId });
|
|
71
|
+
}
|
|
72
|
+
else if (input.forAgent && !input.agentCapable.includes(input.modelId)) {
|
|
73
|
+
// Admission is not inherited either: a model may have lost its verified
|
|
74
|
+
// tool calling since the session was recorded.
|
|
75
|
+
blockers.push({ kind: "model-not-agent-capable", modelId: input.modelId });
|
|
76
|
+
}
|
|
77
|
+
if (!input.workspaceExists) {
|
|
78
|
+
blockers.push({ kind: "workspace-missing", workspace: input.workspace });
|
|
79
|
+
}
|
|
80
|
+
return blockers;
|
|
81
|
+
}
|
|
82
|
+
export function describeBlocker(blocker) {
|
|
83
|
+
switch (blocker.kind) {
|
|
84
|
+
case "model-unavailable":
|
|
85
|
+
return `${blocker.modelId} is not callable with the key you are using now - choose another model to continue`;
|
|
86
|
+
case "model-not-agent-capable":
|
|
87
|
+
return `${blocker.modelId} has no verified tool calling now, so the agent cannot drive it - choose another model`;
|
|
88
|
+
case "workspace-missing":
|
|
89
|
+
return `${blocker.workspace} no longer exists - you can read this conversation, or continue it in another directory`;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export function permissionsForResume(declaredThisRun) {
|
|
93
|
+
return {
|
|
94
|
+
approvals: "none",
|
|
95
|
+
// Only what this process was given. The recorded session's list is not
|
|
96
|
+
// even consulted - reading it would be the first step towards honouring it.
|
|
97
|
+
allowedCommands: [...declaredThisRun],
|
|
98
|
+
mayReplayTools: false,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
export declare const SCHEMA_VERSION = 1;
|
|
2
|
+
export type SessionEvent = {
|
|
3
|
+
readonly type: "user_message";
|
|
4
|
+
readonly content: string;
|
|
5
|
+
} | {
|
|
6
|
+
readonly type: "assistant_message";
|
|
7
|
+
readonly content: string;
|
|
8
|
+
} | {
|
|
9
|
+
readonly type: "tool_call";
|
|
10
|
+
readonly tool: string;
|
|
11
|
+
readonly arguments: unknown;
|
|
12
|
+
} | {
|
|
13
|
+
readonly type: "tool_result";
|
|
14
|
+
readonly tool: string;
|
|
15
|
+
readonly status: string;
|
|
16
|
+
readonly content: string;
|
|
17
|
+
} | {
|
|
18
|
+
/** A 429 and what it cost in waiting. Not a model request; see §20.2. */
|
|
19
|
+
readonly type: "rate_limited";
|
|
20
|
+
readonly wait_ms: number;
|
|
21
|
+
readonly attempt: number;
|
|
22
|
+
} | {
|
|
23
|
+
readonly type: "usage";
|
|
24
|
+
readonly request_id?: string;
|
|
25
|
+
readonly model_id: string;
|
|
26
|
+
readonly prompt_tokens: number;
|
|
27
|
+
readonly completion_tokens: number;
|
|
28
|
+
readonly estimated_charge_micro_usd: string;
|
|
29
|
+
readonly settlement_status: "pending" | "settled" | "unknown";
|
|
30
|
+
/**
|
|
31
|
+
* Which key paid, as an irreversible digest. Plan §20.3 requires
|
|
32
|
+
* per-key attribution and forbids storing the key, so the two are
|
|
33
|
+
* reconciled by storing something that can be compared but not used.
|
|
34
|
+
*/
|
|
35
|
+
readonly api_key_ref?: string;
|
|
36
|
+
} | {
|
|
37
|
+
/**
|
|
38
|
+
* CLI-027 · What the server's ledger says one request actually cost.
|
|
39
|
+
*
|
|
40
|
+
* Appended rather than written back over the `usage` line it settles.
|
|
41
|
+
* The log is append-only for a reason - a transcript that gets rewritten
|
|
42
|
+
* after the fact is no longer a record of what happened - and this way a
|
|
43
|
+
* reconciliation that is interrupted halfway leaves the earlier half
|
|
44
|
+
* settled and the rest simply unsettled.
|
|
45
|
+
*/
|
|
46
|
+
readonly type: "settlement";
|
|
47
|
+
readonly request_id: string;
|
|
48
|
+
readonly settled_charge_micro_usd: string;
|
|
49
|
+
/** The ledger's own word for the request, e.g. `success`. */
|
|
50
|
+
readonly server_status: string;
|
|
51
|
+
readonly observed_at: string;
|
|
52
|
+
};
|
|
53
|
+
export interface StoredEvent {
|
|
54
|
+
readonly seq: number;
|
|
55
|
+
readonly type: string;
|
|
56
|
+
readonly [key: string]: unknown;
|
|
57
|
+
}
|
|
58
|
+
export interface SessionMetadata {
|
|
59
|
+
schema_version: number;
|
|
60
|
+
session_id: string;
|
|
61
|
+
title: string;
|
|
62
|
+
created_at: string;
|
|
63
|
+
updated_at: string;
|
|
64
|
+
model_id: string;
|
|
65
|
+
workspace: string;
|
|
66
|
+
mode: string;
|
|
67
|
+
message_count: number;
|
|
68
|
+
model_requests: number;
|
|
69
|
+
prompt_tokens: number;
|
|
70
|
+
completion_tokens: number;
|
|
71
|
+
estimated_charge_micro_usd: string;
|
|
72
|
+
}
|
|
73
|
+
export declare class SessionStoreError extends Error {
|
|
74
|
+
}
|
|
75
|
+
/** Time-ordered and collision-resistant, without pulling in a ulid package. */
|
|
76
|
+
export declare function newSessionId(now?: number): string;
|
|
77
|
+
export interface OpenOptions {
|
|
78
|
+
readonly root: string;
|
|
79
|
+
readonly sessionId?: string;
|
|
80
|
+
readonly title: string;
|
|
81
|
+
readonly modelId: string;
|
|
82
|
+
readonly workspace: string;
|
|
83
|
+
readonly mode: string;
|
|
84
|
+
readonly now?: () => Date;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* A session being written to.
|
|
88
|
+
*
|
|
89
|
+
* Held open for the life of the process. Every append is a complete line
|
|
90
|
+
* followed by a metadata refresh, so a crash leaves a readable store.
|
|
91
|
+
*/
|
|
92
|
+
export declare class SessionWriter {
|
|
93
|
+
readonly directory: string;
|
|
94
|
+
private meta;
|
|
95
|
+
private readonly now;
|
|
96
|
+
private seq;
|
|
97
|
+
private closed;
|
|
98
|
+
private constructor();
|
|
99
|
+
static create(options: OpenOptions): SessionWriter;
|
|
100
|
+
/**
|
|
101
|
+
* Continue writing to a session that already exists.
|
|
102
|
+
*
|
|
103
|
+
* The sequence picks up from the last complete event, so a resumed session
|
|
104
|
+
* is one file rather than two - and a partial last line from an earlier
|
|
105
|
+
* crash is simply written past, since `readSession` already ignores it.
|
|
106
|
+
*/
|
|
107
|
+
static reopen(directory: string, now?: () => Date): SessionWriter;
|
|
108
|
+
get sessionId(): string;
|
|
109
|
+
get metadata(): Readonly<SessionMetadata>;
|
|
110
|
+
/**
|
|
111
|
+
* Append one event.
|
|
112
|
+
*
|
|
113
|
+
* Redacted first, and deliberately at this boundary rather than at each call
|
|
114
|
+
* site: a single place to be right about, and no way for a new event type to
|
|
115
|
+
* be added that forgets.
|
|
116
|
+
*/
|
|
117
|
+
append(event: SessionEvent): StoredEvent;
|
|
118
|
+
private touch;
|
|
119
|
+
setTitle(title: string): void;
|
|
120
|
+
close(): void;
|
|
121
|
+
/** Temp file plus rename: a crash never leaves half a metadata document. */
|
|
122
|
+
private writeMetadata;
|
|
123
|
+
}
|
|
124
|
+
export interface ReadSession {
|
|
125
|
+
readonly metadata: SessionMetadata;
|
|
126
|
+
readonly events: readonly StoredEvent[];
|
|
127
|
+
/** Set when the last line was incomplete, which a crash mid-append leaves. */
|
|
128
|
+
readonly truncatedTail: boolean;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Read a session back.
|
|
132
|
+
*
|
|
133
|
+
* A partial final line is dropped rather than treated as corruption: it is the
|
|
134
|
+
* expected result of being killed mid-append, and everything before it is
|
|
135
|
+
* still exactly what happened.
|
|
136
|
+
*/
|
|
137
|
+
export declare function readSession(directory: string): ReadSession;
|
|
138
|
+
/** Session directories present on disk, newest first by mtime of metadata. */
|
|
139
|
+
export declare function listSessionDirectories(root: string): string[];
|
|
140
|
+
export declare function deleteSession(directory: string): void;
|
|
141
|
+
//# sourceMappingURL=store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/sessions/store.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// CLI-023 · The local session store.
|
|
2
|
+
//
|
|
3
|
+
// Plan §20.5. One directory per session: a small metadata file that is
|
|
4
|
+
// rewritten atomically, and an append-only JSONL of events. No SQLite, so the
|
|
5
|
+
// package keeps its single runtime dependency.
|
|
6
|
+
//
|
|
7
|
+
// Two properties drive the shape:
|
|
8
|
+
//
|
|
9
|
+
// - An interrupted write must cost at most the last event. Appending whole
|
|
10
|
+
// lines gives that for free; rewriting a JSON blob would not.
|
|
11
|
+
// - Nothing written here may carry a credential. Everything goes through
|
|
12
|
+
// `redact()` on the way to disk - the forbidden list in §20.5 covers what
|
|
13
|
+
// the CLI holds, not a password the user typed into a prompt.
|
|
14
|
+
import { appendFileSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmdirSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { redactDeep } from "../security/redact.js";
|
|
17
|
+
export const SCHEMA_VERSION = 1;
|
|
18
|
+
/** Directory 0700, files 0600: no other user on the machine needs this. */
|
|
19
|
+
const DIR_MODE = 0o700;
|
|
20
|
+
const FILE_MODE = 0o600;
|
|
21
|
+
export class SessionStoreError extends Error {
|
|
22
|
+
}
|
|
23
|
+
/** Time-ordered and collision-resistant, without pulling in a ulid package. */
|
|
24
|
+
export function newSessionId(now = Date.now()) {
|
|
25
|
+
const time = now.toString(36).padStart(9, "0");
|
|
26
|
+
let random = "";
|
|
27
|
+
for (let i = 0; i < 10; i += 1) {
|
|
28
|
+
random += "0123456789abcdefghijklmnopqrstuvwxyz"[Math.floor(Math.random() * 36)];
|
|
29
|
+
}
|
|
30
|
+
return `ses_${time}${random}`;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Refuse a path that is a symlink, and create directories with tight modes.
|
|
34
|
+
*
|
|
35
|
+
* A symlinked session directory is how someone else's file gets appended to,
|
|
36
|
+
* or how a transcript gets written somewhere world-readable.
|
|
37
|
+
*/
|
|
38
|
+
function ensureDirectory(path) {
|
|
39
|
+
mkdirSync(path, { recursive: true, mode: DIR_MODE });
|
|
40
|
+
assertNotLink(path);
|
|
41
|
+
}
|
|
42
|
+
function assertNotLink(path) {
|
|
43
|
+
let stats;
|
|
44
|
+
try {
|
|
45
|
+
stats = lstatSync(path);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return; // Does not exist yet; nothing to impersonate.
|
|
49
|
+
}
|
|
50
|
+
if (stats.isSymbolicLink()) {
|
|
51
|
+
throw new SessionStoreError(`refusing to use ${path}: it is a symbolic link`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* A session being written to.
|
|
56
|
+
*
|
|
57
|
+
* Held open for the life of the process. Every append is a complete line
|
|
58
|
+
* followed by a metadata refresh, so a crash leaves a readable store.
|
|
59
|
+
*/
|
|
60
|
+
export class SessionWriter {
|
|
61
|
+
directory;
|
|
62
|
+
meta;
|
|
63
|
+
now;
|
|
64
|
+
seq = 0;
|
|
65
|
+
closed = false;
|
|
66
|
+
constructor(directory, meta, now) {
|
|
67
|
+
this.directory = directory;
|
|
68
|
+
this.meta = meta;
|
|
69
|
+
this.now = now;
|
|
70
|
+
}
|
|
71
|
+
static create(options) {
|
|
72
|
+
const now = options.now ?? (() => new Date());
|
|
73
|
+
const sessionId = options.sessionId ?? newSessionId();
|
|
74
|
+
const directory = join(options.root, sessionId);
|
|
75
|
+
assertNotLink(options.root);
|
|
76
|
+
ensureDirectory(directory);
|
|
77
|
+
const timestamp = now().toISOString();
|
|
78
|
+
const meta = {
|
|
79
|
+
schema_version: SCHEMA_VERSION,
|
|
80
|
+
session_id: sessionId,
|
|
81
|
+
title: options.title,
|
|
82
|
+
created_at: timestamp,
|
|
83
|
+
updated_at: timestamp,
|
|
84
|
+
model_id: options.modelId,
|
|
85
|
+
workspace: options.workspace,
|
|
86
|
+
mode: options.mode,
|
|
87
|
+
message_count: 0,
|
|
88
|
+
model_requests: 0,
|
|
89
|
+
prompt_tokens: 0,
|
|
90
|
+
completion_tokens: 0,
|
|
91
|
+
estimated_charge_micro_usd: "0",
|
|
92
|
+
};
|
|
93
|
+
const writer = new SessionWriter(directory, meta, now);
|
|
94
|
+
writer.writeMetadata();
|
|
95
|
+
return writer;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Continue writing to a session that already exists.
|
|
99
|
+
*
|
|
100
|
+
* The sequence picks up from the last complete event, so a resumed session
|
|
101
|
+
* is one file rather than two - and a partial last line from an earlier
|
|
102
|
+
* crash is simply written past, since `readSession` already ignores it.
|
|
103
|
+
*/
|
|
104
|
+
static reopen(directory, now) {
|
|
105
|
+
assertNotLink(directory);
|
|
106
|
+
const existing = readSession(directory);
|
|
107
|
+
const writer = new SessionWriter(directory, { ...existing.metadata }, now ?? (() => new Date()));
|
|
108
|
+
writer.seq = existing.events.reduce((highest, event) => Math.max(highest, event.seq), 0);
|
|
109
|
+
return writer;
|
|
110
|
+
}
|
|
111
|
+
get sessionId() {
|
|
112
|
+
return this.meta.session_id;
|
|
113
|
+
}
|
|
114
|
+
get metadata() {
|
|
115
|
+
return this.meta;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Append one event.
|
|
119
|
+
*
|
|
120
|
+
* Redacted first, and deliberately at this boundary rather than at each call
|
|
121
|
+
* site: a single place to be right about, and no way for a new event type to
|
|
122
|
+
* be added that forgets.
|
|
123
|
+
*/
|
|
124
|
+
append(event) {
|
|
125
|
+
if (this.closed)
|
|
126
|
+
throw new SessionStoreError("session is closed");
|
|
127
|
+
this.seq += 1;
|
|
128
|
+
const stored = {
|
|
129
|
+
seq: this.seq,
|
|
130
|
+
...redactDeep(event),
|
|
131
|
+
};
|
|
132
|
+
const line = `${JSON.stringify(stored)}\n`;
|
|
133
|
+
const path = join(this.directory, "events.jsonl");
|
|
134
|
+
assertNotLink(path);
|
|
135
|
+
appendFileSync(path, line, { mode: FILE_MODE });
|
|
136
|
+
this.touch(event);
|
|
137
|
+
return stored;
|
|
138
|
+
}
|
|
139
|
+
touch(event) {
|
|
140
|
+
// Reconciling is bookkeeping about a past request, not new activity: it
|
|
141
|
+
// adds no message and no request, and bumping `updated_at` would push the
|
|
142
|
+
// session to the top of a list ordered by when it was last *used*.
|
|
143
|
+
if (event.type === "settlement")
|
|
144
|
+
return;
|
|
145
|
+
if (event.type === "user_message" || event.type === "assistant_message") {
|
|
146
|
+
this.meta.message_count += 1;
|
|
147
|
+
}
|
|
148
|
+
if (event.type === "usage") {
|
|
149
|
+
this.meta.model_requests += 1;
|
|
150
|
+
this.meta.prompt_tokens += event.prompt_tokens;
|
|
151
|
+
this.meta.completion_tokens += event.completion_tokens;
|
|
152
|
+
// Money stays integral: strings in, BigInt to add, string out.
|
|
153
|
+
this.meta.estimated_charge_micro_usd = (BigInt(this.meta.estimated_charge_micro_usd) +
|
|
154
|
+
BigInt(event.estimated_charge_micro_usd)).toString();
|
|
155
|
+
}
|
|
156
|
+
this.meta.updated_at = this.now().toISOString();
|
|
157
|
+
this.writeMetadata();
|
|
158
|
+
}
|
|
159
|
+
setTitle(title) {
|
|
160
|
+
this.meta.title = title;
|
|
161
|
+
this.meta.updated_at = this.now().toISOString();
|
|
162
|
+
this.writeMetadata();
|
|
163
|
+
}
|
|
164
|
+
close() {
|
|
165
|
+
this.closed = true;
|
|
166
|
+
}
|
|
167
|
+
/** Temp file plus rename: a crash never leaves half a metadata document. */
|
|
168
|
+
writeMetadata() {
|
|
169
|
+
const target = join(this.directory, "metadata.json");
|
|
170
|
+
const temp = join(this.directory, `.metadata.${process.pid}.tmp`);
|
|
171
|
+
try {
|
|
172
|
+
writeFileSync(temp, `${JSON.stringify(this.meta, null, 2)}\n`, {
|
|
173
|
+
mode: FILE_MODE,
|
|
174
|
+
});
|
|
175
|
+
renameSync(temp, target);
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
try {
|
|
179
|
+
unlinkSync(temp);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
// May never have been created.
|
|
183
|
+
}
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Read a session back.
|
|
190
|
+
*
|
|
191
|
+
* A partial final line is dropped rather than treated as corruption: it is the
|
|
192
|
+
* expected result of being killed mid-append, and everything before it is
|
|
193
|
+
* still exactly what happened.
|
|
194
|
+
*/
|
|
195
|
+
export function readSession(directory) {
|
|
196
|
+
const metaPath = join(directory, "metadata.json");
|
|
197
|
+
let metadata;
|
|
198
|
+
try {
|
|
199
|
+
metadata = JSON.parse(readFileSync(metaPath, "utf8"));
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
throw new SessionStoreError(`cannot read ${metaPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
203
|
+
}
|
|
204
|
+
if (typeof metadata.schema_version !== "number") {
|
|
205
|
+
throw new SessionStoreError(`${metaPath} has no schema_version`);
|
|
206
|
+
}
|
|
207
|
+
if (metadata.schema_version > SCHEMA_VERSION) {
|
|
208
|
+
throw new SessionStoreError(`${metaPath} was written by a newer version of the CLI (schema ${metadata.schema_version}); upgrade to read it`);
|
|
209
|
+
}
|
|
210
|
+
let raw = "";
|
|
211
|
+
try {
|
|
212
|
+
raw = readFileSync(join(directory, "events.jsonl"), "utf8");
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return { metadata, events: [], truncatedTail: false };
|
|
216
|
+
}
|
|
217
|
+
const lines = raw.split("\n");
|
|
218
|
+
const trailing = lines.pop();
|
|
219
|
+
const truncatedTail = trailing !== undefined && trailing.length > 0;
|
|
220
|
+
const events = [];
|
|
221
|
+
for (const line of lines) {
|
|
222
|
+
if (line.length === 0)
|
|
223
|
+
continue;
|
|
224
|
+
try {
|
|
225
|
+
events.push(JSON.parse(line));
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
// One unreadable line does not invalidate the rest of the transcript.
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return { metadata, events, truncatedTail };
|
|
232
|
+
}
|
|
233
|
+
/** Session directories present on disk, newest first by mtime of metadata. */
|
|
234
|
+
export function listSessionDirectories(root) {
|
|
235
|
+
let entries;
|
|
236
|
+
try {
|
|
237
|
+
entries = readdirSync(root, { withFileTypes: true });
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
return [];
|
|
241
|
+
}
|
|
242
|
+
return entries
|
|
243
|
+
.filter((entry) => entry.isDirectory() && entry.name.startsWith("ses_"))
|
|
244
|
+
.map((entry) => join(root, entry.name))
|
|
245
|
+
.sort();
|
|
246
|
+
}
|
|
247
|
+
export function deleteSession(directory) {
|
|
248
|
+
assertNotLink(directory);
|
|
249
|
+
for (const name of ["events.jsonl", "metadata.json"]) {
|
|
250
|
+
try {
|
|
251
|
+
unlinkSync(join(directory, name));
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
// Already gone.
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
try {
|
|
258
|
+
// Anything else in there (a stale temp file) goes too, then the directory.
|
|
259
|
+
for (const entry of readdirSync(directory)) {
|
|
260
|
+
unlinkSync(join(directory, entry));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
// Nothing left to remove.
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
rmdirSync(directory);
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
// Leave it; listing filters on metadata anyway.
|
|
271
|
+
}
|
|
272
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { StoredEvent } from "./store.js";
|
|
2
|
+
export type SettlementStatus = "settled" | "pending" | "unknown";
|
|
3
|
+
export interface UsageTotals {
|
|
4
|
+
readonly modelRequests: number;
|
|
5
|
+
readonly promptTokens: number;
|
|
6
|
+
readonly completionTokens: number;
|
|
7
|
+
readonly estimatedMicroUsd: bigint;
|
|
8
|
+
/** Only the requests whose settled figure is actually known. */
|
|
9
|
+
readonly settledMicroUsd: bigint;
|
|
10
|
+
readonly bySettlement: Readonly<Record<SettlementStatus, number>>;
|
|
11
|
+
}
|
|
12
|
+
export interface UsageBreakdown extends UsageTotals {
|
|
13
|
+
/** Conversation shape, which is not the same as the request count (§20.2). */
|
|
14
|
+
readonly userMessages: number;
|
|
15
|
+
readonly toolRuns: number;
|
|
16
|
+
readonly toolRunsRefused: number;
|
|
17
|
+
/** 429s are waiting, not requests: counted apart so neither inflates. */
|
|
18
|
+
readonly rateLimitHits: number;
|
|
19
|
+
readonly rateLimitWaitMs: number;
|
|
20
|
+
readonly byModel: readonly (UsageTotals & {
|
|
21
|
+
readonly modelId: string;
|
|
22
|
+
})[];
|
|
23
|
+
readonly byKey: readonly (UsageTotals & {
|
|
24
|
+
readonly apiKeyRef: string;
|
|
25
|
+
})[];
|
|
26
|
+
}
|
|
27
|
+
export declare function summariseUsage(events: readonly StoredEvent[]): UsageBreakdown;
|
|
28
|
+
/**
|
|
29
|
+
* How to describe the settled column without overclaiming.
|
|
30
|
+
*
|
|
31
|
+
* Returns undefined when there is nothing honest to say, so a caller renders
|
|
32
|
+
* no figure rather than a zero - a zero reads as "it was free".
|
|
33
|
+
*/
|
|
34
|
+
export declare function settledCaveat(totals: UsageTotals): string | undefined;
|
|
35
|
+
//# sourceMappingURL=usage.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"usage.d.ts","sourceRoot":"","sources":["../../src/sessions/usage.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C,MAAM,MAAM,gBAAgB,GAAG,SAAS,GAAG,SAAS,GAAG,SAAS,CAAC;AAEjE,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAC;IACnC,gEAAgE;IAChE,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,YAAY,EAAE,QAAQ,CAAC,MAAM,CAAC,gBAAgB,EAAE,MAAM,CAAC,CAAC,CAAC;CACnE;AAED,MAAM,WAAW,cAAe,SAAQ,WAAW;IACjD,8EAA8E;IAC9E,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,yEAAyE;IACzE,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,OAAO,EAAE,SAAS,CAAC,WAAW,GAAG;QAAE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,EAAE,CAAC;IAC1E,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC,WAAW,GAAG;QAAE,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC,EAAE,CAAC;CAC3E;AA2FD,wBAAgB,cAAc,CAAC,MAAM,EAAE,SAAS,WAAW,EAAE,GAAG,cAAc,CA6D7E;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,WAAW,GAAG,MAAM,GAAG,SAAS,CAQrE"}
|