@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.
Files changed (86) hide show
  1. package/README.md +88 -2
  2. package/dist/cli/daemon.js +10 -9
  3. package/dist/cli/doctor.js +1 -1
  4. package/dist/cli/entities.d.ts +8 -0
  5. package/dist/cli/entities.js +222 -0
  6. package/dist/cli/init.js +6 -2
  7. package/dist/cli/kraftwerk.js +10 -0
  8. package/dist/cli/temp.d.ts +2 -0
  9. package/dist/cli/temp.js +62 -0
  10. package/dist/cli/ui.js +2 -1
  11. package/dist/client/routes.d.ts +18 -0
  12. package/dist/client/routes.js +18 -0
  13. package/dist/config.d.ts +25 -0
  14. package/dist/config.js +17 -6
  15. package/dist/core/chat/acp.js +2 -1
  16. package/dist/core/chat/backend.d.ts +2 -0
  17. package/dist/core/chat/context.js +15 -7
  18. package/dist/core/chat/pi.js +2 -1
  19. package/dist/core/chat/seats.js +3 -0
  20. package/dist/core/chat/sessions.js +3 -0
  21. package/dist/core/entities.d.ts +171 -0
  22. package/dist/core/entities.js +441 -0
  23. package/dist/core/files.js +1 -0
  24. package/dist/core/git.js +3 -1
  25. package/dist/core/journal.d.ts +12 -2
  26. package/dist/core/journal.js +50 -5
  27. package/dist/core/log.d.ts +18 -0
  28. package/dist/core/log.js +23 -0
  29. package/dist/core/projects.d.ts +22 -33
  30. package/dist/core/projects.js +41 -135
  31. package/dist/core/records.d.ts +28 -0
  32. package/dist/core/records.js +71 -0
  33. package/dist/core/refs.d.ts +74 -0
  34. package/dist/core/refs.js +143 -0
  35. package/dist/core/search.d.ts +10 -0
  36. package/dist/core/search.js +12 -0
  37. package/dist/core/settings.d.ts +4 -0
  38. package/dist/core/settings.js +24 -3
  39. package/dist/core/slug.d.ts +9 -0
  40. package/dist/core/slug.js +18 -0
  41. package/dist/core/temp.d.ts +73 -0
  42. package/dist/core/temp.js +154 -0
  43. package/dist/core/trash.d.ts +1 -1
  44. package/dist/harness.d.ts +2 -0
  45. package/dist/harnesses/acp.js +1 -1
  46. package/dist/harnesses/claude.js +1 -0
  47. package/dist/harnesses/codex.js +1 -0
  48. package/dist/harnesses/pi.js +1 -0
  49. package/dist/run.d.ts +7 -0
  50. package/dist/run.js +13 -1
  51. package/dist/server/api/devices.js +2 -1
  52. package/dist/server/api/entities.d.ts +74 -0
  53. package/dist/server/api/entities.js +60 -0
  54. package/dist/server/api/files.d.ts +9 -1
  55. package/dist/server/api/files.js +13 -0
  56. package/dist/server/api/index.d.ts +75 -5
  57. package/dist/server/api/index.js +5 -0
  58. package/dist/server/api/projects.d.ts +3 -0
  59. package/dist/server/api/projects.js +3 -0
  60. package/dist/server/api/workspace.d.ts +15 -0
  61. package/dist/server/api/workspace.js +6 -0
  62. package/dist/server/cloud.js +3 -2
  63. package/dist/server/relay.d.ts +3 -2
  64. package/dist/server/relay.js +7 -7
  65. package/dist/server/server.js +3 -2
  66. package/dist/yaml.js +1 -0
  67. package/package.json +1 -1
  68. package/schema/kraftwerk.schema.json +54 -0
  69. package/web/dist/assets/{dist-cNijUfNl.js → dist-8IOFzZx-.js} +1 -1
  70. package/web/dist/assets/{dist-E48sO9Gc.js → dist-AhnSw4hy.js} +1 -1
  71. package/web/dist/assets/{dist-BTD8k55t.js → dist-BK0VhAPQ.js} +1 -1
  72. package/web/dist/assets/{dist-A0LN_JdM.js → dist-Ba6WSVOB.js} +1 -1
  73. package/web/dist/assets/{dist-DgP31lcC.js → dist-BiU4F98O.js} +1 -1
  74. package/web/dist/assets/{dist-wtIUmbnn.js → dist-Dx3aajX1.js} +1 -1
  75. package/web/dist/assets/{dist-DdOsafun.js → dist-Dy1SvhKC.js} +1 -1
  76. package/web/dist/assets/{dist-D93lSM0a.js → dist-ENZYo2jZ.js} +1 -1
  77. package/web/dist/assets/{dist-BasQkRXA.js → dist-FfKYtQxT.js} +1 -1
  78. package/web/dist/assets/{dist-xLZN2eGO.js → dist-cewmjxFi.js} +1 -1
  79. package/web/dist/assets/dist-lgUK2Fjt.js +1 -0
  80. package/web/dist/assets/{editor-BQlHN7qT.js → editor-CaRa1NMC.js} +3 -3
  81. package/web/dist/assets/index-Dpzux3OC.js +231 -0
  82. package/web/dist/assets/index-Dvt-vE1h.css +2 -0
  83. package/web/dist/index.html +2 -2
  84. package/web/dist/assets/dist-B04mMuvH.js +0 -1
  85. package/web/dist/assets/index-BKDJocDd.css +0 -2
  86. package/web/dist/assets/index-C7ddpWNC.js +0 -231
@@ -0,0 +1,441 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { parse, stringify } from "yaml";
4
+ import { ENTITY_KINDS, entitiesRootFor, resolveWorkspace } from "../config.js";
5
+ import { workspaceRoot } from "./context.js";
6
+ import { cleanRecords, recordLine } from "./records.js";
7
+ import { resolveRefs } from "./refs.js";
8
+ import { SLUG_RE, slugify } from "./slug.js";
9
+ import { moveToTrash } from "./trash.js";
10
+ /* ---------- the kinds ---------- */
11
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?(Z|[+-]\d{2}:\d{2})?)?$/;
12
+ export const CONTACTS = {
13
+ kind: "contacts",
14
+ singular: "contact",
15
+ icon: "person",
16
+ fields: [
17
+ { name: "role", label: "role", type: "text", hint: "What they do (CTO, project lead)" },
18
+ { name: "org", label: "organisation", type: "text", hint: "Company or organisation" },
19
+ { name: "email", label: "email", type: "email", hint: "Email address" },
20
+ { name: "phone", label: "phone", type: "text", hint: "Phone number" },
21
+ { name: "place", label: "place", type: "ref", refKind: "places", hint: "Slug of the place they are usually at (place:<slug>)" },
22
+ { name: "url", label: "website", type: "url", hint: "Profile or website, http(s)" },
23
+ ],
24
+ slugFor: (c) => slugify(c.title),
25
+ sort: (a, b) => a.title.localeCompare(b.title),
26
+ detail: (c) => [c.role, c.org].filter(Boolean).join(", "),
27
+ describe: (c) => `${c.title}${CONTACTS.detail(c) ? ` (${CONTACTS.detail(c)})` : ""}`,
28
+ };
29
+ export const PLACES = {
30
+ kind: "places",
31
+ singular: "place",
32
+ icon: "location_on",
33
+ fields: [
34
+ { name: "address", label: "address", type: "text", hint: "Street, number, postcode, town" },
35
+ { name: "lat", label: "latitude", type: "number", hint: "Decimal degrees (47.05)" },
36
+ { name: "lng", label: "longitude", type: "number", hint: "Decimal degrees (8.30)" },
37
+ { name: "url", label: "website", type: "url", hint: "Website, http(s)" },
38
+ ],
39
+ slugFor: (p) => slugify(p.title),
40
+ sort: (a, b) => a.title.localeCompare(b.title),
41
+ detail: (p) => p.address ?? "",
42
+ describe: (p) => `${p.title}${p.address ? ` — ${p.address}` : ""}`,
43
+ validate: (p) => {
44
+ if ((p.lat === undefined) !== (p.lng === undefined))
45
+ throw new Error("lat and lng go together");
46
+ if (p.lat !== undefined && (p.lat < -90 || p.lat > 90))
47
+ throw new Error("lat must be between -90 and 90");
48
+ if (p.lng !== undefined && (p.lng < -180 || p.lng > 180))
49
+ throw new Error("lng must be between -180 and 180");
50
+ },
51
+ derive: (p) => ({ mapsUrl: mapsUrl(p) }),
52
+ };
53
+ /** The Google Maps link of a place: its google-maps record, else its coordinates, else its address; undefined with none of them. */
54
+ export function mapsUrl(p) {
55
+ const record = p.records.find((r) => r.kind === "google-maps" && r.url);
56
+ if (record?.url)
57
+ return record.url;
58
+ if (p.lat !== undefined && p.lng !== undefined)
59
+ return `https://www.google.com/maps?q=${p.lat},${p.lng}`;
60
+ if (p.address)
61
+ return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(p.address)}`;
62
+ return undefined;
63
+ }
64
+ export const EVENTS = {
65
+ kind: "events",
66
+ singular: "event",
67
+ icon: "event",
68
+ fields: [
69
+ { name: "start", label: "start", type: "date", required: true, hint: "ISO date or date-time: 2026-11-03 or 2026-11-03T14:00" },
70
+ { name: "end", label: "end", type: "date", hint: "ISO date or date-time, not before start" },
71
+ { name: "place", label: "place", type: "ref", refKind: "places", hint: "Slug of the place (place:<slug>)" },
72
+ { name: "contacts", label: "contacts", type: "refs", refKind: "contacts", hint: "Contact slugs, comma-separated" },
73
+ { name: "project", label: "project", type: "ref", refKind: "projects", hint: "Slug of the project it belongs to" },
74
+ { name: "url", label: "link", type: "url", hint: "Meeting link or page, http(s)" },
75
+ ],
76
+ slugFor: (e) => `${e.start.slice(0, 10)}-${slugify(e.title, 37)}`.replace(/-+$/, ""),
77
+ sort: (a, b) => eventTime(a.start) - eventTime(b.start) || a.title.localeCompare(b.title),
78
+ detail: (e) => `${e.start.replace("T", " ")}${e.end ? `–${e.end.replace("T", " ")}` : ""}`,
79
+ describe: (e) => `${EVENTS.detail(e)} ${e.title}`,
80
+ validate: (e) => {
81
+ // A plain day as the end means "until the end of that day": start 14:00, end the same day is fine.
82
+ if (e.end && eventTime(e.end, true) < eventTime(e.start))
83
+ throw new Error("end must not be before start");
84
+ },
85
+ span: (e) => ({ start: e.start, end: e.end ?? e.start }),
86
+ };
87
+ /**
88
+ * When an event's ISO value happens, as a timestamp: a plain day is local
89
+ * midnight (its end, when asked, the last moment of that day), a date-time
90
+ * is what Date reads (local without a zone, else the zone it names) — so
91
+ * values with different offsets order by the instant, not by their text.
92
+ */
93
+ export function eventTime(iso, endOfDay = false) {
94
+ if (/^\d{4}-\d{2}-\d{2}$/.test(iso)) {
95
+ const [y, m, d] = iso.split("-").map(Number);
96
+ return new Date(y, m - 1, d + (endOfDay ? 1 : 0)).getTime() - (endOfDay ? 1 : 0);
97
+ }
98
+ return Date.parse(iso);
99
+ }
100
+ export const ENTITY_DEFS = { contacts: CONTACTS, places: PLACES, events: EVENTS };
101
+ export const entityDef = (kind) => ENTITY_KINDS.includes(kind) ? ENTITY_DEFS[kind] : undefined;
102
+ /* ---------- root ---------- */
103
+ export async function openEntities(kind) {
104
+ let project;
105
+ try {
106
+ project = await resolveWorkspace(workspaceRoot());
107
+ }
108
+ catch (err) {
109
+ return { error: err.message };
110
+ }
111
+ const root = entitiesRootFor(project, kind);
112
+ if (!root)
113
+ return { off: true, error: `${kind} are off (enable them in settings or add \`${kind}:\` to kraftwerk.yml)` };
114
+ return { project, root };
115
+ }
116
+ /** Make sure the root exists; runs when the feature is switched on. */
117
+ export async function ensureEntitiesRoot(kind) {
118
+ const opened = await openEntities(kind);
119
+ if (opened.root)
120
+ await fs.mkdir(opened.root, { recursive: true });
121
+ }
122
+ async function requireRoot(kind) {
123
+ const opened = await openEntities(kind);
124
+ if (!opened.root)
125
+ throw new Error(opened.error ?? `${kind} are off`);
126
+ return opened.root;
127
+ }
128
+ export function safeEntitySlug(def, slug) {
129
+ if (!SLUG_RE.test(slug))
130
+ throw new Error(`invalid ${def.singular} name "${slug}"`);
131
+ return slug;
132
+ }
133
+ /* ---------- fields ---------- */
134
+ const strList = (v, field) => {
135
+ if (v === undefined || v === null || v === "")
136
+ return [];
137
+ const list = typeof v === "string" ? v.split(",") : v;
138
+ if (!Array.isArray(list) || !list.every((x) => typeof x === "string"))
139
+ throw new Error(`${field} must be a list of names`);
140
+ return [...new Set(list.map((x) => x.trim()).filter(Boolean))];
141
+ };
142
+ /** One field's raw value (frontmatter or input) -> its clean value, or undefined when empty; every problem is one error. */
143
+ function cleanField(spec, v) {
144
+ if (v === undefined || v === null || v === "")
145
+ return spec.type === "list" || spec.type === "refs" ? [] : undefined;
146
+ switch (spec.type) {
147
+ case "text":
148
+ case "email":
149
+ case "url":
150
+ case "date": {
151
+ const s = typeof v === "number" || v instanceof Date ? (v instanceof Date ? v.toISOString() : String(v)) : v;
152
+ if (typeof s !== "string")
153
+ throw new Error(`${spec.name} must be a string`);
154
+ const t = s.trim();
155
+ if (!t)
156
+ return undefined;
157
+ if (spec.type === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t))
158
+ throw new Error(`${spec.name} must be an email address`);
159
+ if (spec.type === "url" && !/^https?:\/\//.test(t))
160
+ throw new Error(`${spec.name} must start with http:// or https://`);
161
+ if (spec.type === "date") {
162
+ // YAML reads a bare date as a Date; keep what the person wrote when it was a plain day.
163
+ const iso = v instanceof Date && /T00:00:00\.000Z$/.test(t) ? t.slice(0, 10) : t.replace(/\.\d{3}Z$/, "Z");
164
+ if (!DATE_RE.test(iso) || Number.isNaN(Date.parse(iso)))
165
+ throw new Error(`${spec.name} must be an ISO date (2026-11-03) or date-time (2026-11-03T14:00)`);
166
+ return iso;
167
+ }
168
+ return t;
169
+ }
170
+ case "number": {
171
+ const n = typeof v === "string" ? Number(v.trim()) : v;
172
+ if (typeof n !== "number" || !Number.isFinite(n))
173
+ throw new Error(`${spec.name} must be a number`);
174
+ return n;
175
+ }
176
+ case "list":
177
+ return strList(v, spec.name);
178
+ case "ref": {
179
+ if (typeof v !== "string")
180
+ throw new Error(`${spec.name} must be a ${spec.refKind} slug`);
181
+ const t = v.trim().replace(new RegExp(`^${spec.refKind.replace(/s$/, "")}:`), "");
182
+ if (!t)
183
+ return undefined;
184
+ if (!SLUG_RE.test(t))
185
+ throw new Error(`${spec.name} must be a ${spec.refKind} slug (lowercase, digits, dashes)`);
186
+ return t;
187
+ }
188
+ case "refs": {
189
+ const prefix = new RegExp(`^${spec.refKind.replace(/s$/, "")}:`);
190
+ return strList(v, spec.name).map((t) => {
191
+ const s = t.replace(prefix, "");
192
+ if (!SLUG_RE.test(s))
193
+ throw new Error(`${spec.name} must be ${spec.refKind} slugs (lowercase, digits, dashes)`);
194
+ return s;
195
+ });
196
+ }
197
+ }
198
+ }
199
+ const SHARED = new Set(["title", "tags", "records"]);
200
+ /** Keys a read adds around the fields; a client that sends a GET body back must not write them into the file. */
201
+ const READ_ONLY = new Set(["slug", "notes", "extra", "path", "updatedAt", "configError", "links"]);
202
+ /** Frontmatter (or a save's fields) -> a clean definition, with unknown keys kept under `extra`. */
203
+ function buildDef(def, slug, data) {
204
+ const title = typeof data.title === "string" && data.title.trim() ? data.title.trim() : slug;
205
+ const out = { slug, title, tags: strList(data.tags, "tags"), records: cleanRecords(data.records) };
206
+ const known = new Set([...SHARED, ...READ_ONLY, ...def.fields.map((f) => f.name)]);
207
+ for (const f of def.fields) {
208
+ const v = cleanField(f, data[f.name]);
209
+ if (v === undefined && f.required)
210
+ throw new Error(`${f.name} is required`);
211
+ if (v !== undefined)
212
+ out[f.name] = v;
213
+ }
214
+ for (const k of Object.keys(def.derive?.(out) ?? {}))
215
+ known.add(k);
216
+ const extra = Object.fromEntries(Object.entries(data).filter(([k, v]) => !known.has(k) && v !== undefined));
217
+ if (Object.keys(extra).length)
218
+ out.extra = extra;
219
+ const built = out;
220
+ def.validate?.(built);
221
+ return built;
222
+ }
223
+ const emptyDef = (def, slug) => {
224
+ const out = { slug, title: slug, tags: [], records: [] };
225
+ for (const f of def.fields)
226
+ if (f.type === "refs" || f.type === "list")
227
+ out[f.name] = [];
228
+ // A required field cannot be empty; a placeholder keeps the broken file visible without lying about it.
229
+ for (const f of def.fields)
230
+ if (f.required && f.type === "date")
231
+ out[f.name] = "0000-00-00";
232
+ return out;
233
+ };
234
+ /** With the derived fields added (never written). */
235
+ const withDerived = (def, d) => ({ ...d, ...(def.derive?.(d) ?? {}) });
236
+ /* ---------- files ---------- */
237
+ /** Split a Markdown file into its frontmatter text and body; null frontmatter when the file has none. */
238
+ function splitFrontmatter(raw) {
239
+ if (!raw.startsWith("---"))
240
+ return { fm: null, body: raw };
241
+ // An empty block (`---` straight after `---`) is frontmatter with nothing in it, not two lines of notes.
242
+ const m = /^---[ \t]*\r?\n---[ \t]*(\r?\n|$)/.exec(raw) ?? /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(\r?\n|$)/.exec(raw);
243
+ if (!m)
244
+ return { fm: null, body: raw };
245
+ // The blank line toFile puts between the frontmatter and the body is a separator, not part of the notes.
246
+ return { fm: m[1] ?? "", body: raw.slice(m[0].length).replace(/^\r?\n/, "") };
247
+ }
248
+ /** The file as written: frontmatter with the known fields in their order, extras after, then the notes. */
249
+ function toFile(def, d, notes) {
250
+ const derived = new Set(Object.keys(def.derive?.(d) ?? {}));
251
+ const doc = { title: d.title };
252
+ const rec = d;
253
+ for (const f of def.fields) {
254
+ if (derived.has(f.name))
255
+ continue;
256
+ const v = rec[f.name];
257
+ if (v === undefined || (Array.isArray(v) && v.length === 0))
258
+ continue;
259
+ doc[f.name] = v;
260
+ }
261
+ if (d.tags.length)
262
+ doc.tags = d.tags;
263
+ if (d.records.length)
264
+ doc.records = d.records;
265
+ for (const [k, v] of Object.entries(d.extra ?? {}))
266
+ if (!(k in doc))
267
+ doc[k] = v;
268
+ const body = notes.trim() ? `${notes.replace(/\s+$/, "")}\n` : "";
269
+ return `---\n${stringify(doc)}---\n${body ? `\n${body}` : ""}`;
270
+ }
271
+ async function readFile(def, root, slug) {
272
+ const file = path.join(root, `${slug}.md`);
273
+ let raw;
274
+ let st;
275
+ try {
276
+ [raw, st] = await Promise.all([fs.readFile(file, "utf8"), fs.stat(file)]);
277
+ }
278
+ catch {
279
+ return null;
280
+ }
281
+ const updatedAt = new Date(st.mtimeMs).toISOString();
282
+ const { fm, body } = splitFrontmatter(raw);
283
+ try {
284
+ const data = fm === null ? {} : (parse(fm) ?? {});
285
+ if (typeof data !== "object" || Array.isArray(data))
286
+ throw new Error("frontmatter must be a mapping");
287
+ return { summary: { ...withDerived(def, buildDef(def, slug, data)), path: file, updatedAt }, notes: body };
288
+ }
289
+ catch (err) {
290
+ return { summary: { ...emptyDef(def, slug), path: file, updatedAt, configError: err.message }, notes: body };
291
+ }
292
+ }
293
+ /* ---------- reads ---------- */
294
+ /** Every entry under the kind's root in the kind's order; `from`/`to` narrow kinds with a date (events) by it. */
295
+ export async function listEntities(def, range = {}) {
296
+ const opened = await openEntities(def.kind);
297
+ if (!opened.root)
298
+ return { enabled: false, items: [], error: opened.error };
299
+ const root = opened.root;
300
+ const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
301
+ const names = entries.filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => e.name.slice(0, -3));
302
+ let items = (await Promise.all(names.map(async (name) => {
303
+ if (SLUG_RE.test(name))
304
+ return (await readFile(def, root, name))?.summary ?? null;
305
+ // A file whose name is not a slug cannot be referenced or opened; it is listed with the reason rather than hidden.
306
+ const file = path.join(root, `${name}.md`);
307
+ const st = await fs.stat(file).catch(() => null);
308
+ if (!st)
309
+ return null;
310
+ const configError = `file name must be a slug (lowercase letters, digits and dashes, up to 49 characters) — rename ${name}.md`;
311
+ return { ...emptyDef(def, name), path: file, updatedAt: new Date(st.mtimeMs).toISOString(), configError };
312
+ }))).filter((x) => x != null);
313
+ if (def.span && (range.from || range.to)) {
314
+ const day = (iso) => iso.slice(0, 10);
315
+ items = items.filter((i) => {
316
+ if (i.configError)
317
+ return false;
318
+ const span = def.span(i);
319
+ // From: anything still running on that day; to: anything that has started by then.
320
+ return (!range.from || day(span.end) >= day(range.from)) && (!range.to || day(span.start) <= day(range.to));
321
+ });
322
+ }
323
+ items.sort(def.sort);
324
+ return { enabled: true, root, items };
325
+ }
326
+ /** The ref fields' states: which referenced slugs exist right now. */
327
+ async function linksOf(def, d) {
328
+ const rec = d;
329
+ const refFields = def.fields.filter((f) => f.type === "ref" || f.type === "refs");
330
+ const byKind = {};
331
+ for (const f of refFields) {
332
+ const slugs = f.type === "refs" ? (rec[f.name] ?? []) : rec[f.name] ? [rec[f.name]] : [];
333
+ byKind[f.refKind] = [...new Set([...(byKind[f.refKind] ?? []), ...slugs])];
334
+ }
335
+ const resolved = await resolveRefs(byKind);
336
+ const links = {};
337
+ for (const f of refFields) {
338
+ const slugs = f.type === "refs" ? (rec[f.name] ?? []) : rec[f.name] ? [rec[f.name]] : [];
339
+ links[f.name] = slugs.map((s) => resolved[f.refKind].find((l) => l.slug === s) ?? { slug: s, found: false });
340
+ }
341
+ return links;
342
+ }
343
+ /** One entry with its notes and link states; null when there is no such file. */
344
+ export async function getEntity(def, slug) {
345
+ const root = await requireRoot(def.kind);
346
+ const read = await readFile(def, root, safeEntitySlug(def, slug));
347
+ if (!read)
348
+ return null;
349
+ return { ...read.summary, notes: read.notes, links: await linksOf(def, read.summary) };
350
+ }
351
+ /* ---------- writes ---------- */
352
+ export async function createEntity(def, input) {
353
+ const root = await requireRoot(def.kind);
354
+ const { notes, slug: wanted, ...fields } = input;
355
+ if (typeof fields.title !== "string" || !fields.title.trim())
356
+ throw new Error("title is required");
357
+ const d = buildDef(def, "new", fields);
358
+ const slug = safeEntitySlug(def, (typeof wanted === "string" ? wanted.trim() : "") || def.slugFor(d) || def.singular);
359
+ d.slug = slug;
360
+ const file = path.join(root, `${slug}.md`);
361
+ await fs.mkdir(root, { recursive: true });
362
+ try {
363
+ // Exclusive create: two writers racing for the same slug (the UI and an agent's CLI) cannot overwrite each other.
364
+ await fs.writeFile(file, toFile(def, d, typeof notes === "string" ? notes : ""), { flag: "wx" });
365
+ }
366
+ catch (err) {
367
+ if (err.code === "EEXIST")
368
+ throw new Error(`${def.singular} "${slug}" already exists`);
369
+ throw err;
370
+ }
371
+ return (await getEntity(def, slug));
372
+ }
373
+ /** Merge the given fields into the frontmatter (and the notes when given); lists replace wholesale, "" clears a field. */
374
+ export async function saveEntity(def, slug, input) {
375
+ const current = await getEntity(def, slug);
376
+ if (!current)
377
+ throw new Error(`no ${def.singular} "${slug}"`);
378
+ // A file that could not be read keeps what the person wrote: rewriting it from defaults would throw their frontmatter away.
379
+ if (current.configError)
380
+ throw new Error(`${def.singular} "${slug}" cannot be saved until its file is fixed by hand (${current.configError}): ${current.path}`);
381
+ const { notes, slug: _ignored, ...given } = input;
382
+ const { path: _p, updatedAt: _u, configError: _c, notes: _n, links: _l, extra, ...rest } = current;
383
+ const derived = new Set(Object.keys(def.derive?.(current) ?? {}));
384
+ const base = { ...extra, ...rest };
385
+ for (const k of derived)
386
+ delete base[k];
387
+ for (const [k, v] of Object.entries(given))
388
+ if (v !== undefined)
389
+ base[k] = v;
390
+ if (given.title !== undefined && !(typeof given.title === "string" && given.title.trim()))
391
+ throw new Error("title is required");
392
+ const next = buildDef(def, current.slug, base);
393
+ await fs.writeFile(current.path, toFile(def, next, typeof notes === "string" ? notes : current.notes));
394
+ return (await getEntity(def, slug));
395
+ }
396
+ /** Move the file to the trash. */
397
+ export async function deleteEntity(def, slug) {
398
+ const root = await requireRoot(def.kind);
399
+ const file = path.join(root, `${safeEntitySlug(def, slug)}.md`);
400
+ if (!(await fs.stat(file).catch(() => null)))
401
+ throw new Error(`no ${def.singular} "${slug}"`);
402
+ await moveToTrash(def.kind, slug, file);
403
+ }
404
+ /* ---------- agent context ---------- */
405
+ /** One line for an entry in a context block: its token, how it reads, its records. */
406
+ export function entityLine(def, d) {
407
+ const url = def.kind === "places" ? d.mapsUrl : d.url;
408
+ return `- ${def.singular}:${d.slug} — ${def.describe(d)}${url ? ` (${url})` : ""}`;
409
+ }
410
+ /**
411
+ * The "## Contacts, places and events" block every chat starts with when at
412
+ * least one of the kinds is on: what exists (counts, the next events), how
413
+ * to reference an entry, and the CLI that reads and writes them.
414
+ */
415
+ export async function entitiesContext() {
416
+ const views = await Promise.all([listEntities(CONTACTS), listEntities(PLACES), listEntities(EVENTS)]);
417
+ const [contacts, places, events] = views;
418
+ if (!views.some((v) => v.enabled))
419
+ return "";
420
+ const today = new Date().toISOString().slice(0, 10);
421
+ const upcoming = events.items.filter((e) => !e.configError && (e.end ?? e.start).slice(0, 10) >= today).slice(0, 5);
422
+ const kinds = [
423
+ contacts.enabled ? `- contacts (${contacts.items.length}): people and organisations — role, org, email, phone, place, records (CRM, newsletter tool)` : "",
424
+ places.enabled ? `- places (${places.items.length}): addresses and points of interest — address, lat/lng, records (Google Maps); \`places show <slug>\` prints the map link` : "",
425
+ events.enabled ? `- events (${events.items.length}): dated one-off entries — start, end, place, contacts, project, records (calendar entry)` : "",
426
+ ].filter(Boolean);
427
+ return (`## Contacts, places and events\n` +
428
+ `The workspace keeps its own records of who, where and when as Markdown files (YAML frontmatter + notes). ` +
429
+ `Reference one anywhere — in notes, a project's files, your replies — as \`contact:<slug>\`, \`place:<slug>\`, \`event:<slug>\` ` +
430
+ `(likewise project:, agent:, workflow:, repo:, vibeable:, knowledge:); kraftwerk resolves the token and marks a missing target.\n` +
431
+ kinds.join("\n") +
432
+ `\n\nRead and write them through the CLI, never by hand:\n` +
433
+ `- \`npx kraftwerk contacts|places|events list --json\` (\`events list --from <date> --to <date>\`), \`… show <slug> --json\`\n` +
434
+ `- \`npx kraftwerk contacts create "<name>" --org … --email … --place <place-slug>\`, \`places create "<name>" --address "…"\`, ` +
435
+ `\`events create "<title>" --start 2026-11-03T14:00 --place <slug> --contacts a,b --project <slug>\`\n` +
436
+ `- \`… set <slug> --<field> <value>\` changes fields (\`--notes-file <md>\` replaces the notes), \`… remove <slug>\` moves the file to the trash, ` +
437
+ `\`npx kraftwerk ref <kind:slug>\` checks a reference.\n` +
438
+ `Fields that point at other entries hold slugs; a target that does not exist yet is allowed and reported as missing.` +
439
+ (upcoming.length ? `\n\nNext events:\n${upcoming.map((e) => entityLine(EVENTS, e)).join("\n")}` : ""));
440
+ }
441
+ export { recordLine };
@@ -312,5 +312,6 @@ export async function filesContext(scope, heading, intro) {
312
312
  return (`## ${heading}\n${intro} ${s.root}/ — ${s.count} file${s.count === 1 ? "" : "s"}` +
313
313
  (recent ? `, most recently changed:\n${recent}\n` : " (empty so far)\n") +
314
314
  `Read and write them there like any folder; put what you produce for the user in ${s.root}/deliverables/. ` +
315
+ `Only finished work and the material the user gave you belong here — scratch, intermediate and downloaded files go to your temp folder (see Temporary files), never into this folder. ` +
315
316
  `Files over 25 MB stay out of git automatically. Delete nothing there unless asked.`);
316
317
  }
package/dist/core/git.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { realpathSync } from "node:fs";
3
3
  import path from "node:path";
4
- import { projectsRootFor, reposRootFor, resolveWorkspace, vibeablesRootFor, isSafeGitName } from "../config.js";
4
+ import { ENTITY_KINDS, entitiesRootFor, projectsRootFor, reposRootFor, resolveWorkspace, vibeablesRootFor, isSafeGitName } from "../config.js";
5
5
  import { ENV_FILE } from "../runner/docker.js";
6
6
  import { getOutputDir, workspaceRoot } from "./context.js";
7
7
  import { perWorkspace, workspaceOpen } from "./workspace.js";
@@ -239,6 +239,8 @@ function scopeFor({ project, repoRoot }) {
239
239
  vibeablesRootFor(project),
240
240
  // Projects too: briefs, state and logs are the workspace's own record.
241
241
  projectsRootFor(project),
242
+ // Contacts, places and events: who, where and when — the workspace's records.
243
+ ...ENTITY_KINDS.map((kind) => entitiesRootFor(project, kind)),
242
244
  // Workspace files: shared material, versioned (files over 25 MB ignore themselves, see files.ts).
243
245
  path.resolve(project.root, "kraftwerk-data/files"),
244
246
  project.configPath,
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * An agent's journal: agents/<slug>/journal.md, the agent's own memory
3
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
4
+ * line per entry stamped with its minute, under a dated heading, newest
5
+ * first (like a project's log.md). Times are the machine's local time. The agent writes it through `kraftwerk journal` and reads the
6
6
  * recent part back at the start of every session, so next week it is the
7
7
  * same colleague; a human can add a line from the inspector.
8
8
  */
@@ -19,7 +19,17 @@ export declare function readJournal(slug: string): Promise<string>;
19
19
  export declare function appendJournal(slug: string, entry: string, opts?: {
20
20
  kind?: string;
21
21
  actor?: string;
22
+ now?: Date;
22
23
  }): Promise<string>;
24
+ /**
25
+ * One section per day again, its entries newest first by their minute. Two
26
+ * people adding to a journal on different machines both put a line under
27
+ * the same day (or both open a new day); once git has kept both sides, the
28
+ * next entry tidies the file: a day heading that appears twice is merged
29
+ * into one, and stamped lines are ordered by time, ahead of older unstamped
30
+ * ones, which keep their order.
31
+ */
32
+ export declare function mergeDays(raw: string): string;
23
33
  /**
24
34
  * The recent part of a journal for a session's context: whole days, newest
25
35
  * first, until the budget is spent. Older days stay in the file, where the
@@ -4,8 +4,8 @@ import { agentsRoot, getAgent, safeAgentSlug } from "./agents.js";
4
4
  /**
5
5
  * An agent's journal: agents/<slug>/journal.md, the agent's own memory
6
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
7
+ * line per entry stamped with its minute, under a dated heading, newest
8
+ * first (like a project's log.md). Times are the machine's local time. The agent writes it through `kraftwerk journal` and reads the
9
9
  * recent part back at the start of every session, so next week it is the
10
10
  * same colleague; a human can add a line from the inspector.
11
11
  */
@@ -35,11 +35,12 @@ export async function appendJournal(slug, entry, opts = {}) {
35
35
  throw new Error(`kind must be one of: ${JOURNAL_KINDS.join(", ")}`);
36
36
  const actor = opts.actor?.trim();
37
37
  const own = !actor || actor === slug || actor.startsWith(`${slug}/`);
38
- const line = `* ${kind === "note" ? "" : `**${kind[0].toUpperCase()}${kind.slice(1)}**: `}${text}${own ? "" : ` (${actor})`}`;
38
+ const now = opts.now ?? new Date();
39
+ const line = `* ${clock(now)} ${kind === "note" ? "" : `**${kind[0].toUpperCase()}${kind.slice(1)}**: `}${text}${own ? "" : ` (${actor})`}`;
39
40
  const file = await journalPath(slug);
40
- const today = new Date().toISOString().slice(0, 10);
41
+ const today = localDate(now);
41
42
  const raw = (await fs.readFile(file, "utf8").catch(() => null)) ?? `# ${def.name}'s journal\n`;
42
- const lines = raw.split("\n");
43
+ const lines = mergeDays(raw).split("\n");
43
44
  const todayIdx = lines.findIndex((l) => l.trim() === `## ${today}`);
44
45
  if (todayIdx >= 0) {
45
46
  lines.splice(todayIdx + 1, 0, line);
@@ -52,6 +53,50 @@ export async function appendJournal(slug, entry, opts = {}) {
52
53
  await fs.writeFile(file, out);
53
54
  return out;
54
55
  }
56
+ const pad = (n) => String(n).padStart(2, "0");
57
+ /** YYYY-MM-DD in local time. */
58
+ const localDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
59
+ /** HH:MM in local time. */
60
+ const clock = (d) => `${pad(d.getHours())}:${pad(d.getMinutes())}`;
61
+ /**
62
+ * One section per day again, its entries newest first by their minute. Two
63
+ * people adding to a journal on different machines both put a line under
64
+ * the same day (or both open a new day); once git has kept both sides, the
65
+ * next entry tidies the file: a day heading that appears twice is merged
66
+ * into one, and stamped lines are ordered by time, ahead of older unstamped
67
+ * ones, which keep their order.
68
+ */
69
+ export function mergeDays(raw) {
70
+ const lines = raw.split("\n");
71
+ const firstDay = lines.findIndex((l) => /^## /.test(l));
72
+ if (firstDay < 0)
73
+ return raw;
74
+ const head = lines.slice(0, firstDay);
75
+ const days = new Map();
76
+ let cur = null;
77
+ for (const l of lines.slice(firstDay)) {
78
+ const m = /^## (.+?)\s*$/.exec(l);
79
+ if (m) {
80
+ cur = days.get(m[1]) ?? [];
81
+ days.set(m[1], cur);
82
+ }
83
+ else if (cur && l.trim()) {
84
+ cur.push(l);
85
+ }
86
+ }
87
+ if (days.size === lines.slice(firstDay).filter((l) => /^## /.test(l)).length) {
88
+ // no day twice: leave the order of a day's lines as written
89
+ return raw;
90
+ }
91
+ const stamp = (l) => /^\* (\d\d:\d\d) /.exec(l)?.[1] ?? "";
92
+ const order = [...days.keys()].sort((a, b) => b.localeCompare(a));
93
+ const body = order.map((d) => {
94
+ const entries = days.get(d).map((l, i) => ({ l, i, t: stamp(l) }));
95
+ entries.sort((a, b) => (a.t && b.t ? b.t.localeCompare(a.t) : a.t ? -1 : b.t ? 1 : 0) || a.i - b.i);
96
+ return [`## ${d}`, ...[...new Set(entries.map((e) => e.l))]].join("\n");
97
+ });
98
+ return [...head, body.join("\n\n"), ""].join("\n").replace(/\n{3,}/g, "\n\n");
99
+ }
55
100
  /**
56
101
  * The recent part of a journal for a session's context: whole days, newest
57
102
  * first, until the budget is spent. Older days stay in the file, where the
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The daemon's log line, one event per line in a fixed shape:
3
+ *
4
+ * 2026-10-15T14:26:47.662Z INFO relay connected to https://… as N8I…
5
+ * 2026-10-15T14:26:47.901Z WARN cloud https://… unreachable — no answer (retrying)
6
+ * 2026-10-15T12:25:33.105Z ERROR server crashed: Error: listen EADDRINUSE …
7
+ *
8
+ * ISO-8601 UTC time, a padded level, a padded component, the message —
9
+ * stable columns, grep-able, no glyphs, no color. The daemon's stdout IS
10
+ * ~/.kraftwerk/logs/daemon.log (daemon-control appends the stream), so the
11
+ * daemon and the long-running pieces it hosts (cloud client, relay,
12
+ * scheduler) speak this and nothing else. Interactive commands keep their
13
+ * human output; `kraftwerk daemon status` greps for "crashed:" and
14
+ * "stopped unexpectedly", which stay literal in the message.
15
+ */
16
+ export type LogLevel = "info" | "warn" | "error";
17
+ /** INFO to stdout, WARN and ERROR to stderr — the daemon writes both into one file. */
18
+ export declare function log(level: LogLevel, component: string, message: string): void;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The daemon's log line, one event per line in a fixed shape:
3
+ *
4
+ * 2026-10-15T14:26:47.662Z INFO relay connected to https://… as N8I…
5
+ * 2026-10-15T14:26:47.901Z WARN cloud https://… unreachable — no answer (retrying)
6
+ * 2026-10-15T12:25:33.105Z ERROR server crashed: Error: listen EADDRINUSE …
7
+ *
8
+ * ISO-8601 UTC time, a padded level, a padded component, the message —
9
+ * stable columns, grep-able, no glyphs, no color. The daemon's stdout IS
10
+ * ~/.kraftwerk/logs/daemon.log (daemon-control appends the stream), so the
11
+ * daemon and the long-running pieces it hosts (cloud client, relay,
12
+ * scheduler) speak this and nothing else. Interactive commands keep their
13
+ * human output; `kraftwerk daemon status` greps for "crashed:" and
14
+ * "stopped unexpectedly", which stay literal in the message.
15
+ */
16
+ /** INFO to stdout, WARN and ERROR to stderr — the daemon writes both into one file. */
17
+ export function log(level, component, message) {
18
+ const line = `${new Date().toISOString()} ${level.toUpperCase().padEnd(5)} ${component.padEnd(9)} ${message}`;
19
+ if (level === "info")
20
+ console.log(line);
21
+ else
22
+ console.error(line);
23
+ }