kritya 0.8.49-beta → 0.8.51-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 +3 -0
- package/dist/agent/compactor.js +25 -2
- package/dist/agent/loop.js +25 -4
- package/dist/commands/registry.js +169 -0
- package/dist/commands/templates.js +77 -0
- package/dist/config/models.js +6 -3
- package/dist/git/git.js +69 -0
- package/dist/git/pr.js +204 -0
- package/package.json +1 -1
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 |
|
package/dist/agent/compactor.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/agent/loop.js
CHANGED
|
@@ -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:
|
|
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 });
|
|
@@ -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
|
+
}
|
package/dist/config/models.js
CHANGED
|
@@ -68,7 +68,9 @@ export const CURATED_MODELS = [
|
|
|
68
68
|
contextWindow: 1_048_576,
|
|
69
69
|
},
|
|
70
70
|
// Groq (https://console.groq.com/docs/models). Context windows verified
|
|
71
|
-
// against the docs table; both are 131,072.
|
|
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.
|
|
72
74
|
{
|
|
73
75
|
id: "openai/gpt-oss-120b",
|
|
74
76
|
label: "GPT-OSS 120B",
|
|
@@ -77,9 +79,10 @@ export const CURATED_MODELS = [
|
|
|
77
79
|
contextWindow: 131_072,
|
|
78
80
|
},
|
|
79
81
|
{
|
|
80
|
-
id: "
|
|
81
|
-
label: "
|
|
82
|
+
id: "openai/gpt-oss-20b",
|
|
83
|
+
label: "GPT-OSS 20B",
|
|
82
84
|
provider: "groq",
|
|
85
|
+
note: "fast + cheap",
|
|
83
86
|
contextWindow: 131_072,
|
|
84
87
|
},
|
|
85
88
|
];
|
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
|
+
}
|