kritya 0.8.38-beta → 0.8.40-beta

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/README.md CHANGED
@@ -179,6 +179,8 @@ In-session commands (type `/` to see them with autocomplete; letters filter the
179
179
  | `/redo` | reapply the change most recently undone |
180
180
  | `/checkpoint <name>` | save a named point in the session (`/checkpoint` alone lists saved ones) |
181
181
  | `/rewind <name>` | rewind both the conversation and the files to a checkpoint |
182
+ | `/rename <name>` | rename this session (`/rename` alone shows the current name) |
183
+ | `/export [path]` | export the transcript to markdown (default: `kritya-<name>-<stamp>.md`) |
182
184
  | `/compact` | summarize older conversation to free context space |
183
185
  | `/clear` | start a fresh conversation |
184
186
  | `/cost` | token usage and estimated $ (see Pricing below) |
@@ -193,7 +195,8 @@ Custom `/commands` you define (see [docs/CONFIGURATION.md](docs/CONFIGURATION.md
193
195
 
194
196
  `Esc` cancels a running request. `↑/↓` recalls input history. `Ctrl+O` toggles
195
197
  full tool output. `Ctrl+E` opens the file the agent touched most recently in
196
- `$EDITOR`. `Ctrl+K` is the kill switch (see below). `Ctrl+C` exits.
198
+ `$EDITOR`. `Ctrl+P` opens the command palette (fuzzy-find commands and
199
+ checkpoints). `Ctrl+K` is the kill switch (see below). `Ctrl+C` exits.
197
200
 
198
201
  ### More features
199
202
 
@@ -179,6 +179,18 @@ export class Agent {
179
179
  this.checkpoints.clear();
180
180
  this.session.rotate();
181
181
  }
182
+ /**
183
+ * Rename the current session (/rename). Returns the slug that was set, or
184
+ * undefined when the name had nothing usable in it — the transcript keeps
185
+ * its old name in that case.
186
+ */
187
+ renameSession(name) {
188
+ return this.session.setName(name);
189
+ }
190
+ /** What `-r <name>` and the exit notice call this session right now. */
191
+ sessionName() {
192
+ return this.session.displayName;
193
+ }
182
194
  /** Save (or overwrite) a named checkpoint at the current point in the
183
195
  * conversation, paired with the undo turn so /rewind can restore both
184
196
  * the transcript and the files together. */
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { resolveExportPath, transcriptToMarkdown } from "../session/export.js";
3
4
  import { AuditLog, summarizeAudit } from "../audit/audit.js";
4
5
  import { runMcpCommand } from "./mcpCommand.js";
5
6
  import { mcpPrompts } from "../mcp/client.js";
@@ -97,6 +98,16 @@ export const BUILTIN_COMMANDS = [
97
98
  description: "rewind the conversation and files to a checkpoint: /rewind <name>",
98
99
  category: SESSION,
99
100
  },
101
+ {
102
+ name: "/rename",
103
+ description: "rename this session: /rename <name> (no name shows the current one)",
104
+ category: SESSION,
105
+ },
106
+ {
107
+ name: "/export",
108
+ description: "export the transcript to markdown: /export [path] (default: kritya-<name>-<stamp>.md)",
109
+ category: SESSION,
110
+ },
100
111
  {
101
112
  name: "/plan",
102
113
  description: "toggle plan mode (read-only): /plan, /plan on, /plan off",
@@ -446,6 +457,63 @@ const handlers = {
446
457
  ctx.clearItems();
447
458
  ctx.addItem({ kind: "info", text: "Conversation cleared." });
448
459
  },
460
+ "/rename": (ctx) => {
461
+ const arg = ctx.arg.trim();
462
+ if (!arg) {
463
+ ctx.addItem({ kind: "info", text: `This session is named "${ctx.agent.sessionName()}".` });
464
+ return;
465
+ }
466
+ const slug = ctx.agent.renameSession(arg);
467
+ if (!slug) {
468
+ ctx.addItem({
469
+ kind: "info",
470
+ text: `Couldn't make a session name out of "${arg}" — try letters and numbers.`,
471
+ });
472
+ return;
473
+ }
474
+ ctx.addItem({
475
+ kind: "info",
476
+ text: `Session renamed to "${slug}". Resume it later with: kritya -r ${slug}`,
477
+ });
478
+ },
479
+ "/export": (ctx) => {
480
+ const messages = ctx.agent.history.filter((m) => m.role !== "system");
481
+ if (!messages.length) {
482
+ ctx.addItem({ kind: "info", text: "Nothing to export — the conversation is empty." });
483
+ return;
484
+ }
485
+ let file;
486
+ try {
487
+ file = resolveExportPath(ctx.workspace, ctx.arg, ctx.agent.sessionName());
488
+ }
489
+ catch (err) {
490
+ ctx.addItem({
491
+ kind: "info",
492
+ text: `Can't export there: ${err instanceof Error ? err.message : String(err)}`,
493
+ });
494
+ return;
495
+ }
496
+ const md = transcriptToMarkdown(messages, {
497
+ sessionName: ctx.agent.sessionName(),
498
+ model: ctx.model,
499
+ provider: ctx.provider,
500
+ exportedAt: new Date(),
501
+ });
502
+ try {
503
+ fs.writeFileSync(file, md, "utf8");
504
+ }
505
+ catch (err) {
506
+ ctx.addItem({
507
+ kind: "info",
508
+ text: `Export failed: ${err instanceof Error ? err.message : String(err)}`,
509
+ });
510
+ return;
511
+ }
512
+ ctx.addItem({
513
+ kind: "info",
514
+ text: `Transcript exported to ${file} (${messages.length} messages).`,
515
+ });
516
+ },
449
517
  "/cost": (ctx) => {
450
518
  ctx.addItem({ kind: "info", text: ctx.costReport() });
451
519
  },
@@ -0,0 +1,111 @@
1
+ import path from "node:path";
2
+ import { resolveSafe } from "../tools/common.js";
3
+ /** Tool outputs longer than this are cut — a transcript export is for reading, not replay. */
4
+ const MAX_TOOL_OUTPUT_CHARS = 4000;
5
+ /** Tool call arguments longer than this are cut; the full call is in the audit log. */
6
+ const MAX_TOOL_ARGS_CHARS = 1000;
7
+ function truncate(text, max) {
8
+ if (text.length <= max)
9
+ return text;
10
+ return text.slice(0, max) + `\n… [truncated, ${text.length - max} more characters]`;
11
+ }
12
+ /** Plain text of a message; image parts become a placeholder line. */
13
+ function messageText(message) {
14
+ const content = message.content;
15
+ if (typeof content === "string")
16
+ return content;
17
+ if (Array.isArray(content)) {
18
+ return content
19
+ .map((p) => {
20
+ if (p.type === "text")
21
+ return p.text;
22
+ if (p.type === "image_url")
23
+ return "[image attached]";
24
+ return "";
25
+ })
26
+ .filter(Boolean)
27
+ .join("\n");
28
+ }
29
+ return "";
30
+ }
31
+ /** Map tool_call_id -> the assistant's call, so results render under their call. */
32
+ function indexToolCalls(messages) {
33
+ const index = new Map();
34
+ for (const m of messages) {
35
+ if (m.role !== "assistant" || !("tool_calls" in m) || !m.tool_calls?.length)
36
+ continue;
37
+ for (const call of m.tool_calls) {
38
+ if (call.type !== "function")
39
+ continue;
40
+ index.set(call.id, {
41
+ name: call.function.name,
42
+ args: truncate(call.function.arguments ?? "", MAX_TOOL_ARGS_CHARS),
43
+ });
44
+ }
45
+ }
46
+ return index;
47
+ }
48
+ /**
49
+ * Render the agent's history as markdown. System prompts are skipped (they're
50
+ * scaffolding, not conversation); everything else renders in order — user and
51
+ * assistant turns as sections, tool results under the call that produced them.
52
+ */
53
+ export function transcriptToMarkdown(messages, meta) {
54
+ const calls = indexToolCalls(messages);
55
+ const lines = [
56
+ `# Kritya session: ${meta.sessionName}`,
57
+ "",
58
+ `- Exported: ${meta.exportedAt.toLocaleString()}`,
59
+ `- Model: ${meta.provider}/${meta.model}`,
60
+ `- Messages: ${messages.filter((m) => m.role !== "system").length}`,
61
+ "",
62
+ "---",
63
+ ];
64
+ for (const m of messages) {
65
+ if (m.role === "system")
66
+ continue;
67
+ if (m.role === "user") {
68
+ const text = messageText(m).trim();
69
+ if (!text)
70
+ continue;
71
+ lines.push("", "## User", "", text);
72
+ }
73
+ else if (m.role === "assistant") {
74
+ const text = messageText(m).trim();
75
+ lines.push("", "## Assistant", "");
76
+ if (text)
77
+ lines.push(text);
78
+ }
79
+ else if (m.role === "tool") {
80
+ const id = "tool_call_id" in m ? String(m.tool_call_id ?? "") : "";
81
+ const info = calls.get(id);
82
+ const output = truncate(messageText(m).trim(), MAX_TOOL_OUTPUT_CHARS);
83
+ lines.push("", `### Tool: ${info?.name ?? "result"}`, "");
84
+ if (info?.args.trim())
85
+ lines.push("```json", info.args.trim(), "```", "");
86
+ if (output)
87
+ lines.push("```", output, "```");
88
+ }
89
+ }
90
+ return lines.join("\n") + "\n";
91
+ }
92
+ function stampForFilename(now) {
93
+ const p = (n) => String(n).padStart(2, "0");
94
+ return (`${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}` +
95
+ `-${p(now.getHours())}${p(now.getMinutes())}`);
96
+ }
97
+ /**
98
+ * Where `/export` writes. No argument → `kritya-<session>-<stamp>.md` in the
99
+ * workspace; a bare name gets `.md`; anything else resolves inside the
100
+ * workspace (traversal outside it is rejected, like the file tools).
101
+ */
102
+ export function resolveExportPath(workspace, arg, sessionName, now = new Date()) {
103
+ let target = arg.trim();
104
+ if (!target) {
105
+ target = `kritya-${sessionName}-${stampForFilename(now)}.md`;
106
+ }
107
+ else if (!path.extname(target)) {
108
+ target += ".md";
109
+ }
110
+ return resolveSafe(workspace, target);
111
+ }
@@ -217,6 +217,38 @@ export class SessionStore {
217
217
  get path() {
218
218
  return this.ephemeral ? undefined : this.file;
219
219
  }
220
+ /**
221
+ * What `-r <name>` and the exit notice call this session right now: the
222
+ * auto-derived or user-set name, or the short hash code when unnamed.
223
+ */
224
+ get displayName() {
225
+ return this.ephemeral ? this.id : SessionStore.displayName(this.file);
226
+ }
227
+ /**
228
+ * Override the session's name (see /rename). Unlike the auto-derived name,
229
+ * this is explicit: it replaces whatever was there. The slug rules are the
230
+ * same — the name has to stay something `-r` can match. Returns the slug
231
+ * that was set, or undefined when ephemeral or when nothing usable remains
232
+ * (so the caller can say why instead of silently keeping the old name).
233
+ */
234
+ setName(raw) {
235
+ if (this.ephemeral)
236
+ return undefined;
237
+ const slug = slugifySessionName(raw);
238
+ if (!slug)
239
+ return undefined;
240
+ try {
241
+ fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
242
+ hardenWindowsDir(CONFIG_DIR);
243
+ writeSessionFile(SessionStore.nameFilePathFor(this.file), slug + "\n");
244
+ }
245
+ catch (err) {
246
+ debugLog(`SessionStore.setName(${this.file})`, err);
247
+ return undefined;
248
+ }
249
+ this.nameResolved = true;
250
+ return slug;
251
+ }
220
252
  /** Path of the sidecar file that holds this session's task checklist. */
221
253
  tasksFilePath() {
222
254
  return SessionStore.tasksFilePathFor(this.file);
package/dist/ui/App.js CHANGED
@@ -17,6 +17,7 @@ import { ModelPicker } from "./ModelPicker.js";
17
17
  import { ElicitationPrompt } from "./ElicitationPrompt.js";
18
18
  import { PermissionPrompt } from "./PermissionPrompt.js";
19
19
  import { SelectList } from "./SelectList.js";
20
+ import { buildPaletteItems } from "./palette.js";
20
21
  import { Spinner } from "./Spinner.js";
21
22
  import { StatusLine } from "./StatusLine.js";
22
23
  import { StreamViewport } from "./StreamViewport.js";
@@ -86,6 +87,7 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
86
87
  const [inputKey, setInputKey] = useState(0);
87
88
  const [steerInput, setSteerInput] = useState("");
88
89
  const [resumeFilter, setResumeFilter] = useState("");
90
+ const [paletteFilter, setPaletteFilter] = useState("");
89
91
  const [cmdIndex, setCmdIndex] = useState(0);
90
92
  const [fileIndex, setFileIndex] = useState(0);
91
93
  const [fileList, setFileList] = useState([]);
@@ -243,6 +245,19 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
243
245
  engageKill("Ctrl+K");
244
246
  return;
245
247
  }
248
+ // Ctrl+P opens the command palette — fuzzy-find commands and checkpoints.
249
+ // Idle prompt only, like Ctrl+E: opening it mid-turn would race the agent,
250
+ // and the kill switch above must keep its chord unshared.
251
+ if (key.ctrl && _input === "p") {
252
+ if (phase === "input") {
253
+ setPaletteFilter("");
254
+ setPhase("palette");
255
+ }
256
+ else if (phase === "palette") {
257
+ setPhase("input");
258
+ }
259
+ return;
260
+ }
246
261
  if (key.escape && phase === "working") {
247
262
  setActivity("cancelling…");
248
263
  abortRef.current?.abort();
@@ -315,6 +330,13 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
315
330
  setResumeFilter((f) => f + _input);
316
331
  }
317
332
  }
333
+ if (phase === "palette") {
334
+ if (key.backspace || key.delete)
335
+ setPaletteFilter((f) => f.slice(0, -1));
336
+ else if (_input && !key.upArrow && !key.downArrow && !key.return && !key.escape && !key.tab) {
337
+ setPaletteFilter((f) => f + _input);
338
+ }
339
+ }
318
340
  if (suggestions.length) {
319
341
  if (key.upArrow)
320
342
  setCmdIndex((i) => (i - 1 + suggestions.length) % suggestions.length);
@@ -385,6 +407,24 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
385
407
  };
386
408
  void runCommand(cmd, ctx);
387
409
  };
410
+ // Command palette selection. Commands are inserted into the input line —
411
+ // never executed blind: a fuzzy finder that runs on Enter is how
412
+ // conversations get cleared by accident. Checkpoints rewind immediately;
413
+ // the name is complete and unambiguous, exactly like /rewind <name>.
414
+ const onPaletteSelect = (value) => {
415
+ if (value.startsWith("checkpoint:")) {
416
+ const name = value.slice("checkpoint:".length);
417
+ setPhase("input");
418
+ handleSlash(`/rewind ${name}`);
419
+ return;
420
+ }
421
+ if (value.startsWith("cmd:")) {
422
+ setInput(`${value.slice("cmd:".length)} `);
423
+ setInputKey((k) => k + 1);
424
+ setPhase("input");
425
+ }
426
+ };
427
+ const paletteItems = buildPaletteItems(allCommands, agent.listCheckpoints(), paletteFilter);
388
428
  const expandMentions = async (text) => {
389
429
  const mentions = [...new Set([...text.matchAll(MENTION_ALL_RE)].map((m) => m[1]))];
390
430
  let extra = "";
@@ -517,7 +557,7 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
517
557
  label: s.name || s.title,
518
558
  value: s.file,
519
559
  hint: `${s.shortId} · ${s.date} · ${s.count} msgs`,
520
- })), onSelect: onResumeSelect, onCancel: () => onResumeSelect("") })] })), phase === "input" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { borderStyle: "round", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { color: "green", children: "\u276F " }), _jsx(TextInput, { value: input, onChange: (v) => {
560
+ })), onSelect: onResumeSelect, onCancel: () => onResumeSelect("") })] })), phase === "palette" && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "cyan", children: ["Command palette", " ", _jsx(Text, { dimColor: true, children: "(type to filter \u00B7 \u2191\u2193 navigate \u00B7 Enter selects \u00B7 Esc closes \u00B7 Ctrl+P toggles)" })] }), paletteFilter ? (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "filter: " }), paletteFilter] })) : null, _jsx(SelectList, { items: paletteItems, onSelect: onPaletteSelect, onCancel: () => setPhase("input") })] })), phase === "input" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { borderStyle: "round", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { color: "green", children: "\u276F " }), _jsx(TextInput, { value: input, onChange: (v) => {
521
561
  setInput(v.replace(/\t/g, ""));
522
562
  setCmdIndex(0);
523
563
  setFileIndex(0);
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Subsequence match, case-insensitive: "rwd" matches "/rewind", "exmd"
3
+ * matches "/export". Order matters ("mre" does not match "/rename") but
4
+ * contiguity doesn't — this is what makes a palette feel like a palette
5
+ * rather than the `/` prefix completion.
6
+ */
7
+ export function fuzzyMatch(haystack, needle) {
8
+ const h = haystack.toLowerCase();
9
+ const n = needle.trim().toLowerCase();
10
+ if (!n)
11
+ return true;
12
+ let i = 0;
13
+ for (const ch of h) {
14
+ if (ch === n[i])
15
+ i++;
16
+ if (i === n.length)
17
+ return true;
18
+ }
19
+ return false;
20
+ }
21
+ /**
22
+ * The palette's rows: every command first, then every checkpoint. Values are
23
+ * prefixed so the selection handler knows what was picked without a second
24
+ * lookup — `cmd:/rename` inserts the command into the input line (never runs
25
+ * it blind: a fuzzy finder that executes on Enter is how conversations get
26
+ * cleared by accident), `checkpoint:<name>` rewinds to that checkpoint.
27
+ */
28
+ export function buildPaletteItems(commands, checkpoints, filter) {
29
+ const items = [];
30
+ for (const c of commands) {
31
+ if (!fuzzyMatch(`${c.name} ${c.description}`, filter))
32
+ continue;
33
+ items.push({ label: c.name, value: `cmd:${c.name}`, hint: c.description });
34
+ }
35
+ for (const cp of checkpoints) {
36
+ if (!fuzzyMatch(`checkpoint ${cp.name}`, filter))
37
+ continue;
38
+ items.push({
39
+ label: `checkpoint: ${cp.name}`,
40
+ value: `checkpoint:${cp.name}`,
41
+ hint: `rewind · saved ${new Date(cp.createdAt).toLocaleTimeString()}`,
42
+ });
43
+ }
44
+ return items;
45
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kritya",
3
- "version": "0.8.38-beta",
3
+ "version": "0.8.40-beta",
4
4
  "description": "Kritya — Superintelligence at your command line",
5
5
  "type": "module",
6
6
  "publishConfig": {