@netnodeag/kraftwerk 0.57.4 → 0.58.1
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 +85 -2
- package/dist/acp.js +2 -1
- package/dist/cli/journal.d.ts +10 -0
- package/dist/cli/journal.js +36 -0
- package/dist/cli/knowledge.js +17 -0
- package/dist/cli/kraftwerk.js +6 -0
- package/dist/cli/projects.js +3 -3
- package/dist/cli/repos.js +1 -1
- package/dist/cli/trash.d.ts +2 -0
- package/dist/cli/trash.js +70 -0
- package/dist/cli/vibeables.js +3 -3
- package/dist/inspector/agent-status.d.ts +21 -0
- package/dist/inspector/agent-status.js +25 -0
- package/dist/inspector/agents.d.ts +5 -0
- package/dist/inspector/agents.js +10 -1
- package/dist/inspector/attention.d.ts +48 -0
- package/dist/inspector/attention.js +66 -0
- package/dist/inspector/channels.js +2 -1
- package/dist/inspector/chat/sessions.d.ts +52 -1
- package/dist/inspector/chat/sessions.js +160 -27
- package/dist/inspector/chat/store.d.ts +2 -0
- package/dist/inspector/chat/store.js +5 -1
- package/dist/inspector/files.d.ts +87 -0
- package/dist/inspector/files.js +316 -0
- package/dist/inspector/git.js +3 -1
- package/dist/inspector/journal.d.ts +31 -0
- package/dist/inspector/journal.js +72 -0
- package/dist/inspector/knowledge.d.ts +2 -0
- package/dist/inspector/knowledge.js +7 -0
- package/dist/inspector/projects.d.ts +1 -1
- package/dist/inspector/projects.js +5 -2
- package/dist/inspector/repos.js +2 -1
- package/dist/inspector/runs.d.ts +1 -1
- package/dist/inspector/runs.js +3 -2
- package/dist/inspector/server.js +111 -1
- package/dist/inspector/skills.d.ts +1 -1
- package/dist/inspector/skills.js +3 -2
- package/dist/inspector/status-line.d.ts +14 -0
- package/dist/inspector/status-line.js +44 -0
- package/dist/inspector/trash.d.ts +37 -0
- package/dist/inspector/trash.js +148 -0
- package/dist/inspector/vibeables.d.ts +1 -1
- package/dist/inspector/vibeables.js +3 -2
- package/inspector/dist/assets/{dist-bpX7WAGx.js → dist-BOgAuM_m.js} +1 -1
- package/inspector/dist/assets/{dist-D5fobTtx.js → dist-BW2AWp_e.js} +1 -1
- package/inspector/dist/assets/{dist-BvDLxSg5.js → dist-BcBryE8Y.js} +1 -1
- package/inspector/dist/assets/{dist-slvUmm0x.js → dist-C5aV9g-B.js} +1 -1
- package/inspector/dist/assets/{dist-B53NsyKK.js → dist-CL5nzsfs.js} +1 -1
- package/inspector/dist/assets/dist-D8BaN0Ps.js +1 -0
- package/inspector/dist/assets/{dist-D9p_d6De.js → dist-D9EqytQ1.js} +1 -1
- package/inspector/dist/assets/{dist-q_nB-2jY.js → dist-DCbRyQBr.js} +1 -1
- package/inspector/dist/assets/{dist-DxkFf8wL.js → dist-DvLwKI0v.js} +1 -1
- package/inspector/dist/assets/{dist-D3FXw2rB.js → dist-DyuX-pWN.js} +1 -1
- package/inspector/dist/assets/{dist-DygUNvj6.js → dist-HArst0KY.js} +1 -1
- package/inspector/dist/assets/{editor-qZxomNcW.js → editor-C6eZc_Cr.js} +3 -3
- package/inspector/dist/assets/{editor-Cz0AMLr-.css → editor-CZMrhTTL.css} +1 -1
- package/inspector/dist/assets/index-Bb27CwDb.css +2 -0
- package/inspector/dist/assets/index-kyDjRVVU.js +229 -0
- package/inspector/dist/index.html +2 -2
- package/package.json +1 -1
- package/inspector/dist/assets/dist-BOZxRe-K.js +0 -1
- package/inspector/dist/assets/index-6Jx1rUdP.js +0 -227
- package/inspector/dist/assets/index-B2UPyYuE.css +0 -1
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { createReadStream, createWriteStream, promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { pipeline } from "node:stream/promises";
|
|
4
|
+
import { resolveProject } from "../config.js";
|
|
5
|
+
import { getProjectRoot } from "./context.js";
|
|
6
|
+
import { listProjects, openProjects, safeProjectSlug } from "./projects.js";
|
|
7
|
+
import { moveToTrash } from "./trash.js";
|
|
8
|
+
/**
|
|
9
|
+
* Files: the material of the work, any format, as plain folders. Two
|
|
10
|
+
* owners, by how long a file lives:
|
|
11
|
+
*
|
|
12
|
+
* kraftwerk-data/files/ workspace files: shared, outlive any project
|
|
13
|
+
* kraftwerk-data/projects/<slug>/files/ a project's files: briefing, drafts, deliverables
|
|
14
|
+
*
|
|
15
|
+
* Agents own none (they link folders; see agentFilesContext). Being plain
|
|
16
|
+
* folders, agents read and write them like any directory, the workspace
|
|
17
|
+
* git keeps their history, and a delete goes to the trash. Files over
|
|
18
|
+
* LOCAL_OVER_BYTES stay out of git: listed in the root's own .gitignore,
|
|
19
|
+
* shown as "local only". Knowledge is separate: a file becomes evidence in
|
|
20
|
+
* a bundle's references/ through "learn from this" (later).
|
|
21
|
+
*/
|
|
22
|
+
export const WORKSPACE_FILES_DIR = "kraftwerk-data/files";
|
|
23
|
+
export const PROJECT_FILES_DIR = "files";
|
|
24
|
+
/** Bigger files stay local: kept out of the workspace git. */
|
|
25
|
+
export const LOCAL_OVER_BYTES = 25 * 1024 * 1024;
|
|
26
|
+
/** Upload cap: a guard against a runaway request, not a policy. */
|
|
27
|
+
export const MAX_UPLOAD_BYTES = 2 * 1024 * 1024 * 1024;
|
|
28
|
+
const IGNORE_HEAD = "# kraftwerk: files over 25 MB stay local (not in git)";
|
|
29
|
+
const MIME = {
|
|
30
|
+
".pdf": "application/pdf",
|
|
31
|
+
".png": "image/png",
|
|
32
|
+
".jpg": "image/jpeg",
|
|
33
|
+
".jpeg": "image/jpeg",
|
|
34
|
+
".gif": "image/gif",
|
|
35
|
+
".webp": "image/webp",
|
|
36
|
+
".svg": "image/svg+xml",
|
|
37
|
+
".avif": "image/avif",
|
|
38
|
+
".heic": "image/heic",
|
|
39
|
+
".mp4": "video/mp4",
|
|
40
|
+
".webm": "video/webm",
|
|
41
|
+
".mov": "video/quicktime",
|
|
42
|
+
".mp3": "audio/mpeg",
|
|
43
|
+
".m4a": "audio/mp4",
|
|
44
|
+
".wav": "audio/wav",
|
|
45
|
+
".txt": "text/plain; charset=utf-8",
|
|
46
|
+
".md": "text/markdown; charset=utf-8",
|
|
47
|
+
".csv": "text/csv; charset=utf-8",
|
|
48
|
+
".tsv": "text/tab-separated-values; charset=utf-8",
|
|
49
|
+
".json": "application/json; charset=utf-8",
|
|
50
|
+
".yml": "text/yaml; charset=utf-8",
|
|
51
|
+
".yaml": "text/yaml; charset=utf-8",
|
|
52
|
+
".xml": "text/xml; charset=utf-8",
|
|
53
|
+
".html": "text/html; charset=utf-8",
|
|
54
|
+
".log": "text/plain; charset=utf-8",
|
|
55
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
56
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
57
|
+
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
58
|
+
".zip": "application/zip",
|
|
59
|
+
};
|
|
60
|
+
export const mimeOf = (name) => MIME[path.extname(name).toLowerCase()] ?? "application/octet-stream";
|
|
61
|
+
/** The root folder of a scope, its display label, and the root relative to the workspace. */
|
|
62
|
+
export async function scopeRoot(scope) {
|
|
63
|
+
const project = await resolveProject(getProjectRoot());
|
|
64
|
+
const workspace = project.root;
|
|
65
|
+
if (scope === "workspace") {
|
|
66
|
+
const abs = path.join(workspace, WORKSPACE_FILES_DIR);
|
|
67
|
+
return { abs, rel: WORKSPACE_FILES_DIR, label: "Workspace", workspace };
|
|
68
|
+
}
|
|
69
|
+
const m = /^project:(.+)$/.exec(scope);
|
|
70
|
+
if (!m)
|
|
71
|
+
throw new Error(`invalid files scope "${scope}"`);
|
|
72
|
+
const slug = safeProjectSlug(m[1]);
|
|
73
|
+
const opened = await openProjects();
|
|
74
|
+
if (!opened.root)
|
|
75
|
+
throw new Error(opened.error ?? "projects are off");
|
|
76
|
+
const dir = path.join(opened.root, slug);
|
|
77
|
+
if (!(await fs.stat(path.join(dir, "project.yml")).catch(() => null)))
|
|
78
|
+
throw new Error(`no project "${slug}"`);
|
|
79
|
+
const abs = path.join(dir, PROJECT_FILES_DIR);
|
|
80
|
+
const title = (await listProjects()).projects.find((p) => p.slug === slug)?.title ?? slug;
|
|
81
|
+
return { abs, rel: path.relative(workspace, abs).split(path.sep).join("/"), label: title, workspace };
|
|
82
|
+
}
|
|
83
|
+
/** A path inside a root: no escaping it, no hidden segments (.gitignore and friends are kraftwerk's). */
|
|
84
|
+
function inside(root, rel) {
|
|
85
|
+
const clean = rel.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
|
86
|
+
if (clean.split("/").some((s) => s === ".." || (s.startsWith(".") && s !== "")))
|
|
87
|
+
throw new Error("invalid path");
|
|
88
|
+
const abs = path.resolve(root, clean);
|
|
89
|
+
if (abs !== root && !abs.startsWith(root + path.sep))
|
|
90
|
+
throw new Error("invalid path");
|
|
91
|
+
return abs;
|
|
92
|
+
}
|
|
93
|
+
const toPosix = (p) => p.split(path.sep).join("/");
|
|
94
|
+
/** Number of files below a folder, stopping at `cap`. */
|
|
95
|
+
async function countFiles(dir, cap = 5000) {
|
|
96
|
+
let n = 0;
|
|
97
|
+
const walk = async (d) => {
|
|
98
|
+
if (n >= cap)
|
|
99
|
+
return;
|
|
100
|
+
const kids = await fs.readdir(d, { withFileTypes: true }).catch(() => []);
|
|
101
|
+
for (const k of kids) {
|
|
102
|
+
if (k.name.startsWith("."))
|
|
103
|
+
continue;
|
|
104
|
+
if (k.isDirectory())
|
|
105
|
+
await walk(path.join(d, k.name));
|
|
106
|
+
else if (k.isFile())
|
|
107
|
+
n++;
|
|
108
|
+
if (n >= cap)
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
await walk(dir);
|
|
113
|
+
return n;
|
|
114
|
+
}
|
|
115
|
+
/* ---------- local-only (over the size limit) ---------- */
|
|
116
|
+
async function readIgnore(root) {
|
|
117
|
+
const raw = await fs.readFile(path.join(root, ".gitignore"), "utf8").catch(() => "");
|
|
118
|
+
return raw.split("\n").filter((l) => l.startsWith("/"));
|
|
119
|
+
}
|
|
120
|
+
/** Keep `rel` out of git (on) or let it back in (off), in the root's own .gitignore. */
|
|
121
|
+
async function markLocal(root, rel, on) {
|
|
122
|
+
const line = `/${rel}`;
|
|
123
|
+
const lines = await readIgnore(root);
|
|
124
|
+
const has = lines.includes(line);
|
|
125
|
+
if (on === has)
|
|
126
|
+
return;
|
|
127
|
+
const next = on ? [...lines, line] : lines.filter((l) => l !== line);
|
|
128
|
+
const file = path.join(root, ".gitignore");
|
|
129
|
+
if (next.length === 0)
|
|
130
|
+
await fs.rm(file, { force: true });
|
|
131
|
+
else
|
|
132
|
+
await fs.writeFile(file, `${IGNORE_HEAD}\n${next.sort().join("\n")}\n`);
|
|
133
|
+
}
|
|
134
|
+
/* ---------- reading ---------- */
|
|
135
|
+
export async function listScopes() {
|
|
136
|
+
const ws = await scopeRoot("workspace");
|
|
137
|
+
const out = [{ scope: "workspace", label: ws.label, root: ws.rel, count: await countFiles(ws.abs) }];
|
|
138
|
+
const projects = await listProjects().catch(() => ({ projects: [] }));
|
|
139
|
+
for (const p of projects.projects) {
|
|
140
|
+
const r = await scopeRoot(`project:${p.slug}`).catch(() => null);
|
|
141
|
+
if (!r)
|
|
142
|
+
continue;
|
|
143
|
+
out.push({ scope: `project:${p.slug}`, label: p.title, root: r.rel, count: await countFiles(r.abs), status: p.status });
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
export async function listFiles(scope, rel = "") {
|
|
148
|
+
const root = await scopeRoot(scope);
|
|
149
|
+
const dir = inside(root.abs, rel);
|
|
150
|
+
const st = await fs.stat(dir).catch(() => null);
|
|
151
|
+
if (rel && !st?.isDirectory())
|
|
152
|
+
throw new Error(`no folder "${rel}"`);
|
|
153
|
+
const local = new Set(await readIgnore(root.abs));
|
|
154
|
+
const kids = st ? await fs.readdir(dir, { withFileTypes: true }) : [];
|
|
155
|
+
const entries = [];
|
|
156
|
+
for (const k of kids) {
|
|
157
|
+
if (k.name.startsWith("."))
|
|
158
|
+
continue;
|
|
159
|
+
const abs = path.join(dir, k.name);
|
|
160
|
+
const s = await fs.stat(abs).catch(() => null);
|
|
161
|
+
if (!s)
|
|
162
|
+
continue;
|
|
163
|
+
const p = toPosix(path.relative(root.abs, abs));
|
|
164
|
+
if (s.isDirectory()) {
|
|
165
|
+
entries.push({ name: k.name, path: p, type: "dir", modifiedAt: s.mtime.toISOString(), count: await countFiles(abs, 1000) });
|
|
166
|
+
}
|
|
167
|
+
else if (s.isFile()) {
|
|
168
|
+
// A big file dropped in by hand (Finder, an agent) is kept out of git the moment it is seen.
|
|
169
|
+
const big = s.size > LOCAL_OVER_BYTES;
|
|
170
|
+
if (big && !local.has(`/${p}`))
|
|
171
|
+
await markLocal(root.abs, p, true);
|
|
172
|
+
entries.push({ name: k.name, path: p, type: "file", size: s.size, modifiedAt: s.mtime.toISOString(), mime: mimeOf(k.name), ...(big ? { local: true } : {}) });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
entries.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name, undefined, { numeric: true }) : a.type === "dir" ? -1 : 1));
|
|
176
|
+
return { scope, label: root.label, path: toPosix(path.relative(root.abs, dir)), root: root.rel, entries };
|
|
177
|
+
}
|
|
178
|
+
/** A file to stream to the browser. */
|
|
179
|
+
export async function openFile(scope, rel) {
|
|
180
|
+
const root = await scopeRoot(scope);
|
|
181
|
+
const abs = inside(root.abs, rel);
|
|
182
|
+
const st = await fs.stat(abs).catch(() => null);
|
|
183
|
+
if (!st?.isFile())
|
|
184
|
+
throw new Error(`no file "${rel}"`);
|
|
185
|
+
return { stream: createReadStream(abs), size: st.size, mime: mimeOf(abs), name: path.basename(abs) };
|
|
186
|
+
}
|
|
187
|
+
/* ---------- writing ---------- */
|
|
188
|
+
/** A name for an upload: no folders, no control characters, no leading dot. */
|
|
189
|
+
function cleanName(name) {
|
|
190
|
+
const base = path.basename(name.replace(/\\/g, "/")).replace(/[\u0000-\u001f\u007f/]/g, "").trim().replace(/^\.+/, "");
|
|
191
|
+
if (!base)
|
|
192
|
+
throw new Error("invalid file name");
|
|
193
|
+
return base.slice(0, 200);
|
|
194
|
+
}
|
|
195
|
+
/** "report.pdf" taken -> "report (2).pdf". */
|
|
196
|
+
async function freeName(dir, name) {
|
|
197
|
+
const ext = path.extname(name);
|
|
198
|
+
const stem = name.slice(0, name.length - ext.length);
|
|
199
|
+
let candidate = name;
|
|
200
|
+
for (let n = 2; await fs.stat(path.join(dir, candidate)).then(() => true, () => false); n++)
|
|
201
|
+
candidate = `${stem} (${n})${ext}`;
|
|
202
|
+
return candidate;
|
|
203
|
+
}
|
|
204
|
+
/** Stream an upload into a folder; a name already taken gets " (2)". */
|
|
205
|
+
export async function saveUpload(scope, dirRel, name, body) {
|
|
206
|
+
const root = await scopeRoot(scope);
|
|
207
|
+
const dir = inside(root.abs, dirRel);
|
|
208
|
+
await fs.mkdir(dir, { recursive: true });
|
|
209
|
+
const final = await freeName(dir, cleanName(name));
|
|
210
|
+
const abs = path.join(dir, final);
|
|
211
|
+
const tmp = path.join(dir, `.upload-${process.pid}-${Date.now()}`);
|
|
212
|
+
let size = 0;
|
|
213
|
+
body.on("data", (c) => {
|
|
214
|
+
size += c.length;
|
|
215
|
+
if (size > MAX_UPLOAD_BYTES)
|
|
216
|
+
body.destroy(new Error("file too large"));
|
|
217
|
+
});
|
|
218
|
+
try {
|
|
219
|
+
await pipeline(body, createWriteStream(tmp));
|
|
220
|
+
if (size === 0)
|
|
221
|
+
throw new Error("empty file");
|
|
222
|
+
await fs.rename(tmp, abs);
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
await fs.rm(tmp, { force: true });
|
|
226
|
+
throw err;
|
|
227
|
+
}
|
|
228
|
+
const p = toPosix(path.relative(root.abs, abs));
|
|
229
|
+
if (size > LOCAL_OVER_BYTES)
|
|
230
|
+
await markLocal(root.abs, p, true);
|
|
231
|
+
return { name: final, path: p, type: "file", size, modifiedAt: new Date().toISOString(), mime: mimeOf(final), ...(size > LOCAL_OVER_BYTES ? { local: true } : {}) };
|
|
232
|
+
}
|
|
233
|
+
export async function makeFolder(scope, rel) {
|
|
234
|
+
const root = await scopeRoot(scope);
|
|
235
|
+
const abs = inside(root.abs, rel);
|
|
236
|
+
if (abs === root.abs)
|
|
237
|
+
throw new Error("folder name is required");
|
|
238
|
+
if (await fs.stat(abs).catch(() => null))
|
|
239
|
+
throw new Error(`"${path.basename(abs)}" already exists`);
|
|
240
|
+
await fs.mkdir(abs, { recursive: true });
|
|
241
|
+
return { name: path.basename(abs), path: toPosix(path.relative(root.abs, abs)), type: "dir", modifiedAt: new Date().toISOString(), count: 0 };
|
|
242
|
+
}
|
|
243
|
+
/** Rename or move inside the scope. Refused when the target exists. */
|
|
244
|
+
export async function moveFile(scope, fromRel, toRel) {
|
|
245
|
+
const root = await scopeRoot(scope);
|
|
246
|
+
const from = inside(root.abs, fromRel);
|
|
247
|
+
const to = inside(root.abs, toRel);
|
|
248
|
+
if (from === root.abs || to === root.abs)
|
|
249
|
+
throw new Error("invalid path");
|
|
250
|
+
const st = await fs.stat(from).catch(() => null);
|
|
251
|
+
if (!st)
|
|
252
|
+
throw new Error(`no file "${fromRel}"`);
|
|
253
|
+
if (await fs.stat(to).catch(() => null))
|
|
254
|
+
throw new Error(`"${toRel}" already exists`);
|
|
255
|
+
if (st.isDirectory() && (to + path.sep).startsWith(from + path.sep))
|
|
256
|
+
throw new Error("cannot move a folder into itself");
|
|
257
|
+
await fs.mkdir(path.dirname(to), { recursive: true });
|
|
258
|
+
await fs.rename(from, to);
|
|
259
|
+
if (st.isFile() && st.size > LOCAL_OVER_BYTES) {
|
|
260
|
+
await markLocal(root.abs, toPosix(path.relative(root.abs, from)), false);
|
|
261
|
+
await markLocal(root.abs, toPosix(path.relative(root.abs, to)), true);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/** Move a file or folder to the trash (trash/files/<workspace | projects/slug>/<path>). */
|
|
265
|
+
export async function deleteFile(scope, rel) {
|
|
266
|
+
const root = await scopeRoot(scope);
|
|
267
|
+
const abs = inside(root.abs, rel);
|
|
268
|
+
if (abs === root.abs)
|
|
269
|
+
throw new Error("invalid path");
|
|
270
|
+
const st = await fs.stat(abs).catch(() => null);
|
|
271
|
+
if (!st)
|
|
272
|
+
throw new Error(`no file "${rel}"`);
|
|
273
|
+
const p = toPosix(path.relative(root.abs, abs));
|
|
274
|
+
const owner = scope === "workspace" ? "workspace" : `projects/${scope.slice("project:".length)}`;
|
|
275
|
+
await moveToTrash("files", `${owner}/${p}`, abs);
|
|
276
|
+
if (st.isFile() && st.size > LOCAL_OVER_BYTES)
|
|
277
|
+
await markLocal(root.abs, p, false);
|
|
278
|
+
}
|
|
279
|
+
/* ---------- context for agents ---------- */
|
|
280
|
+
/** "files/ (23 files; recent: designs/home-v3.png 2026-10-05, …)" — where the files are, not every file. */
|
|
281
|
+
export async function filesSummary(scope, recent = 5) {
|
|
282
|
+
const root = await scopeRoot(scope).catch(() => null);
|
|
283
|
+
if (!root)
|
|
284
|
+
return null;
|
|
285
|
+
const all = [];
|
|
286
|
+
const walk = async (d) => {
|
|
287
|
+
if (all.length > 5000)
|
|
288
|
+
return;
|
|
289
|
+
for (const k of await fs.readdir(d, { withFileTypes: true }).catch(() => [])) {
|
|
290
|
+
if (k.name.startsWith("."))
|
|
291
|
+
continue;
|
|
292
|
+
const abs = path.join(d, k.name);
|
|
293
|
+
if (k.isDirectory())
|
|
294
|
+
await walk(abs);
|
|
295
|
+
else if (k.isFile()) {
|
|
296
|
+
const s = await fs.stat(abs).catch(() => null);
|
|
297
|
+
if (s)
|
|
298
|
+
all.push({ path: toPosix(path.relative(root.abs, abs)), at: s.mtime.toISOString() });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
await walk(root.abs);
|
|
303
|
+
all.sort((a, b) => b.at.localeCompare(a.at));
|
|
304
|
+
return { root: root.rel, count: all.length, recent: all.slice(0, recent) };
|
|
305
|
+
}
|
|
306
|
+
/** The "## Files" block of a session's context: the folder to use, what is in it lately. */
|
|
307
|
+
export async function filesContext(scope, heading, intro) {
|
|
308
|
+
const s = await filesSummary(scope);
|
|
309
|
+
if (!s)
|
|
310
|
+
return "";
|
|
311
|
+
const recent = s.recent.map((r) => ` - ${r.path} (${r.at.slice(0, 10)})`).join("\n");
|
|
312
|
+
return (`## ${heading}\n${intro} ${s.root}/ — ${s.count} file${s.count === 1 ? "" : "s"}` +
|
|
313
|
+
(recent ? `, most recently changed:\n${recent}\n` : " (empty so far)\n") +
|
|
314
|
+
`Read and write them there like any folder; put what you produce for the user in ${s.root}/deliverables/. ` +
|
|
315
|
+
`Files over 25 MB stay out of git automatically. Delete nothing there unless asked.`);
|
|
316
|
+
}
|
package/dist/inspector/git.js
CHANGED
|
@@ -204,7 +204,7 @@ async function checkedOut(repo) {
|
|
|
204
204
|
}
|
|
205
205
|
/**
|
|
206
206
|
* The roots that may be committed, as repo-relative paths: the workflows,
|
|
207
|
-
* knowledge, agents, skills, channels, vibeables and
|
|
207
|
+
* knowledge, agents, skills, channels, vibeables, projects and files roots this project
|
|
208
208
|
* declares, plus kraftwerk.yml and the project README. The output directory
|
|
209
209
|
* is excluded even when it lives inside one of them.
|
|
210
210
|
*/
|
|
@@ -237,6 +237,8 @@ function scopeFor({ project, repoRoot }) {
|
|
|
237
237
|
vibeablesRootFor(project),
|
|
238
238
|
// Projects too: briefs, state and logs are the workspace's own record.
|
|
239
239
|
projectsRootFor(project),
|
|
240
|
+
// Workspace files: shared material, versioned (files over 25 MB ignore themselves, see files.ts).
|
|
241
|
+
path.resolve(project.root, "kraftwerk-data/files"),
|
|
240
242
|
project.configPath,
|
|
241
243
|
path.resolve(project.root, "README.md"),
|
|
242
244
|
];
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An agent's journal: agents/<slug>/journal.md, the agent's own memory
|
|
3
|
+
* across sessions. What it learned, decided, promised and finished, one
|
|
4
|
+
* line per entry under a dated heading, newest first (like a project's
|
|
5
|
+
* log.md). The agent writes it through `kraftwerk journal` and reads the
|
|
6
|
+
* recent part back at the start of every session, so next week it is the
|
|
7
|
+
* same colleague; a human can add a line from the inspector.
|
|
8
|
+
*/
|
|
9
|
+
export declare const JOURNAL_FILE = "journal.md";
|
|
10
|
+
export declare const JOURNAL_KINDS: readonly ["learned", "decided", "promised", "done", "note"];
|
|
11
|
+
export type JournalKind = (typeof JOURNAL_KINDS)[number];
|
|
12
|
+
/** The whole journal; empty when nothing was written yet. */
|
|
13
|
+
export declare function readJournal(slug: string): Promise<string>;
|
|
14
|
+
/**
|
|
15
|
+
* Prepend one entry under today's heading. `actor` is stamped unless it is
|
|
16
|
+
* the agent itself: in its own journal, its own lines need no signature,
|
|
17
|
+
* and a human's stand out.
|
|
18
|
+
*/
|
|
19
|
+
export declare function appendJournal(slug: string, entry: string, opts?: {
|
|
20
|
+
kind?: string;
|
|
21
|
+
actor?: string;
|
|
22
|
+
}): Promise<string>;
|
|
23
|
+
/**
|
|
24
|
+
* The recent part of a journal for a session's context: whole days, newest
|
|
25
|
+
* first, until the budget is spent. Older days stay in the file, where the
|
|
26
|
+
* agent can read them when it needs to.
|
|
27
|
+
*/
|
|
28
|
+
export declare function journalExcerpt(raw: string, budget?: number): {
|
|
29
|
+
text: string;
|
|
30
|
+
truncated: boolean;
|
|
31
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { agentsRoot, getAgent, safeAgentSlug } from "./agents.js";
|
|
4
|
+
/**
|
|
5
|
+
* An agent's journal: agents/<slug>/journal.md, the agent's own memory
|
|
6
|
+
* across sessions. What it learned, decided, promised and finished, one
|
|
7
|
+
* line per entry under a dated heading, newest first (like a project's
|
|
8
|
+
* log.md). The agent writes it through `kraftwerk journal` and reads the
|
|
9
|
+
* recent part back at the start of every session, so next week it is the
|
|
10
|
+
* same colleague; a human can add a line from the inspector.
|
|
11
|
+
*/
|
|
12
|
+
export const JOURNAL_FILE = "journal.md";
|
|
13
|
+
export const JOURNAL_KINDS = ["learned", "decided", "promised", "done", "note"];
|
|
14
|
+
async function journalPath(slug) {
|
|
15
|
+
return path.join(await agentsRoot(), safeAgentSlug(slug), JOURNAL_FILE);
|
|
16
|
+
}
|
|
17
|
+
/** The whole journal; empty when nothing was written yet. */
|
|
18
|
+
export async function readJournal(slug) {
|
|
19
|
+
return fs.readFile(await journalPath(slug), "utf8").catch(() => "");
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Prepend one entry under today's heading. `actor` is stamped unless it is
|
|
23
|
+
* the agent itself: in its own journal, its own lines need no signature,
|
|
24
|
+
* and a human's stand out.
|
|
25
|
+
*/
|
|
26
|
+
export async function appendJournal(slug, entry, opts = {}) {
|
|
27
|
+
const def = await getAgent(slug);
|
|
28
|
+
if (!def)
|
|
29
|
+
throw new Error(`no agent "${slug}"`);
|
|
30
|
+
const text = entry.trim().replace(/\s*\n\s*/g, " ");
|
|
31
|
+
if (!text)
|
|
32
|
+
throw new Error("entry is required");
|
|
33
|
+
const kind = (opts.kind ?? "note").trim().toLowerCase();
|
|
34
|
+
if (!JOURNAL_KINDS.includes(kind))
|
|
35
|
+
throw new Error(`kind must be one of: ${JOURNAL_KINDS.join(", ")}`);
|
|
36
|
+
const actor = opts.actor?.trim();
|
|
37
|
+
const own = !actor || actor === slug || actor.startsWith(`${slug}/`);
|
|
38
|
+
const line = `* ${kind === "note" ? "" : `**${kind[0].toUpperCase()}${kind.slice(1)}**: `}${text}${own ? "" : ` (${actor})`}`;
|
|
39
|
+
const file = await journalPath(slug);
|
|
40
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
41
|
+
const raw = (await fs.readFile(file, "utf8").catch(() => null)) ?? `# ${def.name}'s journal\n`;
|
|
42
|
+
const lines = raw.split("\n");
|
|
43
|
+
const todayIdx = lines.findIndex((l) => l.trim() === `## ${today}`);
|
|
44
|
+
if (todayIdx >= 0) {
|
|
45
|
+
lines.splice(todayIdx + 1, 0, line);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
const titleIdx = lines.findIndex((l) => l.startsWith("# "));
|
|
49
|
+
lines.splice(titleIdx + 1, 0, "", `## ${today}`, line);
|
|
50
|
+
}
|
|
51
|
+
const out = lines.join("\n").replace(/\n{3,}/g, "\n\n");
|
|
52
|
+
await fs.writeFile(file, out);
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The recent part of a journal for a session's context: whole days, newest
|
|
57
|
+
* first, until the budget is spent. Older days stay in the file, where the
|
|
58
|
+
* agent can read them when it needs to.
|
|
59
|
+
*/
|
|
60
|
+
export function journalExcerpt(raw, budget = 4000) {
|
|
61
|
+
const days = raw.split(/\n(?=## )/).filter((d) => d.startsWith("## "));
|
|
62
|
+
const kept = [];
|
|
63
|
+
let used = 0;
|
|
64
|
+
for (const d of days) {
|
|
65
|
+
const day = d.trim();
|
|
66
|
+
if (used + day.length > budget && kept.length > 0)
|
|
67
|
+
break;
|
|
68
|
+
kept.push(day.length > budget ? `${day.slice(0, budget)}…` : day);
|
|
69
|
+
used += day.length;
|
|
70
|
+
}
|
|
71
|
+
return { text: kept.join("\n\n"), truncated: kept.length < days.length };
|
|
72
|
+
}
|
|
@@ -18,6 +18,8 @@ export interface BundleDetail {
|
|
|
18
18
|
}
|
|
19
19
|
export declare function knowledgeIndex(): Promise<KnowledgeIndex>;
|
|
20
20
|
export declare function createBundle(name: string): Promise<BundleInfo>;
|
|
21
|
+
/** Move a bundle to the trash. */
|
|
22
|
+
export declare function deleteBundle(name: string): Promise<void>;
|
|
21
23
|
export declare function bundleDetail(name: string): Promise<BundleDetail | null>;
|
|
22
24
|
export declare function conceptDetail(bundle: string, id: string): Promise<ConceptDetail | null>;
|
|
23
25
|
export declare function putConcept(bundle: string, id: string, content: string): Promise<ConceptDetail>;
|
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { resolveProject } from "../config.js";
|
|
4
4
|
import { getConcept, initBundle, knowledgeRoot, listBundles, listConcepts, safeBundleName, verifyConcept, writeConcept, } from "../okf.js";
|
|
5
5
|
import { getProjectRoot } from "./context.js";
|
|
6
|
+
import { moveToTrash } from "./trash.js";
|
|
6
7
|
/**
|
|
7
8
|
* Knowledge for the inspector: a thin API layer over the OKF
|
|
8
9
|
* core (src/okf.ts). Writes from the UI are stamped as `human:user` — the
|
|
@@ -22,6 +23,12 @@ export async function createBundle(name) {
|
|
|
22
23
|
await initBundle(await root(), name);
|
|
23
24
|
return { name, concepts: 0 };
|
|
24
25
|
}
|
|
26
|
+
/** Move a bundle to the trash. */
|
|
27
|
+
export async function deleteBundle(name) {
|
|
28
|
+
const dir = path.join(await root(), safeBundleName(name));
|
|
29
|
+
if (!(await moveToTrash("knowledge", name, dir)))
|
|
30
|
+
throw new Error(`no bundle "${name}"`);
|
|
31
|
+
}
|
|
25
32
|
export async function bundleDetail(name) {
|
|
26
33
|
const kroot = await root();
|
|
27
34
|
const dir = path.join(kroot, safeBundleName(name));
|
|
@@ -161,7 +161,7 @@ export declare function linkProject(slug: string, kind: string, target: string,
|
|
|
161
161
|
* agent reads as such next to a human's.
|
|
162
162
|
*/
|
|
163
163
|
export declare function appendProjectLog(slug: string, entry: string, actor: string): Promise<string>;
|
|
164
|
-
/**
|
|
164
|
+
/** Move the folder to the trash. */
|
|
165
165
|
export declare function deleteProject(slug: string): Promise<void>;
|
|
166
166
|
/** One line per system of record: what it is, where, and what usually lives there. */
|
|
167
167
|
export declare function recordLine(r: SystemOfRecord): string;
|
|
@@ -8,8 +8,10 @@ import { syncWorkspaceProjects } from "./instances.js";
|
|
|
8
8
|
import { knowledgeIndex } from "./knowledge.js";
|
|
9
9
|
import { newestMtime } from "./mtime.js";
|
|
10
10
|
import { listRepos } from "./repos.js";
|
|
11
|
+
import { moveToTrash } from "./trash.js";
|
|
11
12
|
import { listVibeables } from "./vibeables.js";
|
|
12
13
|
import { listWorkflows } from "./workflows.js";
|
|
14
|
+
import { filesContext } from "./files.js";
|
|
13
15
|
/**
|
|
14
16
|
* Projects: a goal with everything the agents need to reach it gathered in
|
|
15
17
|
* one folder — the brief, the systems of record where the truth is managed
|
|
@@ -427,13 +429,13 @@ export async function appendProjectLog(slug, entry, actor) {
|
|
|
427
429
|
await fs.writeFile(file, out);
|
|
428
430
|
return out;
|
|
429
431
|
}
|
|
430
|
-
/**
|
|
432
|
+
/** Move the folder to the trash. */
|
|
431
433
|
export async function deleteProject(slug) {
|
|
432
434
|
const root = await requireRoot();
|
|
433
435
|
const dir = path.join(root, safeProjectSlug(slug));
|
|
434
436
|
if (!(await fs.stat(path.join(dir, PROJECT_FILE)).catch(() => null)))
|
|
435
437
|
throw new Error(`no project "${slug}"`);
|
|
436
|
-
await
|
|
438
|
+
await moveToTrash("projects", slug, dir);
|
|
437
439
|
}
|
|
438
440
|
/* ---------- chat context ---------- */
|
|
439
441
|
/** One line per system of record: what it is, where, and what usually lives there. */
|
|
@@ -500,6 +502,7 @@ export async function projectContext(slug, actor, opts = {}) {
|
|
|
500
502
|
`\`npx kraftwerk repos\` lists every clone, \`repos add <url>\` clones a new one into the root.`
|
|
501
503
|
: `\nRepositories are switched off in this workspace's settings, so these clones are not reachable right now — tell the user.`)
|
|
502
504
|
: "",
|
|
505
|
+
await filesContext(`project:${p.slug}`, "Files", "The project's files (briefings, drafts, designs, deliverables) are in"),
|
|
503
506
|
vibeables
|
|
504
507
|
? `## Vibeables\nSmall apps of this project (one folder each under the vibeables root):\n` +
|
|
505
508
|
linkLines("vibeables", (l) => `- ${l.slug}${l.label ? ` (${l.label})` : ""}`) +
|
package/dist/inspector/repos.js
CHANGED
|
@@ -2,6 +2,7 @@ import { promises as fs } from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { gitignoreHas, ignoreEntryFor, reposRootFor, resolveProject } from "../config.js";
|
|
4
4
|
import { getProjectRoot } from "./context.js";
|
|
5
|
+
import { moveToTrash } from "./trash.js";
|
|
5
6
|
import { newestMtime } from "./mtime.js";
|
|
6
7
|
import { capDiff, DENY, diffAgainstHead, git, gitNet, label, literal, parseStatus, sshCommandFor } from "./git.js";
|
|
7
8
|
/**
|
|
@@ -340,7 +341,7 @@ export async function removeRepo(slug, force = false) {
|
|
|
340
341
|
if (repo.ahead)
|
|
341
342
|
return { ok: false, conflict: true, error: `"${slug}" has ${repo.ahead} unpushed commit(s)` };
|
|
342
343
|
}
|
|
343
|
-
await
|
|
344
|
+
await moveToTrash("repos", slug, dir);
|
|
344
345
|
return { ok: true };
|
|
345
346
|
}
|
|
346
347
|
/**
|
package/dist/inspector/runs.d.ts
CHANGED
|
@@ -66,7 +66,7 @@ export declare function readRunFile(id: string, name: string): Promise<{
|
|
|
66
66
|
size: number;
|
|
67
67
|
} | null>;
|
|
68
68
|
/**
|
|
69
|
-
*
|
|
69
|
+
* Move a run folder to the trash. A run that still looks live is refused: its process
|
|
70
70
|
* would keep writing into a folder that no longer exists — stop it first.
|
|
71
71
|
*/
|
|
72
72
|
export declare function deleteRun(id: string): Promise<"deleted" | "running" | "missing">;
|
package/dist/inspector/runs.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { getOutputDir } from "./context.js";
|
|
4
|
+
import { moveToTrash } from "./trash.js";
|
|
4
5
|
import { awaitingDecision, readDecision } from "./decisions.js";
|
|
5
6
|
/**
|
|
6
7
|
* Filesystem + trace.jsonl reading for the inspector. The output directory
|
|
@@ -306,7 +307,7 @@ export async function readRunFile(id, name) {
|
|
|
306
307
|
return { absPath, size: st.size };
|
|
307
308
|
}
|
|
308
309
|
/**
|
|
309
|
-
*
|
|
310
|
+
* Move a run folder to the trash. A run that still looks live is refused: its process
|
|
310
311
|
* would keep writing into a folder that no longer exists — stop it first.
|
|
311
312
|
*/
|
|
312
313
|
export async function deleteRun(id) {
|
|
@@ -315,6 +316,6 @@ export async function deleteRun(id) {
|
|
|
315
316
|
return "missing";
|
|
316
317
|
if (run.status === "running")
|
|
317
318
|
return "running";
|
|
318
|
-
await
|
|
319
|
+
await moveToTrash("runs", id, safeRunDir(id));
|
|
319
320
|
return "deleted";
|
|
320
321
|
}
|