@vincemakes/kiso-web 0.52.0 → 0.53.0
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/dist/main/agent.js +5 -3
- package/dist/main/artifacts.js +66 -12
- package/dist/main/context.js +20 -0
- package/dist/main/desk-api.js +126 -7
- package/dist/main/env.js +2 -0
- package/dist/main/fork.js +25 -0
- package/dist/main/fs-routes.js +36 -4
- package/dist/main/git.js +117 -0
- package/dist/main/history.js +33 -6
- package/dist/main/host.js +3 -1
- package/dist/main/names.js +15 -0
- package/dist/main/settings.js +75 -8
- package/dist/main/space.js +44 -10
- package/dist/main/tasks.js +4 -0
- package/dist/main/usage.js +19 -0
- package/dist/main/works.js +21 -0
- package/dist/shared/git.js +2 -0
- package/out/renderer/assets/index-C3fCoBdq.css +1 -0
- package/out/renderer/assets/index-DiDTmq9b.js +45 -0
- package/out/renderer/favicon-32.png +0 -0
- package/out/renderer/favicon.svg +1 -0
- package/out/renderer/index.html +5 -3
- package/package.json +10 -10
- package/dist/shared/thinking.js +0 -65
- package/out/renderer/assets/index-CmrQEQ98.js +0 -44
- package/out/renderer/assets/index-D9KtogVK.css +0 -1
package/dist/main/agent.js
CHANGED
|
@@ -27,6 +27,10 @@ class WebAgent extends Agent {
|
|
|
27
27
|
return session;
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
|
+
/** The system prompt a Work's agent is built with: the terminal's prompt for the folder (its project instructions included), and the tier's note. */
|
|
31
|
+
export function promptOf(workspace, tier) {
|
|
32
|
+
return [composeSystemPrompt(workspace, protectedFiles()), modeSystemPrompt(tier)].join("\n\n");
|
|
33
|
+
}
|
|
30
34
|
export function createWebAgent(options) {
|
|
31
35
|
const { store, workspace, model } = options;
|
|
32
36
|
const root = () => workspace;
|
|
@@ -40,13 +44,11 @@ export function createWebAgent(options) {
|
|
|
40
44
|
...(options.toolLimits !== undefined ? { limits: options.toolLimits } : {}),
|
|
41
45
|
};
|
|
42
46
|
const window = model.kind === "profile" ? model.contextWindow : undefined;
|
|
43
|
-
const prompt = composeSystemPrompt(workspace, protectedFiles());
|
|
44
|
-
const modeNote = modeSystemPrompt(options.tier());
|
|
45
47
|
const definition = {
|
|
46
48
|
model: model.kind === "adapter" ? model.model : model.profile.model,
|
|
47
49
|
store,
|
|
48
50
|
workspace,
|
|
49
|
-
systemPrompt:
|
|
51
|
+
systemPrompt: promptOf(workspace, options.tier()),
|
|
50
52
|
toolRules: CODING_TOOL_RULES,
|
|
51
53
|
tools: [...createCodingTools(toolOptions)],
|
|
52
54
|
// the terminal's chain, in its order: the guards that deny speak first
|
package/dist/main/artifacts.js
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
|
-
import { closeSync, fstatSync, openSync, readSync, statSync } from "node:fs";
|
|
2
|
-
import { extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
1
|
+
import { closeSync, copyFileSync, existsSync, fstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, statSync } from "node:fs";
|
|
2
|
+
import { basename, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
3
|
import { resolveInside } from "./files.js";
|
|
4
4
|
/**
|
|
5
5
|
* What a tool call made that the page can show, each "best effort, never a guess": an image block of its result; an artifact
|
|
6
6
|
* file a successful write or edit touched; or, for a command, a path its text or its output head names that is inside the
|
|
7
7
|
* folder, of an artifact type, exists, and changed at or after the step began. No folder scan, no watcher.
|
|
8
|
+
*
|
|
9
|
+
* A picture a command made OUTSIDE the folder (a browser tool saving a screenshot under /tmp: owner 2026-11-03, the screenshot
|
|
10
|
+
* must show in the stream) is kept as a copy in the Work's own `<id>.files/`, under the result's `log:` address: the page only
|
|
11
|
+
* ever reads the copy, never the outside path. Only a raster picture (no svg: it can carry script), named by the command or its
|
|
12
|
+
* output, changed during the step, at most KEEP_MAX bytes; it is copied once, so a later file of the same name does not replace
|
|
13
|
+
* it. This widens the 2026-10-31 rule "a file named outside the folder is not taken" for pictures only.
|
|
8
14
|
*/
|
|
9
15
|
const KINDS = {
|
|
10
16
|
".png": "image", ".jpg": "image", ".jpeg": "image", ".gif": "image", ".webp": "image", ".svg": "image",
|
|
@@ -14,23 +20,57 @@ const KINDS = {
|
|
|
14
20
|
const NAMED = /[\w@+./-]+\.(?:png|jpe?g|gif|webp|svg|html?|md|pdf|csv|mp4|webm|mov|mp3|wav)\b/gi;
|
|
15
21
|
/** a file's clock and the log's may differ by the file system's granularity */
|
|
16
22
|
const SLACK_MS = 50;
|
|
23
|
+
const KEPT = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" };
|
|
24
|
+
const KEEP_MAX = 10 * 1024 * 1024;
|
|
25
|
+
/** where a kept copy lives: the Work's own folder of files beside its log */
|
|
26
|
+
const keptDir = (dir, workId) => join(dir, `${workId}.files`);
|
|
17
27
|
export const artifactKind = (path) => KINDS[extname(path).toLowerCase()];
|
|
18
|
-
/** `root`: the folder's real path. */
|
|
19
|
-
export function artifactsOf(event, workId, call, root) {
|
|
28
|
+
/** `root`: the folder's real path; `dir`: the sessions directory, where a picture made outside the folder is kept. */
|
|
29
|
+
export function artifactsOf(event, workId, call, root, dir) {
|
|
20
30
|
const out = [];
|
|
21
|
-
const ref = (n, kind, uri, mediaType) => {
|
|
22
|
-
out.push({ id: `${event.callId}:${n}`, workId, callId: event.callId, kind, uri, ...(mediaType !== undefined ? { mediaType } : {}) });
|
|
31
|
+
const ref = (n, kind, uri, mediaType, name) => {
|
|
32
|
+
out.push({ id: `${event.callId}:${n}`, workId, callId: event.callId, kind, uri, ...(mediaType !== undefined ? { mediaType } : {}), ...(name !== undefined ? { name } : {}) });
|
|
23
33
|
};
|
|
24
|
-
if (Array.isArray(event.content))
|
|
34
|
+
if (Array.isArray(event.content)) {
|
|
35
|
+
const blocks = event.content.filter((b) => b.type === "image").length;
|
|
36
|
+
let k = 0;
|
|
25
37
|
event.content.forEach((b, n) => {
|
|
26
|
-
if (b.type
|
|
27
|
-
|
|
38
|
+
if (b.type !== "image" || !((b.sourceType === "base64" && b.data !== undefined) || b.url?.startsWith("https:") === true))
|
|
39
|
+
return;
|
|
40
|
+
// an image block has no file name: the tool, the result's number and, of several, its place
|
|
41
|
+
const ext = (b.mediaType ?? "image/png").split("/")[1]?.replace("jpeg", "jpg") ?? "png";
|
|
42
|
+
ref(n, "image", `log:${event.seq}/${n}`, b.mediaType, `${call?.name ?? "image"}-${event.seq}${blocks > 1 ? `-${(k += 1)}` : ""}.${ext}`);
|
|
28
43
|
});
|
|
44
|
+
}
|
|
29
45
|
if (event.isError || call === null)
|
|
30
46
|
return out;
|
|
47
|
+
let kept = 0;
|
|
48
|
+
const outside = (full, since) => {
|
|
49
|
+
const ext = extname(full).toLowerCase();
|
|
50
|
+
if (KEPT[ext] === undefined || out.some((a) => a.id === `${event.callId}:${full}`))
|
|
51
|
+
return;
|
|
52
|
+
const name = `${event.seq}-k${kept}${ext}`;
|
|
53
|
+
const copy = join(keptDir(dir, workId), name);
|
|
54
|
+
try {
|
|
55
|
+
if (!existsSync(copy)) {
|
|
56
|
+
const stat = statSync(full);
|
|
57
|
+
if (!stat.isFile() || stat.mtimeMs < since - SLACK_MS || stat.size > KEEP_MAX)
|
|
58
|
+
return;
|
|
59
|
+
mkdirSync(keptDir(dir, workId), { recursive: true });
|
|
60
|
+
copyFileSync(full, copy);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
ref(full, "image", `log:${event.seq}/k${kept}`, KEPT[ext], basename(full));
|
|
67
|
+
kept += 1;
|
|
68
|
+
};
|
|
31
69
|
const file = (raw, since) => {
|
|
32
70
|
const rel = relative(root, isAbsolute(raw) ? raw : resolve(root, raw));
|
|
33
71
|
const kind = artifactKind(rel);
|
|
72
|
+
if (rel.startsWith("..") && isAbsolute(raw) && since !== null)
|
|
73
|
+
return outside(raw, since);
|
|
34
74
|
if (kind === undefined || rel.startsWith("..") || out.some((a) => a.uri === `file:${rel}`))
|
|
35
75
|
return;
|
|
36
76
|
try {
|
|
@@ -53,7 +93,7 @@ export function artifactsOf(event, workId, call, root) {
|
|
|
53
93
|
return out;
|
|
54
94
|
}
|
|
55
95
|
/**
|
|
56
|
-
* Image blocks of results, read from the session's log: an index of seq to the byte range of its line, built once per log and
|
|
96
|
+
* Image blocks of results and of the person's own lines, read from the session's log: an index of seq to the byte range of its line, built once per log and
|
|
57
97
|
* extended as it grows, so a request reads one event, never the whole log.
|
|
58
98
|
*/
|
|
59
99
|
export class LogImages {
|
|
@@ -62,14 +102,28 @@ export class LogImages {
|
|
|
62
102
|
constructor(dir) {
|
|
63
103
|
this.#dir = dir;
|
|
64
104
|
}
|
|
65
|
-
/** The n-th block of the result at `seq`: its bytes, an https address to pass on, or null
|
|
105
|
+
/** The n-th block of the result or the line at `seq`: its bytes, an https address to pass on, or null; `k<n>`: the n-th
|
|
106
|
+
* picture of that result kept from outside the folder. */
|
|
66
107
|
block(sessionId, seq, n) {
|
|
108
|
+
if (typeof n === "string") {
|
|
109
|
+
const name = (() => {
|
|
110
|
+
try {
|
|
111
|
+
return readdirSync(keptDir(this.#dir, sessionId)).find((f) => f.startsWith(`${seq}-${n}.`));
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
})();
|
|
117
|
+
const type = name === undefined ? undefined : KEPT[extname(name)];
|
|
118
|
+
return name === undefined || type === undefined ? null : { type, bytes: readFileSync(join(keptDir(this.#dir, sessionId), name)) };
|
|
119
|
+
}
|
|
67
120
|
const file = join(this.#dir, `${sessionId}.jsonl`);
|
|
68
121
|
const at = this.#index(file).get(seq);
|
|
69
122
|
if (at === undefined)
|
|
70
123
|
return null;
|
|
124
|
+
// a tool's result, or the person's own line (a picture pasted into the composer)
|
|
71
125
|
const event = JSON.parse(read(file, at[0], at[1]).toString("utf8")).event;
|
|
72
|
-
const b = event.type === "tool_result" && Array.isArray(event.content) ? event.content[n] : undefined;
|
|
126
|
+
const b = (event.type === "tool_result" || event.type === "user_input") && Array.isArray(event.content) ? event.content[n] : undefined;
|
|
73
127
|
if (b?.type !== "image")
|
|
74
128
|
return null;
|
|
75
129
|
if (b.sourceType === "base64" && b.data !== undefined)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { kisoHome } from "@vincemakes/kiso-runtime";
|
|
2
|
+
import { tiersFor } from "@vincemakes/kiso-runtime/internal";
|
|
3
|
+
import { CODING_TOOL_RULES, composeSystemPrompt, SYSTEM_PROMPT } from "@vincemakes/kiso-code/policy";
|
|
4
|
+
import { createCodingTools } from "@vincemakes/kiso-tools-node";
|
|
5
|
+
import { promptOf, protectedFiles } from "./agent.js";
|
|
6
|
+
/** What the Work's requests always carry, in bytes, for the rail's Context breakdown (an estimate the page labels as one), and where
|
|
7
|
+
* compaction starts and must happen for the stated window (the runtime's own tiers; null without a stated window). */
|
|
8
|
+
export function contextParts(workspace, tier, window) {
|
|
9
|
+
const bytes = (s) => Buffer.byteLength(s, "utf8");
|
|
10
|
+
const prompt = promptOf(workspace, tier);
|
|
11
|
+
const instructions = Math.max(0, bytes(composeSystemPrompt(workspace, protectedFiles())) - bytes(SYSTEM_PROMPT));
|
|
12
|
+
const tools = createCodingTools({ workspaceRoot: workspace, excludeRoots: [kisoHome()], secretEnvNames: [], protectedFiles: protectedFiles() });
|
|
13
|
+
const tiers = window === null ? null : tiersFor(window, 0);
|
|
14
|
+
return {
|
|
15
|
+
system: bytes(prompt) - instructions + CODING_TOOL_RULES.reduce((n, r) => n + bytes(r.line) + 1, 0),
|
|
16
|
+
instructions,
|
|
17
|
+
tools: { count: tools.length, bytes: tools.reduce((n, t) => n + bytes(JSON.stringify({ name: t.name, description: t.description, parameters: t.parameters })), 0) },
|
|
18
|
+
compaction: tiers === null ? null : { starts: tiers.soft, must: tiers.hard },
|
|
19
|
+
};
|
|
20
|
+
}
|
package/dist/main/desk-api.js
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { realpathSync, statSync } from "node:fs";
|
|
2
2
|
import { basename } from "node:path";
|
|
3
3
|
import { canonicalPath, claimProjectDir, projectDirFor } from "@vincemakes/kiso-code/config";
|
|
4
|
-
import { deskRead } from "./fs-routes.js";
|
|
4
|
+
import { atFiles, deskRead, SAFE_ID } from "./fs-routes.js";
|
|
5
|
+
import { contextParts } from "./context.js";
|
|
6
|
+
import { rename } from "./names.js";
|
|
7
|
+
import { forkWork } from "./fork.js";
|
|
8
|
+
import { WORK_ACTS, workAct } from "./works.js";
|
|
9
|
+
import { commit, gitOf, openPr, prDraft } from "./git.js";
|
|
5
10
|
import { existing, Refusal, resolveInside } from "./files.js";
|
|
6
11
|
import { artifactKind } from "./artifacts.js";
|
|
7
12
|
import { listDirs, validateFolder } from "./folders.js";
|
|
@@ -32,6 +37,88 @@ export async function deskApi(ctx, req, url) {
|
|
|
32
37
|
const space = where === null || where === "" ? ctx.current : ctx.spaceFor(where);
|
|
33
38
|
const post = req.method === "POST";
|
|
34
39
|
try {
|
|
40
|
+
if (path === "/desk/at-files") {
|
|
41
|
+
if (space === null)
|
|
42
|
+
return json(404, {});
|
|
43
|
+
return req.method === "GET" ? json(200, atFiles(space)) : json(405, {});
|
|
44
|
+
}
|
|
45
|
+
if (path === "/desk/context") {
|
|
46
|
+
if (space === null)
|
|
47
|
+
return json(404, {});
|
|
48
|
+
const session = url.searchParams.get("session") ?? undefined;
|
|
49
|
+
let window = null;
|
|
50
|
+
try {
|
|
51
|
+
const m = ctx.settings.model(session === undefined ? undefined : { sessionId: session, sessionsDir: space.dir });
|
|
52
|
+
window = m.kind === "profile" ? (m.contextWindow ?? null) : null;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// no model: the parts are still known, the compaction sizes are not
|
|
56
|
+
}
|
|
57
|
+
return req.method === "GET" ? json(200, contextParts(space.workspace, ctx.settings.tierOf(session), window)) : json(405, {});
|
|
58
|
+
}
|
|
59
|
+
if (path === "/desk/git") {
|
|
60
|
+
if (space === null)
|
|
61
|
+
return json(404, {});
|
|
62
|
+
return req.method === "GET" ? json(200, url.searchParams.has("draft") ? await prDraft(space.workspace) : await gitOf(space.workspace)) : json(405, {});
|
|
63
|
+
}
|
|
64
|
+
if (path === "/desk/commit" || path === "/desk/pr") {
|
|
65
|
+
if (!post)
|
|
66
|
+
return json(405, {});
|
|
67
|
+
if (space === null)
|
|
68
|
+
return json(404, {});
|
|
69
|
+
const body = await readJson(req);
|
|
70
|
+
if (path === "/desk/commit")
|
|
71
|
+
return json(200, await commit(space.workspace, str(body["message"]) ?? "", Array.isArray(body["paths"]) ? body["paths"].filter((p) => typeof p === "string") : []));
|
|
72
|
+
return json(200, await openPr(space.workspace, str(body["title"]) ?? "", str(body["body"]) ?? ""));
|
|
73
|
+
}
|
|
74
|
+
if (path === "/desk/rename") {
|
|
75
|
+
if (!post)
|
|
76
|
+
return json(405, {});
|
|
77
|
+
if (space === null)
|
|
78
|
+
return json(404, {});
|
|
79
|
+
const body = await readJson(req);
|
|
80
|
+
const session = str(body["session"]) ?? "";
|
|
81
|
+
if (!SAFE_ID.test(session) || !space.store.ids().includes(session))
|
|
82
|
+
return json(404, {});
|
|
83
|
+
return json(200, { name: rename(space.dir, session, str(body["name"]) ?? "") });
|
|
84
|
+
}
|
|
85
|
+
if (path === "/desk/fork") {
|
|
86
|
+
if (!post)
|
|
87
|
+
return json(405, {});
|
|
88
|
+
if (space === null)
|
|
89
|
+
return json(404, {});
|
|
90
|
+
const body = await readJson(req);
|
|
91
|
+
const session = str(body["session"]) ?? "";
|
|
92
|
+
if (!SAFE_ID.test(session))
|
|
93
|
+
return json(404, {});
|
|
94
|
+
const atSeq = typeof body["atSeq"] === "number" ? body["atSeq"] : undefined;
|
|
95
|
+
return json(200, { id: forkWork(space, ctx.settings, session, atSeq).id });
|
|
96
|
+
}
|
|
97
|
+
if (path === "/desk/work") {
|
|
98
|
+
if (!post)
|
|
99
|
+
return json(405, {});
|
|
100
|
+
if (space === null)
|
|
101
|
+
return json(404, {});
|
|
102
|
+
const body = await readJson(req);
|
|
103
|
+
const session = str(body["session"]) ?? "";
|
|
104
|
+
const act = str(body["act"]) ?? "";
|
|
105
|
+
if (!SAFE_ID.test(session) || !WORK_ACTS.includes(act))
|
|
106
|
+
return json(404, {});
|
|
107
|
+
workAct(space, ctx.settings, session, act);
|
|
108
|
+
return json(200, { ok: true });
|
|
109
|
+
}
|
|
110
|
+
if (path === "/desk/task-stop") {
|
|
111
|
+
if (!post)
|
|
112
|
+
return json(405, {});
|
|
113
|
+
if (space === null)
|
|
114
|
+
return json(404, {});
|
|
115
|
+
const body = await readJson(req);
|
|
116
|
+
const session = str(body["session"]) ?? "";
|
|
117
|
+
const task = str(body["task"]) ?? "";
|
|
118
|
+
if (!SAFE_ID.test(session) || !SAFE_ID.test(task) || !space.store.ids().includes(session))
|
|
119
|
+
return json(404, {});
|
|
120
|
+
return json(200, { stopped: space.tasks.managerFor(session).stop(task, "person") });
|
|
121
|
+
}
|
|
35
122
|
if (path === "/desk/fs/tree" || path === "/desk/fs/file" || path === "/desk/task-log") {
|
|
36
123
|
if (space === null)
|
|
37
124
|
return json(404, {});
|
|
@@ -43,11 +130,11 @@ export async function deskApi(ctx, req, url) {
|
|
|
43
130
|
return post ? json(200, { found: existing(realpathSync(space.workspace), (await readJson(req))["paths"]) }) : json(405, {});
|
|
44
131
|
}
|
|
45
132
|
// an image block of a result, from the Work's log: its bytes, or an https address passed on
|
|
46
|
-
const image = /^\/desk\/image\/([A-Za-z0-9._-]+)\/(\d+)\/(\d+)$/.exec(path);
|
|
133
|
+
const image = /^\/desk\/image\/([A-Za-z0-9._-]+)\/(\d+)\/(\d+|k\d+)$/.exec(path);
|
|
47
134
|
if (image !== null) {
|
|
48
135
|
if (space === null || req.method !== "GET" || !space.store.ids().includes(image[1]))
|
|
49
136
|
return json(404, {});
|
|
50
|
-
const b = space.images.block(image[1], Number(image[2]), Number(image[3]));
|
|
137
|
+
const b = space.images.block(image[1], Number(image[2]), image[3].startsWith("k") ? image[3] : Number(image[3]));
|
|
51
138
|
if (b === null)
|
|
52
139
|
return json(404, {});
|
|
53
140
|
return "url" in b ? { status: 302, body: null, headers: { location: b.url } } : { status: 200, body: b.bytes, headers: { "content-type": b.type, "x-content-type-options": "nosniff", "cache-control": "private, max-age=86400" } };
|
|
@@ -66,19 +153,51 @@ export async function deskApi(ctx, req, url) {
|
|
|
66
153
|
ctx.previews.close(String((await readJson(req))["url"] ?? ""));
|
|
67
154
|
return json(200, { ok: true });
|
|
68
155
|
}
|
|
156
|
+
// the line the person may send next after the run that ended at `seq` (asked once, after that run; "" for none)
|
|
157
|
+
if (path === "/desk/followup") {
|
|
158
|
+
if (space === null)
|
|
159
|
+
return json(404, {});
|
|
160
|
+
if (req.method !== "GET")
|
|
161
|
+
return json(405, {});
|
|
162
|
+
const session = url.searchParams.get("session") ?? "";
|
|
163
|
+
const seq = Number(url.searchParams.get("seq"));
|
|
164
|
+
if (!space.store.ids().includes(session) || !Number.isInteger(seq))
|
|
165
|
+
return json(404, {});
|
|
166
|
+
return json(200, { line: await space.followUp(session, seq) });
|
|
167
|
+
}
|
|
69
168
|
if (path === "/desk/env") {
|
|
70
169
|
if (space === null)
|
|
71
170
|
return json(404, {});
|
|
72
|
-
|
|
171
|
+
if (req.method !== "GET")
|
|
172
|
+
return json(405, {});
|
|
173
|
+
const session = url.searchParams.get("session") ?? undefined;
|
|
174
|
+
const fork = session === undefined ? undefined : ctx.settings.forkOf(session);
|
|
175
|
+
return json(200, { ...folderInfo(space.workspace, ctx.settings.tierOf(session)), ...(fork === undefined ? {} : { fork: { parent: fork.parent, title: fork.title, at: fork.at } }) });
|
|
73
176
|
}
|
|
74
177
|
if (path === "/desk/history")
|
|
75
178
|
return req.method === "GET" ? json(200, await ctx.history.snapshot({ key: ctx.current.key, dir: ctx.current.dir }, url.searchParams.get("fresh") ?? undefined)) : json(405, {});
|
|
76
179
|
if (path === "/desk/settings") {
|
|
77
|
-
|
|
78
|
-
|
|
180
|
+
let session = url.searchParams.get("session") ?? undefined;
|
|
181
|
+
if (post) {
|
|
182
|
+
const patch = (await readJson(req));
|
|
183
|
+
// a patch for one Work is answered with that Work's settings
|
|
184
|
+
session ??= patch.session;
|
|
185
|
+
const before = patch.session === undefined ? undefined : ctx.settings.workOf(patch.session);
|
|
186
|
+
const work = ctx.settings.apply(patch);
|
|
187
|
+
// a Work's model changed: rebuild it from its log so its next run uses it; a run in flight refuses, and the choice is put back
|
|
188
|
+
if (work !== null && space !== null && space.opened().has(work)) {
|
|
189
|
+
try {
|
|
190
|
+
await space.service.reopen(work);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
ctx.settings.restoreWork(work, before);
|
|
194
|
+
return json(409, { message: "a run is going: switch the model when it ends" });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
79
198
|
else if (req.method !== "GET")
|
|
80
199
|
return json(405, {});
|
|
81
|
-
return json(200, ctx.settings.snapshot(
|
|
200
|
+
return json(200, ctx.settings.snapshot(session));
|
|
82
201
|
}
|
|
83
202
|
if (path === "/desk/folder" && post) {
|
|
84
203
|
const workspace = validateFolder((await readJson(req))["path"]);
|
package/dist/main/env.js
CHANGED
|
@@ -96,6 +96,8 @@ export async function startFromEnv(options = {}) {
|
|
|
96
96
|
...(options.port !== undefined ? { port: options.port } : {}),
|
|
97
97
|
...(process.env["KISO_WEB_TOKEN"] !== undefined ? { token: process.env["KISO_WEB_TOKEN"] } : {}),
|
|
98
98
|
sanitizer: process.env["KISO_WEB_SANITIZER"] === "default" ? "default" : "web",
|
|
99
|
+
followUps: process.env["KISO_WEB_FOLLOWUP"] !== "off",
|
|
100
|
+
titles: process.env["KISO_AUTO_TITLE"] !== "off",
|
|
99
101
|
...(Number.isFinite(keepalive) && keepalive > 0 ? { keepaliveMs: keepalive } : {}),
|
|
100
102
|
...(process.env["KISO_WEB_MIN_FG_MS"] !== undefined && Number.isFinite(minFg) ? { toolLimits: { minForegroundMs: minFg } } : {}),
|
|
101
103
|
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { ForkRefusedError } from "@vincemakes/kiso-runtime";
|
|
2
|
+
import { Refusal } from "./files.js";
|
|
3
|
+
/**
|
|
4
|
+
* Fork a Work: the runtime's SessionStore.fork (one rule for the terminal, the web app and the products, plan fork rev 2),
|
|
5
|
+
* after the run that ended at `atSeq` (absent: the last). The runtime copies the log to that run's end, writes the fork's
|
|
6
|
+
* sidecar (the parent's profile, "… (fork)", its origin) and gives the fork note to the model with the fork's first input,
|
|
7
|
+
* after the copied history: never in the system prompt, where it cost the fork's first request the whole history's cache
|
|
8
|
+
* (0% against 88% measured on co). The host still remembers the lineage for the forks it made before.
|
|
9
|
+
*/
|
|
10
|
+
export function forkWork(space, settings, parent, atSeq) {
|
|
11
|
+
if (!space.store.ids().includes(parent))
|
|
12
|
+
throw new Refusal(404, "no such Work in this folder");
|
|
13
|
+
let made;
|
|
14
|
+
try {
|
|
15
|
+
made = space.store.fork(parent, atSeq === undefined ? {} : { afterSeq: atSeq });
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
if (error instanceof ForkRefusedError)
|
|
19
|
+
throw new Refusal(409, error.message.replace(/session/g, "Work"));
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
const fork = { parent, title: made.origin.title, atSeq: made.origin.afterSeq, at: made.origin.at };
|
|
23
|
+
settings.forked(parent, made.id, fork);
|
|
24
|
+
return { id: made.id, fork };
|
|
25
|
+
}
|
package/dist/main/fs-routes.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { readdirSync, realpathSync } from "node:fs";
|
|
2
3
|
import { listDir, readPreview, readTaskLog, Refusal } from "./files.js";
|
|
3
4
|
/**
|
|
4
|
-
* The read doors of the page: a Work's folder (a tree, a file
|
|
5
|
-
* background task's log. GET only, behind the host's guard; every path is
|
|
5
|
+
* The read doors of the page: a Work's folder (a tree, a file, the list the
|
|
6
|
+
* composer's @ picks from) and a background task's log. GET only, behind the host's guard; every path is
|
|
6
7
|
* judged by files.ts, a task id only by the task manager of a session that
|
|
7
8
|
* exists in the Work's folder.
|
|
8
9
|
*/
|
|
9
|
-
const SAFE_ID = /^[A-Za-z0-9._-]+$/;
|
|
10
|
+
export const SAFE_ID = /^[A-Za-z0-9._-]+$/;
|
|
10
11
|
export function deskRead(url, space) {
|
|
11
12
|
try {
|
|
12
13
|
const rel = url.searchParams.get("path") ?? "";
|
|
@@ -35,3 +36,34 @@ export function deskRead(url, space) {
|
|
|
35
36
|
return { status: 500, body: { code: "internal", message: "unreadable" } };
|
|
36
37
|
}
|
|
37
38
|
}
|
|
39
|
+
const AT_CAP = 2000;
|
|
40
|
+
const AT_SKIP = new Set([".git", "node_modules", ".kiso", "dist", "build", ".next", ".venv", "__pycache__"]);
|
|
41
|
+
/** The files `@` can name, as the terminal lists them: git's own list (tracked and untracked, ignores honoured), else a bounded walk; `truncated` when the cap cut it. */
|
|
42
|
+
export function atFiles(space) {
|
|
43
|
+
let files;
|
|
44
|
+
try {
|
|
45
|
+
files = execFileSync("git", ["ls-files", "-c", "-o", "--exclude-standard"], { cwd: space.workspace, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 1 << 24, timeout: 3000 }).split("\n").filter((p) => p !== "");
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
files = [];
|
|
49
|
+
const walk = (dir, prefix) => {
|
|
50
|
+
try {
|
|
51
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
52
|
+
if (files.length > AT_CAP)
|
|
53
|
+
return;
|
|
54
|
+
if (AT_SKIP.has(e.name))
|
|
55
|
+
continue;
|
|
56
|
+
if (e.isDirectory())
|
|
57
|
+
walk(`${dir}/${e.name}`, `${prefix}${e.name}/`);
|
|
58
|
+
else if (e.isFile())
|
|
59
|
+
files.push(`${prefix}${e.name}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// an unreadable directory adds nothing
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
walk(space.workspace, "");
|
|
67
|
+
}
|
|
68
|
+
return { files: files.slice(0, AT_CAP), truncated: files.length > AT_CAP };
|
|
69
|
+
}
|
package/dist/main/git.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { Refusal } from "./files.js";
|
|
3
|
+
/**
|
|
4
|
+
* The rail's Changes from git, and the one write it can make on the person's word: commit the files they ticked, push the branch
|
|
5
|
+
* and open a pull request with `gh`. Reads are bounded (a timeout each, nothing on a folder outside git); the writes run only
|
|
6
|
+
* from the dialog's confirm (POST), never by themselves, and refuse what they cannot do in words.
|
|
7
|
+
*/
|
|
8
|
+
const run = (cwd, cmd, args, timeout = 3000) => new Promise((resolve, reject) => execFile(cmd, [...args], { cwd, timeout, maxBuffer: 1 << 23, env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GH_PROMPT_DISABLED: "1" } }, (err, out, errOut) => (err === null ? resolve(out) : reject(new Error((errOut || err.message).trim())))));
|
|
9
|
+
const tryRun = (cwd, cmd, args, timeout) => run(cwd, cmd, args, timeout).then((o) => o.trim(), () => null);
|
|
10
|
+
let ghSignedIn = null;
|
|
11
|
+
/** `gh auth status`, asked once per host process: a sign-in does not come and go between two clicks. */
|
|
12
|
+
const ghReady = (cwd) => (ghSignedIn ??= tryRun(cwd, "gh", ["auth", "status"], 5000).then((o) => o !== null));
|
|
13
|
+
async function baseOf(cwd) {
|
|
14
|
+
const head = await tryRun(cwd, "git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]);
|
|
15
|
+
if (head !== null)
|
|
16
|
+
return head.replace(/^origin\//, "");
|
|
17
|
+
for (const name of ["main", "master"])
|
|
18
|
+
if ((await tryRun(cwd, "git", ["rev-parse", "--verify", "--quiet", `refs/heads/${name}`])) !== null)
|
|
19
|
+
return name;
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
/** The changed paths and their two-letter codes (`git status -z`: NUL-separated, so a path with a space or a quote is read whole; a rename gives its new path). */
|
|
23
|
+
async function statusOf(cwd) {
|
|
24
|
+
const out = (await run(cwd, "git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"]).catch(() => "")).split("\0");
|
|
25
|
+
const map = new Map();
|
|
26
|
+
for (let i = 0; i < out.length; i++) {
|
|
27
|
+
const e = out[i];
|
|
28
|
+
if (e.length < 4)
|
|
29
|
+
continue;
|
|
30
|
+
map.set(e.slice(3), e.slice(0, 2));
|
|
31
|
+
if (e[0] === "R" || e[0] === "C")
|
|
32
|
+
i += 1; // the old path follows a rename or a copy
|
|
33
|
+
}
|
|
34
|
+
return map;
|
|
35
|
+
}
|
|
36
|
+
/** The base as a ref to compare with: origin's copy when there is one (what the PR is judged against), else the local branch. */
|
|
37
|
+
const baseRef = async (cwd, base) => ((await tryRun(cwd, "git", ["rev-parse", "--verify", "--quiet", `refs/remotes/origin/${base}`])) !== null ? `origin/${base}` : base);
|
|
38
|
+
export async function gitOf(cwd) {
|
|
39
|
+
const none = { repo: false, branch: null, base: null, ahead: 0, files: [], dirty: false, canPr: false, pr: null };
|
|
40
|
+
if ((await tryRun(cwd, "git", ["rev-parse", "--is-inside-work-tree"])) !== "true")
|
|
41
|
+
return none;
|
|
42
|
+
const branch = await tryRun(cwd, "git", ["symbolic-ref", "--short", "HEAD"]);
|
|
43
|
+
const base = await baseOf(cwd);
|
|
44
|
+
const ref = base === null ? null : await baseRef(cwd, base);
|
|
45
|
+
const fork = ref === null ? null : await tryRun(cwd, "git", ["merge-base", ref, "HEAD"]);
|
|
46
|
+
const ahead = ref === null ? 0 : Number((await tryRun(cwd, "git", ["rev-list", "--count", `${ref}..HEAD`])) ?? 0);
|
|
47
|
+
const dirty = await statusOf(cwd);
|
|
48
|
+
const numstat = fork === null ? "" : await run(cwd, "git", ["diff", "--numstat", "-z", "--no-renames", fork]).catch(() => "");
|
|
49
|
+
const files = new Map();
|
|
50
|
+
for (const entry of numstat.split("\0").filter((l) => l !== "")) {
|
|
51
|
+
const [a, r, path] = entry.split("\t");
|
|
52
|
+
files.set(path, { path, added: a === "-" ? null : Number(a), removed: r === "-" ? null : Number(r), state: "modified", dirty: dirty.has(path) });
|
|
53
|
+
}
|
|
54
|
+
for (const [path, code] of dirty) {
|
|
55
|
+
const state = code.includes("?") || code.includes("A") ? "new" : code.includes("D") ? "deleted" : "modified";
|
|
56
|
+
files.set(path, { ...(files.get(path) ?? { path, added: null, removed: null }), state, dirty: true });
|
|
57
|
+
}
|
|
58
|
+
const remote = await tryRun(cwd, "git", ["remote", "get-url", "origin"]);
|
|
59
|
+
const canPr = branch !== null && base !== null && branch !== base && remote !== null && (await ghReady(cwd));
|
|
60
|
+
let pr = null;
|
|
61
|
+
if (canPr) {
|
|
62
|
+
const json = await tryRun(cwd, "gh", ["pr", "view", "--json", "number,title,url,state,statusCheckRollup"], 6000);
|
|
63
|
+
if (json !== null) {
|
|
64
|
+
const p = JSON.parse(json);
|
|
65
|
+
const checks = p.statusCheckRollup ?? [];
|
|
66
|
+
pr = { number: p.number, title: p.title, url: p.url, state: p.state, checks: { total: checks.length, done: checks.filter((c) => c.status === "COMPLETED").length, failed: checks.filter((c) => c.conclusion === "FAILURE").length } };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { repo: true, branch, base, ahead, files: [...files.values()], dirty: dirty.size > 0, canPr, pr };
|
|
70
|
+
}
|
|
71
|
+
/** Commit the files the person ticked, with their message: those paths only (never `git add -A`), so nothing they did not see is committed. */
|
|
72
|
+
export async function commit(cwd, message, paths) {
|
|
73
|
+
if (message.trim() === "")
|
|
74
|
+
throw new Refusal(400, "a commit needs a message");
|
|
75
|
+
if (paths.length === 0)
|
|
76
|
+
throw new Refusal(400, "no file was chosen");
|
|
77
|
+
const listed = await statusOf(cwd);
|
|
78
|
+
const strange = paths.filter((p) => !listed.has(p));
|
|
79
|
+
if (strange.length > 0)
|
|
80
|
+
throw new Refusal(400, `not a changed file of this folder: ${strange.join(", ")}`);
|
|
81
|
+
await run(cwd, "git", ["add", "--", ...paths]);
|
|
82
|
+
await run(cwd, "git", ["commit", "-m", message, "--", ...paths]).catch((e) => {
|
|
83
|
+
throw new Refusal(409, `git refused the commit: ${e.message}`);
|
|
84
|
+
});
|
|
85
|
+
return { sha: (await run(cwd, "git", ["rev-parse", "--short", "HEAD"])).trim() };
|
|
86
|
+
}
|
|
87
|
+
/** Push the branch and open its pull request into the base; refused with the reason when the folder cannot. */
|
|
88
|
+
export async function openPr(cwd, title, body) {
|
|
89
|
+
const g = await gitOf(cwd);
|
|
90
|
+
if (!g.canPr || g.branch === null || g.base === null)
|
|
91
|
+
throw new Refusal(409, "a pull request needs a branch other than the base, a remote named origin, and gh signed in");
|
|
92
|
+
if (g.dirty)
|
|
93
|
+
throw new Refusal(409, "commit the changes first: a pull request carries commits, not the working tree");
|
|
94
|
+
if (g.ahead === 0)
|
|
95
|
+
throw new Refusal(409, `${g.branch} has no commit that ${g.base} does not have`);
|
|
96
|
+
if (title.trim() === "")
|
|
97
|
+
throw new Refusal(400, "a pull request needs a title");
|
|
98
|
+
await run(cwd, "git", ["push", "-u", "origin", "HEAD"], 60_000).catch((e) => {
|
|
99
|
+
throw new Refusal(409, `git push failed: ${e.message}`);
|
|
100
|
+
});
|
|
101
|
+
const out = await run(cwd, "gh", ["pr", "create", "--base", g.base, "--head", g.branch, "--title", title, "--body", body], 60_000).catch((e) => {
|
|
102
|
+
throw new Refusal(409, `gh pr create failed: ${e.message}`);
|
|
103
|
+
});
|
|
104
|
+
const url = out.trim().split("\n").pop() ?? "";
|
|
105
|
+
return { number: Number(/\/pull\/(\d+)/.exec(url)?.[1] ?? 0), url };
|
|
106
|
+
}
|
|
107
|
+
/** The dialog's starting draft from the branch's own commits: one commit gives its subject; several, the branch's name and a list. */
|
|
108
|
+
export async function prDraft(cwd) {
|
|
109
|
+
const g = await gitOf(cwd);
|
|
110
|
+
if (g.base === null)
|
|
111
|
+
return { title: "", body: "" };
|
|
112
|
+
const ref = await baseRef(cwd, g.base);
|
|
113
|
+
const subjects = ((await tryRun(cwd, "git", ["log", "--format=%s", `${ref}..HEAD`])) ?? "").split("\n").filter((s) => s !== "").reverse();
|
|
114
|
+
if (subjects.length === 1)
|
|
115
|
+
return { title: subjects[0], body: "" };
|
|
116
|
+
return { title: (g.branch ?? "").replace(/^[^/]*\//, "").replace(/[-_]/g, " "), body: subjects.map((s) => `- ${s}`).join("\n") };
|
|
117
|
+
}
|
package/dist/main/history.js
CHANGED
|
@@ -13,16 +13,33 @@ import { sessionFolders } from "@vincemakes/kiso-code/config";
|
|
|
13
13
|
*/
|
|
14
14
|
const MIN_AGE_MS = 3_000;
|
|
15
15
|
const MAX_ROWS = 400;
|
|
16
|
-
/**
|
|
16
|
+
/** What the host knows about a Work it holds open: a run live, or waiting for an answer. */
|
|
17
|
+
/** A delegated child's parent (the longest id of its folder that `sub-<id>-` starts with: a parent id may have any shape) and its
|
|
18
|
+
* role (what follows the task's number; a role starts with a letter, a hex delegation id may be all digits); nothing for any
|
|
19
|
+
* other session. */
|
|
20
|
+
export function delegated(id, ids) {
|
|
21
|
+
if (!id.startsWith("sub-"))
|
|
22
|
+
return {};
|
|
23
|
+
const parent = ids.filter((p) => p !== id && id.startsWith(`sub-${p}-`)).sort((a, b) => b.length - a.length)[0];
|
|
24
|
+
const role = /-\d+-(\D.*)$/.exec(id.slice(4 + (parent?.length ?? 0)))?.[1];
|
|
25
|
+
return { ...(parent === undefined ? {} : { parent }), ...(role === undefined ? {} : { role }) };
|
|
26
|
+
}
|
|
27
|
+
/** The folder's files as one string: it changes when a log is added or written, or a Work's name file (`<id>.meta.json`, the
|
|
28
|
+
* runtime's titles read it) is written by a rename. */
|
|
17
29
|
function printOf(dir) {
|
|
18
30
|
let count = 0;
|
|
19
31
|
let bytes = 0;
|
|
20
32
|
let newest = 0;
|
|
21
33
|
for (const name of readdirSync(dir)) {
|
|
22
|
-
|
|
34
|
+
const meta = name.endsWith(".meta.json");
|
|
35
|
+
if (!name.endsWith(".jsonl") && !meta)
|
|
23
36
|
continue;
|
|
24
37
|
try {
|
|
25
38
|
const s = statSync(join(dir, name));
|
|
39
|
+
if (meta) {
|
|
40
|
+
newest = Math.max(newest, s.mtimeMs);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
26
43
|
if (s.size === 0)
|
|
27
44
|
continue;
|
|
28
45
|
count += 1;
|
|
@@ -38,13 +55,15 @@ function printOf(dir) {
|
|
|
38
55
|
export class History {
|
|
39
56
|
#home;
|
|
40
57
|
#live;
|
|
58
|
+
#marksOf;
|
|
41
59
|
#reads = new Map();
|
|
42
60
|
#queue = [];
|
|
43
61
|
#worker = null;
|
|
44
62
|
#inflight = null;
|
|
45
|
-
constructor(home, live) {
|
|
63
|
+
constructor(home, live, marksOf = () => ({})) {
|
|
46
64
|
this.#home = home;
|
|
47
65
|
this.#live = live;
|
|
66
|
+
this.#marksOf = marksOf;
|
|
48
67
|
}
|
|
49
68
|
/** `fresh` names a folder whose listing is read again now, whatever its age. */
|
|
50
69
|
async snapshot(current, fresh) {
|
|
@@ -67,8 +86,13 @@ export class History {
|
|
|
67
86
|
if (read === undefined)
|
|
68
87
|
continue;
|
|
69
88
|
const key = basename(folder.dir);
|
|
89
|
+
const ids = read.metas.map((m) => m.id);
|
|
70
90
|
for (const meta of read.metas) {
|
|
71
91
|
rows.push({
|
|
92
|
+
// a fork's parent: the store's own record (any app made it), else the host's (forks it made before)
|
|
93
|
+
...(meta.forkedFrom !== undefined ? { parent: meta.forkedFrom } : {}),
|
|
94
|
+
...this.#marksOf(meta.id),
|
|
95
|
+
...delegated(meta.id, ids),
|
|
72
96
|
id: meta.id,
|
|
73
97
|
key,
|
|
74
98
|
folder: basename(folder.workspace),
|
|
@@ -103,18 +127,21 @@ export class History {
|
|
|
103
127
|
this.#inflight = dir;
|
|
104
128
|
const print = printOf(dir);
|
|
105
129
|
const worker = (this.#worker ??= new Worker(new URL("./history-worker.js", import.meta.url)));
|
|
130
|
+
// both listeners go when either answers: one read, one pair (the worker lives across reads, so a kept listener piles up)
|
|
106
131
|
const done = (message) => {
|
|
107
132
|
worker.off("message", done);
|
|
133
|
+
worker.off("error", failed);
|
|
108
134
|
// an unreadable folder is remembered empty: it is tried again when its files change
|
|
109
135
|
this.#reads.set(dir, { print, metas: message.metas ?? [], at: Date.now() });
|
|
110
136
|
this.#inflight = null;
|
|
111
137
|
this.#pump();
|
|
112
138
|
};
|
|
113
|
-
|
|
114
|
-
worker.once("error", (error) => {
|
|
139
|
+
const failed = (error) => {
|
|
115
140
|
this.#worker = null;
|
|
116
141
|
done({ dir, error: error.message });
|
|
117
|
-
}
|
|
142
|
+
};
|
|
143
|
+
worker.on("message", done);
|
|
144
|
+
worker.on("error", failed);
|
|
118
145
|
worker.postMessage({ dir });
|
|
119
146
|
}
|
|
120
147
|
close() {
|