kritya 0.8.33-beta → 0.8.34-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
@@ -124,8 +124,9 @@ kritya # enter
124
124
  kritya [directory] [options]
125
125
 
126
126
  -c, --continue resume the most recent session for this directory
127
- -r, --resume [name] list past sessions, or resume one by its short name
128
- (e.g. `kritya -r a3f9k2`; plain `-r` opens the list)
127
+ -r, --resume [name] list past sessions, or resume one by its auto-derived
128
+ name (e.g. `kritya -r fix-login-bug`) or 5-character
129
+ code; plain `-r` opens the list
129
130
  -m, --model <id> use any model ID your provider offers
130
131
  -p, --provider <name> nvidia (default), openai, openrouter, groq, deepseek,
131
132
  mistral, together, ollama, switchyard (multi-model
package/dist/index.js CHANGED
@@ -11,7 +11,7 @@ import { loadRules } from "./permissions/rules.js";
11
11
  import { ProviderClient } from "./provider/client.js";
12
12
  import { createSwitchyardClient } from "./provider/switchyardClient.js";
13
13
  import { SWITCHYARD_ROUTE_ID, resolveEffectiveModel, staleSwitchyardModelWarning, } from "./provider/switchyardSidecar.js";
14
- import { SessionStore, shortSessionId } from "./session/store.js";
14
+ import { SessionStore } from "./session/store.js";
15
15
  import { exitNotice } from "./session/exitNotice.js";
16
16
  import { AuditLog } from "./audit/audit.js";
17
17
  import { runAuditCli } from "./audit/cli.js";
@@ -50,8 +50,9 @@ Usage: kritya [directory] [options]
50
50
 
51
51
  Options:
52
52
  -c, --continue resume the most recent session for this directory
53
- -r, --resume [name] list past sessions, or resume one by its short name
54
- (e.g. kritya -r a3f9k2; plain -r opens the picker)
53
+ -r, --resume [name] list past sessions, or resume one by its auto-derived
54
+ name (e.g. kritya -r fix-login-bug) or 5-character code;
55
+ plain -r opens the picker
55
56
  -m, --model <id> model ID to use (any model your provider offers)
56
57
  -p, --provider <n> provider: nvidia (default), openai, openrouter, groq,
57
58
  deepseek, mistral, together, ollama, or a custom one
@@ -518,7 +519,7 @@ async function main() {
518
519
  return [
519
520
  "",
520
521
  `The conversation was saved to ${file}`,
521
- `Resume it with: kritya -r ${shortSessionId(file)}${where}`,
522
+ `Resume it with: kritya -r ${SessionStore.displayName(file)}${where}`,
522
523
  ];
523
524
  },
524
525
  });
@@ -707,7 +708,7 @@ async function main() {
707
708
  // `renderTime` excludes Ink's own throttling, so it is the raw cost of the
708
709
  // frame rather than the interval between frames.
709
710
  const renderHistogram = sessionMeter.histogram("kritya.render.duration_ms", RENDER_BOUNDS_MS);
710
- ui.instance = render(_jsx(App, { agent: agent, workspace: workspace, modelRef: modelRef, providerRef: providerRef, config: config, resumedCount: initialHistory.length, resumedName: resumedFile ? shortSessionId(resumedFile) : undefined, initialTasks: initialTasks, undoStack: undoStack, uiBridge: uiBridge, resumeSessions: resumeSessions.length ? resumeSessions : undefined, customCommands: loadCustomCommands(workspace, trustWorkspace, plugins), mcpToolCount: mcpTools.length, onSwitchClient: (newClient) => {
711
+ ui.instance = render(_jsx(App, { agent: agent, workspace: workspace, modelRef: modelRef, providerRef: providerRef, config: config, resumedCount: initialHistory.length, resumedName: resumedFile ? SessionStore.displayName(resumedFile) : undefined, initialTasks: initialTasks, undoStack: undoStack, uiBridge: uiBridge, resumeSessions: resumeSessions.length ? resumeSessions : undefined, customCommands: loadCustomCommands(workspace, trustWorkspace, plugins), mcpToolCount: mcpTools.length, onSwitchClient: (newClient) => {
711
712
  client = newClient;
712
713
  }, onRequestPermissionReady: (fn) => {
713
714
  permissionRef.current = fn;
@@ -1,4 +1,4 @@
1
- import { shortSessionId } from "./store.js";
1
+ import { SessionStore } from "./store.js";
2
2
  /**
3
3
  * The sign-off printed when an interactive session ends, or null when there is
4
4
  * nothing worth saying.
@@ -25,6 +25,6 @@ export function exitNotice(input) {
25
25
  // so say where that was when it is not where the user is standing.
26
26
  const where = input.workspace === input.cwd ? "" : ` ${input.workspace}`;
27
27
  return (`${dim}Session saved · ${n} message${n === 1 ? "" : "s"}${plain}\n` +
28
- `${dim} resume ${plain}kritya -r ${shortSessionId(input.file)}${where}\n` +
28
+ `${dim} resume ${plain}kritya -r ${SessionStore.displayName(input.file)}${where}\n` +
29
29
  `${dim} list ${plain}kritya -r`);
30
30
  }
@@ -81,6 +81,54 @@ export function shortSessionId(file) {
81
81
  // alphabet rather than clustered by the timestamp's shared prefix.
82
82
  return digest.readUInt32BE(0).toString(36).padStart(5, "0").slice(-5);
83
83
  }
84
+ /**
85
+ * Turn a first user message into a short, typeable session name:
86
+ * "Fix the login bug!!" -> "fix-the-login-bug". Lowercase, runs of
87
+ * non-alphanumerics become one hyphen, capped at 40 characters so the name
88
+ * stays something a person will actually retype. Returns "" when nothing
89
+ * usable remains (empty input, only punctuation).
90
+ */
91
+ export function slugifySessionName(text) {
92
+ const slug = text
93
+ .slice(0, 80)
94
+ .toLowerCase()
95
+ .replace(/[^a-z0-9]+/g, "-")
96
+ .replace(/^-+|-+$/g, "")
97
+ .slice(0, 40)
98
+ .replace(/-+$/g, "");
99
+ return slug;
100
+ }
101
+ /**
102
+ * Pull plain text out of a message for naming/preview purposes. Content can
103
+ * be a string, an array of content parts (text + images), or null — images
104
+ * contribute nothing to a name.
105
+ */
106
+ function messageText(message) {
107
+ const content = message.content;
108
+ if (typeof content === "string")
109
+ return content;
110
+ if (Array.isArray(content)) {
111
+ return content
112
+ .filter((p) => p.type === "text")
113
+ .map((p) => p.text)
114
+ .join(" ");
115
+ }
116
+ return "";
117
+ }
118
+ /**
119
+ * The first message that may name a session: a real user message, not one of
120
+ * the synthetic "[...]" notes the agent writes to itself (undo, summaries).
121
+ * Mirrors the filter listSessions() uses for its preview, so the name and
122
+ * the preview always agree about what the conversation is about.
123
+ */
124
+ function nameableText(message) {
125
+ if (message.role !== "user")
126
+ return undefined;
127
+ const text = messageText(message).replace(/\s+/g, " ").trim();
128
+ if (!text || text.startsWith("["))
129
+ return undefined;
130
+ return text;
131
+ }
84
132
  /**
85
133
  * Transcripts can contain secrets that passed through tool output, so they are
86
134
  * always written 0o600 rather than inheriting whatever mode the file had.
@@ -116,6 +164,39 @@ export class SessionStore {
116
164
  get messageCount() {
117
165
  return this.count;
118
166
  }
167
+ /**
168
+ * Whether this file's name is settled — either a sidecar already exists
169
+ * (written earlier, or by a previous process) or we just wrote one.
170
+ * Reset by rotate(), which starts a new file that deserves its own name.
171
+ */
172
+ nameResolved = false;
173
+ /**
174
+ * Name this session from its first real user message, once. Later messages
175
+ * never rename it: the name is meant to stay stable so `-r <name>` keeps
176
+ * working and the resume list doesn't shift under the user.
177
+ */
178
+ maybeNameSession(message) {
179
+ if (this.nameResolved || this.ephemeral)
180
+ return;
181
+ if (SessionStore.readName(this.file)) {
182
+ this.nameResolved = true;
183
+ return;
184
+ }
185
+ const text = nameableText(message);
186
+ if (!text)
187
+ return;
188
+ const slug = slugifySessionName(text);
189
+ if (!slug)
190
+ return;
191
+ try {
192
+ writeSessionFile(SessionStore.nameFilePathFor(this.file), slug + "\n");
193
+ this.nameResolved = true;
194
+ }
195
+ catch (err) {
196
+ // Naming is cosmetic; never fail a turn over it.
197
+ debugLog(`SessionStore.maybeNameSession(${this.file})`, err);
198
+ }
199
+ }
119
200
  newFilePath() {
120
201
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
121
202
  return path.join(this.dir, `${stamp}.jsonl`);
@@ -143,6 +224,35 @@ export class SessionStore {
143
224
  static tasksFilePathFor(sessionFile) {
144
225
  return sessionFile.replace(/\.jsonl$/, ".tasks.json");
145
226
  }
227
+ /**
228
+ * Sidecar holding the session's human-readable name (see
229
+ * slugifySessionName), written once from the first real user message.
230
+ * A sidecar rather than a rename: the transcript filename is the session's
231
+ * stable id for audit/telemetry correlation, and renaming it mid-session
232
+ * would churn that id plus the tasks sidecar that keys off it.
233
+ */
234
+ static nameFilePathFor(sessionFile) {
235
+ return sessionFile.replace(/\.jsonl$/, ".name");
236
+ }
237
+ /** The auto-derived name for a session file, if it earned one yet. */
238
+ static readName(sessionFile) {
239
+ try {
240
+ const name = fs.readFileSync(SessionStore.nameFilePathFor(sessionFile), "utf8").trim();
241
+ return name || undefined;
242
+ }
243
+ catch {
244
+ return undefined;
245
+ }
246
+ }
247
+ /**
248
+ * What the user types and sees: the auto-derived name when the session has
249
+ * one, otherwise the short hash code. Every surface that names a session —
250
+ * the exit notice, the crash report, the `--resume` picker, `-r <name>` —
251
+ * goes through here so they can never disagree.
252
+ */
253
+ static displayName(sessionFile) {
254
+ return SessionStore.readName(sessionFile) ?? shortSessionId(sessionFile);
255
+ }
146
256
  /**
147
257
  * Persists the current task checklist alongside the session, so `-c`/`-r`
148
258
  * can restore not just the conversation but what was done vs. pending.
@@ -211,6 +321,7 @@ export class SessionStore {
211
321
  mode: 0o600,
212
322
  });
213
323
  this.count++;
324
+ this.maybeNameSession(message);
214
325
  }
215
326
  catch (err) {
216
327
  // Persistence is best-effort; never crash the session over it.
@@ -221,6 +332,8 @@ export class SessionStore {
221
332
  rotate() {
222
333
  this.file = this.newFilePath();
223
334
  this.count = 0;
335
+ // The new file deserves its own name, from its own first message.
336
+ this.nameResolved = false;
224
337
  }
225
338
  /**
226
339
  * Rewrite the session file to hold exactly `messages`, atomically. The log
@@ -331,7 +444,10 @@ export class SessionStore {
331
444
  continue;
332
445
  const base = path.basename(entry, ".jsonl").toLowerCase();
333
446
  const short = shortSessionId(file);
334
- if (short.startsWith(query) || base.startsWith(query))
447
+ const slug = SessionStore.readName(file)?.toLowerCase();
448
+ if (short.startsWith(query) ||
449
+ base.startsWith(query) ||
450
+ (slug !== undefined && (slug === query || slug.startsWith(query))))
335
451
  matches.set(file, short);
336
452
  }
337
453
  if (!matches.size) {
@@ -432,6 +548,7 @@ export class SessionStore {
432
548
  if (fs.statSync(file).mtimeMs < cutoff) {
433
549
  fs.unlinkSync(file);
434
550
  fs.rmSync(SessionStore.tasksFilePathFor(file), { force: true });
551
+ fs.rmSync(SessionStore.nameFilePathFor(file), { force: true });
435
552
  }
436
553
  }
437
554
  catch (err) {
@@ -504,6 +621,7 @@ export class SessionStore {
504
621
  date,
505
622
  preview,
506
623
  title,
624
+ name: SessionStore.readName(file) ?? "",
507
625
  shortId: shortSessionId(file),
508
626
  count: lines.length,
509
627
  });
package/dist/ui/App.js CHANGED
@@ -506,11 +506,15 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
506
506
  }, placeholder: "steer the agent\u2026 (Enter to queue)" })] })] })), phase === "permission" && permission && (_jsx(PermissionPrompt, { toolName: permission.toolName, summary: permission.summary, diff: permission.diff, warning: permission.warning, onDecision: onPermissionDecision })), phase === "elicitation" && elicitation && (_jsx(ElicitationPrompt, { message: elicitation.message, fields: elicitation.fields, onDecision: onElicitationDecision })), phase === "confirmMode" && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, children: [_jsx(Text, { bold: true, color: "green", children: "Switch to accept-edits mode?" }), _jsx(Text, { children: "File writes and edits will auto-approve without asking." }), _jsx(Text, { dimColor: true, children: "Destructive shell commands (rm -rf, force-push, etc.) always still ask, in every mode." }), _jsx(Text, { dimColor: true, children: "Shift+Tab again moves to dry-run mode; once more back to normal." }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsx(Text, { color: "green", children: "Yes (y)" }), " \u00B7 No (n/Esc)"] }) })] })), phase === "confirmBypassMode" && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "red", paddingX: 1, children: [_jsx(Text, { bold: true, color: "red", children: "Switch to auto mode? (sandbox-gated)" }), _jsx(Text, { children: "EVERY tool call will auto-approve, including destructive shell commands (rm -rf, force-push, etc.) \u2014 nothing will ask first." }), _jsx(Text, { dimColor: true, children: "The sandbox \u2014 not a human \u2014 is what contains any damage this mode causes." }), _jsx(Text, { dimColor: true, children: "Shift+Tab again goes back to normal." }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsx(Text, { color: "red", children: "Yes (y)" }), " \u00B7 No (n/Esc)"] }) })] })), phase === "model" && (_jsx(ModelPicker, { current: model, customModels: config.customModels ?? [], onSelect: (id) => {
507
507
  setPhase("input");
508
508
  setModelEverywhere(id);
509
- }, onCancel: () => setPhase("input") })), phase === "resume" && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "magenta", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "magenta", children: ["Resume a session", " ", _jsx(Text, { dimColor: true, children: "(type to search \u00B7 Esc for a fresh one \u00B7 -r <code> to skip this)" })] }), resumeFilter ? (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "filter: " }), resumeFilter] })) : null, _jsx(SelectList, { items: (resumeSessions ?? [])
510
- .filter((s) => s.title.toLowerCase().includes(resumeFilter.toLowerCase()) ||
511
- SessionStore.matchesContent(s.file, resumeFilter))
509
+ }, onCancel: () => setPhase("input") })), phase === "resume" && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "magenta", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "magenta", children: ["Resume a session", " ", _jsx(Text, { dimColor: true, children: "(type to search \u00B7 Esc for a fresh one \u00B7 -r <name> to skip this)" })] }), resumeFilter ? (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "filter: " }), resumeFilter] })) : null, _jsx(SelectList, { items: (resumeSessions ?? [])
510
+ .filter((s) => {
511
+ const f = resumeFilter.toLowerCase();
512
+ return (s.title.toLowerCase().includes(f) ||
513
+ s.name.toLowerCase().includes(f) ||
514
+ SessionStore.matchesContent(s.file, resumeFilter));
515
+ })
512
516
  .map((s) => ({
513
- label: s.title,
517
+ label: s.name || s.title,
514
518
  value: s.file,
515
519
  hint: `${s.shortId} · ${s.date} · ${s.count} msgs`,
516
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) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kritya",
3
- "version": "0.8.33-beta",
3
+ "version": "0.8.34-beta",
4
4
  "description": "Kritya — Superintelligence at your command line",
5
5
  "type": "module",
6
6
  "publishConfig": {