@mafdet/cli 0.4.0 → 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.
Files changed (75) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +64 -3
  3. package/README.zh-CN.md +53 -1
  4. package/dist/agent/loop.d.ts +14 -0
  5. package/dist/agent/loop.d.ts.map +1 -1
  6. package/dist/agent/loop.js +16 -3
  7. package/dist/agent/session.d.ts +5 -0
  8. package/dist/agent/session.d.ts.map +1 -1
  9. package/dist/agent/session.js +1 -1
  10. package/dist/agent/tools/read-only.d.ts.map +1 -1
  11. package/dist/agent/tools/read-only.js +6 -0
  12. package/dist/api/account.d.ts +43 -0
  13. package/dist/api/account.d.ts.map +1 -1
  14. package/dist/api/account.js +40 -0
  15. package/dist/commands/sessions.d.ts +5 -0
  16. package/dist/commands/sessions.d.ts.map +1 -0
  17. package/dist/commands/sessions.js +379 -0
  18. package/dist/config/paths.d.ts +7 -0
  19. package/dist/config/paths.d.ts.map +1 -1
  20. package/dist/config/paths.js +24 -0
  21. package/dist/filesystem/protected-dirs.d.ts +26 -0
  22. package/dist/filesystem/protected-dirs.d.ts.map +1 -0
  23. package/dist/filesystem/protected-dirs.js +78 -0
  24. package/dist/filesystem/workspace.d.ts +12 -1
  25. package/dist/filesystem/workspace.d.ts.map +1 -1
  26. package/dist/filesystem/workspace.js +24 -3
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +8 -0
  29. package/dist/security/redact.d.ts +26 -3
  30. package/dist/security/redact.d.ts.map +1 -1
  31. package/dist/security/redact.js +48 -3
  32. package/dist/sessions/index-file.d.ts +38 -0
  33. package/dist/sessions/index-file.d.ts.map +1 -0
  34. package/dist/sessions/index-file.js +174 -0
  35. package/dist/sessions/lock.d.ts +39 -0
  36. package/dist/sessions/lock.d.ts.map +1 -0
  37. package/dist/sessions/lock.js +134 -0
  38. package/dist/sessions/open.d.ts +45 -0
  39. package/dist/sessions/open.d.ts.map +1 -0
  40. package/dist/sessions/open.js +96 -0
  41. package/dist/sessions/policy.d.ts +32 -0
  42. package/dist/sessions/policy.d.ts.map +1 -0
  43. package/dist/sessions/policy.js +57 -0
  44. package/dist/sessions/reconcile.d.ts +49 -0
  45. package/dist/sessions/reconcile.d.ts.map +1 -0
  46. package/dist/sessions/reconcile.js +115 -0
  47. package/dist/sessions/recorder.d.ts +42 -0
  48. package/dist/sessions/recorder.d.ts.map +1 -0
  49. package/dist/sessions/recorder.js +147 -0
  50. package/dist/sessions/resume.d.ts +67 -0
  51. package/dist/sessions/resume.d.ts.map +1 -0
  52. package/dist/sessions/resume.js +100 -0
  53. package/dist/sessions/store.d.ts +141 -0
  54. package/dist/sessions/store.d.ts.map +1 -0
  55. package/dist/sessions/store.js +272 -0
  56. package/dist/sessions/usage.d.ts +35 -0
  57. package/dist/sessions/usage.d.ts.map +1 -0
  58. package/dist/sessions/usage.js +157 -0
  59. package/dist/tui/agent-surface.d.ts +10 -0
  60. package/dist/tui/agent-surface.d.ts.map +1 -1
  61. package/dist/tui/agent-surface.js +27 -3
  62. package/dist/tui/app.d.ts.map +1 -1
  63. package/dist/tui/app.js +61 -3
  64. package/dist/tui/input.d.ts +13 -1
  65. package/dist/tui/input.d.ts.map +1 -1
  66. package/dist/tui/input.js +9 -2
  67. package/dist/tui/route.d.ts.map +1 -1
  68. package/dist/tui/route.js +19 -2
  69. package/dist/tui/session-centre.d.ts +15 -0
  70. package/dist/tui/session-centre.d.ts.map +1 -0
  71. package/dist/tui/session-centre.js +59 -0
  72. package/dist/tui/start-agent.d.ts +16 -1
  73. package/dist/tui/start-agent.d.ts.map +1 -1
  74. package/dist/tui/start-agent.js +125 -1
  75. package/package.json +1 -1
@@ -0,0 +1,96 @@
1
+ // CLI-024 · Opening a saved session to continue it.
2
+ //
3
+ // The order here is the point: read from disk, check what has changed since,
4
+ // and only then rebuild the context. Nothing reaches the model until the user
5
+ // sends their next message, so opening a conversation is free (plan §20.2) -
6
+ // and the checks are all re-derived from the key in use *now*, never from
7
+ // what the session was recorded with.
8
+ import { existsSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { acquireSessionLock, SessionLockedError, } from "./lock.js";
11
+ import { describeBlocker, permissionsForResume, rebuildContext, resumeBlockers, } from "./resume.js";
12
+ import { SessionWriter, readSession } from "./store.js";
13
+ export function openForResume(input) {
14
+ const directory = join(input.root, input.sessionId);
15
+ if (!existsSync(directory))
16
+ return { kind: "missing" };
17
+ let session;
18
+ try {
19
+ session = readSession(directory);
20
+ }
21
+ catch (error) {
22
+ return {
23
+ kind: "unreadable",
24
+ reason: error instanceof Error ? error.message : String(error),
25
+ };
26
+ }
27
+ // Taken before anything is written, so two processes cannot interleave into
28
+ // one transcript.
29
+ let lock;
30
+ try {
31
+ lock = acquireSessionLock(directory);
32
+ }
33
+ catch (error) {
34
+ if (error instanceof SessionLockedError) {
35
+ return { kind: "locked", reason: error.message };
36
+ }
37
+ throw error;
38
+ }
39
+ let writer;
40
+ try {
41
+ writer = SessionWriter.reopen(directory);
42
+ }
43
+ catch (error) {
44
+ lock.release();
45
+ return {
46
+ kind: "unreadable",
47
+ reason: error instanceof Error ? error.message : String(error),
48
+ };
49
+ }
50
+ const rebuilt = rebuildContext({
51
+ events: session.events,
52
+ systemPrompt: input.systemPrompt,
53
+ });
54
+ return {
55
+ kind: "opened",
56
+ metadata: session.metadata,
57
+ messages: rebuilt.messages,
58
+ writer,
59
+ lock,
60
+ blockers: resumeBlockers({
61
+ modelId: session.metadata.model_id,
62
+ workspace: session.metadata.workspace,
63
+ callable: input.callable,
64
+ agentCapable: input.agentCapable,
65
+ workspaceExists: existsSync(session.metadata.workspace),
66
+ forAgent: true,
67
+ }),
68
+ omitted: rebuilt.omitted,
69
+ toolRunsSummarised: rebuilt.toolRunsSummarised,
70
+ permissions: permissionsForResume(input.allowedCommandsThisRun),
71
+ };
72
+ }
73
+ /**
74
+ * What to tell the user when a session is reopened.
75
+ *
76
+ * The cost sentence is the one that earns its place: opening was free, but the
77
+ * next message re-sends this history as input tokens, and someone who does not
78
+ * know that will be surprised by the bill rather than by the feature.
79
+ */
80
+ export function describeResume(result) {
81
+ const lines = [
82
+ `resuming "${result.metadata.title}" (${result.metadata.model_id})`,
83
+ ];
84
+ if (result.toolRunsSummarised > 0) {
85
+ lines.push(` ${result.toolRunsSummarised} earlier tool run(s) are summarised, not repeated`);
86
+ }
87
+ if (result.omitted > 0) {
88
+ lines.push(` ${result.omitted} older message(s) left out to fit the context`);
89
+ }
90
+ lines.push(" opening this cost nothing; your next message re-sends the history above");
91
+ lines.push(" as input tokens, and approvals from last time do not carry over");
92
+ for (const blocker of result.blockers) {
93
+ lines.push(` ! ${describeBlocker(blocker)}`);
94
+ }
95
+ return lines.join("\n") + "\n";
96
+ }
@@ -0,0 +1,32 @@
1
+ export type RecordingSurface =
2
+ /** TUI or `mafdet agent` with a terminal. */
3
+ "interactive"
4
+ /** A single `mafdet chat "..."`. */
5
+ | "one-shot"
6
+ /** Piped, --json, CI: something is parsing this. */
7
+ | "machine";
8
+ export interface RecordingInput {
9
+ readonly surface: RecordingSurface;
10
+ readonly noHistory: boolean;
11
+ /** `--save` on a one-shot run. */
12
+ readonly save: boolean;
13
+ }
14
+ export interface RecordingDecision {
15
+ readonly record: boolean;
16
+ /** Why, in words, for `mafdet config list` and for the tests. */
17
+ readonly because: string;
18
+ }
19
+ export declare function decideRecording(input: RecordingInput): RecordingDecision;
20
+ /**
21
+ * Said once, the first time anything is written on this machine.
22
+ *
23
+ * Both halves are load-bearing. "Stored locally, we do not read it" is true of
24
+ * the archive and false if read as "we cannot see your conversation" - the
25
+ * messages are sent to the API to be answered regardless of whether a
26
+ * transcript is kept. Saying only the first half would be a privacy claim we
27
+ * cannot stand behind.
28
+ */
29
+ export declare function firstSaveNotice(sessionsPath: string): string;
30
+ /** Marker that the notice has been shown, so it is shown once and not again. */
31
+ export declare const NOTICE_FILE = ".notice-shown";
32
+ //# sourceMappingURL=policy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"policy.d.ts","sourceRoot":"","sources":["../../src/sessions/policy.ts"],"names":[],"mappings":"AAUA,MAAM,MAAM,gBAAgB;AAC1B,6CAA6C;AAC3C,aAAa;AACf,oCAAoC;GAClC,UAAU;AACZ,oDAAoD;GAClD,SAAS,CAAC;AAEd,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,OAAO,EAAE,gBAAgB,CAAC;IACnC,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B,kCAAkC;IAClC,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB,iEAAiE;IACjE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAED,wBAAgB,eAAe,CAAC,KAAK,EAAE,cAAc,GAAG,iBAAiB,CAqBxE;AAED;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAiB5D;AAED,gFAAgF;AAChF,eAAO,MAAM,WAAW,kBAAkB,CAAC"}
@@ -0,0 +1,57 @@
1
+ // CLI-023 · Whether this run records anything, and what the user is told.
2
+ //
3
+ // Plan §20.4. Recording is on by default for interactive work and off
4
+ // everywhere a machine is reading, and `--no-history` must leave *no* file
5
+ // behind - not an empty directory, not an index entry, nothing. A flag that
6
+ // merely hid the transcript would be worse than no flag, because the user
7
+ // would believe it.
8
+ import { redact } from "../security/redact.js";
9
+ export function decideRecording(input) {
10
+ if (input.noHistory) {
11
+ return { record: false, because: "--no-history was given" };
12
+ }
13
+ if (input.surface === "machine") {
14
+ // Not a privacy judgement so much as a correctness one: a script's run is
15
+ // not a conversation anyone will resume.
16
+ return { record: false, because: "output is being read by a machine" };
17
+ }
18
+ if (input.surface === "one-shot") {
19
+ return input.save
20
+ ? { record: true, because: "--save was given" }
21
+ : {
22
+ record: false,
23
+ because: "a single chat is not recorded unless --save",
24
+ };
25
+ }
26
+ return {
27
+ record: true,
28
+ because: "interactive sessions are recorded by default",
29
+ };
30
+ }
31
+ /**
32
+ * Said once, the first time anything is written on this machine.
33
+ *
34
+ * Both halves are load-bearing. "Stored locally, we do not read it" is true of
35
+ * the archive and false if read as "we cannot see your conversation" - the
36
+ * messages are sent to the API to be answered regardless of whether a
37
+ * transcript is kept. Saying only the first half would be a privacy claim we
38
+ * cannot stand behind.
39
+ */
40
+ export function firstSaveNotice(sessionsPath) {
41
+ return ([
42
+ "",
43
+ " Conversations are saved on this computer only, at",
44
+ ` ${redact(sessionsPath)}`,
45
+ " They are not uploaded, and Mafdet does not have a copy of them.",
46
+ "",
47
+ " This is separate from how the model works: the messages you send are",
48
+ " still sent to the API to be answered, and appear in your usage records.",
49
+ " What stays local is the saved transcript.",
50
+ "",
51
+ " The file is plain text and includes any source the agent read.",
52
+ " Turn it off with --no-history, or delete with 'mafdet sessions delete'.",
53
+ "",
54
+ ].join("\n") + "\n");
55
+ }
56
+ /** Marker that the notice has been shown, so it is shown once and not again. */
57
+ export const NOTICE_FILE = ".notice-shown";
@@ -0,0 +1,49 @@
1
+ import type { LedgerUsageEvent } from "../api/account.js";
2
+ import type { StoredEvent } from "./store.js";
3
+ import type { SessionEvent } from "./store.js";
4
+ export interface ReconcilePlan {
5
+ /** Request ids worth asking this key about. */
6
+ readonly wanted: ReadonlySet<string>;
7
+ /** Already reconciled on an earlier run; asking again would be wasted. */
8
+ readonly alreadySettled: number;
9
+ /** No request id was ever returned, so no answer exists to fetch. */
10
+ readonly unanswerable: number;
11
+ /** Paid for by a different key; a different key has to ask. */
12
+ readonly otherKey: number;
13
+ /** Recorded before per-key attribution, so we cannot tell which key paid. */
14
+ readonly unattributed: number;
15
+ }
16
+ /**
17
+ * Decide what to ask about, from the transcript alone.
18
+ *
19
+ * `unattributed` requests are still asked about. They were written by a CLI
20
+ * old enough not to record which key paid, and since the answer can only come
21
+ * back scoped to the current key, a match is itself the proof that this key
22
+ * paid for it. Excluding them would make old sessions permanently
23
+ * unreconcilable in exchange for no safety at all.
24
+ */
25
+ export declare function planReconciliation(events: readonly StoredEvent[], currentKeyDigest: string): ReconcilePlan;
26
+ export interface MatchResult {
27
+ readonly settlements: readonly SessionEvent[];
28
+ /** Asked about but not in the ledger's answer. */
29
+ readonly missing: readonly string[];
30
+ }
31
+ /**
32
+ * Pair the ledger's rows with the ids we asked about.
33
+ *
34
+ * Rows for requests this session never made are dropped rather than recorded:
35
+ * the same key pays for other sessions, and this file is a record of one
36
+ * conversation.
37
+ */
38
+ export declare function matchSettlements(plan: ReconcilePlan, ledger: readonly LedgerUsageEvent[], observedAt: string): MatchResult;
39
+ /**
40
+ * How many days back the ledger has to be asked about.
41
+ *
42
+ * Derived from when the session started, plus a day of slack for clock skew
43
+ * and for a session that ran past midnight. The endpoint caps the window at
44
+ * 90 days; asking for more is rejected outright, so it is clamped here rather
45
+ * than turned into a failed command.
46
+ */
47
+ export declare const MAX_LEDGER_DAYS = 90;
48
+ export declare function daysToCover(createdAt: string, now?: Date): number;
49
+ //# sourceMappingURL=reconcile.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reconcile.d.ts","sourceRoot":"","sources":["../../src/sessions/reconcile.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAC1D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAC9C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAE/C,MAAM,WAAW,aAAa;IAC5B,+CAA+C;IAC/C,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IACrC,0EAA0E;IAC1E,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,qEAAqE;IACrE,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,+DAA+D;IAC/D,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,6EAA6E;IAC7E,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;CAC/B;AAED;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,SAAS,WAAW,EAAE,EAC9B,gBAAgB,EAAE,MAAM,GACvB,aAAa,CAwCf;AAED,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,WAAW,EAAE,SAAS,YAAY,EAAE,CAAC;IAC9C,kDAAkD;IAClD,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;CACrC;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,aAAa,EACnB,MAAM,EAAE,SAAS,gBAAgB,EAAE,EACnC,UAAU,EAAE,MAAM,GACjB,WAAW,CAyBb;AAOD;;;;;;;GAOG;AACH,eAAO,MAAM,eAAe,KAAK,CAAC;AAElC,wBAAgB,WAAW,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,GAAE,IAAiB,GAAG,MAAM,CAM7E"}
@@ -0,0 +1,115 @@
1
+ // CLI-027 · Turning a local estimate into the ledger's own figure.
2
+ //
3
+ // Plan §20.14. The shape of the whole feature follows from one fact: the
4
+ // server's `/keyinfo/usage-events` is scoped to the key that calls it. So the
5
+ // CLI never has to prove ownership of a request - if a request id comes back
6
+ // from a call made with this key, it was this key's request. That is why
7
+ // reconciling needs nothing but the id: no session id, no title, no workspace,
8
+ // no message, no tool argument leaves the machine.
9
+ //
10
+ // What the local key digest adds is not authorisation but honesty. Without it,
11
+ // a request paid for by another key looks exactly like one the ledger has not
12
+ // caught up with yet, and the user is told to wait for something that will
13
+ // never arrive. With it, we can say which key to run this under instead.
14
+ import { isComparableKeyRef } from "../security/redact.js";
15
+ /**
16
+ * Decide what to ask about, from the transcript alone.
17
+ *
18
+ * `unattributed` requests are still asked about. They were written by a CLI
19
+ * old enough not to record which key paid, and since the answer can only come
20
+ * back scoped to the current key, a match is itself the proof that this key
21
+ * paid for it. Excluding them would make old sessions permanently
22
+ * unreconcilable in exchange for no safety at all.
23
+ */
24
+ export function planReconciliation(events, currentKeyDigest) {
25
+ const settled = new Set();
26
+ for (const event of events) {
27
+ if (event.type === "settlement" && typeof event.request_id === "string") {
28
+ settled.add(event.request_id);
29
+ }
30
+ }
31
+ const wanted = new Set();
32
+ let alreadySettled = 0;
33
+ let unanswerable = 0;
34
+ let otherKey = 0;
35
+ let unattributed = 0;
36
+ for (const event of events) {
37
+ if (event.type !== "usage")
38
+ continue;
39
+ const id = event.request_id;
40
+ if (typeof id !== "string" || id.length === 0) {
41
+ unanswerable += 1;
42
+ continue;
43
+ }
44
+ if (settled.has(id)) {
45
+ alreadySettled += 1;
46
+ continue;
47
+ }
48
+ const ref = typeof event.api_key_ref === "string" ? event.api_key_ref : "";
49
+ if (isComparableKeyRef(ref)) {
50
+ if (ref !== currentKeyDigest) {
51
+ otherKey += 1;
52
+ continue;
53
+ }
54
+ }
55
+ else {
56
+ unattributed += 1;
57
+ }
58
+ wanted.add(id);
59
+ }
60
+ return { wanted, alreadySettled, unanswerable, otherKey, unattributed };
61
+ }
62
+ /**
63
+ * Pair the ledger's rows with the ids we asked about.
64
+ *
65
+ * Rows for requests this session never made are dropped rather than recorded:
66
+ * the same key pays for other sessions, and this file is a record of one
67
+ * conversation.
68
+ */
69
+ export function matchSettlements(plan, ledger, observedAt) {
70
+ const byId = new Map();
71
+ for (const row of ledger) {
72
+ if (plan.wanted.has(row.request_id))
73
+ byId.set(row.request_id, row);
74
+ }
75
+ const settlements = [];
76
+ const missing = [];
77
+ for (const id of plan.wanted) {
78
+ const row = byId.get(id);
79
+ if (row === undefined) {
80
+ missing.push(id);
81
+ continue;
82
+ }
83
+ settlements.push({
84
+ type: "settlement",
85
+ request_id: id,
86
+ // Kept as the string the server sent. Parsing it into a number here is
87
+ // how integer micro-USD becomes a float somewhere downstream.
88
+ settled_charge_micro_usd: normaliseMicro(row.platform_charge_micro_usd),
89
+ server_status: typeof row.status === "string" ? row.status : "unknown",
90
+ observed_at: observedAt,
91
+ });
92
+ }
93
+ return { settlements, missing };
94
+ }
95
+ /** Anything that is not an integer count of micro-USD is not a charge. */
96
+ function normaliseMicro(value) {
97
+ return typeof value === "string" && /^-?\d+$/.test(value) ? value : "0";
98
+ }
99
+ /**
100
+ * How many days back the ledger has to be asked about.
101
+ *
102
+ * Derived from when the session started, plus a day of slack for clock skew
103
+ * and for a session that ran past midnight. The endpoint caps the window at
104
+ * 90 days; asking for more is rejected outright, so it is clamped here rather
105
+ * than turned into a failed command.
106
+ */
107
+ export const MAX_LEDGER_DAYS = 90;
108
+ export function daysToCover(createdAt, now = new Date()) {
109
+ const started = Date.parse(createdAt);
110
+ if (!Number.isFinite(started))
111
+ return MAX_LEDGER_DAYS;
112
+ const elapsed = now.getTime() - started;
113
+ const days = Math.ceil(elapsed / 86_400_000) + 1;
114
+ return Math.min(Math.max(days, 1), MAX_LEDGER_DAYS);
115
+ }
@@ -0,0 +1,42 @@
1
+ import type { AgentEvent } from "../agent/loop.js";
2
+ import { SessionWriter } from "./store.js";
3
+ export interface RecorderOptions {
4
+ readonly writer: SessionWriter;
5
+ readonly root: string;
6
+ /**
7
+ * An irreversible fingerprint of the key paying for this run, so usage can
8
+ * be split per key (§20.3) without the key itself ever being written.
9
+ */
10
+ readonly apiKeyRef?: string;
11
+ /** Told once, with the first failure, then never again. */
12
+ readonly onProblem?: (message: string) => void;
13
+ }
14
+ /**
15
+ * Turns agent events into stored events.
16
+ *
17
+ * `tool_awaiting_approval` and `turn` are not recorded: the first is a UI
18
+ * state, and the second is derivable. What is recorded is what happened.
19
+ */
20
+ export declare class SessionRecorder {
21
+ private readonly options;
22
+ private broken;
23
+ private dirty;
24
+ constructor(options: RecorderOptions);
25
+ get sessionId(): string;
26
+ /** The user's own words, which the agent event stream never carries. */
27
+ goal(text: string): void;
28
+ observe(event: AgentEvent): void;
29
+ /** Refresh the listing. Cheap enough per goal, wasteful per event. */
30
+ checkpoint(): void;
31
+ setTitle(title: string): void;
32
+ close(): void;
33
+ private write;
34
+ /**
35
+ * Stop recording, say so once, and let the session carry on.
36
+ *
37
+ * Silence would be worse: the user believes there is a transcript. Repeating
38
+ * it every event would be worse still.
39
+ */
40
+ private fail;
41
+ }
42
+ //# sourceMappingURL=recorder.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"recorder.d.ts","sourceRoot":"","sources":["../../src/sessions/recorder.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAEnD,OAAO,EAAE,aAAa,EAAqB,MAAM,YAAY,CAAC;AAE9D,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;IAC/B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;;OAGG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,2DAA2D;IAC3D,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CAChD;AAED;;;;;GAKG;AACH,qBAAa,eAAe;IAId,OAAO,CAAC,QAAQ,CAAC,OAAO;IAHpC,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,KAAK,CAAS;gBAEO,OAAO,EAAE,eAAe;IAErD,IAAI,SAAS,IAAI,MAAM,CAEtB;IAED,wEAAwE;IACxE,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAIxB,OAAO,CAAC,KAAK,EAAE,UAAU,GAAG,IAAI;IAoDhC,sEAAsE;IACtE,UAAU,IAAI,IAAI;IAUlB,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;IAU7B,KAAK,IAAI,IAAI;IASb,OAAO,CAAC,KAAK;IAUb;;;;;OAKG;IACH,OAAO,CAAC,IAAI;CAOb"}
@@ -0,0 +1,147 @@
1
+ // CLI-024 · Recording a live session.
2
+ //
3
+ // A thin bridge from the agent's event stream to the store, and deliberately
4
+ // thin: it decides nothing about permissions, sends nothing, and every failure
5
+ // it meets is swallowed. A disk that is full, or a state directory that has
6
+ // become unwritable, must not end someone's conversation - losing the
7
+ // transcript is bad, losing the work in progress is worse.
8
+ import { upsertIndexEntry } from "./index-file.js";
9
+ /**
10
+ * Turns agent events into stored events.
11
+ *
12
+ * `tool_awaiting_approval` and `turn` are not recorded: the first is a UI
13
+ * state, and the second is derivable. What is recorded is what happened.
14
+ */
15
+ export class SessionRecorder {
16
+ options;
17
+ broken = false;
18
+ dirty = false;
19
+ constructor(options) {
20
+ this.options = options;
21
+ }
22
+ get sessionId() {
23
+ return this.options.writer.sessionId;
24
+ }
25
+ /** The user's own words, which the agent event stream never carries. */
26
+ goal(text) {
27
+ this.write({ type: "user_message", content: text });
28
+ }
29
+ observe(event) {
30
+ if (event.type === "text") {
31
+ this.write({ type: "assistant_message", content: event.text });
32
+ }
33
+ else if (event.type === "tool_started") {
34
+ this.write({
35
+ type: "tool_call",
36
+ tool: event.name,
37
+ arguments: safeParse(event.arguments),
38
+ });
39
+ }
40
+ else if (event.type === "tool") {
41
+ this.write({
42
+ type: "tool_result",
43
+ tool: event.name,
44
+ status: event.ok ? "succeeded" : "failed",
45
+ content: event.detail,
46
+ });
47
+ }
48
+ else if (event.type === "declined") {
49
+ this.write({
50
+ type: "tool_result",
51
+ tool: event.name,
52
+ status: "refused",
53
+ content: event.summary,
54
+ });
55
+ }
56
+ else if (event.type === "rate_limited") {
57
+ // Recorded because §20.2 is explicit that waiting is not a request: the
58
+ // aggregation must be able to show the delay without inflating the count.
59
+ this.write({
60
+ type: "rate_limited",
61
+ wait_ms: event.waitMs,
62
+ attempt: event.attempt,
63
+ });
64
+ }
65
+ else if (event.type === "usage") {
66
+ this.write({
67
+ type: "usage",
68
+ ...(event.requestId === undefined
69
+ ? {}
70
+ : { request_id: event.requestId }),
71
+ model_id: event.model,
72
+ prompt_tokens: event.promptTokens,
73
+ completion_tokens: event.completionTokens,
74
+ estimated_charge_micro_usd: event.costMicroUsd.toString(),
75
+ ...(this.options.apiKeyRef === undefined
76
+ ? {}
77
+ : { api_key_ref: this.options.apiKeyRef }),
78
+ // Local arithmetic only. Whether the gateway settled at this figure is
79
+ // a separate question, answered in CLI-027, and calling it "settled"
80
+ // here would be a claim we cannot make.
81
+ settlement_status: "pending",
82
+ });
83
+ }
84
+ }
85
+ /** Refresh the listing. Cheap enough per goal, wasteful per event. */
86
+ checkpoint() {
87
+ if (this.broken || !this.dirty)
88
+ return;
89
+ this.dirty = false;
90
+ try {
91
+ upsertIndexEntry(this.options.root, this.options.writer.metadata);
92
+ }
93
+ catch (error) {
94
+ this.fail(error);
95
+ }
96
+ }
97
+ setTitle(title) {
98
+ if (this.broken)
99
+ return;
100
+ try {
101
+ this.options.writer.setTitle(title);
102
+ this.dirty = true;
103
+ }
104
+ catch (error) {
105
+ this.fail(error);
106
+ }
107
+ }
108
+ close() {
109
+ this.checkpoint();
110
+ try {
111
+ this.options.writer.close();
112
+ }
113
+ catch {
114
+ // Closing is bookkeeping; nothing depends on it succeeding.
115
+ }
116
+ }
117
+ write(event) {
118
+ if (this.broken)
119
+ return;
120
+ try {
121
+ this.options.writer.append(event);
122
+ this.dirty = true;
123
+ }
124
+ catch (error) {
125
+ this.fail(error);
126
+ }
127
+ }
128
+ /**
129
+ * Stop recording, say so once, and let the session carry on.
130
+ *
131
+ * Silence would be worse: the user believes there is a transcript. Repeating
132
+ * it every event would be worse still.
133
+ */
134
+ fail(error) {
135
+ this.broken = true;
136
+ const message = error instanceof Error ? error.message : String(error);
137
+ this.options.onProblem?.(`not recording this conversation any more: ${message}`);
138
+ }
139
+ }
140
+ function safeParse(raw) {
141
+ try {
142
+ return JSON.parse(raw || "{}");
143
+ }
144
+ catch {
145
+ return { raw };
146
+ }
147
+ }
@@ -0,0 +1,67 @@
1
+ import type { ChatMessage } from "../agent/loop.js";
2
+ import type { StoredEvent } from "./store.js";
3
+ export interface RebuildInput {
4
+ readonly events: readonly StoredEvent[];
5
+ readonly systemPrompt: string;
6
+ /** Roughly how much of the past to carry. Oldest turns are dropped first. */
7
+ readonly maxMessages?: number;
8
+ }
9
+ export interface RebuiltContext {
10
+ readonly messages: ChatMessage[];
11
+ /** How many stored events were left out, for an honest one-line summary. */
12
+ readonly omitted: number;
13
+ /** Tool activity is summarised rather than replayed; this counts it. */
14
+ readonly toolRunsSummarised: number;
15
+ }
16
+ /**
17
+ * Rebuild the messages to send, from what was stored.
18
+ *
19
+ * Tool activity becomes a short note in the conversation rather than a
20
+ * `tool_calls` block: the model learns that a file was read without being
21
+ * handed a half-finished plan to resume, and the API cannot be asked to
22
+ * reference a tool call whose result no longer exists.
23
+ */
24
+ export declare function rebuildContext(input: RebuildInput): RebuiltContext;
25
+ export type ResumeBlocker = {
26
+ readonly kind: "model-unavailable";
27
+ readonly modelId: string;
28
+ } | {
29
+ readonly kind: "model-not-agent-capable";
30
+ readonly modelId: string;
31
+ } | {
32
+ readonly kind: "workspace-missing";
33
+ readonly workspace: string;
34
+ };
35
+ export interface ResumeCheckInput {
36
+ readonly modelId: string;
37
+ readonly workspace: string;
38
+ /** Model ids this key can call right now. */
39
+ readonly callable: readonly string[];
40
+ /** Of those, the ones the agent may drive. */
41
+ readonly agentCapable: readonly string[];
42
+ readonly workspaceExists: boolean;
43
+ /** Whether the caller intends to run the agent, or only to read the log. */
44
+ readonly forAgent: boolean;
45
+ }
46
+ /**
47
+ * What stands in the way of continuing, checked before anything is sent.
48
+ *
49
+ * Everything here is re-derived from the *current* key: a session recorded
50
+ * with a key that could call a model says nothing about whether this key can.
51
+ */
52
+ export declare function resumeBlockers(input: ResumeCheckInput): readonly ResumeBlocker[];
53
+ export declare function describeBlocker(blocker: ResumeBlocker): string;
54
+ /**
55
+ * What a resumed run starts with, stated so the caller cannot forget.
56
+ *
57
+ * Returned as data rather than left implicit: every field here is a permission
58
+ * that deliberately does *not* survive, and a reviewer should be able to see
59
+ * that in one place.
60
+ */
61
+ export interface ResumedPermissions {
62
+ readonly approvals: "none";
63
+ readonly allowedCommands: readonly string[];
64
+ readonly mayReplayTools: false;
65
+ }
66
+ export declare function permissionsForResume(declaredThisRun: readonly string[]): ResumedPermissions;
67
+ //# sourceMappingURL=resume.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resume.d.ts","sourceRoot":"","sources":["../../src/sessions/resume.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,MAAM,EAAE,SAAS,WAAW,EAAE,CAAC;IACxC,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,6EAA6E;IAC7E,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC/B;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,QAAQ,EAAE,WAAW,EAAE,CAAC;IACjC,4EAA4E;IAC5E,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,wEAAwE;IACxE,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAC;CACrC;AAID;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,YAAY,GAAG,cAAc,CAwClE;AAED,MAAM,MAAM,aAAa,GACrB;IAAE,QAAQ,CAAC,IAAI,EAAE,mBAAmB,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAChE;IAAE,QAAQ,CAAC,IAAI,EAAE,yBAAyB,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GACtE;IAAE,QAAQ,CAAC,IAAI,EAAE,mBAAmB,CAAC;IAAC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;CAAE,CAAC;AAEvE,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,6CAA6C;IAC7C,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC,8CAA8C;IAC9C,QAAQ,CAAC,YAAY,EAAE,SAAS,MAAM,EAAE,CAAC;IACzC,QAAQ,CAAC,eAAe,EAAE,OAAO,CAAC;IAClC,4EAA4E;IAC5E,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;CAC5B;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAC5B,KAAK,EAAE,gBAAgB,GACtB,SAAS,aAAa,EAAE,CAa1B;AAED,wBAAgB,eAAe,CAAC,OAAO,EAAE,aAAa,GAAG,MAAM,CAS9D;AAED;;;;;;GAMG;AACH,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,eAAe,EAAE,SAAS,MAAM,EAAE,CAAC;IAC5C,QAAQ,CAAC,cAAc,EAAE,KAAK,CAAC;CAChC;AAED,wBAAgB,oBAAoB,CAClC,eAAe,EAAE,SAAS,MAAM,EAAE,GACjC,kBAAkB,CAQpB"}