@netnodeag/kraftwerk 0.78.1 → 0.80.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 (70) hide show
  1. package/README.md +83 -0
  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 +3 -0
  7. package/dist/cli/kraftwerk.js +7 -0
  8. package/dist/cli/ui.js +2 -1
  9. package/dist/client/routes.d.ts +16 -0
  10. package/dist/client/routes.js +16 -0
  11. package/dist/config.d.ts +25 -0
  12. package/dist/config.js +17 -6
  13. package/dist/core/chat/context.js +5 -2
  14. package/dist/core/entities.d.ts +171 -0
  15. package/dist/core/entities.js +441 -0
  16. package/dist/core/git.js +3 -1
  17. package/dist/core/journal.d.ts +12 -2
  18. package/dist/core/journal.js +50 -5
  19. package/dist/core/log.d.ts +18 -0
  20. package/dist/core/log.js +23 -0
  21. package/dist/core/projects.d.ts +22 -33
  22. package/dist/core/projects.js +40 -134
  23. package/dist/core/records.d.ts +28 -0
  24. package/dist/core/records.js +71 -0
  25. package/dist/core/refs.d.ts +74 -0
  26. package/dist/core/refs.js +143 -0
  27. package/dist/core/search.d.ts +10 -0
  28. package/dist/core/search.js +12 -0
  29. package/dist/core/settings.d.ts +4 -0
  30. package/dist/core/settings.js +24 -3
  31. package/dist/core/slug.d.ts +9 -0
  32. package/dist/core/slug.js +18 -0
  33. package/dist/core/trash.d.ts +1 -1
  34. package/dist/server/api/devices.js +2 -1
  35. package/dist/server/api/entities.d.ts +74 -0
  36. package/dist/server/api/entities.js +60 -0
  37. package/dist/server/api/index.d.ts +66 -4
  38. package/dist/server/api/index.js +5 -0
  39. package/dist/server/api/projects.d.ts +3 -0
  40. package/dist/server/api/projects.js +3 -0
  41. package/dist/server/api/workspace.d.ts +15 -0
  42. package/dist/server/api/workspace.js +12 -4
  43. package/dist/server/cloud.js +3 -2
  44. package/dist/server/relay.d.ts +3 -2
  45. package/dist/server/relay.js +7 -7
  46. package/dist/server/server.js +3 -2
  47. package/package.json +1 -1
  48. package/schema/kraftwerk.schema.json +54 -0
  49. package/web/dist/assets/{dist-Crnb8VVO.js → dist-1PajaYPg.js} +1 -1
  50. package/web/dist/assets/{dist-B-yVL5lk.js → dist-B4dqwo7_.js} +1 -1
  51. package/web/dist/assets/dist-Bp1iMuQ7.js +1 -0
  52. package/web/dist/assets/{dist-C90kUzps.js → dist-CK2u1OXb.js} +1 -1
  53. package/web/dist/assets/{dist-DQbifCX0.js → dist-D-f0nszo.js} +1 -1
  54. package/web/dist/assets/{dist-BTc878j7.js → dist-DKduisGW.js} +1 -1
  55. package/web/dist/assets/{dist-D0rjnN8S.js → dist-DgKgeYFJ.js} +1 -1
  56. package/web/dist/assets/{dist-D4SDXoGw.js → dist-DlZPeneT.js} +1 -1
  57. package/web/dist/assets/{dist-DHRhdODt.js → dist-K_3Y5Nbl.js} +1 -1
  58. package/web/dist/assets/{dist-B9EMB1Ie.js → dist-NX4_yvho.js} +1 -1
  59. package/web/dist/assets/{dist-CpLdAqFQ.js → dist-u_oAKIhZ.js} +1 -1
  60. package/web/dist/assets/{editor-DHAdW1g_.js → editor-CE9TlKkJ.js} +3 -3
  61. package/web/dist/assets/index-Dvt-vE1h.css +2 -0
  62. package/web/dist/assets/index-IAuwD22j.js +231 -0
  63. package/web/dist/index.html +2 -2
  64. package/web/dist-remote/assets/index-D6Wznb5o.js +27 -0
  65. package/web/dist-remote/index.html +2 -2
  66. package/web/dist-remote/sw.js +1 -1
  67. package/web/dist/assets/dist-lSJ-O7kT.js +0 -1
  68. package/web/dist/assets/index-BKDJocDd.css +0 -2
  69. package/web/dist/assets/index-D7JVAjll.js +0 -231
  70. package/web/dist-remote/assets/index-2nAI1HTV.js +0 -13
package/README.md CHANGED
@@ -130,6 +130,9 @@ set up the rest. From there:
130
130
  Open one and chat with it; its sessions continue where they ended.
131
131
  - **Projects** gather a goal, a brief and links; every chat in a project
132
132
  starts with that context.
133
+ - **Contacts, places and events** are the workspace's records of who, where
134
+ and when — one Markdown file each, referenced anywhere as `contact:<slug>`,
135
+ `place:<slug>`, `event:<slug>`.
133
136
  - **Workflows** are repeatable processes with checked results; run one from
134
137
  its page or with `kraftwerk run <workflow> "<request>"`.
135
138
  - **Knowledge** is the shared, human-verified context agents read.
@@ -256,6 +259,8 @@ kraftwerk workspaces # every workspace on this machine; works
256
259
  kraftwerk devices # paired devices (a phone, another computer); devices pair|revoke
257
260
  kraftwerk remote on|off|pair # reach this machine from anywhere through the relay, end to end encrypted
258
261
  kraftwerk projects # goal-scoped project folders; projects create|show|link|log|remove
262
+ kraftwerk contacts|places|events # who, where and when, one Markdown file each; … show|create|set|remove, events --from/--to
263
+ kraftwerk ref <kind:slug> # resolve a reference (contact:anna-meier, project:relaunch, …); --file <md> resolves every one in a file
259
264
  kraftwerk trash # what was deleted; trash restore|purge <id>, trash empty
260
265
  kraftwerk journal <agent> ["<entry>"] # an agent's memory across sessions: print it, add a line (--kind)
261
266
  kraftwerk doctor # preflight: harness CLIs, docker, workflows, declared env vars, legacy leftovers
@@ -626,6 +631,84 @@ kraftwerk projects remove relaunch-netnode-ch
626
631
  The registry of workspaces on this machine, which answered to
627
632
  `kraftwerk projects` until 0.48, is `kraftwerk workspaces` now.
628
633
 
634
+ ## Contacts, places and events
635
+
636
+ Who, where and when, kept by the workspace itself so people and agents can
637
+ point at them reliably. Three features with one shape — turn each on in
638
+ `kraftwerk.yml` (or its checkbox in settings):
639
+
640
+ ```yaml
641
+ contacts: # people and organisations
642
+ places: # addresses and points of interest
643
+ events: # dated one-off entries
644
+ root: kraftwerk-data/events # default kraftwerk-data/<kind>; part of the workspace, synced like agents and knowledge
645
+ ```
646
+
647
+ One Markdown file per entry: the typed fields in YAML frontmatter, free
648
+ notes in the body. Readable and editable by hand, by agents and by the UI;
649
+ versioned with the workspace, deleted into the trash.
650
+
651
+ ```
652
+ kraftwerk-data/contacts/anna-meier.md # title, role, org, email, phone, place, url, tags, records + notes
653
+ kraftwerk-data/places/netnode-office.md # title, address, lat, lng, url, tags, records + notes
654
+ kraftwerk-data/events/2026-11-03-kickoff.md # title, start, end, place, contacts, project, url, tags, records + notes
655
+ ```
656
+
657
+ ```markdown
658
+ ---
659
+ title: Kickoff
660
+ start: 2026-11-03T14:00 # ISO date or date-time; events are one-off (recurrence lives in the calendar entry)
661
+ end: 2026-11-03T16:00
662
+ place: netnode-office # a place's slug
663
+ contacts: [anna-meier] # contact slugs
664
+ project: relaunch # a project's slug
665
+ records:
666
+ - kind: calendar
667
+ url: https://calendar.google.com/event?eid=…
668
+ ---
669
+ Agenda: scope, timeline, who does what.
670
+ ```
671
+
672
+ **References.** Fields that point at other entries hold slugs, and anything
673
+ in the workspace is referenced in text as `kind:slug` — `contact:anna-meier`,
674
+ `place:netnode-office`, `event:2026-11-03-kickoff`, and likewise `project:`,
675
+ `agent:`, `workflow:`, `repo:`, `vibeable:`, `knowledge:`. The same resolver
676
+ serves a project's links, an event's place and contacts, a token in notes or
677
+ in a chat reply, the ⌘K palette and the context an agent starts with: a
678
+ target that does not exist is reported ("not found"), never an error, and
679
+ only these tokens match, so `https:` never does. Projects link to contacts,
680
+ places and events like to anything else (`kraftwerk projects link <slug>
681
+ contacts anna-meier`), and a project chat starts with them as one-liners.
682
+
683
+ **Systems of record.** As on a project: where the truth lives outside
684
+ kraftwerk — a contact's CRM record and newsletter subscription (`crm`,
685
+ `newsletter`), an event's calendar entry (`calendar`), a place on Google
686
+ Maps (`google-maps`). A place without a maps record gets a Google Maps link
687
+ derived from its coordinates or its address.
688
+
689
+ **Agents** get a "Contacts, places and events" block in every chat: what
690
+ exists, the next events, the token syntax and the CLI — they read and write
691
+ the files through it, never by hand:
692
+
693
+ ```bash
694
+ kraftwerk contacts # list (--json); places, events the same
695
+ kraftwerk contacts create "Anna Meier" --org ACME --email anna@acme.ch --place netnode-office
696
+ kraftwerk contacts show anna-meier # fields, records, references with their state, notes
697
+ kraftwerk contacts set anna-meier --role CTO --email "" # "" clears a field; --notes-file replaces the notes
698
+ kraftwerk places create "NETNODE Office" --address "Zentralstrasse 1, 6003 Luzern" --lat 47.05 --lng 8.3
699
+ kraftwerk places show netnode-office # prints the Google Maps link
700
+ kraftwerk events create "Kickoff" --start 2026-11-03T14:00 --place netnode-office --contacts anna-meier --project relaunch
701
+ kraftwerk events --from 2026-11-01 --to 2026-11-30 # the month's events
702
+ kraftwerk ref contact:anna-meier # ✔ with its label, or ? not found (exit 1); --file notes.md checks a whole file
703
+ kraftwerk contacts remove anna-meier # to the trash
704
+ ```
705
+
706
+ Over the API each kind is its own route family — `contacts.list|get|create|save|delete`,
707
+ `places.*`, `events.*` (`GET /api/events?from=&to=`) — and `refs.resolve`
708
+ resolves tokens or every reference found in a text. In the inspector each
709
+ kind is a page of the workspace (expert mode) with the list beside the open
710
+ entry; a project's context column shows its contacts, places and events.
711
+
629
712
  ## Files
630
713
 
631
714
  Files are the material of the work, in any format: briefings, contracts,
@@ -1,6 +1,7 @@
1
1
  import chalk from "chalk";
2
2
  import { DAEMON_PORT, daemonState, lastOpenRoots, setDaemonVersion, writeDaemonFile } from "../core/daemon.js";
3
3
  import { tildify } from "../core/instances.js";
4
+ import { log } from "../core/log.js";
4
5
  import { networkUrls } from "../server/bind.js";
5
6
  import { startMachineCloud } from "../server/cloud.js";
6
7
  import { startPushWatcher } from "../server/push.js";
@@ -44,31 +45,31 @@ export async function runDaemon(opts) {
44
45
  await hub.open(root);
45
46
  }
46
47
  catch (err) {
47
- console.log(`${chalk.yellow("⚠")} not reopened: ${tildify(root)} — ${err.message}`);
48
+ log("warn", "workspace", `not reopened: ${tildify(root)} — ${err.message}`);
48
49
  }
49
50
  }
50
- console.log(`${chalk.green("✔")} kraftwerk daemon on ${chalk.cyan(`http://localhost:${hub.port}`)} ${chalk.dim(`(pid ${process.pid}, ${new Date().toISOString()})`)}`);
51
+ log("info", "daemon", `listening on http://localhost:${hub.port} (pid ${process.pid}, v${await getPkgVersion()})`);
51
52
  for (const o of hub.list())
52
- console.log(` ${chalk.cyan(o.url)} ${chalk.dim(o.name)}${o.aliasPort ? chalk.dim(` · also :${o.aliasPort}`) : ""}`);
53
+ log("info", "workspace", `open: ${o.url} (${o.name})${o.aliasPort ? ` · also :${o.aliasPort}` : ""}`);
53
54
  if (!hub.list().length)
54
- console.log(chalk.dim(" no workspace open yet — `kraftwerk ui` in a workspace folder opens it here"));
55
+ log("info", "daemon", "no workspace open yet — `kraftwerk ui` in a workspace folder opens it here");
55
56
  // The machine in the kraftwerk cloud: registered once, the open workspaces as its children (`kraftwerk cloud`, `kraftwerk login`).
56
57
  const cloud = await startMachineCloud({ version: await getPkgVersion(), workspaces: () => hub.list(), owner: hub });
57
58
  if (cloud.state === "connected") {
58
- console.log(`${chalk.green("✔")} Cloud: ${chalk.cyan(cloud.url)} ${chalk.dim(cloud.account ? `(claimed by ${cloud.account})` : `(unclaimed — \`kraftwerk login\` claims this machine)`)}`);
59
+ log("info", "cloud", `registered at ${cloud.url} ${cloud.account ? `(claimed by ${cloud.account})` : "(unclaimed — `kraftwerk login` claims this machine)"}`);
59
60
  }
60
61
  else if (cloud.state === "error") {
61
- console.log(`${chalk.yellow("⚠")} Cloud: ${cloud.url} unreachable — ${cloud.error ?? "no answer"} ${chalk.dim("(retrying; nothing else depends on it)")}`);
62
+ log("warn", "cloud", `${cloud.url} unreachable — ${cloud.error ?? "no answer"} (retrying; nothing else depends on it)`);
62
63
  }
63
64
  // The relay (`kraftwerk remote on`): devices reach this machine from anywhere, end to end encrypted.
64
- const relay = await applyRelay(hub.port, await getPkgVersion(), (line) => console.log(chalk.dim(line)));
65
+ const relay = await applyRelay(hub.port, await getPkgVersion(), (level, line) => log(level, "relay", line));
65
66
  // What needs you, pushed to the phones that asked (through the relay, sealed per device).
66
67
  startPushWatcher(hub, relayPush);
67
68
  if (relay.state !== "off")
68
- console.log(`${chalk.green("✔")} Reachable from anywhere through ${chalk.cyan(relay.url ?? "")} ${chalk.dim("(`kraftwerk remote pair` pairs a device)")}`);
69
+ log("info", "relay", `reachable from anywhere through ${relay.url ?? ""} (\`kraftwerk remote pair\` pairs a device)`);
69
70
  const lan = networkUrls(hub.port);
70
71
  if (lan.length)
71
- console.log(`${chalk.green("✔")} On your network: ${lan.map((u) => chalk.cyan(u)).join(", ")} ${chalk.dim("(add /w/<slug>/; devices pair first)")}`);
72
+ log("info", "daemon", `on your network: ${lan.join(", ")} (add /w/<slug>/; devices pair first)`);
72
73
  }
73
74
  export function registerDaemonCommand(program) {
74
75
  const daemon = program
@@ -95,7 +95,7 @@ export async function runDoctor(cwd, opts = {}) {
95
95
  else {
96
96
  const cfg = project.config;
97
97
  const keys = Object.keys(cfg);
98
- // Blocks (git, repos, vibeables, projects, cloud) print as their keys, not [object Object].
98
+ // Blocks (git, repos, vibeables, projects, contacts, places, events, cloud) print as their keys, not [object Object].
99
99
  const show = (v) => typeof v === "object" && v !== null
100
100
  ? Array.isArray(v)
101
101
  ? `[${v.length}]`
@@ -0,0 +1,8 @@
1
+ import type { Command } from "commander";
2
+ import { type EntityKindDef } from "../core/entities.js";
3
+ export declare function registerEntityCommands(program: Command, def: EntityKindDef<any>): void;
4
+ export declare const registerContactCommands: (program: Command) => void;
5
+ export declare const registerPlaceCommands: (program: Command) => void;
6
+ export declare const registerEventCommands: (program: Command) => void;
7
+ /** `kraftwerk ref` — resolve one reference, or every reference in a file. */
8
+ export declare function registerRefCommand(program: Command): void;
@@ -0,0 +1,222 @@
1
+ import chalk from "chalk";
2
+ import Table from "cli-table3";
3
+ import { readFile } from "node:fs/promises";
4
+ import { CONTACTS, createEntity, deleteEntity, EVENTS, getEntity, listEntities, PLACES, saveEntity } from "../core/entities.js";
5
+ import { recordLine } from "../core/records.js";
6
+ import { findRefs, resolveRef, resolveTokens } from "../core/refs.js";
7
+ import { initContext as prepare } from "./routines.js";
8
+ import { fmtAgo } from "./workspaces.js";
9
+ /**
10
+ * `kraftwerk contacts|places|events` — the workspace's records of who,
11
+ * where and when, one Markdown file each (core/entities.ts). One command
12
+ * group per kind, built from the kind's definition so the options are its
13
+ * fields; the same module the inspector uses, so an entry created here
14
+ * shows up in the UI at once, and an agent uses these to keep them current:
15
+ *
16
+ * kraftwerk contacts list (--json)
17
+ * kraftwerk contacts show <slug> fields, records, links (found / missing), notes
18
+ * kraftwerk contacts create <title> [--org ..] [--email ..] [--place <slug>] [--tags a,b] [--notes ..]
19
+ * kraftwerk contacts set <slug> [--role ..] [--notes-file <md>] change fields; "" clears one
20
+ * kraftwerk contacts remove <slug> move the file to the trash
21
+ * kraftwerk places ... --address, --lat, --lng, --url; show prints the Google Maps link
22
+ * kraftwerk events [--from <date>] [--to <date>] --start (required), --end, --place, --contacts a,b, --project
23
+ * kraftwerk ref <kind:slug> | --file <md> resolve references (exit 1 when a target is missing)
24
+ */
25
+ const die = (msg) => {
26
+ console.error(chalk.red(msg));
27
+ process.exit(1);
28
+ };
29
+ const mark = (found) => (found ? chalk.green("✔") : chalk.yellow("?"));
30
+ function printDetail(def, e) {
31
+ const rec = e;
32
+ console.log(`${chalk.bold(e.title)} ${chalk.dim(`(${def.singular}:${e.slug})`)}`);
33
+ console.log(chalk.dim(e.path));
34
+ if (e.configError)
35
+ console.log(chalk.red(`frontmatter: ${e.configError}`));
36
+ for (const f of def.fields) {
37
+ const v = rec[f.name];
38
+ if (v === undefined || (Array.isArray(v) && v.length === 0))
39
+ continue;
40
+ const links = e.links[f.name];
41
+ const shown = links ? links.map((l) => `${mark(l.found)} ${l.slug}${l.label ? chalk.dim(` ${l.label}`) : l.found ? "" : chalk.dim(" (not found)")}`).join(", ") : Array.isArray(v) ? v.join(", ") : String(v);
42
+ console.log(`${chalk.dim(f.label.padEnd(12))} ${shown}`);
43
+ }
44
+ if (def.kind === "places" && rec.mapsUrl)
45
+ console.log(`${chalk.dim("map".padEnd(12))} ${rec.mapsUrl}`);
46
+ if (e.tags.length)
47
+ console.log(`${chalk.dim("tags".padEnd(12))} ${e.tags.join(", ")}`);
48
+ for (const [k, v] of Object.entries(e.extra ?? {}))
49
+ console.log(`${chalk.dim(k.padEnd(12))} ${typeof v === "string" ? v : JSON.stringify(v)}`);
50
+ if (e.records.length) {
51
+ console.log(`\n${chalk.bold("systems of record")}`);
52
+ for (const r of e.records)
53
+ console.log(recordLine(r));
54
+ }
55
+ if (e.notes.trim())
56
+ console.log(`\n${chalk.bold("notes")}\n${e.notes.trim()}`);
57
+ }
58
+ /** The fields given on the command line, as the store takes them (comma lists split there; "" clears). */
59
+ function fieldsOf(def, opts) {
60
+ const out = {};
61
+ for (const f of def.fields)
62
+ if (opts[f.name] !== undefined)
63
+ out[f.name] = opts[f.name];
64
+ if (opts.tags !== undefined)
65
+ out.tags = opts.tags;
66
+ if (opts.title !== undefined)
67
+ out.title = opts.title;
68
+ return out;
69
+ }
70
+ export function registerEntityCommands(program, def) {
71
+ const what = { contacts: "people and organisations", places: "addresses and points of interest", events: "dated one-off entries" }[def.kind];
72
+ const group = program.command(def.kind).description(`${def.kind[0].toUpperCase()}${def.kind.slice(1)}: ${what}, one Markdown file each — list, show, create, set, remove`);
73
+ const withFields = (cmd) => {
74
+ for (const f of def.fields)
75
+ cmd.option(`--${f.name} <value>`, `${f.hint}${f.required ? " (required)" : ""}`);
76
+ cmd.option("--tags <list>", "Tags, comma-separated");
77
+ return cmd;
78
+ };
79
+ const list = group
80
+ .command("list", { isDefault: true })
81
+ .description(`List the ${def.kind} under the ${def.kind} root`)
82
+ .option("--json", "Machine-readable output");
83
+ if (def.span)
84
+ list.option("--from <date>", "Only entries still running on or after this date (ISO)").option("--to <date>", "Only entries that have started by this date (ISO)");
85
+ list.action(async (opts) => {
86
+ await prepare();
87
+ const view = await listEntities(def, { from: opts.from, to: opts.to });
88
+ if (opts.json) {
89
+ console.log(JSON.stringify(view, null, 2));
90
+ return;
91
+ }
92
+ if (!view.enabled)
93
+ die(view.error ?? `${def.kind} are off`);
94
+ if (view.items.length === 0) {
95
+ console.log(chalk.dim(`No ${def.kind} under ${view.root} yet — \`kraftwerk ${def.kind} create "<title>"\` adds one.`));
96
+ return;
97
+ }
98
+ const table = new Table({ head: [def.singular, "details", "changed"].map((h) => chalk.bold(h)), wordWrap: true, colWidths: [30, 56, 14] });
99
+ for (const e of view.items) {
100
+ table.push([`${chalk.cyan(e.title)}\n${chalk.dim(e.slug)}`, e.configError ? chalk.red(e.configError) : def.detail(e) || chalk.dim("—"), chalk.dim(fmtAgo(e.updatedAt))]);
101
+ }
102
+ console.log(table.toString());
103
+ console.log(chalk.dim(view.root ?? ""));
104
+ });
105
+ group
106
+ .command("show")
107
+ .description(`Show one ${def.singular}: fields, records, links with their state, notes`)
108
+ .argument("<slug>", "File name under the root, without .md")
109
+ .option("--json", "Machine-readable output")
110
+ .action(async (slug, opts) => {
111
+ await prepare();
112
+ try {
113
+ const e = await getEntity(def, slug);
114
+ if (!e)
115
+ die(`no ${def.singular} "${slug}"`);
116
+ if (opts.json)
117
+ console.log(JSON.stringify(e, null, 2));
118
+ else
119
+ printDetail(def, e);
120
+ }
121
+ catch (err) {
122
+ die(err.message);
123
+ }
124
+ });
125
+ withFields(group
126
+ .command("create")
127
+ .description(`Create a ${def.singular} file from its title and fields`)
128
+ .argument("<title>", "Display title; the file name derives from it unless --slug is given")
129
+ .option("--slug <slug>", "File name under the root (lowercase, digits, dashes)")
130
+ .option("--notes <text>", "The Markdown body")
131
+ .option("--notes-file <path>", "Read the body from a file")
132
+ .option("--json", `Print the new ${def.singular} as JSON`)).action(async (title, opts) => {
133
+ await prepare();
134
+ try {
135
+ const notes = opts.notesFile ? await readFile(opts.notesFile, "utf8") : opts.notes;
136
+ const e = await createEntity(def, { ...fieldsOf(def, opts), title, slug: opts.slug, ...(notes !== undefined ? { notes } : {}) });
137
+ if (opts.json)
138
+ console.log(JSON.stringify(e, null, 2));
139
+ else {
140
+ const missing = Object.values(e.links).flat().filter((l) => !l.found);
141
+ console.log(`${chalk.green("✔")} ${chalk.cyan(`${def.singular}:${e.slug}`)} → ${e.path}${missing.length ? chalk.yellow(` (not found in this workspace yet: ${missing.map((l) => l.slug).join(", ")})`) : ""}`);
142
+ }
143
+ }
144
+ catch (err) {
145
+ die(err.message);
146
+ }
147
+ });
148
+ withFields(group
149
+ .command("set")
150
+ .description(`Change a ${def.singular}'s fields ("" clears one) or notes`)
151
+ .argument("<slug>", "File name under the root")
152
+ .option("--title <text>", "New title")
153
+ .option("--notes <text>", "Replace the Markdown body")
154
+ .option("--notes-file <path>", "Replace the body with a file's content")
155
+ .option("--json", `Print the ${def.singular} as JSON`)).action(async (slug, opts) => {
156
+ await prepare();
157
+ try {
158
+ const notes = opts.notesFile ? await readFile(opts.notesFile, "utf8") : opts.notes;
159
+ const input = { ...fieldsOf(def, opts), ...(notes !== undefined ? { notes } : {}) };
160
+ if (Object.keys(input).length === 0)
161
+ die(`nothing to set — pass --${def.fields.map((f) => f.name).join(", --")}, --tags, --title or --notes`);
162
+ const e = await saveEntity(def, slug, input);
163
+ if (opts.json)
164
+ console.log(JSON.stringify(e, null, 2));
165
+ else
166
+ console.log(`${chalk.green("✔")} ${chalk.cyan(`${def.singular}:${e.slug}`)} — ${def.describe(e)}`);
167
+ }
168
+ catch (err) {
169
+ die(err.message);
170
+ }
171
+ });
172
+ group
173
+ .command("remove")
174
+ .description(`Move a ${def.singular} file to the trash (\`kraftwerk trash\` puts it back)`)
175
+ .argument("<slug>", "File name under the root")
176
+ .action(async (slug) => {
177
+ await prepare();
178
+ try {
179
+ await deleteEntity(def, slug);
180
+ console.log(`${chalk.green("✔")} moved ${chalk.cyan(`${def.singular}:${slug}`)} to the trash`);
181
+ }
182
+ catch (err) {
183
+ die(err.message);
184
+ }
185
+ });
186
+ }
187
+ export const registerContactCommands = (program) => registerEntityCommands(program, CONTACTS);
188
+ export const registerPlaceCommands = (program) => registerEntityCommands(program, PLACES);
189
+ export const registerEventCommands = (program) => registerEntityCommands(program, EVENTS);
190
+ const refLine = (r) => `${mark(r.found)} ${r.token}${r.label ? chalk.dim(` ${r.label}`) : r.found ? "" : chalk.dim(" (not found)")}`;
191
+ /** `kraftwerk ref` — resolve one reference, or every reference in a file. */
192
+ export function registerRefCommand(program) {
193
+ program
194
+ .command("ref")
195
+ .description("Resolve a reference (contact:anna-meier, place:…, event:…, project:…, agent:…, workflow:…, repo:…, vibeable:…, knowledge:…) or every one in a file")
196
+ .argument("[ref]", "The reference, <kind>:<slug>")
197
+ .option("--file <path>", "Resolve every reference found in this file")
198
+ .option("--json", "Machine-readable output")
199
+ .action(async (ref, opts) => {
200
+ await prepare();
201
+ let refs;
202
+ if (opts.file)
203
+ refs = await resolveTokens(findRefs(await readFile(opts.file, "utf8").catch((e) => die(e.message))));
204
+ else if (ref) {
205
+ const r = await resolveRef(ref);
206
+ if (!r)
207
+ die(`not a reference: "${ref}" (expected <kind>:<slug>, e.g. contact:anna-meier)`);
208
+ refs = [r];
209
+ }
210
+ else
211
+ die("pass a reference like contact:anna-meier, or --file <path>");
212
+ if (opts.json)
213
+ console.log(JSON.stringify({ refs: refs }, null, 2));
214
+ else if (refs.length === 0)
215
+ console.log(chalk.dim("no references found"));
216
+ else
217
+ for (const r of refs)
218
+ console.log(refLine(r));
219
+ if (refs.some((r) => !r.found))
220
+ process.exit(1);
221
+ });
222
+ }
package/dist/cli/init.js CHANGED
@@ -31,6 +31,9 @@ skills: ${DATA_DIR}/skills # workspace skills (shared instruction packag
31
31
  # root: ${DATA_DIR}/repos # clones land here (git-ignored); a bare \`repos:\` uses repos/ instead
32
32
  # vibeables: # small apps built live in a chat with a preview pane (uncomment to enable; one folder per app under ${DATA_DIR}/vibeables, versioned with the workspace)
33
33
  # projects: # goal-scoped folders — brief, systems of record, links to knowledge/vibeables/repos/workflows/agents — that chats work in (uncomment to enable; one folder per project under ${DATA_DIR}/projects)
34
+ # contacts: # people and organisations, one Markdown file each under ${DATA_DIR}/contacts — referenced as contact:<slug> (uncomment to enable)
35
+ # places: # addresses and points of interest, one Markdown file each under ${DATA_DIR}/places — referenced as place:<slug> (uncomment to enable)
36
+ # events: # dated one-off entries with place, contacts and project, one Markdown file each under ${DATA_DIR}/events — referenced as event:<slug> (uncomment to enable)
34
37
  # cloud: # the daemon registers this MACHINE with the kraftwerk cloud and lists this workspace (\`kraftwerk login\` claims the machine)
35
38
  # enabled: false # uncomment both lines to leave this workspace out (\`kraftwerk cloud off\` turns the cloud off for the whole machine)
36
39
  `;
@@ -13,6 +13,7 @@ import { runDoctor } from "./doctor.js";
13
13
  import { runInit } from "./init.js";
14
14
  import { registerKnowledgeCommands } from "./knowledge.js";
15
15
  import { registerProjectCommands } from "./projects.js";
16
+ import { registerContactCommands, registerEventCommands, registerPlaceCommands, registerRefCommand } from "./entities.js";
16
17
  import { registerWorkspaceCommands } from "./workspaces.js";
17
18
  import { registerRepoCommands } from "./repos.js";
18
19
  import { registerVibeableCommands } from "./vibeables.js";
@@ -42,6 +43,8 @@ import { runUi } from "./ui.js";
42
43
  * kraftwerk ui start the inspector web UI (localhost:1981)
43
44
  * kraftwerk workspaces ... known workspaces on this machine (list/start/stop/forget)
44
45
  * kraftwerk projects ... goal-scoped project folders (list/create/show/link/log/remove)
46
+ * kraftwerk contacts|places|events who, where and when: one Markdown file each (list/show/create/set/remove)
47
+ * kraftwerk ref <kind:slug> resolve a reference (contact:anna-meier) or every one in a file
45
48
  * kraftwerk vibeables ... small apps built live in a chat (list/create/remove)
46
49
  * kraftwerk repos ... repositories the agents work on (list/add/update/remove)
47
50
  * kraftwerk journal <agent> [entry] an agent's memory across sessions: print it, add a line
@@ -299,6 +302,10 @@ registerKnowledgeCommands(program);
299
302
  registerRoutineCommands(program);
300
303
  registerWorkspaceCommands(program);
301
304
  registerProjectCommands(program);
305
+ registerContactCommands(program);
306
+ registerPlaceCommands(program);
307
+ registerEventCommands(program);
308
+ registerRefCommand(program);
302
309
  registerRepoCommands(program);
303
310
  registerVibeableCommands(program);
304
311
  registerTrashCommands(program);
package/dist/cli/ui.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { existsSync } from "node:fs";
2
+ import { log } from "../core/log.js";
2
3
  import os from "node:os";
3
4
  import path from "node:path";
4
5
  import { fileURLToPath } from "node:url";
@@ -144,7 +145,7 @@ export async function supervise(argv, what) {
144
145
  if (ran > 120_000)
145
146
  backoff = 1000;
146
147
  const how = result.signal ? `signal ${result.signal}` : `exit ${result.code}`;
147
- console.error(chalk.yellow(`↻ ${new Date().toISOString()} ${what} stopped unexpectedly (${how}) — starting it again in ${backoff / 1000}s`));
148
+ log("warn", "supervise", `${what} stopped unexpectedly (${how}) — starting it again in ${backoff / 1000}s`);
148
149
  await new Promise((r) => setTimeout(r, backoff));
149
150
  backoff = Math.min(backoff * 2, 60_000);
150
151
  continue;
@@ -50,6 +50,11 @@ export declare const ROUTES: {
50
50
  readonly "cloud.status": readonly ["GET", "/api/cloud"];
51
51
  readonly "cloud.sync": readonly ["POST", "/api/cloud/sync"];
52
52
  readonly "cloud.unlink": readonly ["POST", "/api/cloud/unlink"];
53
+ readonly "contacts.create": readonly ["POST", "/api/contacts"];
54
+ readonly "contacts.delete": readonly ["DELETE", "/api/contacts/:slug"];
55
+ readonly "contacts.get": readonly ["GET", "/api/contacts/:slug"];
56
+ readonly "contacts.list": readonly ["GET", "/api/contacts"];
57
+ readonly "contacts.save": readonly ["PUT", "/api/contacts/:slug"];
53
58
  readonly "devices.code": readonly ["POST", "/api/devices/code"];
54
59
  readonly "devices.dropPush": readonly ["DELETE", "/api/devices/push"];
55
60
  readonly "devices.list": readonly ["GET", "/api/devices"];
@@ -59,6 +64,11 @@ export declare const ROUTES: {
59
64
  readonly "devices.self": readonly ["GET", "/api/devices/self"];
60
65
  readonly "devices.setPush": readonly ["POST", "/api/devices/push"];
61
66
  readonly "devices.setRelay": readonly ["POST", "/api/devices/relay"];
67
+ readonly "events.create": readonly ["POST", "/api/events"];
68
+ readonly "events.delete": readonly ["DELETE", "/api/events/:slug"];
69
+ readonly "events.get": readonly ["GET", "/api/events/:slug"];
70
+ readonly "events.list": readonly ["GET", "/api/events"];
71
+ readonly "events.save": readonly ["PUT", "/api/events/:slug"];
62
72
  readonly "files.delete": readonly ["DELETE", "/api/files"];
63
73
  readonly "files.list": readonly ["GET", "/api/files/list"];
64
74
  readonly "files.mkdir": readonly ["POST", "/api/files/folder"];
@@ -89,6 +99,11 @@ export declare const ROUTES: {
89
99
  readonly "notifications.diagnose": readonly ["POST", "/api/notifications/:id/diagnose"];
90
100
  readonly "notifications.list": readonly ["GET", "/api/notifications"];
91
101
  readonly "notifications.read": readonly ["POST", "/api/notifications/read"];
102
+ readonly "places.create": readonly ["POST", "/api/places"];
103
+ readonly "places.delete": readonly ["DELETE", "/api/places/:slug"];
104
+ readonly "places.get": readonly ["GET", "/api/places/:slug"];
105
+ readonly "places.list": readonly ["GET", "/api/places"];
106
+ readonly "places.save": readonly ["PUT", "/api/places/:slug"];
92
107
  readonly "projects.create": readonly ["POST", "/api/projects"];
93
108
  readonly "projects.delete": readonly ["DELETE", "/api/projects/:slug"];
94
109
  readonly "projects.get": readonly ["GET", "/api/projects/:slug"];
@@ -98,6 +113,7 @@ export declare const ROUTES: {
98
113
  readonly "projects.save": readonly ["PUT", "/api/projects/:slug"];
99
114
  readonly "projects.saveLayout": readonly ["PUT", "/api/projects"];
100
115
  readonly "protocol.get": readonly ["GET", "/api/protocol"];
116
+ readonly "refs.resolve": readonly ["POST", "/api/refs/resolve"];
101
117
  readonly "repos.add": readonly ["POST", "/api/repos"];
102
118
  readonly "repos.commit": readonly ["GET", "/api/repos/:slug/commits/:hash"];
103
119
  readonly "repos.diff": readonly ["GET", "/api/repos/:slug/diff"];
@@ -51,6 +51,11 @@ export const ROUTES = {
51
51
  "cloud.status": ["GET", "/api/cloud"],
52
52
  "cloud.sync": ["POST", "/api/cloud/sync"],
53
53
  "cloud.unlink": ["POST", "/api/cloud/unlink"],
54
+ "contacts.create": ["POST", "/api/contacts"],
55
+ "contacts.delete": ["DELETE", "/api/contacts/:slug"],
56
+ "contacts.get": ["GET", "/api/contacts/:slug"],
57
+ "contacts.list": ["GET", "/api/contacts"],
58
+ "contacts.save": ["PUT", "/api/contacts/:slug"],
54
59
  "devices.code": ["POST", "/api/devices/code"],
55
60
  "devices.dropPush": ["DELETE", "/api/devices/push"],
56
61
  "devices.list": ["GET", "/api/devices"],
@@ -60,6 +65,11 @@ export const ROUTES = {
60
65
  "devices.self": ["GET", "/api/devices/self"],
61
66
  "devices.setPush": ["POST", "/api/devices/push"],
62
67
  "devices.setRelay": ["POST", "/api/devices/relay"],
68
+ "events.create": ["POST", "/api/events"],
69
+ "events.delete": ["DELETE", "/api/events/:slug"],
70
+ "events.get": ["GET", "/api/events/:slug"],
71
+ "events.list": ["GET", "/api/events"],
72
+ "events.save": ["PUT", "/api/events/:slug"],
63
73
  "files.delete": ["DELETE", "/api/files"],
64
74
  "files.list": ["GET", "/api/files/list"],
65
75
  "files.mkdir": ["POST", "/api/files/folder"],
@@ -90,6 +100,11 @@ export const ROUTES = {
90
100
  "notifications.diagnose": ["POST", "/api/notifications/:id/diagnose"],
91
101
  "notifications.list": ["GET", "/api/notifications"],
92
102
  "notifications.read": ["POST", "/api/notifications/read"],
103
+ "places.create": ["POST", "/api/places"],
104
+ "places.delete": ["DELETE", "/api/places/:slug"],
105
+ "places.get": ["GET", "/api/places/:slug"],
106
+ "places.list": ["GET", "/api/places"],
107
+ "places.save": ["PUT", "/api/places/:slug"],
93
108
  "projects.create": ["POST", "/api/projects"],
94
109
  "projects.delete": ["DELETE", "/api/projects/:slug"],
95
110
  "projects.get": ["GET", "/api/projects/:slug"],
@@ -99,6 +114,7 @@ export const ROUTES = {
99
114
  "projects.save": ["PUT", "/api/projects/:slug"],
100
115
  "projects.saveLayout": ["PUT", "/api/projects"],
101
116
  "protocol.get": ["GET", "/api/protocol"],
117
+ "refs.resolve": ["POST", "/api/refs/resolve"],
102
118
  "repos.add": ["POST", "/api/repos"],
103
119
  "repos.commit": ["GET", "/api/repos/:slug/commits/:hash"],
104
120
  "repos.diff": ["GET", "/api/repos/:slug/diff"],
package/dist/config.d.ts CHANGED
@@ -121,6 +121,25 @@ export interface ProjectsConfig {
121
121
  export declare const PROJECTS_DEFAULT_ROOT = "kraftwerk-data/projects";
122
122
  /** Absolute projects root when the feature is on, undefined otherwise. */
123
123
  export declare function projectsRootFor(project: ResolvedWorkspace): string | undefined;
124
+ /**
125
+ * Contacts, places and events: the workspace's own records of who, where
126
+ * and when — one Markdown file with YAML frontmatter per entry under the
127
+ * kind's root, so humans, agents and projects can reference them reliably
128
+ * (`contact:anna-meier`). Three features with one shape, each switched on
129
+ * by its own block; part of the workspace and synced by the workspace git.
130
+ */
131
+ export interface EntitiesConfig {
132
+ /** false keeps the block but turns the feature off. Default: true. */
133
+ enabled?: boolean;
134
+ /** Where the files live, relative to the project root. Default: kraftwerk-data/<kind> */
135
+ root?: string;
136
+ }
137
+ export declare const ENTITY_KINDS: readonly ["contacts", "places", "events"];
138
+ export type EntityKind = (typeof ENTITY_KINDS)[number];
139
+ /** Where a kind's files live when its `root` is not set. */
140
+ export declare const entityDefaultRoot: (kind: EntityKind) => string;
141
+ /** Absolute root of a kind when its feature is on, undefined otherwise. */
142
+ export declare function entitiesRootFor(project: ResolvedWorkspace, kind: EntityKind): string | undefined;
124
143
  /**
125
144
  * kraftwerk.yml `cloud:` — since the cloud knows machines (core/cloud.ts,
126
145
  * server/cloud.ts), this block only vetoes: `enabled: false` leaves the
@@ -193,6 +212,12 @@ export interface WorkspaceConfig {
193
212
  vibeables?: VibeablesConfig;
194
213
  /** Projects: goal-scoped folders the chats work in. Absent = off. */
195
214
  projects?: ProjectsConfig;
215
+ /** Contacts: people and organisations, one Markdown file each. Absent = off. */
216
+ contacts?: EntitiesConfig;
217
+ /** Places: addresses and points of interest, one Markdown file each. Absent = off. */
218
+ places?: EntitiesConfig;
219
+ /** Events: dated entries (one-off), one Markdown file each. Absent = off. */
220
+ events?: EntitiesConfig;
196
221
  /** The machine's cloud record lists this workspace. Absent = listed; `enabled: false` leaves it out. */
197
222
  cloud?: CloudConfig;
198
223
  }
package/dist/config.js CHANGED
@@ -96,6 +96,16 @@ export function projectsRootFor(project) {
96
96
  return undefined;
97
97
  return path.resolve(project.root, p.root ?? PROJECTS_DEFAULT_ROOT);
98
98
  }
99
+ export const ENTITY_KINDS = ["contacts", "places", "events"];
100
+ /** Where a kind's files live when its `root` is not set. */
101
+ export const entityDefaultRoot = (kind) => `kraftwerk-data/${kind}`;
102
+ /** Absolute root of a kind when its feature is on, undefined otherwise. */
103
+ export function entitiesRootFor(project, kind) {
104
+ const e = project.config[kind];
105
+ if (!e || e.enabled === false)
106
+ return undefined;
107
+ return path.resolve(project.root, e.root ?? entityDefaultRoot(kind));
108
+ }
99
109
  export const CLOUD_DEFAULT_URL = "https://srv.kraftwerk-cloud.netnode.cloud";
100
110
  /** A directory as a .gitignore entry: relative, forward slashes, no trailing slash; undefined outside the root. */
101
111
  export function ignoreEntryFor(projectRoot, dir) {
@@ -231,7 +241,7 @@ export async function addWorkspaceId(configPath) {
231
241
  await writeFile(configPath, out);
232
242
  return id;
233
243
  }
234
- const KNOWN_KEYS = ["id", "name", "slug", "icon", "color", "port", "workflows", "output", "knowledge", "agents", "skills", "switcher", "git", "repos", "vibeables", "projects", "cloud"];
244
+ const KNOWN_KEYS = ["id", "name", "slug", "icon", "color", "port", "workflows", "output", "knowledge", "agents", "skills", "switcher", "git", "repos", "vibeables", "projects", "contacts", "places", "events", "cloud"];
235
245
  /**
236
246
  * Keys an older kraftwerk understood and this one ignores, so the files
237
247
  * that still carry them keep loading: `public:` and `tunnel:` (the
@@ -298,10 +308,10 @@ async function loadConfig(configPath) {
298
308
  config[key] = {};
299
309
  validateRootBlock(configPath, "vibeables", config[key]);
300
310
  }
301
- else if (key === "projects") {
311
+ else if (key === "projects" || key === "contacts" || key === "places" || key === "events") {
302
312
  if (config[key] === null)
303
313
  config[key] = {};
304
- validateRootBlock(configPath, "projects", config[key]);
314
+ validateRootBlock(configPath, key, config[key]);
305
315
  }
306
316
  else if (key === "cloud") {
307
317
  if (config[key] === null)
@@ -403,9 +413,10 @@ function validateGit(configPath, value) {
403
413
  }
404
414
  }
405
415
  /**
406
- * `repos`, `vibeables` and `projects` share one shape: { enabled?, root? }. The root
407
- * must never be the project itself or anything outside it — it is what
408
- * remove/delete operate under, and what the workspace git may stage.
416
+ * `repos`, `vibeables`, `projects`, `contacts`, `places` and `events` share one
417
+ * shape: { enabled?, root? }. The root must never be the project itself or
418
+ * anything outside it — it is what remove/delete operate under, and what
419
+ * the workspace git may stage.
409
420
  */
410
421
  function validateRootBlock(configPath, block, value) {
411
422
  const file = path.basename(configPath);