klyro 1.0.8 → 1.0.10

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/READ.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Klyro — Complete Build Documentation
2
2
 
3
- **For any coding agent:** This file is the single source of truth for what has been built till now (current version: see `package.json` — v1.0.8; Levels 1-9 complete, Level 10 largely complete incl. MCP/hooks/sub-agents, 34 built-in tools incl. `web_fetch`/`web_search`). The §20 ledger below is the historical record (v0.1.39→v0.1.61); version/test-count numbers inside it are point-in-time, not current. After reading, you have the complete picture.
3
+ **For any coding agent:** This file is the single source of truth for what has been built till now (current version: see `package.json` — v1.0.9; Levels 1-9 complete, Level 10 largely complete incl. MCP/hooks/sub-agents, 34 built-in tools incl. `web_fetch`/`web_search`, cross-turn session memory in the TUI REPL). The section-20 ledger below is the historical record (v0.1.39→v0.1.61); version/test-count numbers inside it are point-in-time, not current. After reading, you have the complete picture.
4
4
 
5
5
  ---
6
6
 
@@ -27,8 +27,8 @@
27
27
  | Test | `vitest 4.1` `fileParallelism:false` `10s timeout` | `node` env, deterministic mocks |
28
28
  | Build | `tsc` (not `tsup`) | `tsc --noEmit` `typecheck`, `tsc` `build` |
29
29
  | Providers | Native `fetch` (Node 20) | No SDK lock-in, 3 adapters |
30
- | Workspace | `pnpm-workspace.yaml` `packages/*` | `shared` `KlyroError` |
31
- | CI | `.github/workflows/ci.yml` `ubuntu/macos/windows × 20/22` | `pnpm install` `typecheck` `test` `build` `pack` |
30
+ | Workspace | `packages/shared` (private, legacy, unused) | Canonical npm + `package-lock.json`; `pnpm-workspace.yaml` ignored by npm |
31
+ | CI | `.github/workflows/ci.yml` `ubuntu/macos/windows × 20/22` | `npm ci` `typecheck` `test` `build` gating smoke + `release-check` + `pack` |
32
32
 
33
33
  **No Docker, no MCP, no browser in MVP** — deferred to post-1.0.
34
34
 
@@ -163,8 +163,7 @@ Eval: FileFixture {dir, task.md, check.sh, meta.json} loadFileFixture() src/eval
163
163
 
164
164
  ```
165
165
  klyro/
166
- ├── package.json # klyro 0.1.15, bin klyro/ky, files [dist], commander/ink/zod
167
- ├── pnpm-workspace.yaml # packages/*
166
+ ├── package.json # klyro 0.1.15, bin klyro/ky, files [dist], commander/ink/zod (canonical npm + package-lock.json; legacy pnpm workspace removed)
168
167
  ├── tsconfig.json # ES2022, NodeNext, strict, noUncheckedIndexedAccess
169
168
  ├── vitest.config.ts # include src/**/*.test, fileParallelism:false
170
169
  ├── .github/workflows/ci.yml # ubuntu/macos/windows × 20/22 → typecheck/test/build/eval smoke
@@ -287,6 +287,12 @@ export async function run(opts, deps) {
287
287
  // 5.2 — stuck detection state
288
288
  const callHistory = [];
289
289
  const fileEditCounts = new Map();
290
+ // Exactly-once: a provider transport retry (or a resume replay) must never
291
+ // re-execute a tool call whose side effect already completed. Completed
292
+ // call ids map to their recorded observations; a repeated id re-commits
293
+ // the cached observation without touching tools, hooks, or the audit log
294
+ // a second time.
295
+ const completedToolCalls = new Map();
290
296
  let stuckTriggers = 0;
291
297
  let stuckAbort = false;
292
298
  // Steerable stop: a stop hook's `{"continue":true}` verdict carries one
@@ -1081,7 +1087,7 @@ export async function run(opts, deps) {
1081
1087
  };
1082
1088
  // Commit phase: fold one execution result into the transcript, in original
1083
1089
  // call order. The only writer — call sequentially, never concurrently.
1084
- const commitResult = async (call, obs, latencyMs, hookContext = []) => {
1090
+ const commitResult = async (call, obs, latencyMs, hookContext = [], replay = false) => {
1085
1091
  const output = obs.ok ? redactOutput(obs.value) : redactOutput({ error: obs.error });
1086
1092
  const toolMsg = {
1087
1093
  role: 'tool',
@@ -1089,6 +1095,14 @@ export async function run(opts, deps) {
1089
1095
  };
1090
1096
  transcript.push(toolMsg);
1091
1097
  await checkpoint(toolMsg, { toolCallId: call.id, toolName: call.name, input: call.input, output, isError: !obs.ok });
1098
+ if (replay) {
1099
+ // Replay of an already-completed id (transport retry / resume):
1100
+ // transcript continuity only. No telemetry, audit, snapshots, stuck
1101
+ // accounting, or hooks — the side effect happened exactly once.
1102
+ emitKlyro({ type: 'tool.result', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: call.id, name: call.name, output, isError: !obs.ok, latencyMs });
1103
+ emit?.({ kind: 'tool_result', id: call.id, name: call.name, output, isError: !obs.ok, latencyMs });
1104
+ return;
1105
+ }
1092
1106
  // Hook-injected context rides as its own user message right after the
1093
1107
  // tool result (uniform across output shapes — no result surgery).
1094
1108
  if (hookContext.length > 0) {
@@ -1177,9 +1191,18 @@ export async function run(opts, deps) {
1177
1191
  }
1178
1192
  }
1179
1193
  }
1194
+ // Exactly-once record: a later turn repeating this id (transport retry
1195
+ // replay or resume) re-commits the cached observation instead of
1196
+ // re-executing.
1197
+ completedToolCalls.set(call.id, { obs, latencyMs });
1180
1198
  };
1181
1199
  // Sequential path: gate → execute → commit per call, in order.
1182
1200
  const runOne = async (call) => {
1201
+ const cached = completedToolCalls.get(call.id);
1202
+ if (cached) {
1203
+ await commitResult(call, cached.obs, cached.latencyMs, [], true);
1204
+ return;
1205
+ }
1183
1206
  if (!(await gateCall(call)))
1184
1207
  return;
1185
1208
  const { obs, latencyMs, hookContext } = await execTool(call);
@@ -1194,6 +1217,12 @@ export async function run(opts, deps) {
1194
1217
  const approved = [];
1195
1218
  for (const call of finalizedCalls) {
1196
1219
  toolCallCount++;
1220
+ // Exactly-once: replay cached observation without gate/hooks/exec.
1221
+ const cached = completedToolCalls.get(call.id);
1222
+ if (cached) {
1223
+ await commitResult(call, cached.obs, cached.latencyMs, [], true);
1224
+ continue;
1225
+ }
1197
1226
  if (await gateCall(call))
1198
1227
  approved.push(call);
1199
1228
  if (opts.signal?.aborted)
package/dist/chat.d.ts CHANGED
@@ -1,4 +1,12 @@
1
1
  /**
2
+ * COMPATIBILITY-ONLY legacy one-shot chat. Prefer `klyro run` / `klyro tui`.
3
+ *
4
+ * Security contract (shared with all current paths — do NOT diverge):
5
+ * base-URL validation via assertSafeBaseURL, error-body caps via
6
+ * MAX_ERROR_BODY_BYTES, and credential handling owned by providers.ts.
7
+ * Covered by src/providers/contract.test.ts; any change here must update
8
+ * the contract tests first.
9
+ *
2
10
  * One-shot chat. POSTs to an OpenAI-compatible /v1/chat/completions endpoint
3
11
  * and streams the response to stdout.
4
12
  *
package/dist/chat.js CHANGED
@@ -1,4 +1,12 @@
1
1
  /**
2
+ * COMPATIBILITY-ONLY legacy one-shot chat. Prefer `klyro run` / `klyro tui`.
3
+ *
4
+ * Security contract (shared with all current paths — do NOT diverge):
5
+ * base-URL validation via assertSafeBaseURL, error-body caps via
6
+ * MAX_ERROR_BODY_BYTES, and credential handling owned by providers.ts.
7
+ * Covered by src/providers/contract.test.ts; any change here must update
8
+ * the contract tests first.
9
+ *
2
10
  * One-shot chat. POSTs to an OpenAI-compatible /v1/chat/completions endpoint
3
11
  * and streams the response to stdout.
4
12
  *
@@ -86,12 +86,21 @@ export async function snapshot(cwd, files) {
86
86
  missing.push(f);
87
87
  }
88
88
  }
89
- // Save meta (fsync before the checkpoint is visible — mirrors the
90
- // SessionStore.writeIndex atomic pattern).
89
+ // Save meta (tmp + fsync + rename before the checkpoint is visible —
90
+ // mirrors the SessionStore.writeIndex atomic pattern so a crash never
91
+ // leaves a truncated .meta.json that undo() then trusts).
91
92
  const metaPath = path.join(dest, '.meta.json');
92
- await fs.writeFile(metaPath, JSON.stringify({ id, files: kept, missing, ts: Date.now() }, null, 2));
93
- lockDown(metaPath, 0o600);
94
- await fsyncFile(metaPath);
93
+ const metaTmp = `${metaPath}.tmp-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
94
+ await fs.writeFile(metaTmp, JSON.stringify({ id, files: kept, missing, ts: Date.now() }, null, 2));
95
+ lockDown(metaTmp, 0o600);
96
+ await fsyncFile(metaTmp);
97
+ try {
98
+ await fs.rename(metaTmp, metaPath);
99
+ }
100
+ catch {
101
+ await fs.unlink(metaTmp).catch(() => undefined);
102
+ throw new Error(`Failed to write checkpoint meta ${id}`);
103
+ }
95
104
  // Best-effort last.diff for the repair guard (guardRepair reads it).
96
105
  try {
97
106
  const { spawn } = await import('node:child_process');
@@ -130,11 +139,21 @@ export async function snapshot(cwd, files) {
130
139
  });
131
140
  });
132
141
  if (diffText) {
133
- await fs.writeFile(path.join(dir, 'last.diff'), diffText, 'utf-8');
142
+ const atomicWrite = async (p, data) => {
143
+ const tmp = `${p}.tmp-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
144
+ await fs.writeFile(tmp, data, 'utf-8');
145
+ try {
146
+ await fs.rename(tmp, p);
147
+ }
148
+ catch {
149
+ await fs.unlink(tmp).catch(() => undefined);
150
+ }
151
+ };
152
+ await atomicWrite(path.join(dir, 'last.diff'), diffText);
134
153
  // Per-checkpoint diff file (best-effort); the repair guard keeps
135
154
  // reading last.diff, so its behavior is unchanged.
136
155
  try {
137
- await fs.writeFile(path.join(dir, `${id}.diff`), diffText, 'utf-8');
156
+ await atomicWrite(path.join(dir, `${id}.diff`), diffText);
138
157
  }
139
158
  catch { /* best-effort only */ }
140
159
  }
@@ -0,0 +1 @@
1
+ export declare function parsePositiveInt(name: string, v: string): number;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Shared CLI argument coercions (extracted from src/index.ts).
3
+ *
4
+ * Single source of truth for Commander option parsing so command modules
5
+ * registered from src/cli/* behave identically to the entrypoint.
6
+ */
7
+ import { InvalidArgumentError } from 'commander';
8
+ export function parsePositiveInt(name, v) {
9
+ const n = Number(v);
10
+ if (!Number.isFinite(n) || n <= 0) {
11
+ throw new InvalidArgumentError(`invalid ${name}: ${v}`);
12
+ }
13
+ return n;
14
+ }
package/dist/cli/repl.js CHANGED
@@ -29,6 +29,7 @@ import { MouseFilter, MOUSE_ENABLE, MOUSE_DISABLE, PASTE_ENABLE, PASTE_DISABLE,
29
29
  import { inferProviderFromBaseURL } from '../agent/registry.js';
30
30
  import { getDefaultSessionStore } from '../persistence/session.js';
31
31
  import { buildSystemPrompt, parseImageInput } from '../context/system-prompt.js';
32
+ import { shouldUseSimpleChat, userMessage, appendTurn, adoptTranscript, summaryAnchor } from './session-history.js';
32
33
  import { memoryBlock } from '../context/memory.js';
33
34
  import { estimateCost } from '../providers/model-info.js';
34
35
  import { ContextTrust } from '../context/trust.js';
@@ -543,6 +544,11 @@ export async function startRepl(opts = {}) {
543
544
  let fastMode = false;
544
545
  let displayMode = 'default';
545
546
  let lastAssistantText = '';
547
+ // Cross-turn conversation memory: prior turns are fed back as
548
+ // initialTranscript (full runs) or message history (simple chat) so
549
+ // follow-ups ("what models are there") resolve against earlier turns.
550
+ // Bounded by session-history.ts caps; reset by /clear, /new, /compact.
551
+ let sessionMessages = [];
546
552
  // P2 state (commands.md Priority 2)
547
553
  let activeAgent = 'default';
548
554
  let verboseMode = false;
@@ -792,7 +798,10 @@ export async function startRepl(opts = {}) {
792
798
  const { text: cleanText, images } = parseImageInput(text);
793
799
  let taskText = images.length > 0 ? `${cleanText}\n\n[images: ${images.join(', ')}]` : cleanText;
794
800
  // Only create session for non-trivial tasks (with tools/verify) — plain chat like "hello" is not a persisted session
795
- const isSimpleChat = taskText.trim().split(/\s+/).length <= 5 && !taskText.toLowerCase().includes('fix') && !taskText.toLowerCase().includes('add') && !taskText.toLowerCase().includes('create');
801
+ // Cross-turn memory lives here: every prompt (simple or full) sees prior
802
+ // turns via sessionMessages. shouldUseSimpleChat keeps short chit-chat
803
+ // tool-free but forces URL-bearing prompts into the full loop (web_fetch).
804
+ const isSimpleChat = shouldUseSimpleChat(taskText);
796
805
  let sessionId;
797
806
  if (!isSimpleChat) {
798
807
  try {
@@ -816,7 +825,7 @@ export async function startRepl(opts = {}) {
816
825
  model,
817
826
  // Legacy chat path (no modes): plain string system, untouched by the split.
818
827
  system: resolveSystemPrompt(systemPromptFn, { cwd, telemetry: '' }).system,
819
- messages: [{ role: 'user', content: [{ kind: 'text', text: taskText }] }],
828
+ messages: [...sessionMessages, userMessage(taskText)],
820
829
  tools: [],
821
830
  signal: ac.signal,
822
831
  };
@@ -832,6 +841,7 @@ export async function startRepl(opts = {}) {
832
841
  throw new Error(ev.message);
833
842
  }
834
843
  lastAssistantText = simpleText;
844
+ sessionMessages = appendTurn(sessionMessages, taskText, simpleText);
835
845
  clearThinking();
836
846
  queuedStatus({ status: 'done' });
837
847
  return;
@@ -850,6 +860,7 @@ export async function startRepl(opts = {}) {
850
860
  try {
851
861
  const result = await run({
852
862
  task: taskText,
863
+ initialTranscript: sessionMessages.length > 0 ? [...sessionMessages] : undefined,
853
864
  cwd,
854
865
  model,
855
866
  maxSteps: currentMaxSteps,
@@ -979,6 +990,9 @@ export async function startRepl(opts = {}) {
979
990
  });
980
991
  if (result.finalText)
981
992
  lastAssistantText = result.finalText;
993
+ // Adopt the run's full transcript (prior history + this task, tools
994
+ // included) so the next turn resolves references against this one.
995
+ sessionMessages = adoptTranscript(result.transcript);
982
996
  runEndStatus = result.status;
983
997
  if (result.verification) {
984
998
  const v = result.verification;
@@ -1037,6 +1051,7 @@ export async function startRepl(opts = {}) {
1037
1051
  return;
1038
1052
  case 'clear':
1039
1053
  queuedClear();
1054
+ sessionMessages = [];
1040
1055
  queuedAppend({ id: `sep-${Date.now()}`, kind: 'text', text: '--- cleared ---', role: 'assistant' });
1041
1056
  return;
1042
1057
  case 'help': {
@@ -1345,6 +1360,7 @@ export async function startRepl(opts = {}) {
1345
1360
  const focus = cmd.focus?.trim();
1346
1361
  const compactFallback = () => {
1347
1362
  queuedClear();
1363
+ sessionMessages = [];
1348
1364
  queuedAppend({
1349
1365
  id: `compact-${Date.now()}`,
1350
1366
  kind: 'text',
@@ -1379,6 +1395,7 @@ export async function startRepl(opts = {}) {
1379
1395
  return;
1380
1396
  }
1381
1397
  queuedClear();
1398
+ sessionMessages = summaryAnchor(summary.slice(0, 4000));
1382
1399
  queuedAppend({
1383
1400
  id: `compact-done-${Date.now()}`,
1384
1401
  kind: 'text',
@@ -1520,6 +1537,7 @@ export async function startRepl(opts = {}) {
1520
1537
  }
1521
1538
  case 'new': {
1522
1539
  queuedClear();
1540
+ sessionMessages = [];
1523
1541
  sessionLabel = '';
1524
1542
  currentBranch = '';
1525
1543
  try {
@@ -0,0 +1,9 @@
1
+ import type { Command } from 'commander';
2
+ /**
3
+ * Session command namespace (extracted from src/index.ts entrypoint).
4
+ *
5
+ * `session` and `sessions` accept the SAME subcommands
6
+ * (list/show/resume/export/import/fork/delete); `resume` is an alias for
7
+ * `session resume`. Handlers live here once and all groups delegate.
8
+ */
9
+ export declare function registerSessionCommands(program: Command): void;
@@ -0,0 +1,262 @@
1
+ import { runOnce } from './run.js';
2
+ import { parsePositiveInt } from './args.js';
3
+ /**
4
+ * Session command namespace (extracted from src/index.ts entrypoint).
5
+ *
6
+ * `session` and `sessions` accept the SAME subcommands
7
+ * (list/show/resume/export/import/fork/delete); `resume` is an alias for
8
+ * `session resume`. Handlers live here once and all groups delegate.
9
+ */
10
+ export function registerSessionCommands(program) {
11
+ // Level 9 — Session management.
12
+ // One namespace: `session` and `sessions` accept the SAME subcommands
13
+ // (list/show/resume/export/import/fork/delete). Handlers live here once
14
+ // and both command groups delegate to them.
15
+ async function sessionList(opts) {
16
+ const { getDefaultSessionStore, formatSession } = await import('../persistence/session.js');
17
+ const store = getDefaultSessionStore();
18
+ const all = await store.list(opts.status ? { status: opts.status } : undefined);
19
+ if (opts.json) {
20
+ process.stdout.write(JSON.stringify(all, null, 2) + '\n');
21
+ }
22
+ else {
23
+ if (all.length === 0) {
24
+ process.stdout.write('No sessions\n');
25
+ }
26
+ else {
27
+ for (const r of all.sort((a, b) => b.updatedAt - a.updatedAt)) {
28
+ process.stdout.write(formatSession(r) + '\n');
29
+ }
30
+ }
31
+ }
32
+ }
33
+ async function resolveOrExit(id) {
34
+ const { getDefaultSessionStore, resolveSessionId, matchSessionIds } = await import('../persistence/session.js');
35
+ const store = getDefaultSessionStore();
36
+ const full = await resolveSessionId(store, id);
37
+ if (full)
38
+ return full;
39
+ const matches = await matchSessionIds(store, id);
40
+ if (matches.length > 1) {
41
+ process.stderr.write(`ambiguous id "${id}" matches:\n${matches.map((r) => ` ${r.id.slice(0, 8)} ${r.task.slice(0, 50)}`).join('\n')}\n`);
42
+ }
43
+ else {
44
+ process.stderr.write(`session not found: ${id}\n`);
45
+ }
46
+ process.exit(2);
47
+ }
48
+ async function sessionShow(id, opts) {
49
+ const { getDefaultSessionStore } = await import('../persistence/session.js');
50
+ const store = getDefaultSessionStore();
51
+ const full = await resolveOrExit(id);
52
+ const rec = await store.get(full);
53
+ const msgs = await store.loadMessages(full);
54
+ const obs = await store.loadObservations(full);
55
+ if (opts.json) {
56
+ process.stdout.write(JSON.stringify({ record: rec, messages: msgs, observations: obs }, null, 2) + '\n');
57
+ }
58
+ else {
59
+ process.stdout.write(`Session ${rec?.id}\n task: ${rec?.task}\n status: ${rec?.status}\n cwd: ${rec?.cwd}\n created: ${new Date(rec?.createdAt ?? 0).toISOString()}\n`);
60
+ process.stdout.write(`\nMessages (${msgs.length}):\n`);
61
+ for (const m of msgs)
62
+ process.stdout.write(` [${m.role}] ${JSON.stringify(m.content).slice(0, 200)}\n`);
63
+ process.stdout.write(`\nObservations (${obs.length}):\n`);
64
+ for (const o of obs)
65
+ process.stdout.write(` ${o.toolName} -> ${o.isError ? 'ERR' : 'ok'} ${JSON.stringify(o.output).slice(0, 120)}\n`);
66
+ }
67
+ }
68
+ async function sessionResume(id, opts) {
69
+ const { getDefaultSessionStore } = await import('../persistence/session.js');
70
+ const store = getDefaultSessionStore();
71
+ const full = await resolveOrExit(id);
72
+ const rec = await store.get(full);
73
+ if (!rec) {
74
+ process.stderr.write(`session not found: ${id}\n`);
75
+ process.exit(2);
76
+ }
77
+ // Resume precondition (review §9): the session is bound to the cwd /
78
+ // worktree it was created in. Resuming from a different directory
79
+ // continues in the ORIGINAL cwd (authoritative) but warns loudly so a
80
+ // moved checkout or wrong terminal cannot silently continue elsewhere.
81
+ if (rec.cwd !== process.cwd()) {
82
+ process.stderr.write(`klyro: warning: session created in ${rec.cwd}, resuming there (current dir is ${process.cwd()})\n`);
83
+ }
84
+ const model = opts.model ?? rec.config.model ?? process.env.KLYRO_MODEL;
85
+ if (!model) {
86
+ process.stderr.write('klyro: KLYRO_MODEL is not set (or pass --model)\n');
87
+ process.exit(2);
88
+ }
89
+ const code = await runOnce({
90
+ task: rec.task,
91
+ cwd: rec.cwd,
92
+ model,
93
+ maxSteps: opts.maxSteps ?? rec.config.maxSteps,
94
+ sessionId: full,
95
+ verify: opts.verify,
96
+ verifyCommand: opts.verifyCommand,
97
+ });
98
+ process.exit(code);
99
+ }
100
+ async function sessionExport(id, file) {
101
+ const { getDefaultSessionStore } = await import('../persistence/session.js');
102
+ const store = getDefaultSessionStore();
103
+ const full = await resolveOrExit(id);
104
+ const rec = await store.get(full);
105
+ const msgs = await store.loadMessages(full);
106
+ const obs = await store.loadObservations(full);
107
+ const out = file ?? `${full}.export.json`;
108
+ await (await import('node:fs/promises')).writeFile(out, JSON.stringify({ record: rec, messages: msgs, observations: obs }, null, 2));
109
+ process.stdout.write(`exported ${full} → ${out}\n`);
110
+ }
111
+ async function sessionImport(file) {
112
+ let data;
113
+ try {
114
+ data = JSON.parse(await (await import('node:fs/promises')).readFile(file, 'utf-8'));
115
+ }
116
+ catch (err) {
117
+ process.stderr.write(`klyro: cannot import ${file}: ${err instanceof Error ? err.message : String(err)}\n`);
118
+ process.exit(2);
119
+ }
120
+ const rec = data.record ?? {};
121
+ const { getDefaultSessionStore } = await import('../persistence/session.js');
122
+ const store = getDefaultSessionStore();
123
+ // Validate config shape (model string, bounded maxSteps) — an imported
124
+ // file is untrusted input and must not inject arbitrary session config.
125
+ const rawCfg = (rec.config && typeof rec.config === 'object' ? rec.config : { model: 'imported', maxSteps: 30 });
126
+ const cfg = {
127
+ model: typeof rawCfg.model === 'string' && rawCfg.model.length > 0 && rawCfg.model.length <= 200 ? rawCfg.model : 'imported',
128
+ maxSteps: typeof rawCfg.maxSteps === 'number' && Number.isFinite(rawCfg.maxSteps) && rawCfg.maxSteps > 0 && rawCfg.maxSteps <= 500 ? Math.floor(rawCfg.maxSteps) : 30,
129
+ };
130
+ const taskStr = typeof rec.task === 'string' ? rec.task.slice(0, 20_000) : 'imported';
131
+ const created = await store.create({ cwd: typeof rec.cwd === 'string' ? rec.cwd : process.cwd(), task: taskStr, config: cfg });
132
+ // Restore the transcript — previously this was silently dropped (lossy
133
+ // import). Messages/observations go through append* so at-rest redaction
134
+ // still applies. Malformed entries fail loudly instead of half-importing.
135
+ // Caps: at most 5000 messages / 2000 observations — an import file is
136
+ // untrusted and must not exhaust memory or disk.
137
+ const d = data;
138
+ let restored = 0;
139
+ if (d.messages !== undefined) {
140
+ if (!Array.isArray(d.messages)) {
141
+ process.stderr.write(`klyro: import failed: "messages" is not an array in ${file}\n`);
142
+ process.exit(2);
143
+ }
144
+ if (d.messages.length > 5000) {
145
+ process.stderr.write(`klyro: import failed: too many messages (${d.messages.length} > 5000) in ${file}\n`);
146
+ process.exit(2);
147
+ }
148
+ for (const m of d.messages) {
149
+ const role = m?.role;
150
+ if (!m || typeof m !== 'object' || (role !== 'user' && role !== 'assistant' && role !== 'tool' && role !== 'system') || !('content' in m)) {
151
+ process.stderr.write(`klyro: import failed: malformed message entry in ${file}\n`);
152
+ process.exit(2);
153
+ }
154
+ await store.appendMessage(created.id, m);
155
+ restored++;
156
+ }
157
+ }
158
+ if (d.observations !== undefined) {
159
+ if (!Array.isArray(d.observations)) {
160
+ process.stderr.write(`klyro: import failed: "observations" is not an array in ${file}\n`);
161
+ process.exit(2);
162
+ }
163
+ if (d.observations.length > 2000) {
164
+ process.stderr.write(`klyro: import failed: too many observations (${d.observations.length} > 2000) in ${file}\n`);
165
+ process.exit(2);
166
+ }
167
+ for (const o of d.observations) {
168
+ if (!o || typeof o !== 'object') {
169
+ process.stderr.write(`klyro: import failed: malformed observation entry in ${file}\n`);
170
+ process.exit(2);
171
+ }
172
+ await store.appendObservation(created.id, o);
173
+ }
174
+ }
175
+ process.stdout.write(`imported → ${created.id} (${restored} messages restored)\n`);
176
+ }
177
+ async function sessionFork(id) {
178
+ const { getDefaultSessionStore, matchSessionIds } = await import('../persistence/session.js');
179
+ const store = getDefaultSessionStore();
180
+ const matches = await matchSessionIds(store, id);
181
+ if (matches.length === 0) {
182
+ process.stderr.write(`session not found: ${id}\n`);
183
+ process.exit(2);
184
+ }
185
+ if (matches.length > 1) {
186
+ process.stderr.write(`ambiguous id "${id}" matches:\n${matches.map((r) => ` ${r.id.slice(0, 8)} ${r.task.slice(0, 50)}`).join('\n')}\n`);
187
+ process.exit(2);
188
+ }
189
+ const full = matches[0].id;
190
+ const forked = await store.fork(full);
191
+ const msgs = await store.loadMessages(forked.id);
192
+ process.stdout.write(`forked ${full.slice(0, 8)} → ${forked.id.slice(0, 8)} (${msgs.length} messages carried over)\n`);
193
+ }
194
+ async function sessionDelete(id) {
195
+ const { getDefaultSessionStore, matchSessionIds } = await import('../persistence/session.js');
196
+ const store = getDefaultSessionStore();
197
+ const matches = await matchSessionIds(store, id);
198
+ if (matches.length === 0) {
199
+ process.stderr.write(`session not found: ${id}\n`);
200
+ process.exit(2);
201
+ }
202
+ if (matches.length > 1) {
203
+ process.stderr.write(`ambiguous id "${id}" matches:\n${matches.map((r) => ` ${r.id.slice(0, 8)} ${r.task.slice(0, 50)}`).join('\n')}\n`);
204
+ process.exit(2);
205
+ }
206
+ const full = matches[0].id;
207
+ await store.delete(full);
208
+ process.stdout.write(`deleted ${full.slice(0, 8)}\n`);
209
+ }
210
+ const session = program.command('session').description('Session persistence (Level 9)');
211
+ session
212
+ .command('list')
213
+ .description('List persisted sessions')
214
+ .option('--status <s>', 'Filter by status: open|complete|verify_failed|aborted|max_steps')
215
+ .option('--json', 'Output JSON')
216
+ .action(async (opts) => { await sessionList(opts); });
217
+ session
218
+ .command('show <id>')
219
+ .description('Show session transcript and observations')
220
+ .option('--json', 'Output JSON')
221
+ .action(async (id, opts) => { await sessionShow(id, opts); });
222
+ session
223
+ .command('resume <id>')
224
+ .description('Resume a persisted session (requires KLYRO_MODEL etc.)')
225
+ .option('-m, --model <id>', 'Model (default: from session or env)')
226
+ .option('--max-steps <n>', 'Max steps (default 30)', (v) => parsePositiveInt('--max-steps', v))
227
+ .option('--verify-command <cmd>', 'Override verification command')
228
+ .option('--verify', 'Enable verification (default: enabled)')
229
+ .action(async (id, opts) => { await sessionResume(id, opts); });
230
+ session
231
+ .command('export <id> [file]')
232
+ .description('Export session to file (9.4)')
233
+ .action(async (id, file) => { await sessionExport(id, file); });
234
+ session
235
+ .command('import <file>')
236
+ .description('Import session from file (restores record + messages + observations)')
237
+ .action(async (file) => { await sessionImport(file); });
238
+ session
239
+ .command('fork <id>')
240
+ .description('Fork session with full context (9.4)')
241
+ .action(async (id) => { await sessionFork(id); });
242
+ session
243
+ .command('delete <id>')
244
+ .description('Delete a session and its artifacts')
245
+ .action(async (id) => { await sessionDelete(id); });
246
+ // Alias: klyro resume <id> → klyro session resume <id>
247
+ program
248
+ .command('resume <id>')
249
+ .description('Alias for `klyro session resume <id>`')
250
+ .option('-m, --model <id>', 'Model')
251
+ .option('--max-steps <n>', 'Max steps', (v) => parsePositiveInt('--max-steps', v))
252
+ .action(async (id, opts) => { await sessionResume(id, opts); });
253
+ // 9.4 — same namespace as `session`: every subcommand works under both.
254
+ const sessions = program.command('sessions').description('Alias for session (same subcommands)');
255
+ sessions.command('list').description('List persisted sessions').option('--status <s>', 'Filter by status').option('--json', 'Output JSON').action(async (opts) => { await sessionList(opts); });
256
+ sessions.command('show <id>').description('Show session transcript and observations').option('--json', 'Output JSON').action(async (id, opts) => { await sessionShow(id, opts); });
257
+ sessions.command('resume <id>').description('Resume a persisted session').option('-m, --model <id>', 'Model').option('--max-steps <n>', 'Max steps', (v) => parsePositiveInt('--max-steps', v)).option('--verify-command <cmd>', 'Override verification command').option('--verify', 'Enable verification (default: enabled)').action(async (id, opts) => { await sessionResume(id, opts); });
258
+ sessions.command('export <id> [file]').description('Export session to file (9.4)').action(async (id, file) => { await sessionExport(id, file); });
259
+ sessions.command('import <file>').description('Import session from file (restores record + messages + observations)').action(async (file) => { await sessionImport(file); });
260
+ sessions.command('fork <id>').description('Fork session with full context (9.4)').action(async (id) => { await sessionFork(id); });
261
+ sessions.command('delete <id>').description('Delete a session and its artifacts').action(async (id) => { await sessionDelete(id); });
262
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Cross-turn conversation memory for the interactive TUI REPL.
3
+ *
4
+ * Root cause it fixes: every TUI prompt previously started a brand-new
5
+ * `run()` with only the current message, so "what models are there" asked
6
+ * after pasting a URL could never resolve what "models" referred to.
7
+ * These helpers keep a bounded `Message[]` across turns and feed it back
8
+ * as `initialTranscript` (full runs) or message history (simple chat).
9
+ *
10
+ * Bounds keep cost predictable: at most MAX_HISTORY_MESSAGES messages and
11
+ * MAX_HISTORY_CHARS of content. Trimming cuts only at user-message
12
+ * boundaries and never orphans a `tool_result` from its `tool_use`
13
+ * (providers reject dangling tool blocks).
14
+ */
15
+ import type { Message } from '../agent/message.js';
16
+ export declare const MAX_HISTORY_MESSAGES = 60;
17
+ export declare const MAX_HISTORY_CHARS = 60000;
18
+ /** True when the text references a web URL (needs tools — never simple chat). */
19
+ export declare function looksLikeUrl(text: string): boolean;
20
+ /**
21
+ * Fast-path gate: short chit-chat skips tools. Extracted (was inline in
22
+ * repl.ts) so the URL carve-out is unit-tested: any URL forces the full
23
+ * agent loop where web_fetch + approval can engage.
24
+ */
25
+ export declare function shouldUseSimpleChat(text: string): boolean;
26
+ export declare function userMessage(text: string): Message;
27
+ export declare function assistantMessage(text: string): Message;
28
+ /** Plain-text projection of a message (tool blocks count JSON-encoded). */
29
+ export declare function messageText(m: Message): string;
30
+ /**
31
+ * Trim to budget, cutting only at user-message boundaries with no orphaned
32
+ * tool_result. Falls back to the last user/assistant text pair when no
33
+ * valid cut fits (e.g. one giant tool result).
34
+ */
35
+ export declare function trimHistory(history: Message[]): Message[];
36
+ /** Record a completed simple-chat turn. */
37
+ export declare function appendTurn(history: Message[], userText: string, assistantText: string): Message[];
38
+ /** Adopt a full run's transcript (already includes prior history + task). */
39
+ export declare function adoptTranscript(transcript: Message[]): Message[];
40
+ /**
41
+ * Seed history after /compact: earlier turns are replaced by the retained
42
+ * summary so follow-ups still resolve ("the models we discussed").
43
+ */
44
+ export declare function summaryAnchor(summary: string): Message[];