kritya 0.8.38-beta → 0.8.39-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) |
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kritya",
3
- "version": "0.8.38-beta",
3
+ "version": "0.8.39-beta",
4
4
  "description": "Kritya — Superintelligence at your command line",
5
5
  "type": "module",
6
6
  "publishConfig": {