@netnodeag/kraftwerk 0.79.0 → 0.81.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/README.md +88 -2
- package/dist/cli/daemon.js +10 -9
- package/dist/cli/doctor.js +1 -1
- package/dist/cli/entities.d.ts +8 -0
- package/dist/cli/entities.js +222 -0
- package/dist/cli/init.js +6 -2
- package/dist/cli/kraftwerk.js +10 -0
- package/dist/cli/temp.d.ts +2 -0
- package/dist/cli/temp.js +62 -0
- package/dist/cli/ui.js +2 -1
- package/dist/client/routes.d.ts +18 -0
- package/dist/client/routes.js +18 -0
- package/dist/config.d.ts +25 -0
- package/dist/config.js +17 -6
- package/dist/core/chat/acp.js +2 -1
- package/dist/core/chat/backend.d.ts +2 -0
- package/dist/core/chat/context.js +15 -7
- package/dist/core/chat/pi.js +2 -1
- package/dist/core/chat/seats.js +3 -0
- package/dist/core/chat/sessions.js +3 -0
- package/dist/core/entities.d.ts +171 -0
- package/dist/core/entities.js +441 -0
- package/dist/core/files.js +1 -0
- package/dist/core/git.js +3 -1
- package/dist/core/journal.d.ts +12 -2
- package/dist/core/journal.js +50 -5
- package/dist/core/log.d.ts +18 -0
- package/dist/core/log.js +23 -0
- package/dist/core/projects.d.ts +22 -33
- package/dist/core/projects.js +41 -135
- package/dist/core/records.d.ts +28 -0
- package/dist/core/records.js +71 -0
- package/dist/core/refs.d.ts +74 -0
- package/dist/core/refs.js +143 -0
- package/dist/core/search.d.ts +10 -0
- package/dist/core/search.js +12 -0
- package/dist/core/settings.d.ts +4 -0
- package/dist/core/settings.js +24 -3
- package/dist/core/slug.d.ts +9 -0
- package/dist/core/slug.js +18 -0
- package/dist/core/temp.d.ts +73 -0
- package/dist/core/temp.js +154 -0
- package/dist/core/trash.d.ts +1 -1
- package/dist/harness.d.ts +2 -0
- package/dist/harnesses/acp.js +1 -1
- package/dist/harnesses/claude.js +1 -0
- package/dist/harnesses/codex.js +1 -0
- package/dist/harnesses/pi.js +1 -0
- package/dist/run.d.ts +7 -0
- package/dist/run.js +13 -1
- package/dist/server/api/devices.js +2 -1
- package/dist/server/api/entities.d.ts +74 -0
- package/dist/server/api/entities.js +60 -0
- package/dist/server/api/files.d.ts +9 -1
- package/dist/server/api/files.js +13 -0
- package/dist/server/api/index.d.ts +75 -5
- package/dist/server/api/index.js +5 -0
- package/dist/server/api/projects.d.ts +3 -0
- package/dist/server/api/projects.js +3 -0
- package/dist/server/api/workspace.d.ts +15 -0
- package/dist/server/api/workspace.js +6 -0
- package/dist/server/cloud.js +3 -2
- package/dist/server/relay.d.ts +3 -2
- package/dist/server/relay.js +7 -7
- package/dist/server/server.js +3 -2
- package/dist/yaml.js +1 -0
- package/package.json +1 -1
- package/schema/kraftwerk.schema.json +54 -0
- package/web/dist/assets/{dist-cNijUfNl.js → dist-8IOFzZx-.js} +1 -1
- package/web/dist/assets/{dist-E48sO9Gc.js → dist-AhnSw4hy.js} +1 -1
- package/web/dist/assets/{dist-BTD8k55t.js → dist-BK0VhAPQ.js} +1 -1
- package/web/dist/assets/{dist-A0LN_JdM.js → dist-Ba6WSVOB.js} +1 -1
- package/web/dist/assets/{dist-DgP31lcC.js → dist-BiU4F98O.js} +1 -1
- package/web/dist/assets/{dist-wtIUmbnn.js → dist-Dx3aajX1.js} +1 -1
- package/web/dist/assets/{dist-DdOsafun.js → dist-Dy1SvhKC.js} +1 -1
- package/web/dist/assets/{dist-D93lSM0a.js → dist-ENZYo2jZ.js} +1 -1
- package/web/dist/assets/{dist-BasQkRXA.js → dist-FfKYtQxT.js} +1 -1
- package/web/dist/assets/{dist-xLZN2eGO.js → dist-cewmjxFi.js} +1 -1
- package/web/dist/assets/dist-lgUK2Fjt.js +1 -0
- package/web/dist/assets/{editor-BQlHN7qT.js → editor-CaRa1NMC.js} +3 -3
- package/web/dist/assets/index-Dpzux3OC.js +231 -0
- package/web/dist/assets/index-Dvt-vE1h.css +2 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/dist-B04mMuvH.js +0 -1
- package/web/dist/assets/index-BKDJocDd.css +0 -2
- package/web/dist/assets/index-C7ddpWNC.js +0 -231
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { listAgents } from "./agents.js";
|
|
2
|
+
import { CONTACTS, EVENTS, listEntities, PLACES } from "./entities.js";
|
|
3
|
+
import { knowledgeIndex } from "./knowledge.js";
|
|
4
|
+
import { listProjects } from "./projects.js";
|
|
5
|
+
import { listRepos } from "./repos.js";
|
|
6
|
+
import { listVibeables } from "./vibeables.js";
|
|
7
|
+
import { listWorkflows } from "./workflows.js";
|
|
8
|
+
/**
|
|
9
|
+
* The reference registry: everything in a workspace that can be pointed
|
|
10
|
+
* at by name — knowledge bundles, vibeables, repositories, workflows,
|
|
11
|
+
* agents, projects, contacts, places and events. Each kind registers only
|
|
12
|
+
* a lookup (its storage and routes stay where they are); one resolver then
|
|
13
|
+
* serves every place a reference appears: a project's link lists, an
|
|
14
|
+
* event's place and contacts, a `contact:anna-meier` token in text, the
|
|
15
|
+
* ⌘K palette and the context an agent starts with.
|
|
16
|
+
*
|
|
17
|
+
* A reference is `<token>:<slug>` — the kind's singular token (contact,
|
|
18
|
+
* place, event, project, agent, workflow, repo, vibeable, knowledge) and
|
|
19
|
+
* the target's slug. Only registered tokens match, so `https:` and the
|
|
20
|
+
* like never do. A target that does not exist is reported (found: false),
|
|
21
|
+
* never an error — the same rule project links have always followed.
|
|
22
|
+
*/
|
|
23
|
+
export const REF_KIND_NAMES = ["knowledge", "vibeables", "repos", "workflows", "agents", "projects", "contacts", "places", "events"];
|
|
24
|
+
/** A list that fails to read is an empty list: a reference into it is "not found", never an error. */
|
|
25
|
+
const safe = async (p, fallback) => p.catch(() => fallback);
|
|
26
|
+
export const REF_KINDS = [
|
|
27
|
+
{
|
|
28
|
+
kind: "knowledge",
|
|
29
|
+
token: "knowledge",
|
|
30
|
+
noun: "knowledge bundle",
|
|
31
|
+
list: async () => (await safe(knowledgeIndex(), { bundles: [] })).bundles.map((b) => ({ slug: b.name, label: `${b.concepts} concepts`, href: `/knowledge/${b.name}` })),
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
kind: "vibeables",
|
|
35
|
+
token: "vibeable",
|
|
36
|
+
noun: "vibeable",
|
|
37
|
+
list: async () => (await safe(listVibeables(), { vibeables: [] })).vibeables.map((v) => ({ slug: v.slug, label: v.dev ? "dev" : "static", href: `/vibeables/${v.slug}` })),
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
kind: "repos",
|
|
41
|
+
token: "repo",
|
|
42
|
+
noun: "repository",
|
|
43
|
+
list: async () => (await safe(listRepos(), { repos: [] })).repos.map((r) => ({ slug: r.slug, label: r.branch ?? "", href: `/repos/${r.slug}` })),
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
kind: "workflows",
|
|
47
|
+
token: "workflow",
|
|
48
|
+
noun: "workflow",
|
|
49
|
+
list: async () => (await safe(listWorkflows(), { workflows: [] })).workflows.map((w) => ({ slug: w.slug, label: w.description ?? w.name ?? "", href: `/workflows/${w.slug}` })),
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
kind: "agents",
|
|
53
|
+
token: "agent",
|
|
54
|
+
noun: "agent",
|
|
55
|
+
list: async () => (await safe(listAgents(), []))
|
|
56
|
+
.filter((a) => !a.archived)
|
|
57
|
+
.map((a) => ({ slug: a.slug, label: `${a.emoji} ${a.name}`, href: `/agents/${a.slug}` })),
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
kind: "projects",
|
|
61
|
+
token: "project",
|
|
62
|
+
noun: "project",
|
|
63
|
+
list: async () => (await safe(listProjects(), { projects: [] })).projects.map((p) => ({ slug: p.slug, label: p.title, href: `/projects/${p.slug}` })),
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
kind: "contacts",
|
|
67
|
+
token: "contact",
|
|
68
|
+
noun: "contact",
|
|
69
|
+
list: async () => (await safe(listEntities(CONTACTS), { items: [] })).items.map((c) => ({ slug: c.slug, label: CONTACTS.describe(c), href: `/contacts/${c.slug}` })),
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
kind: "places",
|
|
73
|
+
token: "place",
|
|
74
|
+
noun: "place",
|
|
75
|
+
list: async () => (await safe(listEntities(PLACES), { items: [] })).items.map((p) => ({ slug: p.slug, label: PLACES.describe(p), href: `/places/${p.slug}` })),
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
kind: "events",
|
|
79
|
+
token: "event",
|
|
80
|
+
noun: "event",
|
|
81
|
+
list: async () => (await safe(listEntities(EVENTS), { items: [] })).items.map((e) => ({ slug: e.slug, label: EVENTS.describe(e), href: `/events/${e.slug}` })),
|
|
82
|
+
},
|
|
83
|
+
];
|
|
84
|
+
const byKind = new Map(REF_KINDS.map((d) => [d.kind, d]));
|
|
85
|
+
const byToken = new Map(REF_KINDS.map((d) => [d.token, d]));
|
|
86
|
+
/** The kind registered under a plural name or a token; undefined for anything else. */
|
|
87
|
+
export const refKind = (name) => byKind.get(name) ?? byToken.get(name);
|
|
88
|
+
/** The link states of some slugs against one list of targets. */
|
|
89
|
+
const states = (slugs, targets) => slugs.map((slug) => {
|
|
90
|
+
const t = targets.find((x) => x.slug === slug);
|
|
91
|
+
return t ? { slug, found: true, ...(t.label ? { label: t.label } : {}) } : { slug, found: false };
|
|
92
|
+
});
|
|
93
|
+
/** Which of the slugs exist right now, per kind. Kinds without slugs are not listed at all. */
|
|
94
|
+
export async function resolveRefs(byKind) {
|
|
95
|
+
const out = {};
|
|
96
|
+
await Promise.all(Object.keys(byKind).map(async (kind) => {
|
|
97
|
+
const slugs = byKind[kind];
|
|
98
|
+
const def = refKind(kind);
|
|
99
|
+
out[kind] = !def || slugs.length === 0 ? [] : states(slugs, await def.list());
|
|
100
|
+
}));
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
const SLUG = "[a-z0-9][a-z0-9-]{0,48}";
|
|
104
|
+
/** A `token:slug` reference in text: a registered token, a colon, a slug — not inside a word, a path or a URL. */
|
|
105
|
+
export const REF_TOKEN_RE = new RegExp(`(?<![\\w/:.@-])(${REF_KINDS.map((d) => d.token).join("|")}):(${SLUG})(?![\\w-])`, "g");
|
|
106
|
+
/** Every distinct reference in a text, in order of appearance. */
|
|
107
|
+
export function findRefs(text) {
|
|
108
|
+
const seen = new Set();
|
|
109
|
+
const out = [];
|
|
110
|
+
for (const m of text.matchAll(REF_TOKEN_RE)) {
|
|
111
|
+
const token = m[0];
|
|
112
|
+
if (seen.has(token))
|
|
113
|
+
continue;
|
|
114
|
+
seen.add(token);
|
|
115
|
+
out.push({ token, kind: byToken.get(m[1]).kind, slug: m[2] });
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
/** Parse one reference ("contact:anna-meier"); null when it is not one. */
|
|
120
|
+
export function parseRef(text) {
|
|
121
|
+
const m = new RegExp(`^(${REF_KINDS.map((d) => d.token).join("|")}|${REF_KIND_NAMES.join("|")}):(${SLUG})$`).exec(text.trim());
|
|
122
|
+
if (!m)
|
|
123
|
+
return null;
|
|
124
|
+
const def = refKind(m[1]);
|
|
125
|
+
return { token: `${def.token}:${m[2]}`, kind: def.kind, slug: m[2] };
|
|
126
|
+
}
|
|
127
|
+
/** Resolve references: each with whether its target exists and where it opens. */
|
|
128
|
+
export async function resolveTokens(refs) {
|
|
129
|
+
const kinds = [...new Set(refs.map((r) => r.kind))];
|
|
130
|
+
const lists = new Map(await Promise.all(kinds.map(async (k) => [k, await refKind(k).list()])));
|
|
131
|
+
return refs.map((r) => {
|
|
132
|
+
const def = refKind(r.kind);
|
|
133
|
+
const t = lists.get(r.kind).find((x) => x.slug === r.slug);
|
|
134
|
+
return { kind: r.kind, token: `${def.token}:${r.slug}`, slug: r.slug, found: !!t, ...(t?.label ? { label: t.label } : {}), href: t?.href ?? `/${r.kind}/${r.slug}` };
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
/** Resolve one reference written as text; null when the text is not a reference. */
|
|
138
|
+
export async function resolveRef(text) {
|
|
139
|
+
const ref = parseRef(text);
|
|
140
|
+
if (!ref)
|
|
141
|
+
return null;
|
|
142
|
+
return (await resolveTokens([ref]))[0];
|
|
143
|
+
}
|
package/dist/core/search.d.ts
CHANGED
|
@@ -25,6 +25,16 @@ export interface WorkspaceAgents {
|
|
|
25
25
|
channels: ChannelSummary[];
|
|
26
26
|
/** Projects that are not archived; empty when the feature is off. */
|
|
27
27
|
projects: ProjectHit[];
|
|
28
|
+
/** Contacts, places and events of this instance (the registry does not carry them); empty for other workspaces and while the features are off. */
|
|
29
|
+
entities: EntityHit[];
|
|
30
|
+
}
|
|
31
|
+
/** What the palette needs of a contact, place or event. */
|
|
32
|
+
export interface EntityHit {
|
|
33
|
+
kind: "contacts" | "places" | "events";
|
|
34
|
+
slug: string;
|
|
35
|
+
title: string;
|
|
36
|
+
/** The rest of its one-line description (role and org, address, date). */
|
|
37
|
+
detail?: string;
|
|
28
38
|
}
|
|
29
39
|
export interface AgentSearch {
|
|
30
40
|
workspaces: WorkspaceAgents[];
|
package/dist/core/search.js
CHANGED
|
@@ -4,7 +4,17 @@ import { workspaceRoot } from "./context.js";
|
|
|
4
4
|
import { listAgents, toSummary } from "./agents.js";
|
|
5
5
|
import { listChannels, toChannelSummary } from "./channels.js";
|
|
6
6
|
import { currentInstanceUrl, discoverWorkspaces, listWorkspaceRecords } from "./instances.js";
|
|
7
|
+
import { CONTACTS, EVENTS, listEntities, PLACES } from "./entities.js";
|
|
7
8
|
import { listProjects, toProjectHit } from "./projects.js";
|
|
9
|
+
async function entityHits(def) {
|
|
10
|
+
const view = await listEntities(def).catch(() => ({ items: [] }));
|
|
11
|
+
return view.items
|
|
12
|
+
.filter((e) => !e.configError)
|
|
13
|
+
.map((e) => {
|
|
14
|
+
const detail = def.detail(e);
|
|
15
|
+
return { kind: def.kind, slug: e.slug, title: e.title, ...(detail ? { detail } : {}) };
|
|
16
|
+
});
|
|
17
|
+
}
|
|
8
18
|
export async function searchAgents() {
|
|
9
19
|
const project = await resolveWorkspace(workspaceRoot()).catch(() => null);
|
|
10
20
|
const self = {
|
|
@@ -17,6 +27,7 @@ export async function searchAgents() {
|
|
|
17
27
|
agents: (await listAgents().catch(() => [])).filter((a) => !a.archived).map(toSummary),
|
|
18
28
|
channels: (await listChannels().catch(() => [])).map(toChannelSummary),
|
|
19
29
|
projects: (await listProjects().catch(() => ({ projects: [] }))).projects.filter((p) => p.status !== "archived").map(toProjectHit),
|
|
30
|
+
entities: (await Promise.all([CONTACTS, PLACES, EVENTS].map(entityHits))).flat(),
|
|
20
31
|
};
|
|
21
32
|
const [workspaces, known] = await Promise.all([discoverWorkspaces(), listWorkspaceRecords()]);
|
|
22
33
|
const records = new Map(known.map((p) => [p.root, p]));
|
|
@@ -33,6 +44,7 @@ export async function searchAgents() {
|
|
|
33
44
|
agents: records.get(w.root)?.agents ?? [],
|
|
34
45
|
channels: records.get(w.root)?.channels ?? [],
|
|
35
46
|
projects: records.get(w.root)?.projects ?? [],
|
|
47
|
+
entities: [],
|
|
36
48
|
}));
|
|
37
49
|
return { workspaces: [self, ...others] };
|
|
38
50
|
}
|
package/dist/core/settings.d.ts
CHANGED
|
@@ -32,6 +32,10 @@ export interface SaveSettingsInput {
|
|
|
32
32
|
vibeables?: ReposSettingsInput;
|
|
33
33
|
/** Omitted = untouched. Same shape and rules as repos. */
|
|
34
34
|
projects?: ReposSettingsInput;
|
|
35
|
+
/** Omitted = untouched. Same shape and rules as repos. */
|
|
36
|
+
contacts?: ReposSettingsInput;
|
|
37
|
+
places?: ReposSettingsInput;
|
|
38
|
+
events?: ReposSettingsInput;
|
|
35
39
|
}
|
|
36
40
|
export interface ReposSettingsInput {
|
|
37
41
|
enabled: boolean;
|
package/dist/core/settings.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { parseDocument } from "yaml";
|
|
4
|
-
import { ignoreEntryFor, PROJECTS_DEFAULT_ROOT, REPOS_DEFAULT_ROOT, resolveWorkspace, VIBEABLES_DEFAULT_ROOT, isSafeGitName } from "../config.js";
|
|
4
|
+
import { ENTITY_KINDS, entityDefaultRoot, ignoreEntryFor, PROJECTS_DEFAULT_ROOT, REPOS_DEFAULT_ROOT, resolveWorkspace, VIBEABLES_DEFAULT_ROOT, isSafeGitName } from "../config.js";
|
|
5
|
+
import { ensureEntitiesRoot } from "./entities.js";
|
|
5
6
|
import { ensureProjectsRoot } from "./projects.js";
|
|
6
7
|
import { disposeAllDevs, ensureVibeablesRoot } from "./vibeables.js";
|
|
7
8
|
import { workspaceRoot } from "./context.js";
|
|
@@ -76,8 +77,15 @@ function cleanGit(value) {
|
|
|
76
77
|
return null;
|
|
77
78
|
return { enabled: false, ...out };
|
|
78
79
|
}
|
|
79
|
-
const ROOT_DEFAULTS = {
|
|
80
|
-
|
|
80
|
+
const ROOT_DEFAULTS = {
|
|
81
|
+
repos: REPOS_DEFAULT_ROOT,
|
|
82
|
+
vibeables: VIBEABLES_DEFAULT_ROOT,
|
|
83
|
+
projects: PROJECTS_DEFAULT_ROOT,
|
|
84
|
+
contacts: entityDefaultRoot("contacts"),
|
|
85
|
+
places: entityDefaultRoot("places"),
|
|
86
|
+
events: entityDefaultRoot("events"),
|
|
87
|
+
};
|
|
88
|
+
/** The repos, vibeables, projects, contacts, places or events block to write; null when the feature is off and nothing else is set. */
|
|
81
89
|
function cleanRootBlock(block, value, projectRoot) {
|
|
82
90
|
if (typeof value !== "object" || value === null)
|
|
83
91
|
throw new Error(`${block} must be an object`);
|
|
@@ -163,6 +171,16 @@ export async function saveSettings(input) {
|
|
|
163
171
|
else
|
|
164
172
|
doc.set("projects", projects);
|
|
165
173
|
}
|
|
174
|
+
for (const kind of ENTITY_KINDS) {
|
|
175
|
+
const given = input[kind];
|
|
176
|
+
if (given === undefined)
|
|
177
|
+
continue;
|
|
178
|
+
const block = cleanRootBlock(kind, given, project.root);
|
|
179
|
+
if (block === null)
|
|
180
|
+
doc.delete(kind);
|
|
181
|
+
else
|
|
182
|
+
doc.set(kind, block);
|
|
183
|
+
}
|
|
166
184
|
await fs.writeFile(configPath, doc.toString());
|
|
167
185
|
// Turning repositories on prepares the folder so the first clone and the
|
|
168
186
|
// workspace git both find it in the right state.
|
|
@@ -172,5 +190,8 @@ export async function saveSettings(input) {
|
|
|
172
190
|
await ensureVibeablesRoot().catch(() => { });
|
|
173
191
|
if (input.projects?.enabled)
|
|
174
192
|
await ensureProjectsRoot().catch(() => { });
|
|
193
|
+
for (const kind of ENTITY_KINDS)
|
|
194
|
+
if (input[kind]?.enabled)
|
|
195
|
+
await ensureEntitiesRoot(kind).catch(() => { });
|
|
175
196
|
return getSettings();
|
|
176
197
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one slug rule for folders and files kraftwerk names after a title:
|
|
3
|
+
* lowercase letters, digits and dashes, at most 49 characters, starting
|
|
4
|
+
* with a letter or digit. Projects, contacts, places and events share it,
|
|
5
|
+
* so a `kind:slug` reference has one shape everywhere.
|
|
6
|
+
*/
|
|
7
|
+
export declare const SLUG_RE: RegExp;
|
|
8
|
+
/** Derive a slug from a title: "Relaunch netnode.ch" -> "relaunch-netnode-ch". */
|
|
9
|
+
export declare function slugify(title: string, max?: number): string;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one slug rule for folders and files kraftwerk names after a title:
|
|
3
|
+
* lowercase letters, digits and dashes, at most 49 characters, starting
|
|
4
|
+
* with a letter or digit. Projects, contacts, places and events share it,
|
|
5
|
+
* so a `kind:slug` reference has one shape everywhere.
|
|
6
|
+
*/
|
|
7
|
+
export const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,48}$/;
|
|
8
|
+
/** Derive a slug from a title: "Relaunch netnode.ch" -> "relaunch-netnode-ch". */
|
|
9
|
+
export function slugify(title, max = 48) {
|
|
10
|
+
return title
|
|
11
|
+
.normalize("NFKD")
|
|
12
|
+
.replace(/[̀-ͯ]/g, "")
|
|
13
|
+
.toLowerCase()
|
|
14
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
15
|
+
.replace(/^-+|-+$/g, "")
|
|
16
|
+
.slice(0, max)
|
|
17
|
+
.replace(/-+$/, "");
|
|
18
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The temp folder: where a session's scratch work goes — intermediate
|
|
3
|
+
* results, downloads, build output, a markdown written for a CLI to read —
|
|
4
|
+
* instead of the system's temp dir or, worse, the workspace's files.
|
|
5
|
+
*
|
|
6
|
+
* kraftwerk-data/temp/<session-id>/ one folder per chat session
|
|
7
|
+
* <run dir>/temp/ a workflow run's (inside its mount)
|
|
8
|
+
*
|
|
9
|
+
* Three things make it stick, so it does not depend on an agent reading a
|
|
10
|
+
* sentence: the folder is handed to every spawned agent as TMPDIR (and
|
|
11
|
+
* TMP, TEMP, KRAFTWERK_TEMP), so mktemp, Python's tempfile, Node's
|
|
12
|
+
* os.tmpdir() and the agent's own scratchpad land there by themselves; it
|
|
13
|
+
* sits inside the workspace, so writes there need no approval from the
|
|
14
|
+
* harness; and the session prompt names it next to the files folders,
|
|
15
|
+
* which say in turn that nothing temporary belongs in them.
|
|
16
|
+
*
|
|
17
|
+
* The temp root is local: a .gitignore of its own keeps everything in it
|
|
18
|
+
* out of the workspace git (and `kraftwerk init` lists it too). Nothing in
|
|
19
|
+
* it is a deliverable, so nothing goes to the trash: a chat's folder is
|
|
20
|
+
* removed with the chat, and folders nobody touched for SWEEP_AFTER_MS are
|
|
21
|
+
* swept when a session starts (at most once an hour) or on demand
|
|
22
|
+
* (`kraftwerk temp clean`, DELETE /api/temp).
|
|
23
|
+
*/
|
|
24
|
+
export declare const TEMP_DIR = "kraftwerk-data/temp";
|
|
25
|
+
/** A folder idle this long is swept. */
|
|
26
|
+
export declare const SWEEP_AFTER_MS: number;
|
|
27
|
+
export interface TempEntry {
|
|
28
|
+
/** The folder name: the session's id. */
|
|
29
|
+
id: string;
|
|
30
|
+
/** Relative to the workspace root, forward slashes. */
|
|
31
|
+
path: string;
|
|
32
|
+
files: number;
|
|
33
|
+
bytes: number;
|
|
34
|
+
/** The newest change inside (the folder's own when empty). */
|
|
35
|
+
modifiedAt: string;
|
|
36
|
+
}
|
|
37
|
+
export interface TempView {
|
|
38
|
+
root: string;
|
|
39
|
+
entries: TempEntry[];
|
|
40
|
+
bytes: number;
|
|
41
|
+
}
|
|
42
|
+
/** Absolute temp root of the current workspace. */
|
|
43
|
+
export declare function tempRoot(): Promise<string>;
|
|
44
|
+
/** The environment that points a process at `dir` for everything temporary. */
|
|
45
|
+
export declare function tempEnv(dir: string): Record<string, string>;
|
|
46
|
+
/** Create a folder outside the workspace's temp root (a workflow run's) and return the env for it. */
|
|
47
|
+
export declare function tempEnvFor(dir: string): Promise<Record<string, string>>;
|
|
48
|
+
/**
|
|
49
|
+
* A session's own folder, created: absolute path and the path relative to
|
|
50
|
+
* the workspace root (for the prompt). Starting a session is also when
|
|
51
|
+
* folders nobody uses any more are swept, at most once an hour.
|
|
52
|
+
*/
|
|
53
|
+
export declare function sessionTemp(id: string): Promise<{
|
|
54
|
+
abs: string;
|
|
55
|
+
rel: string;
|
|
56
|
+
env: Record<string, string>;
|
|
57
|
+
}>;
|
|
58
|
+
/** Remove one session's folder for good (nothing there is a deliverable). */
|
|
59
|
+
export declare function removeSessionTemp(id: string): Promise<void>;
|
|
60
|
+
/** Every session folder in the temp root, newest first. */
|
|
61
|
+
export declare function listTemp(): Promise<TempView>;
|
|
62
|
+
/**
|
|
63
|
+
* Remove session folders: all of them, or those idle longer than
|
|
64
|
+
* `olderThanMs` (default SWEEP_AFTER_MS). `keep` names folders that stay
|
|
65
|
+
* whatever their age (the session being started). Returns what went.
|
|
66
|
+
*/
|
|
67
|
+
export declare function cleanTemp(opts?: {
|
|
68
|
+
all?: boolean;
|
|
69
|
+
olderThanMs?: number;
|
|
70
|
+
keep?: string[];
|
|
71
|
+
}): Promise<TempEntry[]>;
|
|
72
|
+
/** The "## Temporary files" block of a session's context. */
|
|
73
|
+
export declare function tempContext(rel: string): string;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { resolveWorkspace } from "../config.js";
|
|
4
|
+
import { workspaceRoot } from "./context.js";
|
|
5
|
+
import { perWorkspace } from "./workspace.js";
|
|
6
|
+
/**
|
|
7
|
+
* The temp folder: where a session's scratch work goes — intermediate
|
|
8
|
+
* results, downloads, build output, a markdown written for a CLI to read —
|
|
9
|
+
* instead of the system's temp dir or, worse, the workspace's files.
|
|
10
|
+
*
|
|
11
|
+
* kraftwerk-data/temp/<session-id>/ one folder per chat session
|
|
12
|
+
* <run dir>/temp/ a workflow run's (inside its mount)
|
|
13
|
+
*
|
|
14
|
+
* Three things make it stick, so it does not depend on an agent reading a
|
|
15
|
+
* sentence: the folder is handed to every spawned agent as TMPDIR (and
|
|
16
|
+
* TMP, TEMP, KRAFTWERK_TEMP), so mktemp, Python's tempfile, Node's
|
|
17
|
+
* os.tmpdir() and the agent's own scratchpad land there by themselves; it
|
|
18
|
+
* sits inside the workspace, so writes there need no approval from the
|
|
19
|
+
* harness; and the session prompt names it next to the files folders,
|
|
20
|
+
* which say in turn that nothing temporary belongs in them.
|
|
21
|
+
*
|
|
22
|
+
* The temp root is local: a .gitignore of its own keeps everything in it
|
|
23
|
+
* out of the workspace git (and `kraftwerk init` lists it too). Nothing in
|
|
24
|
+
* it is a deliverable, so nothing goes to the trash: a chat's folder is
|
|
25
|
+
* removed with the chat, and folders nobody touched for SWEEP_AFTER_MS are
|
|
26
|
+
* swept when a session starts (at most once an hour) or on demand
|
|
27
|
+
* (`kraftwerk temp clean`, DELETE /api/temp).
|
|
28
|
+
*/
|
|
29
|
+
export const TEMP_DIR = "kraftwerk-data/temp";
|
|
30
|
+
/** A folder idle this long is swept. */
|
|
31
|
+
export const SWEEP_AFTER_MS = 7 * 24 * 60 * 60 * 1000;
|
|
32
|
+
const SWEEP_EVERY_MS = 60 * 60 * 1000;
|
|
33
|
+
/** A session id as a folder name: what newChatId() produces, nothing that could leave the root. */
|
|
34
|
+
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,120}$/;
|
|
35
|
+
async function resolvedRoot() {
|
|
36
|
+
const ws = await resolveWorkspace(workspaceRoot()).catch(() => null);
|
|
37
|
+
return ws?.root ?? workspaceRoot();
|
|
38
|
+
}
|
|
39
|
+
/** Absolute temp root of the current workspace. */
|
|
40
|
+
export async function tempRoot() {
|
|
41
|
+
return path.join(await resolvedRoot(), TEMP_DIR);
|
|
42
|
+
}
|
|
43
|
+
/** Create the root and keep it out of the workspace git: a .gitignore of its own ignores everything in it, itself included. */
|
|
44
|
+
async function prepare(root) {
|
|
45
|
+
await fs.mkdir(root, { recursive: true });
|
|
46
|
+
await fs.writeFile(path.join(root, ".gitignore"), "*\n", { flag: "wx" }).catch(() => { });
|
|
47
|
+
}
|
|
48
|
+
/** The environment that points a process at `dir` for everything temporary. */
|
|
49
|
+
export function tempEnv(dir) {
|
|
50
|
+
return { KRAFTWERK_TEMP: dir, TMPDIR: dir, TMP: dir, TEMP: dir };
|
|
51
|
+
}
|
|
52
|
+
/** Create a folder outside the workspace's temp root (a workflow run's) and return the env for it. */
|
|
53
|
+
export async function tempEnvFor(dir) {
|
|
54
|
+
await fs.mkdir(dir, { recursive: true });
|
|
55
|
+
return tempEnv(dir);
|
|
56
|
+
}
|
|
57
|
+
const lastSweep = perWorkspace(() => ({ at: 0 }));
|
|
58
|
+
/**
|
|
59
|
+
* A session's own folder, created: absolute path and the path relative to
|
|
60
|
+
* the workspace root (for the prompt). Starting a session is also when
|
|
61
|
+
* folders nobody uses any more are swept, at most once an hour.
|
|
62
|
+
*/
|
|
63
|
+
export async function sessionTemp(id) {
|
|
64
|
+
if (!ID_RE.test(id))
|
|
65
|
+
throw new Error("invalid session id");
|
|
66
|
+
const root = await tempRoot();
|
|
67
|
+
await prepare(root);
|
|
68
|
+
const abs = path.join(root, id);
|
|
69
|
+
await fs.mkdir(abs, { recursive: true });
|
|
70
|
+
const sweep = lastSweep();
|
|
71
|
+
if (Date.now() - sweep.at > SWEEP_EVERY_MS) {
|
|
72
|
+
sweep.at = Date.now();
|
|
73
|
+
void cleanTemp({ keep: [id] }).catch(() => { });
|
|
74
|
+
}
|
|
75
|
+
return { abs, rel: `${TEMP_DIR}/${id}`, env: tempEnv(abs) };
|
|
76
|
+
}
|
|
77
|
+
/** Remove one session's folder for good (nothing there is a deliverable). */
|
|
78
|
+
export async function removeSessionTemp(id) {
|
|
79
|
+
if (!ID_RE.test(id))
|
|
80
|
+
return;
|
|
81
|
+
await fs.rm(path.join(await tempRoot(), id), { recursive: true, force: true });
|
|
82
|
+
}
|
|
83
|
+
/** Files, bytes and the newest mtime below a folder. */
|
|
84
|
+
async function measure(dir) {
|
|
85
|
+
const st = await fs.stat(dir);
|
|
86
|
+
const out = { files: 0, bytes: 0, newest: st.mtimeMs };
|
|
87
|
+
const walk = async (d) => {
|
|
88
|
+
if (out.files > 20000)
|
|
89
|
+
return;
|
|
90
|
+
for (const k of await fs.readdir(d, { withFileTypes: true }).catch(() => [])) {
|
|
91
|
+
const abs = path.join(d, k.name);
|
|
92
|
+
if (k.isDirectory())
|
|
93
|
+
await walk(abs);
|
|
94
|
+
else if (k.isFile()) {
|
|
95
|
+
const s = await fs.stat(abs).catch(() => null);
|
|
96
|
+
if (!s)
|
|
97
|
+
continue;
|
|
98
|
+
out.files++;
|
|
99
|
+
out.bytes += s.size;
|
|
100
|
+
if (s.mtimeMs > out.newest)
|
|
101
|
+
out.newest = s.mtimeMs;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
await walk(dir);
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
/** Every session folder in the temp root, newest first. */
|
|
109
|
+
export async function listTemp() {
|
|
110
|
+
const root = await tempRoot();
|
|
111
|
+
const kids = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
|
|
112
|
+
const entries = [];
|
|
113
|
+
for (const k of kids) {
|
|
114
|
+
if (!k.isDirectory() || k.name.startsWith("."))
|
|
115
|
+
continue;
|
|
116
|
+
const m = await measure(path.join(root, k.name)).catch(() => null);
|
|
117
|
+
if (!m)
|
|
118
|
+
continue;
|
|
119
|
+
entries.push({ id: k.name, path: `${TEMP_DIR}/${k.name}`, files: m.files, bytes: m.bytes, modifiedAt: new Date(m.newest).toISOString() });
|
|
120
|
+
}
|
|
121
|
+
entries.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt));
|
|
122
|
+
return { root, entries, bytes: entries.reduce((n, e) => n + e.bytes, 0) };
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Remove session folders: all of them, or those idle longer than
|
|
126
|
+
* `olderThanMs` (default SWEEP_AFTER_MS). `keep` names folders that stay
|
|
127
|
+
* whatever their age (the session being started). Returns what went.
|
|
128
|
+
*/
|
|
129
|
+
export async function cleanTemp(opts = {}) {
|
|
130
|
+
const view = await listTemp();
|
|
131
|
+
const cutoff = Date.now() - (opts.olderThanMs ?? SWEEP_AFTER_MS);
|
|
132
|
+
const keep = new Set(opts.keep ?? []);
|
|
133
|
+
const gone = [];
|
|
134
|
+
for (const e of view.entries) {
|
|
135
|
+
if (keep.has(e.id))
|
|
136
|
+
continue;
|
|
137
|
+
if (!opts.all && Date.parse(e.modifiedAt) > cutoff)
|
|
138
|
+
continue;
|
|
139
|
+
await fs.rm(path.join(view.root, e.id), { recursive: true, force: true });
|
|
140
|
+
gone.push(e);
|
|
141
|
+
}
|
|
142
|
+
return gone;
|
|
143
|
+
}
|
|
144
|
+
/** The "## Temporary files" block of a session's context. */
|
|
145
|
+
export function tempContext(rel) {
|
|
146
|
+
return (`## Temporary files\n` +
|
|
147
|
+
`Your scratch folder is ${rel}/ — TMPDIR (and KRAFTWERK_TEMP) point there, so \`mktemp\`, Python's tempfile, ` +
|
|
148
|
+
`os.tmpdir() and your own scratchpad land in it by themselves. Put everything that is not a result there: ` +
|
|
149
|
+
`intermediate output, downloads, extracted archives, build output, one-off scripts, a markdown written for a CLI ` +
|
|
150
|
+
`to read. It is private to this session, kept out of git, and removed when the session is deleted or after a week ` +
|
|
151
|
+
`unused — never store anything there you or the user want to keep. The files folders named above hold only what ` +
|
|
152
|
+
`the user keeps: what you produce for them, and the material they gave you. Never create temporary or ` +
|
|
153
|
+
`intermediate files there, and never in the system's temp dir.`);
|
|
154
|
+
}
|
package/dist/core/trash.d.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
export declare const TRASH_DIR = "kraftwerk-data/trash";
|
|
10
10
|
/** What can be in the trash; also the folder under trash/ it lands in. */
|
|
11
|
-
export type TrashKind = "agents" | "agent-skills" | "projects" | "workflows" | "knowledge" | "vibeables" | "channels" | "repos" | "chats" | "runs" | "files";
|
|
11
|
+
export type TrashKind = "agents" | "agent-skills" | "projects" | "contacts" | "places" | "events" | "workflows" | "knowledge" | "vibeables" | "channels" | "repos" | "chats" | "runs" | "files";
|
|
12
12
|
export interface TrashEntry {
|
|
13
13
|
/** Path inside the trash, forward slashes — the entry's handle. */
|
|
14
14
|
id: string;
|
package/dist/harness.d.ts
CHANGED
|
@@ -47,6 +47,8 @@ export interface AgentInvocation {
|
|
|
47
47
|
clis?: string[];
|
|
48
48
|
/** MCP servers available in this phase, keyed by server name. */
|
|
49
49
|
mcpServers?: Record<string, McpServerConfig>;
|
|
50
|
+
/** Extra environment for the agent process (the run's temp folder as TMPDIR, see core/temp.ts); on top of the workspace's. */
|
|
51
|
+
env?: Record<string, string>;
|
|
50
52
|
resume?: string;
|
|
51
53
|
onToolUse?: (tool: string, target: string) => void;
|
|
52
54
|
onText?: (text: string) => void;
|
package/dist/harnesses/acp.js
CHANGED
|
@@ -83,7 +83,7 @@ async function open(agent, inv, resume) {
|
|
|
83
83
|
};
|
|
84
84
|
const { child, conn } = await connectAcp(agent, {
|
|
85
85
|
cwd: inv.cwd,
|
|
86
|
-
env: adapterEnv(agent, { model: inv.model, effort: inv.effort }),
|
|
86
|
+
env: { ...adapterEnv(agent, { model: inv.model, effort: inv.effort }), ...inv.env },
|
|
87
87
|
client,
|
|
88
88
|
clientName: "kraftwerk",
|
|
89
89
|
onError: (err) => end(`could not start the ${agent} adapter: ${err.message}`),
|
package/dist/harnesses/claude.js
CHANGED
package/dist/harnesses/codex.js
CHANGED
package/dist/harnesses/pi.js
CHANGED
package/dist/run.d.ts
CHANGED
|
@@ -39,6 +39,13 @@ export declare class Run {
|
|
|
39
39
|
private readonly verbose;
|
|
40
40
|
private readonly maxGateRetries;
|
|
41
41
|
constructor(options: RunOptions);
|
|
42
|
+
/**
|
|
43
|
+
* The run's scratch folder, <run dir>/temp, as TMPDIR for every phase
|
|
44
|
+
* (core/temp.ts): inside the run dir so a sandboxed run finds it in its
|
|
45
|
+
* mount, created when the first phase starts.
|
|
46
|
+
*/
|
|
47
|
+
private temp?;
|
|
48
|
+
private tempEnv;
|
|
42
49
|
trace(event: string, data?: Record<string, unknown>): Promise<void>;
|
|
43
50
|
agentPhase(params: {
|
|
44
51
|
name: string;
|
package/dist/run.js
CHANGED
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { harnessFor } from "./harnesses/registry.js";
|
|
5
5
|
import { correctionPrompt, parseEnvelope } from "./envelope.js";
|
|
6
6
|
import { fmtDuration, phaseStatsLine, summaryTable } from "./stats.js";
|
|
7
|
+
import { tempEnvFor } from "./core/temp.js";
|
|
7
8
|
export class Run {
|
|
8
9
|
/** Session per harness AND protocol: a CLI session id means nothing to the ACP adapter and vice versa. */
|
|
9
10
|
sessions = new Map();
|
|
@@ -19,6 +20,15 @@ export class Run {
|
|
|
19
20
|
this.verbose = options.verbose ?? false;
|
|
20
21
|
this.maxGateRetries = options.maxGateRetries ?? 2;
|
|
21
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* The run's scratch folder, <run dir>/temp, as TMPDIR for every phase
|
|
25
|
+
* (core/temp.ts): inside the run dir so a sandboxed run finds it in its
|
|
26
|
+
* mount, created when the first phase starts.
|
|
27
|
+
*/
|
|
28
|
+
temp;
|
|
29
|
+
tempEnv() {
|
|
30
|
+
return (this.temp ??= tempEnvFor(path.join(this.runDir, "temp")));
|
|
31
|
+
}
|
|
22
32
|
async trace(event, data = {}) {
|
|
23
33
|
await appendFile(path.join(this.runDir, "trace.jsonl"), JSON.stringify({ ts: new Date().toISOString(), event, ...data }) + "\n");
|
|
24
34
|
}
|
|
@@ -78,6 +88,7 @@ export class Run {
|
|
|
78
88
|
tools: agent.tools,
|
|
79
89
|
clis: cliEntries.map(([name]) => name),
|
|
80
90
|
mcpServers: agent.mcp,
|
|
91
|
+
env: await this.tempEnv(),
|
|
81
92
|
resume: this.sessions.get(sessionKey),
|
|
82
93
|
onToolUse: (tool, target) => {
|
|
83
94
|
console.log(` ⚙ ${tool} ${target}`.trimEnd());
|
|
@@ -160,10 +171,11 @@ export class Run {
|
|
|
160
171
|
console.log(`\n▸ Phase "${params.name}" — script (bash)`);
|
|
161
172
|
await this.trace("phase_start", { phase: params.name, kind: "script" });
|
|
162
173
|
const started = Date.now();
|
|
174
|
+
const temp = await this.tempEnv();
|
|
163
175
|
const result = await new Promise((resolve, reject) => {
|
|
164
176
|
const child = spawn("bash", ["-c", params.script], {
|
|
165
177
|
cwd: this.runDir,
|
|
166
|
-
env: { ...process.env, RUN_DIR: this.runDir, PHASE: params.name, ...params.env },
|
|
178
|
+
env: { ...process.env, ...temp, RUN_DIR: this.runDir, PHASE: params.name, ...params.env },
|
|
167
179
|
});
|
|
168
180
|
let stdout = "";
|
|
169
181
|
let stderr = "";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
|
+
import { log } from "../../core/log.js";
|
|
2
3
|
import { createPairCode, listDevices, redeemPairCode, revokeDevice, setDevicePush } from "../../core/devices.js";
|
|
3
4
|
import { networkUrls } from "../bind.js";
|
|
4
5
|
import { currentHub } from "../hub-context.js";
|
|
@@ -108,7 +109,7 @@ export const deviceRoutes = [
|
|
|
108
109
|
const { enabled, url } = await c.body();
|
|
109
110
|
const s = await setRelay(enabled, url);
|
|
110
111
|
const hub = currentHub();
|
|
111
|
-
const status = hub.daemon ? await applyRelay(hub.port, await getPkgVersion(), (l) =>
|
|
112
|
+
const status = hub.daemon ? await applyRelay(hub.port, await getPkgVersion(), (level, l) => log(level, "relay", l)) : relayStatus();
|
|
112
113
|
return { enabled: s.enabled, url: s.url, fingerprint: keyFingerprint(s.publicKey), daemon: hub.daemon === true, ...status };
|
|
113
114
|
}),
|
|
114
115
|
route({ name: "devices.revoke", method: "DELETE", path: "/api/devices/:id", summary: "unpair a device: its token stops working at once", access: "local" }, async (c) => (await revokeDevice(c.params.id)) ? { ok: true } : fail(404, "no such device")),
|