kritya 0.8.48-beta → 0.8.50-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
@@ -175,6 +175,9 @@ In-session commands (type `/` to see them with autocomplete; letters filter the
175
175
  | `/diff` | show the cumulative git diff of this session's changes |
176
176
  | `/init` | scan the repo and generate a `KRITYA.md` project-memory file |
177
177
  | `/commit` | have the agent review, stage, and commit the current git changes |
178
+ | `/pr` | push the branch and open a GitHub PR: `/pr [--draft] [--title T] [--body B] [--base BR]` |
179
+ | `/summarize` | one-shot summary of the session so far (history is left untouched) |
180
+ | `/template` | prompt templates: list, /template <name> [words], /template save\|delete <name> |
178
181
  | `/web-search <query>` | search the web via Tavily; results are shown and added to context |
179
182
  | `/mcp` | MCP server status; `/mcp add\|remove <name>`, `/mcp login\|logout <name>`, `/mcp trust` ([more](docs/CONFIGURATION.md#mcp-servers)) |
180
183
  | `/skills` | list discovered skills (project + user-global) and why any were skipped |
@@ -1,5 +1,10 @@
1
+ import { estimateHistoryTokens } from "./tokens.js";
1
2
  /** How many recent messages survive compaction untouched. */
2
3
  const KEEP_TAIL = 8;
4
+ /** The briefing prompt shared by compaction and the one-shot /summarize. */
5
+ export const SUMMARY_SYSTEM_PROMPT = "You summarize coding-session transcripts. Produce a dense briefing: the user's goals, " +
6
+ "key decisions, files created/modified (with paths), commands run and their outcomes, " +
7
+ "current state, and open items. Plain text, no preamble.";
3
8
  /**
4
9
  * Split history into an older part to summarize and a recent tail to keep.
5
10
  * The tail never starts on a tool reply: the cut moves back to the assistant
@@ -61,8 +66,7 @@ export function fallbackSummary(toSummarize) {
61
66
  }
62
67
  return lines.join("\n");
63
68
  }
64
- /** Render messages as a plain-text transcript for the summarization request. */
65
- export function renderTranscript(messages) {
69
+ /** Render messages as a plain-text transcript for the summarization request. */ export function renderTranscript(messages) {
66
70
  return messages
67
71
  .map((m) => {
68
72
  if (m.role === "tool") {
@@ -76,3 +80,22 @@ export function renderTranscript(messages) {
76
80
  })
77
81
  .join("\n\n");
78
82
  }
83
+ /**
84
+ * Choose which messages the one-shot summary covers. The whole history when
85
+ * it fits comfortably; otherwise the newest messages that fit within half
86
+ * the context window (the summarization call needs headroom for its reply).
87
+ * Oldest drop first, and the selection never starts on a tool reply, so
88
+ * call/reply pairs stay together. Returns the selection and how many oldest
89
+ * messages were dropped.
90
+ */
91
+ export function selectMessagesForSummary(history, contextWindow) {
92
+ const budget = Math.floor(contextWindow / 2);
93
+ let messages = history;
94
+ while (messages.length > 1 && estimateHistoryTokens(messages) > budget) {
95
+ messages = messages.slice(1);
96
+ }
97
+ while (messages.length > 1 && messages[0].role === "tool") {
98
+ messages = messages.slice(1);
99
+ }
100
+ return { messages, dropped: history.length - messages.length };
101
+ }
@@ -1,5 +1,5 @@
1
1
  import { isContextOverflowError } from "../provider/client.js";
2
- import { splitForCompaction, renderTranscript, fallbackSummary } from "./compactor.js";
2
+ import { splitForCompaction, renderTranscript, fallbackSummary, selectMessagesForSummary, SUMMARY_SYSTEM_PROMPT, } from "./compactor.js";
3
3
  import { estimateHistoryTokens, estimateTokens } from "./tokens.js";
4
4
  import { buildSystemPrompt } from "./systemPrompt.js";
5
5
  import { NOOP_TRACER } from "../telemetry/tracer.js";
@@ -325,6 +325,29 @@ export class Agent {
325
325
  this.onCompactEnd?.();
326
326
  }
327
327
  }
328
+ /**
329
+ * One-shot briefing of the session so far, for display. Unlike compact(),
330
+ * this never touches the history — it just asks the model to summarize
331
+ * what happened and returns the text. A failure is reported, not degraded:
332
+ * nothing is being destroyed, so there is no need for the mechanical
333
+ * fallback that compaction uses.
334
+ */
335
+ async summarize(signal) {
336
+ this.kill.assertLive();
337
+ if (this.history.length <= 4) {
338
+ return "Nothing to summarize yet — the session just started.";
339
+ }
340
+ const { messages, dropped } = selectMessagesForSummary(this.history, this.contextWindow);
341
+ const result = await this.client.chat(this.getModel(), [
342
+ { role: "system", content: SUMMARY_SYSTEM_PROMPT },
343
+ { role: "user", content: renderTranscript(messages) },
344
+ ], [], { onTextDelta: () => { }, onReasoningDelta: () => { } }, signal, { tracer: this.tracer, parent: this.currentTurnSpan ?? undefined });
345
+ const summary = result.text.trim() || "(summary unavailable)";
346
+ return dropped > 0
347
+ ? `Session summary (most recent ${messages.length} messages — ` +
348
+ `${dropped} oldest omitted to fit the context window):\n\n${summary}`
349
+ : `Session summary:\n\n${summary}`;
350
+ }
328
351
  async doCompact(signal, compactSpan) {
329
352
  const { toSummarize, keep } = splitForCompaction(this.history);
330
353
  if (!toSummarize.length) {
@@ -344,9 +367,7 @@ export class Agent {
344
367
  const result = await this.client.chat(this.getModel(), [
345
368
  {
346
369
  role: "system",
347
- content: "You summarize coding-session transcripts. Produce a dense briefing: the user's goals, " +
348
- "key decisions, files created/modified (with paths), commands run and their outcomes, " +
349
- "current state, and open items. Plain text, no preamble.",
370
+ content: SUMMARY_SYSTEM_PROMPT,
350
371
  },
351
372
  { role: "user", content: renderTranscript(toSummarize) },
352
373
  ], [], { onTextDelta: () => { }, onReasoningDelta: () => { } }, signal, { tracer: this.tracer, parent: compactSpan });
@@ -3,7 +3,7 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { createRequire } from "node:module";
5
5
  import { BUILTIN_PROVIDERS, CONFIG_DIR, CONFIG_FILE, legacyGlobalModel, loadConfig, loadDotEnv, privacyModeFor, resolveProvider, } from "../config/config.js";
6
- import { DEFAULT_MODEL, contextWindowFor } from "../config/models.js";
6
+ import { defaultModelFor, contextWindowFor } from "../config/models.js";
7
7
  import { retentionDaysFor } from "../config/retention.js";
8
8
  import { assertJsonWithinLimits } from "../config/jsonSafety.js";
9
9
  import { isGitRepo } from "../agent/worktree.js";
@@ -144,7 +144,7 @@ function providerSection(config, provider) {
144
144
  else {
145
145
  checks.push(fail(`no API key resolved for provider "${provider.name}"`));
146
146
  }
147
- const model = resolveEffectiveModel(provider.name, [config.providers?.[provider.name]?.model, legacyGlobalModel(config, provider.name)], provider.name === "switchyard" ? SWITCHYARD_ROUTE_ID : DEFAULT_MODEL);
147
+ const model = resolveEffectiveModel(provider.name, [config.providers?.[provider.name]?.model, legacyGlobalModel(config, provider.name)], provider.name === "switchyard" ? SWITCHYARD_ROUTE_ID : defaultModelFor(provider.name));
148
148
  checks.push(ok(`model: ${model} (context ${contextWindowFor(model, config).toLocaleString("en-US")})`));
149
149
  // A pinned provider name with no matching entry is a silent misconfiguration:
150
150
  // resolveProvider falls back to NVIDIA's URL, so requests go somewhere the
@@ -10,6 +10,9 @@ import { scanPluginMcpServers } from "../plugins/mcp.js";
10
10
  import { listProviders } from "../config/config.js";
11
11
  import { expandCommand } from "./custom.js";
12
12
  import { recordCommandUse } from "../ui/recentCommands.js";
13
+ import { allTemplates, applyTemplate, isValidName, lastUserText, loadTemplates, saveTemplates, } from "./templates.js";
14
+ import { gitBranch, gitCommitsAhead, gitDefaultBranch, gitHasUpstream, gitPush, gitRefExists, gitRemoteUrl, gitUnpushedCount, } from "../git/git.js";
15
+ import { createPullRequest, findOpenPullRequest, parseGitHubRemote, parsePrFlags, prBodyFor, prTitleFor, resolveGitHubToken, } from "../git/pr.js";
13
16
  import { artifactExists, artifactPath, BACK_COMMAND, clearProjectState, DEFAULT_GATES, listProjects, loadProjectState, MAX_REVISITS, nextPhase, parseIdea, parsePhase, phaseBlocker, phaseDoneMessage, phaseIndex, phasePrompt, planRun, prefillAfter, renameProject, resumeProject, revisitsRemaining, saveProjectState, shouldStopAfter, staleArtifacts, takeRevisit, PHASE_COMMAND, PHASE_ORDER, PHASE_SUMMARY, slugify, } from "../agent/workflow.js";
14
17
  const WORKFLOW = "Workflow";
15
18
  const SESSION = "Session";
@@ -92,11 +95,26 @@ export const BUILTIN_COMMANDS = [
92
95
  description: "have the agent stage and commit the current changes",
93
96
  category: SESSION,
94
97
  },
98
+ {
99
+ name: "/pr",
100
+ description: "push the branch and open a GitHub PR: /pr [--draft] [--title T] [--body B] [--base BR]",
101
+ category: SESSION,
102
+ },
95
103
  {
96
104
  name: "/compact",
97
105
  description: "summarize older conversation to free context space",
98
106
  category: SESSION,
99
107
  },
108
+ {
109
+ name: "/summarize",
110
+ description: "one-shot summary of the session so far (history is left untouched)",
111
+ category: SESSION,
112
+ },
113
+ {
114
+ name: "/template",
115
+ description: "prompt templates: list, /template <name> [words], /template save|delete <name>",
116
+ category: SESSION,
117
+ },
100
118
  { name: "/clear", description: "start a fresh conversation", category: SESSION },
101
119
  {
102
120
  name: "/undo",
@@ -803,6 +821,79 @@ const handlers = {
803
821
  ctx.setPhase("input");
804
822
  });
805
823
  },
824
+ "/template": (ctx) => {
825
+ ctx.addItem({ kind: "user", text: ctx.raw.trim() });
826
+ const say = (text) => ctx.addItem({ kind: "info", text });
827
+ const custom = loadTemplates();
828
+ const all = allTemplates(custom);
829
+ const arg = ctx.arg.trim();
830
+ if (!arg) {
831
+ const names = Object.keys(all).sort();
832
+ if (!names.length) {
833
+ say("No prompt templates yet. Save your last message as one: /template save <name>");
834
+ return;
835
+ }
836
+ const lines = names.map((n) => custom[n] !== undefined ? ` ${n} (yours)` : ` ${n} (built-in)`);
837
+ say("Prompt templates — `/template <name> [words...]` drops one into the input line:\n" +
838
+ lines.join("\n") +
839
+ "\n\nSave your last message: `/template save <name>` · remove one: `/template delete <name>`");
840
+ return;
841
+ }
842
+ const [sub, ...rest] = arg.split(/\s+/);
843
+ if (sub === "save" || sub === "delete") {
844
+ const name = rest[0] ?? "";
845
+ if (!isValidName(name)) {
846
+ say(`Usage: /template ${sub} <name> — letters, numbers, - and _ only, max 32 chars.`);
847
+ return;
848
+ }
849
+ if (sub === "save") {
850
+ const text = lastUserText(ctx.agent.history);
851
+ if (!text) {
852
+ say("Nothing to save — send a message first, then `/template save <name>`.");
853
+ return;
854
+ }
855
+ custom[name] = text;
856
+ saveTemplates(custom);
857
+ say(`Saved template "${name}" (${text.length} chars). Use it: /template ${name}`);
858
+ }
859
+ else {
860
+ if (custom[name] === undefined) {
861
+ say(`No saved template named "${name}". Built-ins can't be deleted.`);
862
+ return;
863
+ }
864
+ delete custom[name];
865
+ saveTemplates(custom);
866
+ say(`Deleted template "${name}".`);
867
+ }
868
+ return;
869
+ }
870
+ const template = all[sub];
871
+ if (template === undefined) {
872
+ say(`No template named "${sub}". See /template for the list.`);
873
+ return;
874
+ }
875
+ // Into the input line, not executed: the user edits and hits Enter.
876
+ ctx.setPrefill(applyTemplate(template, rest.join(" ")));
877
+ say(`Template "${sub}" is in the input line — edit it and hit Enter. Nothing was sent.`);
878
+ },
879
+ "/summarize": (ctx) => {
880
+ ctx.addItem({ kind: "user", text: "/summarize" });
881
+ ctx.setPhase("working");
882
+ ctx.setActivity("Summarizing session…");
883
+ return ctx.agent
884
+ .summarize()
885
+ .then((summary) => {
886
+ ctx.addItem({ kind: "info", text: summary });
887
+ })
888
+ .catch((err) => ctx.addItem({
889
+ kind: "info",
890
+ text: `Summarization failed: ${err instanceof Error ? err.message : String(err)}`,
891
+ }))
892
+ .finally(() => {
893
+ ctx.setActivity(null);
894
+ ctx.setPhase("input");
895
+ });
896
+ },
806
897
  "/init": (ctx) => {
807
898
  ctx.addItem({ kind: "user", text: "/init" });
808
899
  return ctx.runAgent("Explore this repository (README, package/build files, src layout, test setup) and write " +
@@ -821,6 +912,84 @@ const handlers = {
821
912
  "and create a commit with a well-written conventional-commit message that describes " +
822
913
  `the change.${trailerInstruction} Do not push. Show the final commit hash and message.`);
823
914
  },
915
+ "/pr": async (ctx) => {
916
+ ctx.addItem({ kind: "user", text: ctx.raw.trim() });
917
+ const say = (text) => ctx.addItem({ kind: "info", text });
918
+ const opts = parsePrFlags(ctx.arg);
919
+ const ws = ctx.workspace;
920
+ const branch = gitBranch(ws);
921
+ if (!branch) {
922
+ say("Not a git repository — /pr needs one to push from.");
923
+ return;
924
+ }
925
+ const remote = gitRemoteUrl(ws);
926
+ const gh = remote ? parseGitHubRemote(remote) : null;
927
+ if (!gh) {
928
+ say("No GitHub origin remote found — /pr opens pull requests on GitHub.\n" +
929
+ "Set it with: git remote add origin <your-github-repo-url>");
930
+ return;
931
+ }
932
+ const base = opts.base ?? gitDefaultBranch(ws);
933
+ if (!gitRefExists(ws, base)) {
934
+ say(`Base branch "${base}" does not exist locally. Pass one that does: /pr --base <branch>`);
935
+ return;
936
+ }
937
+ if (branch === base) {
938
+ say(`You are on "${branch}", the default branch — a pull request needs a feature branch.\n` +
939
+ "Create one first: git checkout -b <name>");
940
+ return;
941
+ }
942
+ const auth = resolveGitHubToken();
943
+ if (!auth) {
944
+ say("No GitHub token found. Provide one of:\n" +
945
+ " export GITHUB_TOKEN=<token> (a classic or fine-grained PAT from github.com/settings/tokens)\n" +
946
+ " gh auth login (then /pr reads it via `gh auth token`)");
947
+ return;
948
+ }
949
+ const commits = gitCommitsAhead(ws, base);
950
+ if (commits.length === 0) {
951
+ say(`"${branch}" has no commits that "${base}" does not — nothing to open a PR for.`);
952
+ return;
953
+ }
954
+ // Push first: the PR is opened against the pushed branch. Only pushes
955
+ // when there is something to send — a no-op push that fails on network
956
+ // would otherwise block opening the PR for no reason.
957
+ if (!gitHasUpstream(ws, branch) || gitUnpushedCount(ws) > 0) {
958
+ say(`Pushing ${branch} to origin…`);
959
+ const err = gitPush(ws, branch, !gitHasUpstream(ws, branch));
960
+ if (err) {
961
+ say(err);
962
+ return;
963
+ }
964
+ }
965
+ const title = prTitleFor(commits, branch, opts.title);
966
+ const body = prBodyFor(commits, opts.body);
967
+ try {
968
+ const pr = await createPullRequest({
969
+ owner: gh.owner,
970
+ repo: gh.repo,
971
+ head: branch,
972
+ base,
973
+ title,
974
+ body,
975
+ draft: opts.draft,
976
+ token: auth.token,
977
+ });
978
+ say(`✓ PR #${pr.number} opened${opts.draft ? " (draft)" : ""}: ${pr.url}`);
979
+ }
980
+ catch (err) {
981
+ // A 422 almost always means the PR already exists — report it instead
982
+ // of failing on a duplicate.
983
+ if (err.alreadyExists) {
984
+ const existing = await findOpenPullRequest(gh.owner, gh.repo, branch, auth.token);
985
+ say(existing
986
+ ? `A pull request for "${branch}" already exists: PR #${existing.number}: ${existing.url}`
987
+ : "GitHub says a pull request for this branch already exists, but it could not be found.");
988
+ return;
989
+ }
990
+ say(err.message);
991
+ }
992
+ },
824
993
  "/flow-brainstorm": async (ctx) => {
825
994
  const req = takeFlowFlags(ctx.arg);
826
995
  if (req.error) {
@@ -0,0 +1,77 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { CONFIG_DIR } from "../config/config.js";
4
+ import { writeFileAtomicSync } from "../atomicWrite.js";
5
+ /**
6
+ * Prompt templates: named starter prompts kept in ~/.kritya/templates.json.
7
+ * `/template <name> [words...]` drops the template into the input line with
8
+ * `{{input}}` replaced by the words (nothing runs without the user hitting
9
+ * Enter); `/template save <name>` stores the last sent message as a template.
10
+ */
11
+ const TEMPLATES_FILE = "templates.json";
12
+ const INPUT_PLACEHOLDER = "{{input}}";
13
+ const MAX_NAME_LENGTH = 32;
14
+ const NAME_RE = /^[a-zA-Z0-9_-]+$/;
15
+ /** Starter templates; a user template with the same name wins. */
16
+ export const BUILTIN_TEMPLATES = {
17
+ review: "Review this diff for bugs, edge cases, and style issues:\n\n{{input}}",
18
+ explain: "Explain this code like I'm new to the codebase:\n\n{{input}}",
19
+ };
20
+ export function isValidName(name) {
21
+ return name.length > 0 && name.length <= MAX_NAME_LENGTH && NAME_RE.test(name);
22
+ }
23
+ /** The user's own templates from disk. Never throws — missing or broken file means none. */
24
+ export function loadTemplates(dir = CONFIG_DIR) {
25
+ try {
26
+ const parsed = JSON.parse(fs.readFileSync(path.join(dir, TEMPLATES_FILE), "utf8"));
27
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
28
+ return {};
29
+ const out = {};
30
+ for (const [k, v] of Object.entries(parsed)) {
31
+ if (typeof v === "string" && isValidName(k))
32
+ out[k] = v;
33
+ }
34
+ return out;
35
+ }
36
+ catch {
37
+ return {};
38
+ }
39
+ }
40
+ export function saveTemplates(custom, dir = CONFIG_DIR) {
41
+ fs.mkdirSync(dir, { recursive: true });
42
+ writeFileAtomicSync(path.join(dir, TEMPLATES_FILE), JSON.stringify(custom, null, 2) + "\n");
43
+ }
44
+ /** Built-ins under the user's own, so a custom template can override a starter. */
45
+ export function allTemplates(custom) {
46
+ return { ...BUILTIN_TEMPLATES, ...custom };
47
+ }
48
+ /**
49
+ * Fill `{{input}}` with the user's words. With no words, the placeholder is
50
+ * dropped rather than left visible; with no placeholder, the words are
51
+ * appended so `/template review src/foo.ts` still does something sensible.
52
+ */
53
+ export function applyTemplate(template, input) {
54
+ const words = input.trim();
55
+ if (!template.includes(INPUT_PLACEHOLDER)) {
56
+ return words ? `${template.trimEnd()} ${words}` : template;
57
+ }
58
+ const filled = template.split(INPUT_PLACEHOLDER).join(words);
59
+ return words ? filled : filled.replace(/\n{3,}/g, "\n\n").trimEnd();
60
+ }
61
+ /**
62
+ * The last prompt the user actually sent. Slash commands never reach the
63
+ * agent history, but anything starting with "/" is skipped anyway, along
64
+ * with non-text (multimodal) messages.
65
+ */
66
+ export function lastUserText(history) {
67
+ for (let i = history.length - 1; i >= 0; i--) {
68
+ const m = history[i];
69
+ if (m.role !== "user" || typeof m.content !== "string")
70
+ continue;
71
+ const text = m.content.trim();
72
+ if (!text || text.startsWith("/"))
73
+ continue;
74
+ return text;
75
+ }
76
+ return null;
77
+ }
@@ -1,3 +1,7 @@
1
+ /** The provider a `ModelInfo` belongs to, defaulting the historical NVIDIA catalog. */
2
+ export function providerOfModel(m) {
3
+ return m.provider ?? "nvidia";
4
+ }
1
5
  /** Fallback context window when neither config nor the registry knows the model. */
2
6
  export const DEFAULT_CONTEXT_WINDOW = 120_000;
3
7
  /**
@@ -63,8 +67,56 @@ export const CURATED_MODELS = [
63
67
  note: "fast + cheap",
64
68
  contextWindow: 1_048_576,
65
69
  },
70
+ // Groq (https://console.groq.com/docs/models). Context windows verified
71
+ // against the docs table; both are 131,072. llama-3.3-70b-versatile is
72
+ // enterprise-only on Groq ("Contact Sales", no self-serve), so it is
73
+ // deliberately not listed — it 404s on a normal API key.
74
+ {
75
+ id: "openai/gpt-oss-120b",
76
+ label: "GPT-OSS 120B",
77
+ provider: "groq",
78
+ note: "default",
79
+ contextWindow: 131_072,
80
+ },
81
+ {
82
+ id: "openai/gpt-oss-20b",
83
+ label: "GPT-OSS 20B",
84
+ provider: "groq",
85
+ note: "fast + cheap",
86
+ contextWindow: 131_072,
87
+ },
66
88
  ];
89
+ /** Models registered for one provider, in registry order. */
90
+ export function modelsForProvider(provider) {
91
+ return CURATED_MODELS.filter((m) => providerOfModel(m) === provider);
92
+ }
93
+ /**
94
+ * The provider a curated model belongs to, or undefined for a model this
95
+ * registry doesn't know (custom models, arbitrary `-m` ids).
96
+ */
97
+ export function curatedProviderFor(modelId) {
98
+ const m = CURATED_MODELS.find((x) => x.id === modelId);
99
+ return m ? providerOfModel(m) : undefined;
100
+ }
101
+ /** The curated default model for a provider, if it has one. */
102
+ export function defaultModelForProvider(provider) {
103
+ const models = modelsForProvider(provider);
104
+ if (models.length === 0)
105
+ return undefined;
106
+ // Prefer an entry flagged `note: "default"`, else the first registered.
107
+ return (models.find((m) => m.note === "default") ?? models[0]).id;
108
+ }
67
109
  export const DEFAULT_MODEL = CURATED_MODELS[0].id;
110
+ /**
111
+ * The default model id for a given provider. Providers with a curated list use
112
+ * their own default, so `-p groq` with no `-m` sends a Groq model id rather
113
+ * than the NVIDIA default (which the Groq API would reject). Providers with no
114
+ * curated models keep the historical behaviour of falling back to the NVIDIA
115
+ * default, since those are callers passing an arbitrary provider/model pair.
116
+ */
117
+ export function defaultModelFor(provider) {
118
+ return defaultModelForProvider(provider) ?? DEFAULT_MODEL;
119
+ }
68
120
  /**
69
121
  * Context window for a model. Explicit config.contextWindow always wins; then
70
122
  * the curated registry; then the default. Custom models can carry a window via
package/dist/engine.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import path from "node:path";
2
2
  import { Agent } from "./agent/loop.js";
3
3
  import { CONFIG_DIR, legacyGlobalModel, loadConfig, loadDotEnv, privacyModeFor, resolveProvider, } from "./config/config.js";
4
- import { DEFAULT_MODEL, contextWindowFor } from "./config/models.js";
4
+ import { defaultModelFor, contextWindowFor } from "./config/models.js";
5
5
  import { PermissionManager } from "./permissions/permissions.js";
6
6
  import { loadRules } from "./permissions/rules.js";
7
7
  import { ProviderClient } from "./provider/client.js";
@@ -41,7 +41,7 @@ export async function createEngineSession(dir, opts = {}) {
41
41
  throw new Error(`No API key found for provider "${provider.name}". Set it via env var, a .env file, or ~/.kritya/config.json.`);
42
42
  }
43
43
  const providerDefaultModel = config.providers?.[provider.name]?.model;
44
- let currentModel = resolveEffectiveModel(provider.name, [opts.model, providerDefaultModel, legacyGlobalModel(config, provider.name)], provider.name === "switchyard" ? SWITCHYARD_ROUTE_ID : DEFAULT_MODEL);
44
+ let currentModel = resolveEffectiveModel(provider.name, [opts.model, providerDefaultModel, legacyGlobalModel(config, provider.name)], provider.name === "switchyard" ? SWITCHYARD_ROUTE_ID : defaultModelFor(provider.name));
45
45
  const sampling = {
46
46
  temperature: provider.temperature,
47
47
  topP: provider.topP,
package/dist/git/git.js CHANGED
@@ -17,6 +17,75 @@ export function gitBranch(cwd) {
17
17
  const out = git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
18
18
  return out || null;
19
19
  }
20
+ /** Remote URL of `origin`, or null when there is none. */
21
+ export function gitRemoteUrl(cwd) {
22
+ const out = git(cwd, ["remote", "get-url", "origin"]);
23
+ return out || null;
24
+ }
25
+ /**
26
+ * The repo's default branch, from the origin/HEAD symref. Falls back to
27
+ * "main" when the symref is missing (a repo cloned without --no-remote-head
28
+ * oddities, or a remote the local clone has never fetched).
29
+ */
30
+ export function gitDefaultBranch(cwd) {
31
+ const out = git(cwd, ["symbolic-ref", "refs/remotes/origin/HEAD"]);
32
+ const m = out && /^refs\/remotes\/origin\/(.+)$/.exec(out);
33
+ return m ? m[1] : "main";
34
+ }
35
+ /** Whether `branch` has an upstream configured. */
36
+ export function gitHasUpstream(cwd, branch) {
37
+ return git(cwd, ["rev-parse", "--verify", `refs/remotes/${branch}@{upstream}`]) !== null;
38
+ }
39
+ /** Whether `ref` (branch, tag, …) exists locally. */
40
+ export function gitRefExists(cwd, ref) {
41
+ return git(cwd, ["rev-parse", "--verify", ref]) !== null;
42
+ }
43
+ /** Commits on the current branch not yet on its upstream. 0 when there is no upstream. */
44
+ export function gitUnpushedCount(cwd) {
45
+ const out = git(cwd, ["rev-list", "--count", "@{u}..HEAD"]);
46
+ const n = out === null ? NaN : parseInt(out, 10);
47
+ return Number.isFinite(n) ? n : 0;
48
+ }
49
+ /**
50
+ * Commits on HEAD that `base` does not have, oldest first. Empty when the
51
+ * branch is up to date with base (or on any git failure).
52
+ */
53
+ export function gitCommitsAhead(cwd, base) {
54
+ // \x1e separates records, \x1f separates subject from body — both are
55
+ // control characters that cannot appear in a commit message as written by
56
+ // any sane tooling, and far less likely than newlines.
57
+ const out = git(cwd, ["log", "--reverse", "--format=%s%x1f%b%x1e", `${base}..HEAD`]);
58
+ if (!out)
59
+ return [];
60
+ return out
61
+ .split("\x1e")
62
+ .filter((rec) => rec.trim().length > 0)
63
+ .map((rec) => {
64
+ const [subject = "", body = ""] = rec.split("\x1f");
65
+ return { subject: subject.trim(), body: body.trim() };
66
+ });
67
+ }
68
+ /**
69
+ * Push the branch, setting the upstream when it has none. Returns an error
70
+ * message rather than throwing — the caller is a slash command reporting
71
+ * back to the user. The push runs under the user's own git credentials,
72
+ * exactly as if they had typed it themselves.
73
+ */
74
+ export function gitPush(cwd, branch, setUpstream) {
75
+ const args = setUpstream ? ["push", "-u", "origin", branch] : ["push", "origin", branch];
76
+ try {
77
+ execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"], timeout: 60000 });
78
+ return null;
79
+ }
80
+ catch (err) {
81
+ const stderr = err?.stderr?.toString().trim();
82
+ // Git's own error already says what went wrong (auth, rejected, no
83
+ // network); keep the first few lines so the user sees that, not a
84
+ // stack trace.
85
+ const detail = stderr ? stderr.split("\n").slice(0, 4).join("\n") : err.message;
86
+ return `git push failed:\n${detail}`;
87
+ }
88
+ }
20
89
  /** Branch + working-tree status, capped at 30 lines; null outside a repo. */
21
90
  export function gitStatusShort(cwd) {
22
91
  const out = git(cwd, ["status", "--porcelain", "-b"]);
package/dist/git/pr.js ADDED
@@ -0,0 +1,204 @@
1
+ import { execFileSync } from "node:child_process";
2
+ /**
3
+ * Parse `owner/repo` out of the usual origin URL shapes:
4
+ * `https://github.com/owner/repo(.git)`, `git@github.com:owner/repo(.git)`,
5
+ * and `ssh://git@github.com/owner/repo(.git)`. Returns null for anything
6
+ * else — a non-GitHub remote, or a shape we do not recognise, is not
7
+ * something `/pr` can open a pull request against.
8
+ */
9
+ export function parseGitHubRemote(url) {
10
+ const t = url.trim();
11
+ const m = /^https?:\/\/github\.com\/([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/.exec(t) ??
12
+ /^(?:ssh:\/\/)?git@github\.com:([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/.exec(t) ??
13
+ /^ssh:\/\/git@github\.com\/([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/.exec(t);
14
+ if (!m)
15
+ return null;
16
+ return { owner: m[1], repo: m[2] };
17
+ }
18
+ /**
19
+ * Find a token for the GitHub API without asking the user to paste one into
20
+ * chat. `GITHUB_TOKEN` wins when set; otherwise `gh auth token` is tried
21
+ * when the gh CLI is installed and logged in. Returns null when neither
22
+ * works, and the caller tells the user exactly how to provide one.
23
+ */
24
+ export function resolveGitHubToken(env = process.env) {
25
+ const fromEnv = env.GITHUB_TOKEN?.trim();
26
+ if (fromEnv)
27
+ return { token: fromEnv, source: "GITHUB_TOKEN" };
28
+ try {
29
+ const fromGh = execFileSync("gh", ["auth", "token"], {
30
+ stdio: ["ignore", "pipe", "ignore"],
31
+ timeout: 5000,
32
+ })
33
+ .toString()
34
+ .trim();
35
+ if (fromGh)
36
+ return { token: fromGh, source: "gh CLI" };
37
+ }
38
+ catch {
39
+ // gh missing or not logged in — fall through to null.
40
+ }
41
+ return null;
42
+ }
43
+ /**
44
+ * Pull the `/pr` flags out of the command argument. `--title` and `--body`
45
+ * take every word up to the next flag, so titles can contain spaces without
46
+ * quoting and flags can come in any order; `--base` takes one word (or
47
+ * `--base=<branch>`). A bare `/pr` takes no positional argument, so anything
48
+ * else is ignored.
49
+ */
50
+ export function parsePrFlags(arg) {
51
+ const opts = { draft: false };
52
+ const tokens = arg.trim().split(/\s+/).filter(Boolean);
53
+ const isFlag = (t) => t === "--draft" ||
54
+ t === "--title" ||
55
+ t === "--body" ||
56
+ t === "--base" ||
57
+ t.startsWith("--base=");
58
+ let i = 0;
59
+ while (i < tokens.length) {
60
+ const t = tokens[i];
61
+ if (t === "--draft") {
62
+ opts.draft = true;
63
+ i += 1;
64
+ }
65
+ else if (t === "--title" || t === "--body") {
66
+ const words = [];
67
+ i += 1;
68
+ while (i < tokens.length && !isFlag(tokens[i])) {
69
+ words.push(tokens[i]);
70
+ i += 1;
71
+ }
72
+ const value = words.join(" ").trim() || undefined;
73
+ if (t === "--title")
74
+ opts.title = value;
75
+ else
76
+ opts.body = value;
77
+ }
78
+ else if (t === "--base" || t.startsWith("--base=")) {
79
+ const inline = t.startsWith("--base=") ? t.slice("--base=".length).trim() : "";
80
+ const next = tokens[i + 1];
81
+ if (inline) {
82
+ opts.base = inline;
83
+ i += 1;
84
+ }
85
+ else if (next && !isFlag(next)) {
86
+ opts.base = next;
87
+ i += 2;
88
+ }
89
+ else {
90
+ i += 1;
91
+ }
92
+ }
93
+ else {
94
+ i += 1;
95
+ }
96
+ }
97
+ return opts;
98
+ }
99
+ /**
100
+ * The PR title when the user did not pass `--title`. One commit ahead names
101
+ * itself; several get the branch name, since no single commit speaks for the
102
+ * change. An explicit `--title` always wins.
103
+ */
104
+ export function prTitleFor(commits, branch, override) {
105
+ if (override?.trim())
106
+ return override.trim();
107
+ if (commits.length === 1)
108
+ return commits[0].subject || branch;
109
+ return branch;
110
+ }
111
+ /**
112
+ * The PR body when the user did not pass `--body`. One commit contributes
113
+ * its own body (often empty — fine); several become a list of subjects so
114
+ * the PR still says what it contains. An explicit `--body` always wins.
115
+ */
116
+ export function prBodyFor(commits, override) {
117
+ if (override !== undefined)
118
+ return override;
119
+ if (commits.length <= 1)
120
+ return commits[0]?.body || undefined;
121
+ return commits.map((c) => `- ${c.subject}`).join("\n");
122
+ }
123
+ /**
124
+ * Create the pull request via the GitHub REST API. `fetchFn` is injectable
125
+ * so tests can fake the API without network access.
126
+ *
127
+ * A 422 from GitHub usually means a PR for the branch already exists — the
128
+ * caller then looks it up and reports its URL rather than failing.
129
+ */
130
+ export async function createPullRequest(req, fetchFn = fetch) {
131
+ let res;
132
+ try {
133
+ res = await fetchFn(`https://api.github.com/repos/${req.owner}/${req.repo}/pulls`, {
134
+ method: "POST",
135
+ headers: {
136
+ Authorization: `Bearer ${req.token}`,
137
+ Accept: "application/vnd.github+json",
138
+ "Content-Type": "application/json",
139
+ "User-Agent": "kritya",
140
+ },
141
+ body: JSON.stringify({
142
+ title: req.title,
143
+ head: req.head,
144
+ base: req.base,
145
+ body: req.body ?? "",
146
+ draft: req.draft,
147
+ }),
148
+ });
149
+ }
150
+ catch (err) {
151
+ throw new Error(`Could not reach api.github.com: ${err.message}`, {
152
+ cause: err,
153
+ });
154
+ }
155
+ if (res.status === 401) {
156
+ throw new Error("GitHub rejected the token (401). Check that GITHUB_TOKEN is valid, or re-run `gh auth login`.");
157
+ }
158
+ if (res.status === 403) {
159
+ throw new Error("GitHub refused the request (403) — the token may lack repo scope, or the API rate limit was hit.");
160
+ }
161
+ if (res.status === 404) {
162
+ throw new Error(`Repository ${req.owner}/${req.repo} not found (404). The token may not have access to it.`);
163
+ }
164
+ if (res.status === 422) {
165
+ const err = new Error("ALREADY_EXISTS");
166
+ err.alreadyExists = true;
167
+ throw err;
168
+ }
169
+ if (!res.ok) {
170
+ throw new Error(`GitHub API returned ${res.status}.`);
171
+ }
172
+ const data = (await res.json());
173
+ if (typeof data.number !== "number" || typeof data.html_url !== "string") {
174
+ throw new Error("GitHub's response did not include the pull request URL.");
175
+ }
176
+ return { number: data.number, url: data.html_url };
177
+ }
178
+ /**
179
+ * Find an already-open PR from `head` into any base, so `/pr` reports the
180
+ * existing one instead of failing on a duplicate. Returns null when there
181
+ * is none (or the lookup itself fails — absence of evidence either way).
182
+ */
183
+ export async function findOpenPullRequest(owner, repo, head, token, fetchFn = fetch) {
184
+ try {
185
+ const res = await fetchFn(`https://api.github.com/repos/${owner}/${repo}/pulls?head=${owner}:${head}&state=open`, {
186
+ headers: {
187
+ Authorization: `Bearer ${token}`,
188
+ Accept: "application/vnd.github+json",
189
+ "User-Agent": "kritya",
190
+ },
191
+ });
192
+ if (!res.ok)
193
+ return null;
194
+ const data = (await res.json());
195
+ const first = Array.isArray(data) ? data[0] : undefined;
196
+ if (first && typeof first.number === "number" && typeof first.html_url === "string") {
197
+ return { number: first.number, url: first.html_url };
198
+ }
199
+ return null;
200
+ }
201
+ catch {
202
+ return null;
203
+ }
204
+ }
package/dist/headless.js CHANGED
@@ -2,7 +2,7 @@ import path from "node:path";
2
2
  import { Agent } from "./agent/loop.js";
3
3
  import { KillSwitchError } from "./agent/killSwitch.js";
4
4
  import { CONFIG_DIR, legacyGlobalModel, listProviders, loadConfig, loadDotEnv, privacyModeFor, resolveProvider, } from "./config/config.js";
5
- import { DEFAULT_MODEL, contextWindowFor } from "./config/models.js";
5
+ import { defaultModelFor, contextWindowFor } from "./config/models.js";
6
6
  import { PermissionManager } from "./permissions/permissions.js";
7
7
  import { loadRules } from "./permissions/rules.js";
8
8
  import { ProviderClient, RetryExhaustedError, friendlyProviderErrorHint, } from "./provider/client.js";
@@ -125,7 +125,7 @@ export async function runHeadless(args) {
125
125
  });
126
126
  }
127
127
  const providerDefaultModel = config.providers?.[provider.name]?.model;
128
- const model = resolveEffectiveModel(provider.name, [args.model, providerDefaultModel, legacyGlobalModel(config, provider.name)], provider.name === "switchyard" ? SWITCHYARD_ROUTE_ID : DEFAULT_MODEL);
128
+ const model = resolveEffectiveModel(provider.name, [args.model, providerDefaultModel, legacyGlobalModel(config, provider.name)], provider.name === "switchyard" ? SWITCHYARD_ROUTE_ID : defaultModelFor(provider.name));
129
129
  const staleModelWarning = staleSwitchyardModelWarning(provider.name, model, args.model);
130
130
  if (staleModelWarning)
131
131
  process.stderr.write(`${staleModelWarning}\n`);
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import { render } from "ink";
6
6
  import { Agent } from "./agent/loop.js";
7
7
  import { CONFIG_DIR, appendGlobalEnvVar, legacyGlobalModel, loadConfig, loadDotEnv, privacyModeFor, resolveProvider, } from "./config/config.js";
8
- import { DEFAULT_MODEL, contextWindowFor } from "./config/models.js";
8
+ import { defaultModelFor, contextWindowFor } from "./config/models.js";
9
9
  import { PermissionManager } from "./permissions/permissions.js";
10
10
  import { loadRules } from "./permissions/rules.js";
11
11
  import { ProviderClient } from "./provider/client.js";
@@ -404,7 +404,7 @@ async function main() {
404
404
  }
405
405
  const providerDefaultModel = config.providers?.[provider.name]?.model;
406
406
  const modelRef = {
407
- current: resolveEffectiveModel(provider.name, [args.model, providerDefaultModel, legacyGlobalModel(config, provider.name)], provider.name === "switchyard" ? SWITCHYARD_ROUTE_ID : DEFAULT_MODEL),
407
+ current: resolveEffectiveModel(provider.name, [args.model, providerDefaultModel, legacyGlobalModel(config, provider.name)], provider.name === "switchyard" ? SWITCHYARD_ROUTE_ID : defaultModelFor(provider.name)),
408
408
  };
409
409
  const staleModelWarning = staleSwitchyardModelWarning(provider.name, modelRef.current, args.model);
410
410
  if (staleModelWarning)
package/dist/ui/App.js CHANGED
@@ -721,7 +721,7 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
721
721
  }
722
722
  addItem({ kind: "user", text: `${text} (queued)` });
723
723
  void expandMentions(text).then((t) => agent.queueSteer(t));
724
- }, 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) => {
724
+ }, 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, provider: provider, customModels: config.customModels ?? [], onSelect: (id) => {
725
725
  setPhase("input");
726
726
  setModelEverywhere(id);
727
727
  }, 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 ?? [])
@@ -1,13 +1,26 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
- import { CURATED_MODELS } from "../config/models.js";
3
+ import { CURATED_MODELS, providerOfModel } from "../config/models.js";
4
4
  import { SelectList } from "./SelectList.js";
5
- export function ModelPicker({ current, customModels, onSelect, onCancel, }) {
5
+ /** Provider label for a hint, e.g. `groq` — what the user would pass to `-p`. */
6
+ function providerHint(m) {
7
+ return providerOfModel(m);
8
+ }
9
+ export function ModelPicker({ current, provider, customModels, onSelect, onCancel, }) {
10
+ // Show the active provider's models first and unlabelled, then any other
11
+ // providers' under a `provider/` prefix — so a Groq user sees Groq models
12
+ // at the top, and an NVIDIA user still sees the rest without the picker
13
+ // silently hiding them or lying about which provider they belong to.
14
+ const own = CURATED_MODELS.filter((m) => providerOfModel(m) === provider);
15
+ const others = CURATED_MODELS.filter((m) => providerOfModel(m) !== provider);
16
+ // Fall back to the whole list when the active provider has no curated
17
+ // models, so the picker is never empty.
18
+ const listed = own.length > 0 ? [...own, ...others] : CURATED_MODELS;
6
19
  const items = [
7
- ...CURATED_MODELS.map((m) => ({
20
+ ...listed.map((m) => ({
8
21
  label: m.label + (m.id === current ? " (current)" : ""),
9
22
  value: m.id,
10
- hint: m.note ? `${m.id} · ${m.note}` : m.id,
23
+ hint: `${m.id} · ${providerHint(m)}${m.note ? ` · ${m.note}` : ""}`,
11
24
  })),
12
25
  ...customModels.map((m) => ({
13
26
  label: (m.label ?? m.id) + (m.id === current ? " (current)" : ""),
@@ -15,5 +28,5 @@ export function ModelPicker({ current, customModels, onSelect, onCancel, }) {
15
28
  hint: `${m.id} · custom`,
16
29
  })),
17
30
  ];
18
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "magenta", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "magenta", children: ["Select model", " ", _jsx(Text, { dimColor: true, children: "(Esc to cancel, or /model <id> for any NVIDIA model)" })] }), _jsx(SelectList, { items: items, onSelect: onSelect, onCancel: onCancel })] }));
31
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "magenta", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "magenta", children: ["Select model", " ", _jsxs(Text, { dimColor: true, children: ["(Esc to cancel, or /model <id> for any ", provider, " model)"] })] }), _jsx(SelectList, { items: items, onSelect: onSelect, onCancel: onCancel })] }));
19
32
  }
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import path from "node:path";
3
3
  import { gitBranch } from "../git/git.js";
4
4
  import { listProviders, resolveProvider, saveConfig, saveProviderModel, } from "../config/config.js";
5
- import { DEFAULT_MODEL, contextWindowFor, displayModelId } from "../config/models.js";
5
+ import { defaultModelFor, contextWindowFor, displayModelId } from "../config/models.js";
6
6
  import { ProviderClient, RetryExhaustedError, friendlyProviderErrorHint, } from "../provider/client.js";
7
7
  import { createSwitchyardClient } from "../provider/switchyardClient.js";
8
8
  import { SWITCHYARD_ROUTE_ID, resolveEffectiveModel } from "../provider/switchyardSidecar.js";
@@ -382,7 +382,7 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
382
382
  providerDefaultModel,
383
383
  name === "switchyard" ? SWITCHYARD_ROUTE_ID : undefined,
384
384
  modelRef.current,
385
- ], name === "switchyard" ? SWITCHYARD_ROUTE_ID : DEFAULT_MODEL);
385
+ ], name === "switchyard" ? SWITCHYARD_ROUTE_ID : defaultModelFor(name));
386
386
  let note = `Switched provider to ${name} — conversation history kept.`;
387
387
  if (targetModel !== modelRef.current) {
388
388
  setModelEverywhere(targetModel);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kritya",
3
- "version": "0.8.48-beta",
3
+ "version": "0.8.50-beta",
4
4
  "description": "Kritya — Superintelligence at your command line",
5
5
  "type": "module",
6
6
  "publishConfig": {