@time-machine-lab/tmlbrain 0.1.0 → 0.1.2

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/bin/tmlbrain.js CHANGED
@@ -19,11 +19,12 @@ const CONFLICT_DIR = path.join(LOCAL_DIR, "conflicts");
19
19
  const PATCH_DIR = path.join(LOCAL_DIR, "patches");
20
20
  const LOG_DIR = path.join(LOCAL_DIR, "logs");
21
21
  const STATE_FILE = path.join(LOCAL_DIR, "state.json");
22
+ const PACKAGE_INFO = readPackageInfo();
22
23
 
23
24
  const VALID_TYPES = new Set(["project", "area", "resource", "reference", "meeting", "decision"]);
24
25
  const VALID_STATUSES = new Set(["draft", "active", "stale", "archived"]);
25
26
  const DEFAULT_SERVER_HOST = "127.0.0.1";
26
- const DEFAULT_SERVER_PORT = 7389;
27
+ const DEFAULT_SERVER_PORT = 8477;
27
28
 
28
29
  main().catch((error) => fail(error.message || String(error)));
29
30
 
@@ -38,10 +39,18 @@ async function main() {
38
39
  case "--help":
39
40
  printHelp();
40
41
  break;
42
+ case "version":
43
+ case "-v":
44
+ case "--version":
45
+ console.log(PACKAGE_INFO.version);
46
+ break;
41
47
  case "install":
42
48
  case "init":
43
49
  cmdInstall(args);
44
50
  break;
51
+ case "update":
52
+ await cmdUpdate(args);
53
+ break;
45
54
  case "client":
46
55
  await cmdClient(args);
47
56
  break;
@@ -109,6 +118,8 @@ function printHelp() {
109
118
 
110
119
  Usage:
111
120
  tmlbrain client install [--server <http-url>] [--token <token>] [--local] [--yes]
121
+ tmlbrain update [latest|<version>] [--sync] [--dry-run]
122
+ tmlbrain version|--version
112
123
  tmlbrain install [--server <http-url>] [--token <token>] [--graph] [--with-git]
113
124
  tmlbrain config show|set-server|clear-token
114
125
  tmlbrain doctor
@@ -121,6 +132,7 @@ Usage:
121
132
  tmlbrain remote add --title <title> [--folder <path>] [--type <type>] [--content <text>]
122
133
  tmlbrain remote ingest <file> [--title <title>] [--folder <path>] [--type <type>]
123
134
  tmlbrain remote update --file <path> (--replace <text> --with <text> | --content-file <file>)
135
+ tmlbrain remote delete --file <path>
124
136
  tmlbrain sync [--dry-run] [--pull]
125
137
  tmlbrain serve [--host <host>] [--port <port>] [--token <token>]
126
138
  tmlbrain patch create|check|apply [file]
@@ -128,6 +140,8 @@ Usage:
128
140
  tmlbrain backup [--dry-run] [--remote <remote>]
129
141
  tmlbrain publish [--dry-run] [--site <dir>] [--pagefind]
130
142
  tmlbrain graph setup [--dry-run]
143
+ tmlbrain graph index [--json]
144
+ tmlbrain graph query <query> [--limit <n>] [--json]
131
145
 
132
146
  Clients use local snapshots for search and call the TMLBrain server for writes.
133
147
  HTTP-only clients do not need Git. Git is required only for the server runtime
@@ -143,6 +157,7 @@ async function cmdClient(args) {
143
157
 
144
158
  Usage:
145
159
  tmlbrain client install [--server <http-url>] [--token <token>] [--local] [--yes]
160
+ tmlbrain client update [latest|<version>] [--sync] [--dry-run]
146
161
  tmlbrain client doctor
147
162
 
148
163
  If --server is omitted, interactive install asks for one. Press Enter to start
@@ -153,6 +168,10 @@ in local-only mode.`);
153
168
  await cmdClientInstall(args);
154
169
  return;
155
170
  }
171
+ if (action === "update") {
172
+ await cmdUpdate(args);
173
+ return;
174
+ }
156
175
  if (action === "doctor") {
157
176
  cmdDoctor(args);
158
177
  return;
@@ -164,9 +183,10 @@ async function cmdClientInstall(args) {
164
183
  const opts = parseArgs(args);
165
184
  const interactive = !opts.yes && !opts["non-interactive"];
166
185
  ensureLocalDirs();
186
+ const existingState = readState();
167
187
 
168
- let server = opts.local ? "" : (opts.server || "");
169
- let token = opts.token || "";
188
+ let server = opts.local ? "" : (opts.server || existingState.server || "");
189
+ let token = opts.local ? "" : (opts.token || existingState.token || "");
170
190
  let graph = Boolean(opts.graph);
171
191
 
172
192
  if (interactive) {
@@ -180,6 +200,7 @@ async function cmdClientInstall(args) {
180
200
  if (server && !token) fail("Server token is required when a server URL is configured. Use --token <token> or leave the server URL empty for local-only mode.");
181
201
 
182
202
  const installArgs = [];
203
+ if (opts.local) installArgs.push("--local");
183
204
  if (server) installArgs.push("--server", server);
184
205
  if (token) installArgs.push("--token", token);
185
206
  if (graph) installArgs.push("--graph");
@@ -207,9 +228,17 @@ function cmdInstall(args) {
207
228
  ? ensureGit({ install: true, dryRun: Boolean(opts["dry-run"]) })
208
229
  : checkCommand("git", ["--version"]);
209
230
  const state = readState();
231
+ if (opts.local) {
232
+ delete state.server;
233
+ delete state.token;
234
+ }
210
235
  if (opts.server) state.server = opts.server;
211
236
  if (opts.token) state.token = opts.token;
212
237
  if (!state.workspaceVersion) state.workspaceVersion = 1;
238
+ state.package = {
239
+ name: PACKAGE_INFO.name,
240
+ version: PACKAGE_INFO.version
241
+ };
213
242
  state.git = { detected: gitInfo.ok, version: gitInfo.version || null };
214
243
  state.updatedAt = new Date().toISOString();
215
244
  writeJson(STATE_FILE, state);
@@ -218,6 +247,59 @@ function cmdInstall(args) {
218
247
  console.log("TMLBrain workspace is ready.");
219
248
  }
220
249
 
250
+ async function cmdUpdate(args) {
251
+ const opts = parseArgs(args);
252
+ ensureLocalDirs();
253
+ const state = readState();
254
+ const target = opts.version || opts.tag || opts._[0] || "latest";
255
+ const packageSpec = `${PACKAGE_INFO.name}@${target}`;
256
+ const dryRun = Boolean(opts["dry-run"]);
257
+ const skipNpm = Boolean(opts["skip-npm"]);
258
+ const server = opts.local ? "" : (opts.server || state.server || "");
259
+ const token = opts.local ? "" : (opts.token || state.token || "");
260
+ const installArgs = ["install"];
261
+
262
+ if (opts.local) installArgs.push("--local");
263
+ if (opts.server) installArgs.push("--server", opts.server);
264
+ if (opts.token) installArgs.push("--token", opts.token);
265
+ if (opts.graph) installArgs.push("--graph");
266
+ if (dryRun) installArgs.push("--dry-run");
267
+
268
+ if (dryRun) {
269
+ console.log(`Would update npm package: npm install -g ${packageSpec}`);
270
+ console.log(`Would refresh local runtime: tmlbrain ${redactArgs(installArgs).join(" ")}`);
271
+ if (server && !opts.server) console.log("Would preserve the existing configured server.");
272
+ if (token && !opts.token) console.log("Would preserve the existing configured token.");
273
+ return;
274
+ }
275
+
276
+ if (!skipNpm) {
277
+ if (!checkCommand("npm", ["--version"]).ok) {
278
+ fail("npm is required for `tmlbrain update`. Install Node.js/npm first, or use `npx -y @time-machine-lab/tmlbrain@latest client install`.");
279
+ }
280
+ console.log(`Updating ${packageSpec} with npm...`);
281
+ const npmResult = runCommand("npm", ["install", "-g", packageSpec], { stdio: "inherit" });
282
+ if (npmResult.status !== 0) fail(`npm update failed for ${packageSpec}.`);
283
+ }
284
+
285
+ console.log("Refreshing TMLBrain workspace runtime and skill from the installed package...");
286
+ const cliPath = path.join(PACKAGE_ROOT, "bin", "tmlbrain.js");
287
+ const refreshResult = fs.existsSync(cliPath)
288
+ ? spawnSync(process.execPath, [cliPath, ...installArgs], { cwd: ROOT, stdio: "inherit" })
289
+ : runCommand("tmlbrain", installArgs, { cwd: ROOT, stdio: "inherit" });
290
+
291
+ if (refreshResult.status !== 0) {
292
+ console.warn("Updated package, but automatic runtime refresh failed. Falling back to current CLI runtime.");
293
+ cmdInstall(installArgs.slice(1));
294
+ }
295
+
296
+ if (opts.sync && server) {
297
+ await cmdSync(["--pull", ...(opts.json ? ["--json"] : [])]);
298
+ }
299
+
300
+ console.log("TMLBrain update is complete.");
301
+ }
302
+
221
303
  function cmdDoctor(args) {
222
304
  const opts = parseArgs(args);
223
305
  const state = readState();
@@ -350,10 +432,11 @@ async function cmdSave(args) {
350
432
  content = fs.readFileSync(sourcePath, "utf8");
351
433
  }
352
434
  if (!title) fail("Use: tmlbrain save --title <title> --content <text>, or tmlbrain save <file>");
435
+ const placement = classifyKnowledgePlacement({ title, content, type: opts.type, folder: opts.folder });
353
436
  const created = createKnowledgeDocument({
354
437
  title,
355
- type: opts.type || "resource",
356
- folder: opts.folder || "knowledge/00-inbox",
438
+ type: placement.type,
439
+ folder: placement.folder,
357
440
  slug: opts.slug || title,
358
441
  owner: opts.owner || "TML",
359
442
  content,
@@ -364,13 +447,15 @@ async function cmdSave(args) {
364
447
  }
365
448
  if (sourcePath && fs.existsSync(sourcePath) && fs.statSync(sourcePath).isFile() && !opts.content && !opts["content-file"]) {
366
449
  const title = opts.title || path.basename(sourcePath, path.extname(sourcePath));
450
+ const content = fs.readFileSync(sourcePath, "utf8");
451
+ const placement = classifyKnowledgePlacement({ title, content, type: opts.type, folder: opts.folder });
367
452
  const response = await requestServerJson("/knowledge/add", {
368
453
  title,
369
- type: opts.type || "resource",
370
- folder: opts.folder || "knowledge/00-inbox",
454
+ type: placement.type,
455
+ folder: placement.folder,
371
456
  slug: opts.slug || null,
372
457
  owner: opts.owner || "TML",
373
- content: fs.readFileSync(sourcePath, "utf8"),
458
+ content,
374
459
  message: opts.message || `Save knowledge: ${title}`
375
460
  }, opts);
376
461
  output(response, opts);
@@ -381,13 +466,15 @@ async function cmdSave(args) {
381
466
  }
382
467
  const title = opts.title || opts._.join(" ").trim();
383
468
  if (!title) fail("Use: tmlbrain save --title <title> --content <text>, or tmlbrain save <file>");
469
+ const content = opts.content || readContentOption(opts) || "";
470
+ const placement = classifyKnowledgePlacement({ title, content, type: opts.type, folder: opts.folder });
384
471
  const response = await requestServerJson("/knowledge/add", {
385
472
  title,
386
- type: opts.type || "resource",
387
- folder: opts.folder || "knowledge/00-inbox",
473
+ type: placement.type,
474
+ folder: placement.folder,
388
475
  slug: opts.slug || null,
389
476
  owner: opts.owner || "TML",
390
- content: opts.content || readContentOption(opts) || "",
477
+ content,
391
478
  message: opts.message || `Save knowledge: ${title}`
392
479
  }, opts);
393
480
  output(response, opts);
@@ -403,7 +490,8 @@ Usage:
403
490
  tmlbrain remote capabilities [--json]
404
491
  tmlbrain remote add --title <title> [--folder <path>] [--type <type>] [--content <text>]
405
492
  tmlbrain remote ingest <file> [--title <title>] [--folder <path>] [--type <type>]
406
- tmlbrain remote update --file <path> (--replace <text> --with <text> | --content-file <file>)`);
493
+ tmlbrain remote update --file <path> (--replace <text> --with <text> | --content-file <file>)
494
+ tmlbrain remote delete --file <path>`);
407
495
  return;
408
496
  }
409
497
  if (action === "capabilities") {
@@ -445,16 +533,78 @@ Usage:
445
533
  if (action === "update") {
446
534
  const file = opts.file || opts._[0];
447
535
  if (!file) fail("Use: tmlbrain remote update --file <path> ...");
536
+ const expectedSha256 = opts["expected-sha256"] || localSnapshotSha(file) || null;
448
537
  const payload = {
449
538
  path: file,
450
539
  replace: opts.replace,
451
540
  with: opts.with,
452
541
  content: opts.content || readContentOption(opts),
453
- expectedSha256: opts["expected-sha256"] || null,
542
+ expectedSha256,
454
543
  message: opts.message || null
455
544
  };
456
- const response = await requestServerJson("/knowledge/update", payload, opts);
457
- output(response, opts);
545
+ try {
546
+ const response = await requestServerJson("/knowledge/update", payload, opts);
547
+ output(response, opts);
548
+ } catch (error) {
549
+ if (error.statusCode === 409 && error.data?.conflict) {
550
+ const conflictFile = createApiConflictPackage(error.data.conflict, payload);
551
+ try {
552
+ const snapshot = await requestServerGetJson("/snapshot", opts);
553
+ applySnapshot(snapshot);
554
+ } catch {
555
+ // Keep the conflict package even when refresh fails.
556
+ }
557
+ output({
558
+ ok: false,
559
+ conflict: rel(conflictFile),
560
+ serverConflict: error.data.conflict,
561
+ actions: [
562
+ "Created a TMLBrain conflict package.",
563
+ "Refreshed the local snapshot when possible.",
564
+ "Review with `tmlbrain conflict show <id>` and retry with a more precise update."
565
+ ]
566
+ }, opts);
567
+ return;
568
+ }
569
+ throw error;
570
+ }
571
+ return;
572
+ }
573
+ if (action === "delete" || action === "remove") {
574
+ const file = opts.file || opts._[0];
575
+ if (!file) fail("Use: tmlbrain remote delete --file <path>");
576
+ const expectedSha256 = opts["expected-sha256"] || localSnapshotSha(file) || null;
577
+ const payload = {
578
+ path: file,
579
+ expectedSha256,
580
+ message: opts.message || null
581
+ };
582
+ try {
583
+ const response = await requestServerJson("/knowledge/delete", payload, opts);
584
+ output(response, opts);
585
+ } catch (error) {
586
+ if (error.statusCode === 409 && error.data?.conflict) {
587
+ const conflictFile = createApiConflictPackage(error.data.conflict, payload);
588
+ try {
589
+ const snapshot = await requestServerGetJson("/snapshot", opts);
590
+ applySnapshot(snapshot);
591
+ } catch {
592
+ // Keep the conflict package even when refresh fails.
593
+ }
594
+ output({
595
+ ok: false,
596
+ conflict: rel(conflictFile),
597
+ serverConflict: error.data.conflict,
598
+ actions: [
599
+ "Created a TMLBrain conflict package.",
600
+ "Refreshed the local snapshot when possible.",
601
+ "Review with `tmlbrain conflict show <id>` before retrying the delete."
602
+ ]
603
+ }, opts);
604
+ return;
605
+ }
606
+ throw error;
607
+ }
458
608
  return;
459
609
  }
460
610
  fail(`Unknown remote action: ${action}`);
@@ -550,7 +700,7 @@ function cmdServe(args) {
550
700
  const token = opts.token || process.env.TMLBRAIN_SERVER_TOKEN || null;
551
701
  const server = http.createServer((req, res) => {
552
702
  routeServerRequest(req, res, token).catch((error) => {
553
- sendJson(res, error.statusCode || 500, { ok: false, error: error.message || String(error) });
703
+ sendJson(res, error.statusCode || 500, error.payload || { ok: false, error: error.message || String(error) });
554
704
  });
555
705
  });
556
706
  server.listen(port, host, () => {
@@ -652,30 +802,9 @@ function cmdConflict(args) {
652
802
  function cmdBackup(args) {
653
803
  const opts = parseArgs(args);
654
804
  ensureGit();
655
- ensureDir(LOG_DIR);
656
805
  const remote = opts.remote || "backup";
657
806
  const dryRun = Boolean(opts["dry-run"]);
658
- const source = git(["rev-parse", "HEAD"], { allowFail: true });
659
- const remotes = git(["remote"], { allowFail: true });
660
- const hasRemote = remotes.ok && remotes.stdout.split(/\r?\n/).includes(remote);
661
- const entry = {
662
- time: new Date().toISOString(),
663
- dryRun,
664
- remote,
665
- sourceCommit: source.ok ? source.stdout.trim() : null,
666
- ok: false
667
- };
668
- if (!hasRemote) {
669
- entry.message = `Remote "${remote}" is not configured.`;
670
- } else {
671
- const pushArgs = ["push"];
672
- if (dryRun) pushArgs.push("--dry-run");
673
- pushArgs.push(remote, "HEAD");
674
- const push = git(pushArgs, { allowFail: true });
675
- entry.ok = push.ok;
676
- entry.message = sanitizeGit(push.stderr || push.stdout);
677
- }
678
- fs.appendFileSync(path.join(LOG_DIR, "backup.log"), JSON.stringify(entry) + "\n", "utf8");
807
+ const entry = pushBackupRepository(remote, { dryRun, reason: "manual" });
679
808
  output(entry, opts);
680
809
  if (!entry.ok && !dryRun) process.exit(1);
681
810
  }
@@ -722,8 +851,21 @@ function cmdPublish(args) {
722
851
  function cmdGraph(args) {
723
852
  const action = args.shift() || "setup";
724
853
  const opts = parseArgs(args);
725
- if (action !== "setup") fail("Graph action must be setup.");
726
- output(setupGraphRuntime({ dryRun: Boolean(opts["dry-run"]) }), opts);
854
+ if (action === "setup") {
855
+ output(setupGraphRuntime({ dryRun: Boolean(opts["dry-run"]) }), opts);
856
+ return;
857
+ }
858
+ if (action === "index") {
859
+ output(runGraphScript("cocoindex_pipeline.py", ["--root", ROOT, "--json"], opts), opts);
860
+ return;
861
+ }
862
+ if (action === "query" || action === "search") {
863
+ const query = opts._.join(" ").trim();
864
+ if (!query) fail("Use: tmlbrain graph query <query>");
865
+ output(runGraphScript("lightrag_retrieval.py", [query, "--root", ROOT, "--limit", String(opts.limit || 5), "--json"], opts), opts);
866
+ return;
867
+ }
868
+ fail("Graph action must be setup, index, or query.");
727
869
  }
728
870
 
729
871
  function setupGraphRuntime({ dryRun = false } = {}) {
@@ -748,6 +890,36 @@ function setupGraphRuntime({ dryRun = false } = {}) {
748
890
  return result;
749
891
  }
750
892
 
893
+ function runGraphScript(scriptName, args) {
894
+ const python = checkCommand("python", ["--version"]).ok ? "python" : (checkCommand("py", ["--version"]).ok ? "py" : null);
895
+ if (!python) {
896
+ return {
897
+ ok: false,
898
+ degraded: true,
899
+ reason: "python is not available; exact search and deterministic Node index remain usable"
900
+ };
901
+ }
902
+ const script = path.join(PACKAGE_ROOT, "scripts", "index", scriptName);
903
+ if (!fs.existsSync(script)) fail(`Graph script not found: ${script}`);
904
+ const result = spawnSync(python, [script, ...args], {
905
+ cwd: ROOT,
906
+ encoding: "utf8",
907
+ env: { ...process.env, PYTHONIOENCODING: "utf-8" }
908
+ });
909
+ const text = (result.stdout || "").trim();
910
+ let payload;
911
+ try {
912
+ payload = text ? JSON.parse(text) : { ok: result.status === 0 };
913
+ } catch {
914
+ payload = { ok: result.status === 0, output: text };
915
+ }
916
+ if (result.status !== 0 && result.status !== 2) {
917
+ payload.ok = false;
918
+ payload.error = sanitizeGit(result.stderr || result.stdout);
919
+ }
920
+ return payload;
921
+ }
922
+
751
923
  function safeConfigView(state) {
752
924
  return {
753
925
  configFile: rel(STATE_FILE),
@@ -770,7 +942,7 @@ function capabilities() {
770
942
  client: [
771
943
  "pull read-only knowledge snapshots from the TMLBrain server",
772
944
  "build local indexes and run local search",
773
- "request server-side save/update operations through the TMLBrain server API"
945
+ "request server-side save/update/delete operations through the TMLBrain server API"
774
946
  ],
775
947
  server: [
776
948
  "mutate Markdown knowledge files",
@@ -788,24 +960,32 @@ function capabilities() {
788
960
  configuredServer: state.server || null,
789
961
  config: safeConfigView(state),
790
962
  userCommands: [
963
+ { command: "tmlbrain update", role: "client", description: "Update the npm package, refresh the local Skill/runtime, and preserve existing client configuration." },
791
964
  { command: "tmlbrain config show", role: "client", description: "Show the configured server without printing the token." },
792
965
  { command: "tmlbrain config set-server <http-url> --token <token>", role: "client", description: "Switch this client to another TMLBrain server." },
793
966
  { command: "tmlbrain sync --pull", role: "client", description: "Refresh the local read-only knowledge snapshot from the configured server." },
794
967
  { command: "tmlbrain find <query>", role: "client", description: "Search the local knowledge snapshot." },
795
- { command: "tmlbrain save --title <title> --content <text>", role: "client-request", description: "Save new knowledge through the server API." },
796
- { command: "tmlbrain save <file>", role: "client-request", description: "Save a local file into the knowledge base through the server API." },
797
- { command: "tmlbrain remote update --file <path> --replace <old> --with <new>", role: "client-request", description: "Ask the server to update a precise text region." }
968
+ { command: "tmlbrain graph index", role: "client", description: "Build the CocoIndex-compatible local Markdown index under .tmlbrain/index/." },
969
+ { command: "tmlbrain graph query <query>", role: "client", description: "Query local chunks and graph context through the LightRAG-compatible retrieval adapter." },
970
+ { command: "tmlbrain save --title <title> --content <text>", role: "client-request", description: "Classify and save new knowledge through the server API." },
971
+ { command: "tmlbrain save <file>", role: "client-request", description: "Classify and save a local file into the knowledge base through the server API." },
972
+ { command: "tmlbrain remote update --file <path> --replace <old> --with <new>", role: "client-request", description: "Ask the server to update a precise text region." },
973
+ { command: "tmlbrain remote delete --file <path>", role: "client-request", description: "Ask the server to delete a knowledge document after explicit user confirmation." }
798
974
  ],
799
975
  commands: [
976
+ { command: "tmlbrain update", role: "client", description: "Update the globally installed package and refresh local runtime files without asking for setup again." },
800
977
  { command: "tmlbrain sync --pull", role: "client", description: "Fetch a read-only knowledge snapshot from the configured HTTP server." },
801
978
  { command: "tmlbrain search <query>", role: "client", description: "Search local Markdown snapshot." },
802
979
  { command: "tmlbrain find <query>", role: "client", description: "Alias for local search." },
803
980
  { command: "tmlbrain index", role: "client", description: "Build local deterministic index and graph files." },
804
- { command: "tmlbrain save --title <title> --content <text>", role: "client-request", description: "User-facing alias that asks the server to save knowledge." },
805
- { command: "tmlbrain save <file>", role: "client-request", description: "User-facing alias that asks the server to save a local file." },
981
+ { command: "tmlbrain graph index", role: "client", description: "Build CocoIndex-compatible local index artifacts from knowledge/**/*.md." },
982
+ { command: "tmlbrain graph query <query>", role: "client", description: "Retrieve relevant local chunks and graph context through the LightRAG-compatible adapter." },
983
+ { command: "tmlbrain save --title <title> --content <text>", role: "client-request", description: "User-facing alias that classifies knowledge and asks the server to save it." },
984
+ { command: "tmlbrain save <file>", role: "client-request", description: "User-facing alias that classifies a local file and asks the server to save it." },
806
985
  { command: "tmlbrain remote add --title <title> --content <text>", role: "client-request", description: "Ask the server to create a knowledge document and commit it." },
807
986
  { command: "tmlbrain remote ingest <file>", role: "client-request", description: "Ask the server to ingest a local file as a knowledge document." },
808
987
  { command: "tmlbrain remote update --file <path> --replace <old> --with <new>", role: "client-request", description: "Ask the server to apply a precise text replacement and commit it." },
988
+ { command: "tmlbrain remote delete --file <path>", role: "client-request", description: "Ask the server to delete a Markdown knowledge document and commit it." },
809
989
  { command: "tmlbrain serve", role: "server", description: "Run the TMLBrain server API from the server worktree." },
810
990
  { command: "tmlbrain backup --remote backup", role: "server", description: "Push the server repository state to GitHub backup." }
811
991
  ]
@@ -845,6 +1025,11 @@ async function routeServerRequest(req, res, token) {
845
1025
  sendJson(res, 200, serverUpdateKnowledge(body));
846
1026
  return;
847
1027
  }
1028
+ if (req.method === "POST" && requestUrl.pathname === "/knowledge/delete") {
1029
+ const body = await readJsonBody(req);
1030
+ sendJson(res, 200, serverDeleteKnowledge(body));
1031
+ return;
1032
+ }
848
1033
  sendJson(res, 404, { ok: false, error: `Unknown TMLBrain endpoint: ${req.method} ${requestUrl.pathname}` });
849
1034
  }
850
1035
 
@@ -867,8 +1052,17 @@ function serverUpdateKnowledge(payload) {
867
1052
  return serverMutation(payload.message || `Update knowledge: ${payload.path || "unknown"}`, () => {
868
1053
  const target = safeKnowledgeFile(payload.path);
869
1054
  const before = fs.readFileSync(target, "utf8");
1055
+ const currentSha256 = sha256(before);
870
1056
  if (payload.expectedSha256 && sha256(before) !== payload.expectedSha256) {
871
- failRequest(409, `Expected sha256 ${payload.expectedSha256}, found ${sha256(before)}.`);
1057
+ failConflict(structuredConflict({
1058
+ pathName: rel(target),
1059
+ reason: "expected-sha256-mismatch",
1060
+ message: `Expected sha256 ${payload.expectedSha256}, found ${currentSha256}.`,
1061
+ currentText: before,
1062
+ rejected: payload,
1063
+ currentSha256,
1064
+ expectedSha256: payload.expectedSha256
1065
+ }));
872
1066
  }
873
1067
  let after = before;
874
1068
  if (payload.content !== undefined && payload.content !== null) {
@@ -877,8 +1071,27 @@ function serverUpdateKnowledge(payload) {
877
1071
  const needle = String(payload.replace);
878
1072
  if (!needle) failRequest(400, "Replacement text cannot be empty.");
879
1073
  const occurrences = countOccurrences(before, needle);
880
- if (occurrences === 0) failRequest(404, "Replacement text was not found in the server document.");
881
- if (occurrences > 1 && !payload.all) failRequest(409, `Replacement text matched ${occurrences} times. Provide a more specific replacement.`);
1074
+ if (occurrences === 0) {
1075
+ failConflict(structuredConflict({
1076
+ pathName: rel(target),
1077
+ reason: "replacement-not-found",
1078
+ message: "Replacement text was not found in the server document.",
1079
+ currentText: before,
1080
+ rejected: payload,
1081
+ currentSha256
1082
+ }));
1083
+ }
1084
+ if (occurrences > 1 && !payload.all) {
1085
+ failConflict(structuredConflict({
1086
+ pathName: rel(target),
1087
+ reason: "replacement-ambiguous",
1088
+ message: `Replacement text matched ${occurrences} times. Provide a more specific replacement.`,
1089
+ currentText: before,
1090
+ rejected: payload,
1091
+ currentSha256,
1092
+ occurrences
1093
+ }));
1094
+ }
882
1095
  after = payload.all ? before.split(needle).join(String(payload.with)) : before.replace(needle, String(payload.with));
883
1096
  } else {
884
1097
  failRequest(400, "Use either `content` or `replace` plus `with` for update.");
@@ -894,14 +1107,40 @@ function serverUpdateKnowledge(payload) {
894
1107
  });
895
1108
  }
896
1109
 
1110
+ function serverDeleteKnowledge(payload) {
1111
+ return serverMutation(payload.message || `Delete knowledge: ${payload.path || "unknown"}`, () => {
1112
+ const target = safeKnowledgeFile(payload.path);
1113
+ if (!fs.existsSync(target)) failRequest(404, `Knowledge file not found: ${rel(target)}`);
1114
+ const before = fs.readFileSync(target, "utf8");
1115
+ const currentSha256 = sha256(before);
1116
+ if (payload.expectedSha256 && currentSha256 !== payload.expectedSha256) {
1117
+ failConflict(structuredConflict({
1118
+ pathName: rel(target),
1119
+ reason: "expected-sha256-mismatch",
1120
+ message: `Expected sha256 ${payload.expectedSha256}, found ${currentSha256}.`,
1121
+ currentText: before,
1122
+ rejected: { ...payload, delete: true },
1123
+ currentSha256,
1124
+ expectedSha256: payload.expectedSha256
1125
+ }));
1126
+ }
1127
+ fs.unlinkSync(target);
1128
+ return {
1129
+ action: "delete",
1130
+ path: rel(target),
1131
+ beforeSha256: currentSha256
1132
+ };
1133
+ });
1134
+ }
1135
+
897
1136
  function serverMutation(message, mutate) {
898
1137
  ensureGit();
899
1138
  ensureGitWorktree();
900
1139
  ensureServerGitIdentity();
901
1140
  const beforeHead = currentHead();
902
- const beforeStatus = git(["status", "--porcelain"], { allowFail: true });
1141
+ const beforeStatus = git(["status", "--porcelain", "--", "knowledge"], { allowFail: true });
903
1142
  if (beforeStatus.ok && beforeStatus.stdout.trim()) {
904
- failRequest(409, "Server worktree has uncommitted changes; refusing to mutate knowledge.");
1143
+ failRequest(409, "Server knowledge files have uncommitted changes; refusing to mutate knowledge.");
905
1144
  }
906
1145
  const result = mutate();
907
1146
  const validation = validateKnowledge();
@@ -921,6 +1160,7 @@ function serverMutation(message, mutate) {
921
1160
  failRequest(500, sanitizeGit(commit.stderr || commit.stdout));
922
1161
  }
923
1162
  const pushResult = pushServerRepository();
1163
+ const backupResult = pushBackupRepository("backup", { reason: "api-write" });
924
1164
  const head = currentHead();
925
1165
  snapshotBase();
926
1166
  return {
@@ -930,6 +1170,7 @@ function serverMutation(message, mutate) {
930
1170
  head,
931
1171
  pushed: pushResult.ok,
932
1172
  pushMessage: pushResult.message,
1173
+ backup: backupResult,
933
1174
  validation,
934
1175
  result,
935
1176
  diff: diff.stdout || ""
@@ -945,6 +1186,34 @@ function pushServerRepository() {
945
1186
  return { ok: push.ok, message: sanitizeGit(push.stderr || push.stdout) };
946
1187
  }
947
1188
 
1189
+ function pushBackupRepository(remote = "backup", { dryRun = false, reason = "manual" } = {}) {
1190
+ ensureDir(LOG_DIR);
1191
+ const source = git(["rev-parse", "HEAD"], { allowFail: true });
1192
+ const remotes = git(["remote"], { allowFail: true });
1193
+ const hasRemote = remotes.ok && remotes.stdout.split(/\r?\n/).includes(remote);
1194
+ const entry = {
1195
+ time: new Date().toISOString(),
1196
+ reason,
1197
+ dryRun,
1198
+ remote,
1199
+ sourceCommit: source.ok ? source.stdout.trim() : null,
1200
+ ok: false
1201
+ };
1202
+ if (!hasRemote) {
1203
+ entry.skipped = true;
1204
+ entry.message = `Remote "${remote}" is not configured.`;
1205
+ } else {
1206
+ const pushArgs = ["push"];
1207
+ if (dryRun) pushArgs.push("--dry-run");
1208
+ pushArgs.push(remote, "HEAD");
1209
+ const push = git(pushArgs, { allowFail: true });
1210
+ entry.ok = push.ok;
1211
+ entry.message = sanitizeGit(push.stderr || push.stdout);
1212
+ }
1213
+ fs.appendFileSync(path.join(LOG_DIR, "backup.log"), JSON.stringify(entry) + "\n", "utf8");
1214
+ return entry;
1215
+ }
1216
+
948
1217
  function revertServerWorktree() {
949
1218
  git(["reset", "--hard"], { allowFail: true });
950
1219
  git(["clean", "-fd", "knowledge"], { allowFail: true });
@@ -1060,6 +1329,12 @@ function readContentOption(opts) {
1060
1329
  return fs.readFileSync(filePath, "utf8");
1061
1330
  }
1062
1331
 
1332
+ function localSnapshotSha(filePath) {
1333
+ const normalized = String(filePath || "").replace(/\\/g, "/");
1334
+ const state = readState();
1335
+ return state.files?.[normalized]?.sha256 || null;
1336
+ }
1337
+
1063
1338
  async function requestServerGetJson(endpoint, opts) {
1064
1339
  return requestServer("GET", endpoint, null, opts);
1065
1340
  }
@@ -1071,7 +1346,7 @@ async function requestServerJson(endpoint, body, opts) {
1071
1346
  async function requestServer(method, endpoint, body, opts) {
1072
1347
  const state = readState();
1073
1348
  const base = opts.server || state.server;
1074
- if (!base || !isHttpUrl(base)) fail("Configure an HTTP TMLBrain server first: tmlbrain install --server http://host:7389");
1349
+ if (!base || !isHttpUrl(base)) fail("Configure an HTTP TMLBrain server first: tmlbrain install --server http://host:8477");
1075
1350
  const token = opts.token || state.token || process.env.TMLBRAIN_TOKEN || null;
1076
1351
  const url = new URL(endpoint, base.endsWith("/") ? base : `${base}/`);
1077
1352
  const transport = url.protocol === "https:" ? https : http;
@@ -1096,7 +1371,10 @@ async function requestServer(method, endpoint, body, opts) {
1096
1371
  return;
1097
1372
  }
1098
1373
  if (res.statusCode >= 400) {
1099
- reject(new Error(data.error || `TMLBrain server returned HTTP ${res.statusCode}`));
1374
+ const error = new Error(data.error || `TMLBrain server returned HTTP ${res.statusCode}`);
1375
+ error.statusCode = res.statusCode;
1376
+ error.data = data;
1377
+ reject(error);
1100
1378
  return;
1101
1379
  }
1102
1380
  resolve(data);
@@ -1173,6 +1451,47 @@ function failRequest(statusCode, message) {
1173
1451
  throw error;
1174
1452
  }
1175
1453
 
1454
+ function failConflict(conflict) {
1455
+ const error = new Error(conflict.message || "TMLBrain update conflict.");
1456
+ error.statusCode = 409;
1457
+ error.payload = { ok: false, error: error.message, conflict };
1458
+ throw error;
1459
+ }
1460
+
1461
+ function structuredConflict({ pathName, reason, message, currentText, rejected, currentSha256, expectedSha256 = null, occurrences = null }) {
1462
+ const relevant = relevantConflictText(currentText, rejected?.replace);
1463
+ return {
1464
+ path: pathName,
1465
+ reason,
1466
+ message,
1467
+ currentSha256,
1468
+ expectedSha256,
1469
+ currentRelevantText: relevant,
1470
+ rejectedIntent: {
1471
+ replace: rejected?.replace ?? null,
1472
+ with: rejected?.with ?? null,
1473
+ contentProvided: rejected?.content !== undefined && rejected?.content !== null,
1474
+ expectedSha256: rejected?.expectedSha256 ?? null
1475
+ },
1476
+ occurrences,
1477
+ suggestedNextActions: [
1478
+ "Run `tmlbrain sync --pull` to refresh the local snapshot.",
1479
+ "Review the current relevant text.",
1480
+ "Retry with a more precise replacement or ask the user to confirm a merged result."
1481
+ ]
1482
+ };
1483
+ }
1484
+
1485
+ function relevantConflictText(text, needle) {
1486
+ if (!needle) return String(text || "").slice(0, 4000);
1487
+ const source = String(text || "");
1488
+ const index = source.indexOf(String(needle));
1489
+ if (index < 0) return source.slice(0, 4000);
1490
+ const start = Math.max(0, index - 1000);
1491
+ const end = Math.min(source.length, index + String(needle).length + 1000);
1492
+ return source.slice(start, end);
1493
+ }
1494
+
1176
1495
  function validateKnowledge() {
1177
1496
  const errors = [];
1178
1497
  const warnings = [];
@@ -1288,6 +1607,25 @@ function createConflictPackage({ reason, patchPath, pathName, base, local, remot
1288
1607
  return file;
1289
1608
  }
1290
1609
 
1610
+ function createApiConflictPackage(conflict, rejectedPayload) {
1611
+ return createConflictPackage({
1612
+ reason: conflict.reason || "api update conflict",
1613
+ patchPath: null,
1614
+ pathName: conflict.path || rejectedPayload.path || "knowledge",
1615
+ base: conflict.expectedSha256 ? `expectedSha256: ${conflict.expectedSha256}` : "",
1616
+ local: JSON.stringify(conflict.rejectedIntent || rejectedPayload, null, 2),
1617
+ remote: [
1618
+ conflict.message || "Server rejected the update.",
1619
+ "",
1620
+ "Current relevant text:",
1621
+ conflict.currentRelevantText || "",
1622
+ "",
1623
+ "Suggested next actions:",
1624
+ ...(conflict.suggestedNextActions || [])
1625
+ ].join("\n")
1626
+ });
1627
+ }
1628
+
1291
1629
  function snapshotBase() {
1292
1630
  if (!fs.existsSync(KNOWLEDGE_DIR)) return;
1293
1631
  ensureDir(BASE_DIR);
@@ -1370,9 +1708,32 @@ function folderForType(type) {
1370
1708
  area: "knowledge/20-areas",
1371
1709
  resource: "knowledge/30-resources",
1372
1710
  reference: "knowledge/40-references",
1373
- meeting: "knowledge/00-inbox",
1711
+ meeting: "knowledge/10-projects",
1374
1712
  decision: "knowledge/10-projects"
1375
- }[type] || "knowledge/00-inbox";
1713
+ }[type] || "knowledge/30-resources";
1714
+ }
1715
+
1716
+ function classifyKnowledgePlacement({ title = "", content = "", type, folder }) {
1717
+ if (folder) {
1718
+ const normalizedType = VALID_TYPES.has(type) ? type : inferKnowledgeType(title, content);
1719
+ return { type: normalizedType, folder, reason: "explicit-folder" };
1720
+ }
1721
+ if (VALID_TYPES.has(type)) return { type, folder: folderForType(type), reason: "explicit-type" };
1722
+ const inferredType = inferKnowledgeType(title, content);
1723
+ return { type: inferredType, folder: folderForType(inferredType), reason: "classified" };
1724
+ }
1725
+
1726
+ function inferKnowledgeType(title = "", content = "") {
1727
+ const text = `${title}\n${content}`.toLowerCase();
1728
+ const frontMatter = parseFrontMatter(String(content || "")).data || {};
1729
+ if (VALID_TYPES.has(String(frontMatter.type))) return String(frontMatter.type);
1730
+
1731
+ if (/(决策|决定|decision|adr|选型|结论|取舍|trade-?off)/i.test(text)) return "decision";
1732
+ if (/(会议|纪要|meeting|minutes|复盘|retro|standup|周会|例会)/i.test(text)) return "meeting";
1733
+ if (/(项目|project|roadmap|里程碑|计划|排期|需求|方案|proposal|prd)/i.test(text)) return "project";
1734
+ if (/(规范|流程|制度|职责|责任区|area|长期维护|运维|运营规则|治理|标准)/i.test(text)) return "area";
1735
+ if (/(参考|reference|api|接口|配置项|清单|checklist|手册|词典|字段|参数|索引)/i.test(text)) return "reference";
1736
+ return "resource";
1376
1737
  }
1377
1738
 
1378
1739
  function looksLikeFilePath(value) {
@@ -1399,6 +1760,18 @@ function parseArgs(args) {
1399
1760
  return opts;
1400
1761
  }
1401
1762
 
1763
+ function redactArgs(args) {
1764
+ const redacted = [];
1765
+ for (let i = 0; i < args.length; i += 1) {
1766
+ redacted.push(args[i]);
1767
+ if (args[i] === "--token" && i + 1 < args.length) {
1768
+ redacted.push("<token>");
1769
+ i += 1;
1770
+ }
1771
+ }
1772
+ return redacted;
1773
+ }
1774
+
1402
1775
  function promptText(label, defaultValue = "") {
1403
1776
  const suffix = defaultValue ? ` [${defaultValue}]` : "";
1404
1777
  return promptLine(`${label}${suffix}: `).then((answer) => {
@@ -1556,6 +1929,21 @@ function parseRgLine(line) {
1556
1929
  return { path: match[1], line: Number(match[2]), text: match[3] };
1557
1930
  }
1558
1931
 
1932
+ function readPackageInfo() {
1933
+ try {
1934
+ const pkg = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, "package.json"), "utf8"));
1935
+ return {
1936
+ name: pkg.name || "@time-machine-lab/tmlbrain",
1937
+ version: pkg.version || "0.0.0"
1938
+ };
1939
+ } catch {
1940
+ return {
1941
+ name: "@time-machine-lab/tmlbrain",
1942
+ version: "0.0.0"
1943
+ };
1944
+ }
1945
+ }
1946
+
1559
1947
  function readState() {
1560
1948
  return fs.existsSync(STATE_FILE) ? JSON.parse(fs.readFileSync(STATE_FILE, "utf8")) : {};
1561
1949
  }
@@ -1591,7 +1979,12 @@ function sha256(value) {
1591
1979
  }
1592
1980
 
1593
1981
  function slugify(value) {
1594
- return String(value).trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "untitled";
1982
+ return String(value)
1983
+ .trim()
1984
+ .toLowerCase()
1985
+ .normalize("NFKC")
1986
+ .replace(/[^\p{Letter}\p{Number}]+/gu, "-")
1987
+ .replace(/^-+|-+$/g, "") || "untitled";
1595
1988
  }
1596
1989
 
1597
1990
  function today() {
@@ -1610,10 +2003,20 @@ function checkCommand(command, args = []) {
1610
2003
  if (process.env.TMLBRAIN_SIMULATE_MISSING_GIT === "1" && command === "git") {
1611
2004
  return { ok: false, version: null };
1612
2005
  }
1613
- const result = spawnSync(command, args, { encoding: "utf8" });
2006
+ const result = runCommand(command, args, { encoding: "utf8" });
1614
2007
  return { ok: result.status === 0, version: result.stdout ? result.stdout.split(/\r?\n/)[0] : null };
1615
2008
  }
1616
2009
 
2010
+ function runCommand(command, args = [], opts = {}) {
2011
+ const spawnOpts = {
2012
+ cwd: opts.cwd || ROOT,
2013
+ shell: process.platform === "win32"
2014
+ };
2015
+ if (opts.stdio) spawnOpts.stdio = opts.stdio;
2016
+ else spawnOpts.encoding = opts.encoding || "utf8";
2017
+ return spawnSync(command, args, spawnOpts);
2018
+ }
2019
+
1617
2020
  function git(args, opts = {}) {
1618
2021
  const result = spawnSync("git", args, { cwd: ROOT, encoding: "utf8" });
1619
2022
  const out = { ok: result.status === 0, stdout: result.stdout || "", stderr: result.stderr || "" };