@time-machine-lab/tmlbrain 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +152 -0
- package/bin/tmlbrain.js +1653 -0
- package/docs/.gitkeep +0 -0
- package/docs/api/.gitkeep +0 -0
- package/docs/architecture.md +84 -0
- package/docs/backup.md +76 -0
- package/docs/decisions/.gitkeep +1 -0
- package/docs/decisions/0001-core-runtime.md +18 -0
- package/docs/design/.gitkeep +0 -0
- package/docs/fixtures/knowledge-sample/alpha-reference.md +14 -0
- package/docs/fixtures/knowledge-sample/project-alpha.md +18 -0
- package/docs/fixtures/patches/update-alpha-reference.patch +9 -0
- package/docs/indexing.md +32 -0
- package/docs/install.md +166 -0
- package/docs/preview/.gitkeep +0 -0
- package/docs/roadmap.md +27 -0
- package/docs/runtime.md +95 -0
- package/docs/server-api.md +120 -0
- package/docs/spec/.gitkeep +0 -0
- package/docs/spikes/cocoindex-local-indexing.md +31 -0
- package/docs/spikes/lightrag-retrieval.md +27 -0
- package/docs/sql/.gitkeep +0 -0
- package/docs/sync.md +110 -0
- package/knowledge/README.md +71 -0
- package/knowledge/_templates/area.md +18 -0
- package/knowledge/_templates/decision.md +18 -0
- package/knowledge/_templates/meeting.md +18 -0
- package/knowledge/_templates/project.md +18 -0
- package/knowledge/_templates/reference.md +18 -0
- package/knowledge/_templates/resource.md +18 -0
- package/package.json +41 -0
- package/scripts/README.md +12 -0
- package/scripts/backup/server-to-github.ps1 +20 -0
- package/scripts/backup/server-to-github.sh +13 -0
- package/scripts/docker/server-entrypoint.sh +84 -0
- package/scripts/install/install.ps1 +44 -0
- package/scripts/install/install.sh +41 -0
- package/scripts/release/parse-auto-release.js +166 -0
- package/scripts/sync/.gitkeep +1 -0
- package/scripts/sync/post-receive.sample +8 -0
- package/skills/tmlbrain/SKILL.md +192 -0
- package/skills/tmlbrain/agents/openai.yaml +7 -0
package/bin/tmlbrain.js
ADDED
|
@@ -0,0 +1,1653 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
const crypto = require("crypto");
|
|
7
|
+
const http = require("http");
|
|
8
|
+
const https = require("https");
|
|
9
|
+
const readline = require("readline");
|
|
10
|
+
const { spawnSync } = require("child_process");
|
|
11
|
+
|
|
12
|
+
const PACKAGE_ROOT = path.resolve(__dirname, "..");
|
|
13
|
+
const ROOT = findRoot(process.cwd());
|
|
14
|
+
const KNOWLEDGE_DIR = path.join(ROOT, "knowledge");
|
|
15
|
+
const LOCAL_DIR = path.join(ROOT, ".tmlbrain");
|
|
16
|
+
const INDEX_DIR = path.join(LOCAL_DIR, "index");
|
|
17
|
+
const BASE_DIR = path.join(LOCAL_DIR, "base");
|
|
18
|
+
const CONFLICT_DIR = path.join(LOCAL_DIR, "conflicts");
|
|
19
|
+
const PATCH_DIR = path.join(LOCAL_DIR, "patches");
|
|
20
|
+
const LOG_DIR = path.join(LOCAL_DIR, "logs");
|
|
21
|
+
const STATE_FILE = path.join(LOCAL_DIR, "state.json");
|
|
22
|
+
|
|
23
|
+
const VALID_TYPES = new Set(["project", "area", "resource", "reference", "meeting", "decision"]);
|
|
24
|
+
const VALID_STATUSES = new Set(["draft", "active", "stale", "archived"]);
|
|
25
|
+
const DEFAULT_SERVER_HOST = "127.0.0.1";
|
|
26
|
+
const DEFAULT_SERVER_PORT = 7389;
|
|
27
|
+
|
|
28
|
+
main().catch((error) => fail(error.message || String(error)));
|
|
29
|
+
|
|
30
|
+
async function main() {
|
|
31
|
+
const args = process.argv.slice(2);
|
|
32
|
+
const command = args.shift() || "help";
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
switch (command) {
|
|
36
|
+
case "help":
|
|
37
|
+
case "-h":
|
|
38
|
+
case "--help":
|
|
39
|
+
printHelp();
|
|
40
|
+
break;
|
|
41
|
+
case "install":
|
|
42
|
+
case "init":
|
|
43
|
+
cmdInstall(args);
|
|
44
|
+
break;
|
|
45
|
+
case "client":
|
|
46
|
+
await cmdClient(args);
|
|
47
|
+
break;
|
|
48
|
+
case "doctor":
|
|
49
|
+
cmdDoctor(args);
|
|
50
|
+
break;
|
|
51
|
+
case "validate":
|
|
52
|
+
cmdValidate(args);
|
|
53
|
+
break;
|
|
54
|
+
case "search":
|
|
55
|
+
case "find":
|
|
56
|
+
cmdSearch(args);
|
|
57
|
+
break;
|
|
58
|
+
case "index":
|
|
59
|
+
case "reindex":
|
|
60
|
+
cmdIndex(args);
|
|
61
|
+
break;
|
|
62
|
+
case "capabilities":
|
|
63
|
+
cmdCapabilities(args);
|
|
64
|
+
break;
|
|
65
|
+
case "add":
|
|
66
|
+
await cmdAdd(args);
|
|
67
|
+
break;
|
|
68
|
+
case "save":
|
|
69
|
+
await cmdSave(args);
|
|
70
|
+
break;
|
|
71
|
+
case "remote":
|
|
72
|
+
await cmdRemote(args);
|
|
73
|
+
break;
|
|
74
|
+
case "config":
|
|
75
|
+
cmdConfig(args);
|
|
76
|
+
break;
|
|
77
|
+
case "sync":
|
|
78
|
+
await cmdSync(args);
|
|
79
|
+
break;
|
|
80
|
+
case "serve":
|
|
81
|
+
case "server":
|
|
82
|
+
cmdServe(args);
|
|
83
|
+
break;
|
|
84
|
+
case "patch":
|
|
85
|
+
cmdPatch(args);
|
|
86
|
+
break;
|
|
87
|
+
case "conflict":
|
|
88
|
+
cmdConflict(args);
|
|
89
|
+
break;
|
|
90
|
+
case "backup":
|
|
91
|
+
cmdBackup(args);
|
|
92
|
+
break;
|
|
93
|
+
case "publish":
|
|
94
|
+
cmdPublish(args);
|
|
95
|
+
break;
|
|
96
|
+
case "graph":
|
|
97
|
+
cmdGraph(args);
|
|
98
|
+
break;
|
|
99
|
+
default:
|
|
100
|
+
fail(`Unknown command: ${command}\nRun "tmlbrain help" for usage.`);
|
|
101
|
+
}
|
|
102
|
+
} catch (error) {
|
|
103
|
+
fail(error.message || String(error));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function printHelp() {
|
|
108
|
+
console.log(`TMLBrain knowledge base toolkit
|
|
109
|
+
|
|
110
|
+
Usage:
|
|
111
|
+
tmlbrain client install [--server <http-url>] [--token <token>] [--local] [--yes]
|
|
112
|
+
tmlbrain install [--server <http-url>] [--token <token>] [--graph] [--with-git]
|
|
113
|
+
tmlbrain config show|set-server|clear-token
|
|
114
|
+
tmlbrain doctor
|
|
115
|
+
tmlbrain capabilities [--json]
|
|
116
|
+
tmlbrain validate [--json]
|
|
117
|
+
tmlbrain search|find <query> [--json]
|
|
118
|
+
tmlbrain index [--json]
|
|
119
|
+
tmlbrain save (--title <title> --content <text> | <file>) [--folder <path>] [--type <type>]
|
|
120
|
+
tmlbrain add --title <title> [--folder <path>] [--type <type>] [--content <text>] [--local]
|
|
121
|
+
tmlbrain remote add --title <title> [--folder <path>] [--type <type>] [--content <text>]
|
|
122
|
+
tmlbrain remote ingest <file> [--title <title>] [--folder <path>] [--type <type>]
|
|
123
|
+
tmlbrain remote update --file <path> (--replace <text> --with <text> | --content-file <file>)
|
|
124
|
+
tmlbrain sync [--dry-run] [--pull]
|
|
125
|
+
tmlbrain serve [--host <host>] [--port <port>] [--token <token>]
|
|
126
|
+
tmlbrain patch create|check|apply [file]
|
|
127
|
+
tmlbrain conflict list|show|resolve|demo
|
|
128
|
+
tmlbrain backup [--dry-run] [--remote <remote>]
|
|
129
|
+
tmlbrain publish [--dry-run] [--site <dir>] [--pagefind]
|
|
130
|
+
tmlbrain graph setup [--dry-run]
|
|
131
|
+
|
|
132
|
+
Clients use local snapshots for search and call the TMLBrain server for writes.
|
|
133
|
+
HTTP-only clients do not need Git. Git is required only for the server runtime
|
|
134
|
+
or legacy Git migration flows.
|
|
135
|
+
Only the server performs repository writes, GitHub backup, or remote Git operations.
|
|
136
|
+
CocoIndex/LightRAG are optional graph-runtime features enabled with "tmlbrain graph setup".`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function cmdClient(args) {
|
|
140
|
+
const action = args.shift() || "install";
|
|
141
|
+
if (action === "help" || action === "-h" || action === "--help") {
|
|
142
|
+
console.log(`TMLBrain client commands
|
|
143
|
+
|
|
144
|
+
Usage:
|
|
145
|
+
tmlbrain client install [--server <http-url>] [--token <token>] [--local] [--yes]
|
|
146
|
+
tmlbrain client doctor
|
|
147
|
+
|
|
148
|
+
If --server is omitted, interactive install asks for one. Press Enter to start
|
|
149
|
+
in local-only mode.`);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (action === "install" || action === "init") {
|
|
153
|
+
await cmdClientInstall(args);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (action === "doctor") {
|
|
157
|
+
cmdDoctor(args);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
fail(`Unknown client action: ${action}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function cmdClientInstall(args) {
|
|
164
|
+
const opts = parseArgs(args);
|
|
165
|
+
const interactive = !opts.yes && !opts["non-interactive"];
|
|
166
|
+
ensureLocalDirs();
|
|
167
|
+
|
|
168
|
+
let server = opts.local ? "" : (opts.server || "");
|
|
169
|
+
let token = opts.token || "";
|
|
170
|
+
let graph = Boolean(opts.graph);
|
|
171
|
+
|
|
172
|
+
if (interactive) {
|
|
173
|
+
console.log("TMLBrain client setup");
|
|
174
|
+
console.log(`Workspace: ${ROOT}`);
|
|
175
|
+
server = await promptText("Server URL (leave empty for local-only)", server);
|
|
176
|
+
if (server && !isHttpUrl(server)) fail("Server URL must start with http:// or https://");
|
|
177
|
+
if (server) token = await promptSecret("Server token", token);
|
|
178
|
+
graph = await promptYesNo("Enable optional CocoIndex/LightRAG graph runtime now?", graph);
|
|
179
|
+
}
|
|
180
|
+
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
|
+
|
|
182
|
+
const installArgs = [];
|
|
183
|
+
if (server) installArgs.push("--server", server);
|
|
184
|
+
if (token) installArgs.push("--token", token);
|
|
185
|
+
if (graph) installArgs.push("--graph");
|
|
186
|
+
if (opts["dry-run"]) installArgs.push("--dry-run");
|
|
187
|
+
cmdInstall(installArgs);
|
|
188
|
+
|
|
189
|
+
if (server && !opts["skip-sync"] && !opts["dry-run"]) {
|
|
190
|
+
try {
|
|
191
|
+
await cmdSync(["--pull", ...(opts.json ? ["--json"] : [])]);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
console.warn(`Initial sync failed: ${error.message || String(error)}`);
|
|
194
|
+
console.warn("Run `tmlbrain sync --pull` after checking the server URL/token.");
|
|
195
|
+
}
|
|
196
|
+
} else if (!server) {
|
|
197
|
+
ensureDir(KNOWLEDGE_DIR);
|
|
198
|
+
console.log("Local-only mode is ready. Configure a server later with `tmlbrain config set-server <url> --token <token>`.");
|
|
199
|
+
}
|
|
200
|
+
console.log("Try `tmlbrain capabilities --json` to see available commands.");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function cmdInstall(args) {
|
|
204
|
+
const opts = parseArgs(args);
|
|
205
|
+
ensureLocalDirs();
|
|
206
|
+
const gitInfo = opts["with-git"] || opts["legacy-git"]
|
|
207
|
+
? ensureGit({ install: true, dryRun: Boolean(opts["dry-run"]) })
|
|
208
|
+
: checkCommand("git", ["--version"]);
|
|
209
|
+
const state = readState();
|
|
210
|
+
if (opts.server) state.server = opts.server;
|
|
211
|
+
if (opts.token) state.token = opts.token;
|
|
212
|
+
if (!state.workspaceVersion) state.workspaceVersion = 1;
|
|
213
|
+
state.git = { detected: gitInfo.ok, version: gitInfo.version || null };
|
|
214
|
+
state.updatedAt = new Date().toISOString();
|
|
215
|
+
writeJson(STATE_FILE, state);
|
|
216
|
+
installSkillToCodex({ dryRun: Boolean(opts["dry-run"]) });
|
|
217
|
+
if (opts.graph) setupGraphRuntime({ dryRun: Boolean(opts["dry-run"]) });
|
|
218
|
+
console.log("TMLBrain workspace is ready.");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function cmdDoctor(args) {
|
|
222
|
+
const opts = parseArgs(args);
|
|
223
|
+
const state = readState();
|
|
224
|
+
output({
|
|
225
|
+
root: ROOT,
|
|
226
|
+
git: checkCommand("git", ["--version"]),
|
|
227
|
+
node: { ok: true, version: process.version },
|
|
228
|
+
ripgrep: checkCommand("rg", ["--version"]),
|
|
229
|
+
state: fs.existsSync(STATE_FILE),
|
|
230
|
+
server: state.server || null,
|
|
231
|
+
serverApi: Boolean(state.server && isHttpUrl(state.server)),
|
|
232
|
+
knowledge: fs.existsSync(KNOWLEDGE_DIR),
|
|
233
|
+
graphRuntime: detectGraphRuntime()
|
|
234
|
+
}, opts);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function cmdCapabilities(args) {
|
|
238
|
+
const opts = parseArgs(args);
|
|
239
|
+
output(capabilities(), opts);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function cmdConfig(args) {
|
|
243
|
+
const action = args.shift() || "show";
|
|
244
|
+
const opts = parseArgs(args);
|
|
245
|
+
ensureLocalDirs();
|
|
246
|
+
const state = readState();
|
|
247
|
+
if (action === "show") {
|
|
248
|
+
output(safeConfigView(state), opts);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (action === "set-server") {
|
|
252
|
+
const server = opts.server || opts._[0];
|
|
253
|
+
if (!server || !isHttpUrl(server)) fail("Use: tmlbrain config set-server <http-url> [--token <token>]");
|
|
254
|
+
state.server = server;
|
|
255
|
+
if (opts.token) state.token = opts.token;
|
|
256
|
+
state.updatedAt = new Date().toISOString();
|
|
257
|
+
writeJson(STATE_FILE, state);
|
|
258
|
+
output(safeConfigView(state), opts);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (action === "clear-token") {
|
|
262
|
+
delete state.token;
|
|
263
|
+
state.updatedAt = new Date().toISOString();
|
|
264
|
+
writeJson(STATE_FILE, state);
|
|
265
|
+
output(safeConfigView(state), opts);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
fail("Config action must be show, set-server, or clear-token.");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function cmdValidate(args) {
|
|
272
|
+
const opts = parseArgs(args);
|
|
273
|
+
const result = validateKnowledge();
|
|
274
|
+
output(result, opts);
|
|
275
|
+
if (result.errors.length > 0) process.exit(1);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function cmdSearch(args) {
|
|
279
|
+
const opts = parseArgs(args);
|
|
280
|
+
const query = opts._.join(" ").trim();
|
|
281
|
+
if (!query) fail("Search query is required.");
|
|
282
|
+
const matches = exactSearch(query);
|
|
283
|
+
output({ query, count: matches.length, matches }, opts);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function cmdIndex(args) {
|
|
287
|
+
const opts = parseArgs(args);
|
|
288
|
+
ensureLocalDirs();
|
|
289
|
+
const index = buildIndex();
|
|
290
|
+
writeJson(path.join(INDEX_DIR, "documents.json"), index.documents);
|
|
291
|
+
writeJson(path.join(INDEX_DIR, "chunks.json"), index.chunks);
|
|
292
|
+
writeJson(path.join(INDEX_DIR, "graph.json"), index.graph);
|
|
293
|
+
writeJson(path.join(INDEX_DIR, "manifest.json"), {
|
|
294
|
+
generatedAt: new Date().toISOString(),
|
|
295
|
+
documents: index.documents.length,
|
|
296
|
+
chunks: index.chunks.length,
|
|
297
|
+
edges: index.graph.edges.length,
|
|
298
|
+
degraded: detectGraphRuntime().available ? [] : ["graph-semantic-runtime-disabled"]
|
|
299
|
+
});
|
|
300
|
+
output({
|
|
301
|
+
generatedAt: new Date().toISOString(),
|
|
302
|
+
documents: index.documents.length,
|
|
303
|
+
chunks: index.chunks.length,
|
|
304
|
+
edges: index.graph.edges.length,
|
|
305
|
+
indexDir: rel(INDEX_DIR)
|
|
306
|
+
}, opts);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function cmdAdd(args) {
|
|
310
|
+
const opts = parseArgs(args);
|
|
311
|
+
if (!opts.local && shouldUseServerWrite(opts)) {
|
|
312
|
+
const response = await requestServerJson("/knowledge/add", {
|
|
313
|
+
title: opts.title || opts._.join(" ").trim(),
|
|
314
|
+
type: opts.type || "resource",
|
|
315
|
+
folder: opts.folder || folderForType(opts.type || "resource"),
|
|
316
|
+
slug: opts.slug || null,
|
|
317
|
+
owner: opts.owner || "TML",
|
|
318
|
+
content: opts.content || "",
|
|
319
|
+
message: opts.message || null
|
|
320
|
+
}, opts);
|
|
321
|
+
output(response, opts);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const title = opts.title || opts._.join(" ").trim();
|
|
325
|
+
if (!title) fail("Document title is required. Use --title <title>.");
|
|
326
|
+
const created = createKnowledgeDocument({
|
|
327
|
+
title,
|
|
328
|
+
type: opts.type || "resource",
|
|
329
|
+
folder: opts.folder || folderForType(opts.type || "resource"),
|
|
330
|
+
slug: opts.slug || title,
|
|
331
|
+
owner: opts.owner || "TML",
|
|
332
|
+
content: opts.content || "",
|
|
333
|
+
force: Boolean(opts.force)
|
|
334
|
+
});
|
|
335
|
+
console.log(`Created ${created.path}`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function cmdSave(args) {
|
|
339
|
+
const opts = parseArgs(args);
|
|
340
|
+
const source = opts.file || opts._[0];
|
|
341
|
+
const sourcePath = source ? path.resolve(ROOT, source) : null;
|
|
342
|
+
if (!shouldUseServerWrite(opts)) {
|
|
343
|
+
if (source && !opts.title && !opts.content && !opts["content-file"] && looksLikeFilePath(source) && (!sourcePath || !fs.existsSync(sourcePath))) {
|
|
344
|
+
fail(`File not found: ${source}`);
|
|
345
|
+
}
|
|
346
|
+
let title = opts.title || opts._.join(" ").trim();
|
|
347
|
+
let content = opts.content || readContentOption(opts) || "";
|
|
348
|
+
if (sourcePath && fs.existsSync(sourcePath) && fs.statSync(sourcePath).isFile() && !opts.content && !opts["content-file"]) {
|
|
349
|
+
title = opts.title || path.basename(sourcePath, path.extname(sourcePath));
|
|
350
|
+
content = fs.readFileSync(sourcePath, "utf8");
|
|
351
|
+
}
|
|
352
|
+
if (!title) fail("Use: tmlbrain save --title <title> --content <text>, or tmlbrain save <file>");
|
|
353
|
+
const created = createKnowledgeDocument({
|
|
354
|
+
title,
|
|
355
|
+
type: opts.type || "resource",
|
|
356
|
+
folder: opts.folder || "knowledge/00-inbox",
|
|
357
|
+
slug: opts.slug || title,
|
|
358
|
+
owner: opts.owner || "TML",
|
|
359
|
+
content,
|
|
360
|
+
force: Boolean(opts.force)
|
|
361
|
+
});
|
|
362
|
+
output({ ok: true, mode: "local-only", action: "save", path: created.path, sha256: created.sha256 }, opts);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
if (sourcePath && fs.existsSync(sourcePath) && fs.statSync(sourcePath).isFile() && !opts.content && !opts["content-file"]) {
|
|
366
|
+
const title = opts.title || path.basename(sourcePath, path.extname(sourcePath));
|
|
367
|
+
const response = await requestServerJson("/knowledge/add", {
|
|
368
|
+
title,
|
|
369
|
+
type: opts.type || "resource",
|
|
370
|
+
folder: opts.folder || "knowledge/00-inbox",
|
|
371
|
+
slug: opts.slug || null,
|
|
372
|
+
owner: opts.owner || "TML",
|
|
373
|
+
content: fs.readFileSync(sourcePath, "utf8"),
|
|
374
|
+
message: opts.message || `Save knowledge: ${title}`
|
|
375
|
+
}, opts);
|
|
376
|
+
output(response, opts);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (source && !opts.title && !opts.content && !opts["content-file"] && looksLikeFilePath(source)) {
|
|
380
|
+
fail(`File not found: ${source}`);
|
|
381
|
+
}
|
|
382
|
+
const title = opts.title || opts._.join(" ").trim();
|
|
383
|
+
if (!title) fail("Use: tmlbrain save --title <title> --content <text>, or tmlbrain save <file>");
|
|
384
|
+
const response = await requestServerJson("/knowledge/add", {
|
|
385
|
+
title,
|
|
386
|
+
type: opts.type || "resource",
|
|
387
|
+
folder: opts.folder || "knowledge/00-inbox",
|
|
388
|
+
slug: opts.slug || null,
|
|
389
|
+
owner: opts.owner || "TML",
|
|
390
|
+
content: opts.content || readContentOption(opts) || "",
|
|
391
|
+
message: opts.message || `Save knowledge: ${title}`
|
|
392
|
+
}, opts);
|
|
393
|
+
output(response, opts);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function cmdRemote(args) {
|
|
397
|
+
const action = args.shift() || "help";
|
|
398
|
+
const opts = parseArgs(args);
|
|
399
|
+
if (action === "help" || action === "-h" || action === "--help") {
|
|
400
|
+
console.log(`TMLBrain remote write commands
|
|
401
|
+
|
|
402
|
+
Usage:
|
|
403
|
+
tmlbrain remote capabilities [--json]
|
|
404
|
+
tmlbrain remote add --title <title> [--folder <path>] [--type <type>] [--content <text>]
|
|
405
|
+
tmlbrain remote ingest <file> [--title <title>] [--folder <path>] [--type <type>]
|
|
406
|
+
tmlbrain remote update --file <path> (--replace <text> --with <text> | --content-file <file>)`);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
if (action === "capabilities") {
|
|
410
|
+
output(await requestServerGetJson("/capabilities", opts), opts);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
if (action === "add") {
|
|
414
|
+
const title = opts.title || opts._.join(" ").trim();
|
|
415
|
+
const response = await requestServerJson("/knowledge/add", {
|
|
416
|
+
title,
|
|
417
|
+
type: opts.type || "resource",
|
|
418
|
+
folder: opts.folder || folderForType(opts.type || "resource"),
|
|
419
|
+
slug: opts.slug || null,
|
|
420
|
+
owner: opts.owner || "TML",
|
|
421
|
+
content: opts.content || readContentOption(opts),
|
|
422
|
+
message: opts.message || null
|
|
423
|
+
}, opts);
|
|
424
|
+
output(response, opts);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
if (action === "ingest") {
|
|
428
|
+
const source = opts._[0] || opts.file;
|
|
429
|
+
if (!source) fail("Use: tmlbrain remote ingest <file>");
|
|
430
|
+
const sourcePath = path.resolve(ROOT, source);
|
|
431
|
+
if (!fs.existsSync(sourcePath)) fail(`File not found: ${source}`);
|
|
432
|
+
const title = opts.title || path.basename(sourcePath, path.extname(sourcePath));
|
|
433
|
+
const response = await requestServerJson("/knowledge/add", {
|
|
434
|
+
title,
|
|
435
|
+
type: opts.type || "resource",
|
|
436
|
+
folder: opts.folder || "knowledge/00-inbox",
|
|
437
|
+
slug: opts.slug || null,
|
|
438
|
+
owner: opts.owner || "TML",
|
|
439
|
+
content: fs.readFileSync(sourcePath, "utf8"),
|
|
440
|
+
message: opts.message || `Ingest knowledge: ${title}`
|
|
441
|
+
}, opts);
|
|
442
|
+
output(response, opts);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
if (action === "update") {
|
|
446
|
+
const file = opts.file || opts._[0];
|
|
447
|
+
if (!file) fail("Use: tmlbrain remote update --file <path> ...");
|
|
448
|
+
const payload = {
|
|
449
|
+
path: file,
|
|
450
|
+
replace: opts.replace,
|
|
451
|
+
with: opts.with,
|
|
452
|
+
content: opts.content || readContentOption(opts),
|
|
453
|
+
expectedSha256: opts["expected-sha256"] || null,
|
|
454
|
+
message: opts.message || null
|
|
455
|
+
};
|
|
456
|
+
const response = await requestServerJson("/knowledge/update", payload, opts);
|
|
457
|
+
output(response, opts);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
fail(`Unknown remote action: ${action}`);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async function cmdSync(args) {
|
|
464
|
+
const opts = parseArgs(args);
|
|
465
|
+
ensureLocalDirs();
|
|
466
|
+
const dryRun = Boolean(opts["dry-run"]);
|
|
467
|
+
if (opts.push) {
|
|
468
|
+
fail("Client push is disabled. Use `tmlbrain remote add`, `tmlbrain remote update`, or `tmlbrain remote ingest`; only the TMLBrain server may write repositories.");
|
|
469
|
+
}
|
|
470
|
+
const state = readState();
|
|
471
|
+
if (state.server && isHttpUrl(state.server)) {
|
|
472
|
+
const result = { dryRun, server: state.server, actions: [] };
|
|
473
|
+
if (dryRun) {
|
|
474
|
+
result.actions.push("Would fetch a read-only knowledge snapshot from the TMLBrain server.");
|
|
475
|
+
output(result, opts);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
const snapshot = await requestServerGetJson("/snapshot", opts);
|
|
479
|
+
applySnapshot(snapshot);
|
|
480
|
+
result.head = snapshot.head;
|
|
481
|
+
result.files = snapshot.files.length;
|
|
482
|
+
result.actions.push("Fetched and applied read-only knowledge snapshot from TMLBrain server.");
|
|
483
|
+
output(result, opts);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
if (!opts.pull) {
|
|
487
|
+
fail("Client Git synchronization is disabled. Configure an HTTP server with `tmlbrain install --server <http-url>` and run `tmlbrain sync --pull`.");
|
|
488
|
+
}
|
|
489
|
+
if (!opts["legacy-git"]) {
|
|
490
|
+
fail("Client Git pull is disabled. Configure an HTTP TMLBrain server with `tmlbrain install --server <http-url>`; use `--legacy-git` only for migration.");
|
|
491
|
+
}
|
|
492
|
+
ensureGit();
|
|
493
|
+
const status = git(["status", "--porcelain"]);
|
|
494
|
+
const upstream = git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], { allowFail: true });
|
|
495
|
+
const result = {
|
|
496
|
+
dryRun,
|
|
497
|
+
workingTreeChanged: status.stdout.trim().length > 0,
|
|
498
|
+
upstream: upstream.ok ? upstream.stdout.trim() : null,
|
|
499
|
+
actions: []
|
|
500
|
+
};
|
|
501
|
+
if (!upstream.ok) {
|
|
502
|
+
result.actions.push("No upstream remote is configured. TMLBrain sync cannot contact the server yet.");
|
|
503
|
+
output(result, opts);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
const fetch = dryRun ? { ok: true, stdout: "" } : git(["fetch", "--prune"], { allowFail: true });
|
|
507
|
+
result.actions.push(fetch.ok ? "Fetched remote state." : "Remote fetch failed.");
|
|
508
|
+
if (!fetch.ok) {
|
|
509
|
+
result.error = sanitizeGit(fetch.stderr || fetch.stdout);
|
|
510
|
+
output(result, opts);
|
|
511
|
+
process.exit(1);
|
|
512
|
+
}
|
|
513
|
+
if (result.workingTreeChanged) {
|
|
514
|
+
const patch = createLocalPatch();
|
|
515
|
+
result.actions.push(`Captured local patch at ${rel(patch)}`);
|
|
516
|
+
if (hasRemoteChanges()) {
|
|
517
|
+
const conflict = createConflictPackage({
|
|
518
|
+
reason: "local changes and remote updates both exist",
|
|
519
|
+
patchPath: patch,
|
|
520
|
+
pathName: "workspace",
|
|
521
|
+
base: "",
|
|
522
|
+
local: fs.readFileSync(patch, "utf8"),
|
|
523
|
+
remote: "Remote has updates. Review with tmlbrain conflict show."
|
|
524
|
+
});
|
|
525
|
+
result.conflict = rel(conflict);
|
|
526
|
+
result.actions.push("Created a TMLBrain conflict package instead of entering raw Git conflict handling.");
|
|
527
|
+
output(result, opts);
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
if (opts.pull || !opts.push) {
|
|
532
|
+
const pull = dryRun ? { ok: true, stdout: "dry-run" } : git(["pull", "--ff-only"], { allowFail: true });
|
|
533
|
+
result.actions.push(pull.ok ? "Local workspace is refreshed." : "Could not fast-forward local workspace.");
|
|
534
|
+
if (!pull.ok) {
|
|
535
|
+
result.error = sanitizeGit(pull.stderr || pull.stdout);
|
|
536
|
+
output(result, opts);
|
|
537
|
+
process.exit(1);
|
|
538
|
+
}
|
|
539
|
+
if (!dryRun) snapshotBase();
|
|
540
|
+
}
|
|
541
|
+
output(result, opts);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function cmdServe(args) {
|
|
545
|
+
const opts = parseArgs(args);
|
|
546
|
+
ensureGit();
|
|
547
|
+
ensureGitWorktree();
|
|
548
|
+
const host = opts.host || process.env.TMLBRAIN_HOST || DEFAULT_SERVER_HOST;
|
|
549
|
+
const port = Number(opts.port || process.env.TMLBRAIN_PORT || DEFAULT_SERVER_PORT);
|
|
550
|
+
const token = opts.token || process.env.TMLBRAIN_SERVER_TOKEN || null;
|
|
551
|
+
const server = http.createServer((req, res) => {
|
|
552
|
+
routeServerRequest(req, res, token).catch((error) => {
|
|
553
|
+
sendJson(res, error.statusCode || 500, { ok: false, error: error.message || String(error) });
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
server.listen(port, host, () => {
|
|
557
|
+
console.log(`TMLBrain server listening on http://${host}:${port}`);
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function cmdPatch(args) {
|
|
562
|
+
const action = args.shift();
|
|
563
|
+
const opts = parseArgs(args);
|
|
564
|
+
ensureGit();
|
|
565
|
+
ensureLocalDirs();
|
|
566
|
+
if (action === "create") {
|
|
567
|
+
console.log(rel(createLocalPatch()));
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
const file = opts._[0];
|
|
571
|
+
if (!file) fail("Patch file is required.");
|
|
572
|
+
const patchPath = path.resolve(ROOT, file);
|
|
573
|
+
const patchText = fs.existsSync(patchPath) ? fs.readFileSync(patchPath, "utf8") : "";
|
|
574
|
+
if ((action === "check" || action === "apply") && patchText.trim() === "") {
|
|
575
|
+
console.log("Patch is empty; no changes to apply.");
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
if (action === "check") {
|
|
579
|
+
let check = git(["apply", "--3way", "--check", patchPath], { allowFail: true });
|
|
580
|
+
if (!check.ok && /does not exist in index/.test(check.stderr || check.stdout)) {
|
|
581
|
+
check = git(["apply", "--check", patchPath], { allowFail: true });
|
|
582
|
+
}
|
|
583
|
+
if (!check.ok) fail(`Patch check failed: ${sanitizeGit(check.stderr || check.stdout)}`);
|
|
584
|
+
console.log("Patch can be applied.");
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
if (action === "apply") {
|
|
588
|
+
let apply = git(["apply", "--3way", patchPath], { allowFail: true });
|
|
589
|
+
if (!apply.ok && /does not exist in index/.test(apply.stderr || apply.stdout)) {
|
|
590
|
+
apply = git(["apply", patchPath], { allowFail: true });
|
|
591
|
+
}
|
|
592
|
+
if (!apply.ok) {
|
|
593
|
+
const conflict = createConflictPackage({
|
|
594
|
+
reason: "patch application failed",
|
|
595
|
+
patchPath,
|
|
596
|
+
pathName: file,
|
|
597
|
+
base: "",
|
|
598
|
+
local: fs.readFileSync(patchPath, "utf8"),
|
|
599
|
+
remote: sanitizeGit(apply.stderr || apply.stdout)
|
|
600
|
+
});
|
|
601
|
+
fail(`Patch could not be applied. Conflict package: ${rel(conflict)}`);
|
|
602
|
+
}
|
|
603
|
+
console.log("Patch applied.");
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
fail("Patch action must be create, check, or apply.");
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function cmdConflict(args) {
|
|
610
|
+
const action = args.shift() || "list";
|
|
611
|
+
const opts = parseArgs(args);
|
|
612
|
+
ensureDir(CONFLICT_DIR);
|
|
613
|
+
const files = fs.readdirSync(CONFLICT_DIR).filter((name) => name.endsWith(".json")).sort();
|
|
614
|
+
if (action === "list") {
|
|
615
|
+
const conflicts = files.map((name) => JSON.parse(fs.readFileSync(path.join(CONFLICT_DIR, name), "utf8")));
|
|
616
|
+
output({ count: conflicts.length, conflicts }, opts);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
if (action === "show") {
|
|
620
|
+
const id = opts._[0] || files[0];
|
|
621
|
+
if (!id) fail("No conflict packages found.");
|
|
622
|
+
output(JSON.parse(fs.readFileSync(resolveConflictFile(id, files), "utf8")), opts);
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
if (action === "resolve") {
|
|
626
|
+
const id = opts._[0];
|
|
627
|
+
const mergedFile = opts["merged-file"];
|
|
628
|
+
if (!id || !mergedFile) fail("Use: tmlbrain conflict resolve <id> --merged-file <path>");
|
|
629
|
+
const file = resolveConflictFile(id, files);
|
|
630
|
+
const conflict = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
631
|
+
fs.copyFileSync(path.resolve(ROOT, mergedFile), path.join(ROOT, conflict.path));
|
|
632
|
+
conflict.resolvedAt = new Date().toISOString();
|
|
633
|
+
writeJson(file, conflict);
|
|
634
|
+
console.log(`Resolved ${conflict.id} into ${conflict.path}`);
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
if (action === "demo") {
|
|
638
|
+
const conflict = createConflictPackage({
|
|
639
|
+
reason: "simulated conflict package",
|
|
640
|
+
patchPath: null,
|
|
641
|
+
pathName: opts.path || "knowledge/00-inbox/demo.md",
|
|
642
|
+
base: "Base version\n",
|
|
643
|
+
local: "Local version\n",
|
|
644
|
+
remote: "Remote version\n"
|
|
645
|
+
});
|
|
646
|
+
console.log(rel(conflict));
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
fail("Conflict action must be list, show, resolve, or demo.");
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function cmdBackup(args) {
|
|
653
|
+
const opts = parseArgs(args);
|
|
654
|
+
ensureGit();
|
|
655
|
+
ensureDir(LOG_DIR);
|
|
656
|
+
const remote = opts.remote || "backup";
|
|
657
|
+
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");
|
|
679
|
+
output(entry, opts);
|
|
680
|
+
if (!entry.ok && !dryRun) process.exit(1);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function cmdPublish(args) {
|
|
684
|
+
const opts = parseArgs(args);
|
|
685
|
+
const dryRun = Boolean(opts["dry-run"]);
|
|
686
|
+
const site = opts.site || "site";
|
|
687
|
+
const result = {
|
|
688
|
+
dryRun,
|
|
689
|
+
reader: opts.reader || "quartz",
|
|
690
|
+
pagefind: Boolean(opts.pagefind),
|
|
691
|
+
site,
|
|
692
|
+
source: "server worktree or clean clone",
|
|
693
|
+
canonicalEditingSurface: false,
|
|
694
|
+
actions: []
|
|
695
|
+
};
|
|
696
|
+
if (opts.pagefind) {
|
|
697
|
+
if (dryRun) {
|
|
698
|
+
result.actions.push(`Would run Pagefind against ${site}.`);
|
|
699
|
+
} else if (!fs.existsSync(path.resolve(ROOT, site))) {
|
|
700
|
+
result.ok = false;
|
|
701
|
+
result.actions.push(`Site directory does not exist: ${site}`);
|
|
702
|
+
output(result, opts);
|
|
703
|
+
process.exit(1);
|
|
704
|
+
} else if (!checkCommand("npx", ["--version"]).ok) {
|
|
705
|
+
result.ok = false;
|
|
706
|
+
result.actions.push("npx is not available; cannot run Pagefind.");
|
|
707
|
+
output(result, opts);
|
|
708
|
+
process.exit(1);
|
|
709
|
+
} else {
|
|
710
|
+
const pagefind = spawnSync("npx", ["-y", "pagefind", "--site", site], { cwd: ROOT, encoding: "utf8" });
|
|
711
|
+
result.ok = pagefind.status === 0;
|
|
712
|
+
result.actions.push(result.ok ? "Generated Pagefind static search index." : sanitizeGit(pagefind.stderr || pagefind.stdout));
|
|
713
|
+
if (!result.ok) {
|
|
714
|
+
output(result, opts);
|
|
715
|
+
process.exit(1);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
output(result, opts);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function cmdGraph(args) {
|
|
723
|
+
const action = args.shift() || "setup";
|
|
724
|
+
const opts = parseArgs(args);
|
|
725
|
+
if (action !== "setup") fail("Graph action must be setup.");
|
|
726
|
+
output(setupGraphRuntime({ dryRun: Boolean(opts["dry-run"]) }), opts);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function setupGraphRuntime({ dryRun = false } = {}) {
|
|
730
|
+
const uv = checkCommand("uv", ["--version"]);
|
|
731
|
+
const python = checkCommand("python", ["--version"]);
|
|
732
|
+
const result = {
|
|
733
|
+
dryRun,
|
|
734
|
+
preferred: "uv",
|
|
735
|
+
uv,
|
|
736
|
+
python,
|
|
737
|
+
packages: ["cocoindex", "lightrag-hku"],
|
|
738
|
+
actions: []
|
|
739
|
+
};
|
|
740
|
+
if (dryRun) {
|
|
741
|
+
result.actions.push("Would create or update an isolated graph runtime for CocoIndex and LightRAG.");
|
|
742
|
+
return result;
|
|
743
|
+
}
|
|
744
|
+
ensureDir(path.join(LOCAL_DIR, "runtime"));
|
|
745
|
+
if (uv.ok) result.actions.push("uv detected. Use uv to create graph runtime in implementation packaging.");
|
|
746
|
+
else if (python.ok) result.actions.push("python detected. Use venv/pip fallback for graph runtime in implementation packaging.");
|
|
747
|
+
else result.actions.push("No Python runtime detected. Core TMLBrain remains usable; graph retrieval is degraded.");
|
|
748
|
+
return result;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function safeConfigView(state) {
|
|
752
|
+
return {
|
|
753
|
+
configFile: rel(STATE_FILE),
|
|
754
|
+
server: state.server || null,
|
|
755
|
+
tokenConfigured: Boolean(state.token),
|
|
756
|
+
workspaceVersion: state.workspaceVersion || null,
|
|
757
|
+
updatedAt: state.updatedAt || null
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function capabilities() {
|
|
762
|
+
const state = readState();
|
|
763
|
+
return {
|
|
764
|
+
name: "tmlbrain",
|
|
765
|
+
terminology: {
|
|
766
|
+
saveKnowledge: "When a user says save, store, record, or archive something into the knowledge base, treat it as a knowledge save request.",
|
|
767
|
+
internalArchive: "Use knowledge/90-archive and status: archived only when the user explicitly asks to mark inactive historical content."
|
|
768
|
+
},
|
|
769
|
+
boundary: {
|
|
770
|
+
client: [
|
|
771
|
+
"pull read-only knowledge snapshots from the TMLBrain server",
|
|
772
|
+
"build local indexes and run local search",
|
|
773
|
+
"request server-side save/update operations through the TMLBrain server API"
|
|
774
|
+
],
|
|
775
|
+
server: [
|
|
776
|
+
"mutate Markdown knowledge files",
|
|
777
|
+
"validate knowledge before commit",
|
|
778
|
+
"commit repository changes",
|
|
779
|
+
"push server worktree changes to the server bare repository",
|
|
780
|
+
"run GitHub backup jobs"
|
|
781
|
+
],
|
|
782
|
+
forbiddenForClients: [
|
|
783
|
+
"git push to the server repository",
|
|
784
|
+
"git push to GitHub backup",
|
|
785
|
+
"direct mutation of remote server files"
|
|
786
|
+
]
|
|
787
|
+
},
|
|
788
|
+
configuredServer: state.server || null,
|
|
789
|
+
config: safeConfigView(state),
|
|
790
|
+
userCommands: [
|
|
791
|
+
{ command: "tmlbrain config show", role: "client", description: "Show the configured server without printing the token." },
|
|
792
|
+
{ command: "tmlbrain config set-server <http-url> --token <token>", role: "client", description: "Switch this client to another TMLBrain server." },
|
|
793
|
+
{ command: "tmlbrain sync --pull", role: "client", description: "Refresh the local read-only knowledge snapshot from the configured server." },
|
|
794
|
+
{ 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." }
|
|
798
|
+
],
|
|
799
|
+
commands: [
|
|
800
|
+
{ command: "tmlbrain sync --pull", role: "client", description: "Fetch a read-only knowledge snapshot from the configured HTTP server." },
|
|
801
|
+
{ command: "tmlbrain search <query>", role: "client", description: "Search local Markdown snapshot." },
|
|
802
|
+
{ command: "tmlbrain find <query>", role: "client", description: "Alias for local search." },
|
|
803
|
+
{ 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." },
|
|
806
|
+
{ command: "tmlbrain remote add --title <title> --content <text>", role: "client-request", description: "Ask the server to create a knowledge document and commit it." },
|
|
807
|
+
{ command: "tmlbrain remote ingest <file>", role: "client-request", description: "Ask the server to ingest a local file as a knowledge document." },
|
|
808
|
+
{ 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." },
|
|
809
|
+
{ command: "tmlbrain serve", role: "server", description: "Run the TMLBrain server API from the server worktree." },
|
|
810
|
+
{ command: "tmlbrain backup --remote backup", role: "server", description: "Push the server repository state to GitHub backup." }
|
|
811
|
+
]
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
async function routeServerRequest(req, res, token) {
|
|
816
|
+
const requestUrl = new URL(req.url, "http://localhost");
|
|
817
|
+
if (req.method === "GET" && requestUrl.pathname === "/health") {
|
|
818
|
+
sendJson(res, 200, {
|
|
819
|
+
ok: true,
|
|
820
|
+
root: ROOT,
|
|
821
|
+
head: currentHead(),
|
|
822
|
+
documents: markdownFiles(KNOWLEDGE_DIR).filter((file) => !rel(file).includes("/_templates/")).length
|
|
823
|
+
});
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
if (!isAuthorized(req, token)) {
|
|
827
|
+
sendJson(res, 401, { ok: false, error: "Unauthorized TMLBrain request." });
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
if (req.method === "GET" && requestUrl.pathname === "/capabilities") {
|
|
831
|
+
sendJson(res, 200, capabilities());
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
if (req.method === "GET" && requestUrl.pathname === "/snapshot") {
|
|
835
|
+
sendJson(res, 200, snapshotKnowledge());
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
if (req.method === "POST" && requestUrl.pathname === "/knowledge/add") {
|
|
839
|
+
const body = await readJsonBody(req);
|
|
840
|
+
sendJson(res, 200, serverAddKnowledge(body));
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
if (req.method === "POST" && requestUrl.pathname === "/knowledge/update") {
|
|
844
|
+
const body = await readJsonBody(req);
|
|
845
|
+
sendJson(res, 200, serverUpdateKnowledge(body));
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
sendJson(res, 404, { ok: false, error: `Unknown TMLBrain endpoint: ${req.method} ${requestUrl.pathname}` });
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function serverAddKnowledge(payload) {
|
|
852
|
+
return serverMutation(payload.message || `Add knowledge: ${payload.title || "untitled"}`, () => {
|
|
853
|
+
const created = createKnowledgeDocument({
|
|
854
|
+
title: requiredString(payload.title, "title"),
|
|
855
|
+
type: payload.type || "resource",
|
|
856
|
+
folder: payload.folder || folderForType(payload.type || "resource"),
|
|
857
|
+
slug: payload.slug || payload.title,
|
|
858
|
+
owner: payload.owner || "TML",
|
|
859
|
+
content: payload.content || "",
|
|
860
|
+
force: Boolean(payload.force)
|
|
861
|
+
});
|
|
862
|
+
return { action: "add", path: created.path, sha256: created.sha256 };
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function serverUpdateKnowledge(payload) {
|
|
867
|
+
return serverMutation(payload.message || `Update knowledge: ${payload.path || "unknown"}`, () => {
|
|
868
|
+
const target = safeKnowledgeFile(payload.path);
|
|
869
|
+
const before = fs.readFileSync(target, "utf8");
|
|
870
|
+
if (payload.expectedSha256 && sha256(before) !== payload.expectedSha256) {
|
|
871
|
+
failRequest(409, `Expected sha256 ${payload.expectedSha256}, found ${sha256(before)}.`);
|
|
872
|
+
}
|
|
873
|
+
let after = before;
|
|
874
|
+
if (payload.content !== undefined && payload.content !== null) {
|
|
875
|
+
after = String(payload.content);
|
|
876
|
+
} else if (payload.replace !== undefined && payload.with !== undefined) {
|
|
877
|
+
const needle = String(payload.replace);
|
|
878
|
+
if (!needle) failRequest(400, "Replacement text cannot be empty.");
|
|
879
|
+
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.`);
|
|
882
|
+
after = payload.all ? before.split(needle).join(String(payload.with)) : before.replace(needle, String(payload.with));
|
|
883
|
+
} else {
|
|
884
|
+
failRequest(400, "Use either `content` or `replace` plus `with` for update.");
|
|
885
|
+
}
|
|
886
|
+
if (after === before) failRequest(400, "Update produced no document changes.");
|
|
887
|
+
fs.writeFileSync(target, after.endsWith("\n") ? after : `${after}\n`, "utf8");
|
|
888
|
+
return {
|
|
889
|
+
action: "update",
|
|
890
|
+
path: rel(target),
|
|
891
|
+
beforeSha256: sha256(before),
|
|
892
|
+
afterSha256: sha256(fs.readFileSync(target, "utf8"))
|
|
893
|
+
};
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function serverMutation(message, mutate) {
|
|
898
|
+
ensureGit();
|
|
899
|
+
ensureGitWorktree();
|
|
900
|
+
ensureServerGitIdentity();
|
|
901
|
+
const beforeHead = currentHead();
|
|
902
|
+
const beforeStatus = git(["status", "--porcelain"], { allowFail: true });
|
|
903
|
+
if (beforeStatus.ok && beforeStatus.stdout.trim()) {
|
|
904
|
+
failRequest(409, "Server worktree has uncommitted changes; refusing to mutate knowledge.");
|
|
905
|
+
}
|
|
906
|
+
const result = mutate();
|
|
907
|
+
const validation = validateKnowledge();
|
|
908
|
+
if (validation.errors.length > 0) {
|
|
909
|
+
revertServerWorktree();
|
|
910
|
+
failRequest(400, `Knowledge validation failed: ${validation.errors.join("; ")}`);
|
|
911
|
+
}
|
|
912
|
+
git(["add", "knowledge"], { allowFail: true });
|
|
913
|
+
const staged = git(["diff", "--cached", "--quiet"], { allowFail: true });
|
|
914
|
+
if (staged.ok) {
|
|
915
|
+
return { ok: true, changed: false, beforeHead, head: beforeHead, validation, result };
|
|
916
|
+
}
|
|
917
|
+
const diff = git(["diff", "--cached", "--", "knowledge"], { allowFail: true });
|
|
918
|
+
const commit = git(["commit", "-m", message], { allowFail: true });
|
|
919
|
+
if (!commit.ok) {
|
|
920
|
+
revertServerWorktree();
|
|
921
|
+
failRequest(500, sanitizeGit(commit.stderr || commit.stdout));
|
|
922
|
+
}
|
|
923
|
+
const pushResult = pushServerRepository();
|
|
924
|
+
const head = currentHead();
|
|
925
|
+
snapshotBase();
|
|
926
|
+
return {
|
|
927
|
+
ok: true,
|
|
928
|
+
changed: true,
|
|
929
|
+
beforeHead,
|
|
930
|
+
head,
|
|
931
|
+
pushed: pushResult.ok,
|
|
932
|
+
pushMessage: pushResult.message,
|
|
933
|
+
validation,
|
|
934
|
+
result,
|
|
935
|
+
diff: diff.stdout || ""
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
function pushServerRepository() {
|
|
940
|
+
const remotes = git(["remote"], { allowFail: true });
|
|
941
|
+
if (!remotes.ok || !remotes.stdout.split(/\r?\n/).includes("origin")) {
|
|
942
|
+
return { ok: false, message: "No origin remote configured on server worktree." };
|
|
943
|
+
}
|
|
944
|
+
const push = git(["push", "origin", "HEAD:main"], { allowFail: true });
|
|
945
|
+
return { ok: push.ok, message: sanitizeGit(push.stderr || push.stdout) };
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function revertServerWorktree() {
|
|
949
|
+
git(["reset", "--hard"], { allowFail: true });
|
|
950
|
+
git(["clean", "-fd", "knowledge"], { allowFail: true });
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function ensureServerGitIdentity() {
|
|
954
|
+
const name = git(["config", "user.name"], { allowFail: true });
|
|
955
|
+
const email = git(["config", "user.email"], { allowFail: true });
|
|
956
|
+
if (!name.ok || !name.stdout.trim()) git(["config", "user.name", "TMLBrain Server"], { allowFail: true });
|
|
957
|
+
if (!email.ok || !email.stdout.trim()) git(["config", "user.email", "tmlbrain@localhost"], { allowFail: true });
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function ensureGitWorktree() {
|
|
961
|
+
const inside = git(["rev-parse", "--is-inside-work-tree"], { allowFail: true });
|
|
962
|
+
if (!inside.ok || inside.stdout.trim() !== "true") {
|
|
963
|
+
failRequest(500, "TMLBrain server writes require a Git worktree. Run `tmlbrain serve` from the server worktree.");
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function snapshotKnowledge() {
|
|
968
|
+
const files = markdownFiles(KNOWLEDGE_DIR).map((file) => {
|
|
969
|
+
const content = fs.readFileSync(file, "utf8");
|
|
970
|
+
return { path: rel(file), sha256: sha256(content), content };
|
|
971
|
+
});
|
|
972
|
+
return { ok: true, head: currentHead(), generatedAt: new Date().toISOString(), files };
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function applySnapshot(snapshot) {
|
|
976
|
+
if (!snapshot || !Array.isArray(snapshot.files)) fail("Invalid TMLBrain snapshot response.");
|
|
977
|
+
ensureDir(KNOWLEDGE_DIR);
|
|
978
|
+
const allowed = new Set();
|
|
979
|
+
for (const file of snapshot.files) {
|
|
980
|
+
const target = safeKnowledgeFile(file.path);
|
|
981
|
+
allowed.add(rel(target));
|
|
982
|
+
ensureDir(path.dirname(target));
|
|
983
|
+
fs.writeFileSync(target, file.content || "", "utf8");
|
|
984
|
+
}
|
|
985
|
+
for (const file of markdownFiles(KNOWLEDGE_DIR)) {
|
|
986
|
+
const fileRel = rel(file);
|
|
987
|
+
if (!allowed.has(fileRel) && !fileRel.includes("/_templates/")) fs.unlinkSync(file);
|
|
988
|
+
}
|
|
989
|
+
const state = readState();
|
|
990
|
+
state.lastSyncedRevision = snapshot.head || null;
|
|
991
|
+
state.lastSyncedAt = new Date().toISOString();
|
|
992
|
+
state.files = {};
|
|
993
|
+
for (const file of snapshot.files) state.files[file.path] = { sha256: file.sha256 };
|
|
994
|
+
writeJson(STATE_FILE, state);
|
|
995
|
+
const index = buildIndex();
|
|
996
|
+
writeJson(path.join(INDEX_DIR, "documents.json"), index.documents);
|
|
997
|
+
writeJson(path.join(INDEX_DIR, "chunks.json"), index.chunks);
|
|
998
|
+
writeJson(path.join(INDEX_DIR, "graph.json"), index.graph);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function createKnowledgeDocument({ title, type, folder, slug, owner, content, force }) {
|
|
1002
|
+
if (!title) failRequest(400, "Document title is required.");
|
|
1003
|
+
if (!VALID_TYPES.has(type)) failRequest(400, `Invalid type "${type}". Allowed: ${Array.from(VALID_TYPES).join(", ")}`);
|
|
1004
|
+
const safeFolder = safeKnowledgeFolder(folder || folderForType(type));
|
|
1005
|
+
const fileName = `${slugify(slug || title)}.md`;
|
|
1006
|
+
const target = path.join(safeFolder, fileName);
|
|
1007
|
+
if (fs.existsSync(target) && !force) failRequest(409, `Document already exists: ${rel(target)}`);
|
|
1008
|
+
ensureDir(path.dirname(target));
|
|
1009
|
+
const doc = [
|
|
1010
|
+
"---",
|
|
1011
|
+
`title: ${title}`,
|
|
1012
|
+
`type: ${type}`,
|
|
1013
|
+
"status: draft",
|
|
1014
|
+
`owner: ${owner || "TML"}`,
|
|
1015
|
+
"tags: []",
|
|
1016
|
+
`updated: ${today()}`,
|
|
1017
|
+
"---",
|
|
1018
|
+
"",
|
|
1019
|
+
`# ${title}`,
|
|
1020
|
+
"",
|
|
1021
|
+
content || ""
|
|
1022
|
+
].join("\n").replace(/\n+$/, "\n");
|
|
1023
|
+
fs.writeFileSync(target, doc, "utf8");
|
|
1024
|
+
return { path: rel(target), sha256: sha256(doc) };
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
function safeKnowledgeFolder(folder) {
|
|
1028
|
+
const normalized = normalizeKnowledgePath(folder || "knowledge/00-inbox");
|
|
1029
|
+
const target = path.resolve(ROOT, normalized);
|
|
1030
|
+
if (!target.startsWith(KNOWLEDGE_DIR)) failRequest(400, "Knowledge folder must be under knowledge/.");
|
|
1031
|
+
return target;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
function safeKnowledgeFile(filePath) {
|
|
1035
|
+
const normalized = normalizeKnowledgePath(filePath);
|
|
1036
|
+
if (!normalized.endsWith(".md")) failRequest(400, "Knowledge file must be a Markdown file.");
|
|
1037
|
+
const target = path.resolve(ROOT, normalized);
|
|
1038
|
+
if (!target.startsWith(KNOWLEDGE_DIR)) failRequest(400, "Knowledge file must be under knowledge/.");
|
|
1039
|
+
return target;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function normalizeKnowledgePath(value) {
|
|
1043
|
+
const normalized = String(value || "").replace(/\\/g, "/").replace(/^\/+/, "");
|
|
1044
|
+
if (!normalized || normalized.includes("..") || path.isAbsolute(normalized)) failRequest(400, `Invalid knowledge path: ${value}`);
|
|
1045
|
+
if (!normalized.startsWith("knowledge/")) failRequest(400, "Knowledge paths must start with knowledge/.");
|
|
1046
|
+
return path.posix.normalize(normalized);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function shouldUseServerWrite(opts) {
|
|
1050
|
+
if (opts.remote) return true;
|
|
1051
|
+
const state = readState();
|
|
1052
|
+
return Boolean(state.server && isHttpUrl(state.server));
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
function readContentOption(opts) {
|
|
1056
|
+
const file = opts["content-file"];
|
|
1057
|
+
if (!file) return undefined;
|
|
1058
|
+
const filePath = path.resolve(ROOT, file);
|
|
1059
|
+
if (!fs.existsSync(filePath)) fail(`Content file not found: ${file}`);
|
|
1060
|
+
return fs.readFileSync(filePath, "utf8");
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
async function requestServerGetJson(endpoint, opts) {
|
|
1064
|
+
return requestServer("GET", endpoint, null, opts);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
async function requestServerJson(endpoint, body, opts) {
|
|
1068
|
+
return requestServer("POST", endpoint, body, opts);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
async function requestServer(method, endpoint, body, opts) {
|
|
1072
|
+
const state = readState();
|
|
1073
|
+
const base = opts.server || state.server;
|
|
1074
|
+
if (!base || !isHttpUrl(base)) fail("Configure an HTTP TMLBrain server first: tmlbrain install --server http://host:7389");
|
|
1075
|
+
const token = opts.token || state.token || process.env.TMLBRAIN_TOKEN || null;
|
|
1076
|
+
const url = new URL(endpoint, base.endsWith("/") ? base : `${base}/`);
|
|
1077
|
+
const transport = url.protocol === "https:" ? https : http;
|
|
1078
|
+
const payload = body ? Buffer.from(JSON.stringify(body), "utf8") : null;
|
|
1079
|
+
const headers = { Accept: "application/json" };
|
|
1080
|
+
if (payload) {
|
|
1081
|
+
headers["Content-Type"] = "application/json";
|
|
1082
|
+
headers["Content-Length"] = String(payload.length);
|
|
1083
|
+
}
|
|
1084
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
1085
|
+
return new Promise((resolve, reject) => {
|
|
1086
|
+
const req = transport.request(url, { method, headers }, (res) => {
|
|
1087
|
+
const chunks = [];
|
|
1088
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
1089
|
+
res.on("end", () => {
|
|
1090
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
1091
|
+
let data;
|
|
1092
|
+
try {
|
|
1093
|
+
data = text ? JSON.parse(text) : {};
|
|
1094
|
+
} catch (error) {
|
|
1095
|
+
reject(new Error(`Invalid JSON from TMLBrain server: ${text}`));
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
if (res.statusCode >= 400) {
|
|
1099
|
+
reject(new Error(data.error || `TMLBrain server returned HTTP ${res.statusCode}`));
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
resolve(data);
|
|
1103
|
+
});
|
|
1104
|
+
});
|
|
1105
|
+
req.on("error", reject);
|
|
1106
|
+
if (payload) req.write(payload);
|
|
1107
|
+
req.end();
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function isHttpUrl(value) {
|
|
1112
|
+
return /^https?:\/\//i.test(String(value || ""));
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
function isAuthorized(req, token) {
|
|
1116
|
+
if (!token) return true;
|
|
1117
|
+
const authorization = req.headers.authorization || "";
|
|
1118
|
+
const headerToken = req.headers["x-tmlbrain-token"] || "";
|
|
1119
|
+
return authorization === `Bearer ${token}` || headerToken === token;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
function readJsonBody(req) {
|
|
1123
|
+
return new Promise((resolve, reject) => {
|
|
1124
|
+
const chunks = [];
|
|
1125
|
+
let size = 0;
|
|
1126
|
+
req.on("data", (chunk) => {
|
|
1127
|
+
size += chunk.length;
|
|
1128
|
+
if (size > 5 * 1024 * 1024) {
|
|
1129
|
+
reject(new Error("Request body is too large."));
|
|
1130
|
+
req.destroy();
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
chunks.push(chunk);
|
|
1134
|
+
});
|
|
1135
|
+
req.on("end", () => {
|
|
1136
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
1137
|
+
try {
|
|
1138
|
+
resolve(text ? JSON.parse(text) : {});
|
|
1139
|
+
} catch (error) {
|
|
1140
|
+
reject(new Error("Request body must be valid JSON."));
|
|
1141
|
+
}
|
|
1142
|
+
});
|
|
1143
|
+
req.on("error", reject);
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
function sendJson(res, statusCode, value) {
|
|
1148
|
+
const text = JSON.stringify(value, null, 2) + "\n";
|
|
1149
|
+
res.writeHead(statusCode, {
|
|
1150
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
1151
|
+
"Content-Length": Buffer.byteLength(text)
|
|
1152
|
+
});
|
|
1153
|
+
res.end(text);
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
function requiredString(value, name) {
|
|
1157
|
+
if (!value || !String(value).trim()) failRequest(400, `${name} is required.`);
|
|
1158
|
+
return String(value);
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
function countOccurrences(text, needle) {
|
|
1162
|
+
return text.split(needle).length - 1;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
function currentHead() {
|
|
1166
|
+
const head = git(["rev-parse", "HEAD"], { allowFail: true });
|
|
1167
|
+
return head.ok ? head.stdout.trim() : null;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
function failRequest(statusCode, message) {
|
|
1171
|
+
const error = new Error(message);
|
|
1172
|
+
error.statusCode = statusCode;
|
|
1173
|
+
throw error;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
function validateKnowledge() {
|
|
1177
|
+
const errors = [];
|
|
1178
|
+
const warnings = [];
|
|
1179
|
+
const files = markdownFiles(KNOWLEDGE_DIR).filter((file) => !rel(file).includes("/_templates/"));
|
|
1180
|
+
const docs = files.map((file) => {
|
|
1181
|
+
const text = fs.readFileSync(file, "utf8");
|
|
1182
|
+
const parsed = parseFrontMatter(text);
|
|
1183
|
+
return { file, rel: rel(file), parsed };
|
|
1184
|
+
});
|
|
1185
|
+
const knownFiles = new Set(docs.map((doc) => doc.rel.replace(/\\/g, "/")));
|
|
1186
|
+
for (const doc of docs) {
|
|
1187
|
+
if (doc.rel.includes("/00-inbox/")) {
|
|
1188
|
+
if (!doc.parsed.data.title) warnings.push(`${doc.rel}: pending classification in 00-inbox`);
|
|
1189
|
+
continue;
|
|
1190
|
+
}
|
|
1191
|
+
for (const field of ["title", "type", "status", "owner", "tags", "updated"]) {
|
|
1192
|
+
if (doc.parsed.data[field] === undefined || doc.parsed.data[field] === "") {
|
|
1193
|
+
errors.push(`${doc.rel}: missing required metadata field "${field}"`);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
if (doc.parsed.data.type && !VALID_TYPES.has(String(doc.parsed.data.type))) errors.push(`${doc.rel}: invalid type "${doc.parsed.data.type}"`);
|
|
1197
|
+
if (doc.parsed.data.status && !VALID_STATUSES.has(String(doc.parsed.data.status))) errors.push(`${doc.rel}: invalid status "${doc.parsed.data.status}"`);
|
|
1198
|
+
if (doc.rel.includes("/90-archive/") && doc.parsed.data.status !== "archived") errors.push(`${doc.rel}: archived documents must use status: archived`);
|
|
1199
|
+
if (doc.parsed.data.status === "stale") warnings.push(`${doc.rel}: stale but retained and searchable`);
|
|
1200
|
+
for (const link of extractMarkdownLinks(doc.parsed.body)) {
|
|
1201
|
+
if (link.startsWith("http") || link.startsWith("#") || link.startsWith("mailto:")) continue;
|
|
1202
|
+
const linkPath = path.posix.normalize(path.posix.join(path.posix.dirname(doc.rel.replace(/\\/g, "/")), link.split("#")[0]));
|
|
1203
|
+
if (linkPath.endsWith(".md") && !knownFiles.has(linkPath)) warnings.push(`${doc.rel}: unresolved internal link ${link}`);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
return { ok: errors.length === 0, errors, warnings, checked: docs.length };
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
function buildIndex() {
|
|
1210
|
+
const documents = [];
|
|
1211
|
+
const chunks = [];
|
|
1212
|
+
const edges = [];
|
|
1213
|
+
for (const file of markdownFiles(KNOWLEDGE_DIR).filter((f) => !rel(f).includes("/_templates/"))) {
|
|
1214
|
+
const text = fs.readFileSync(file, "utf8");
|
|
1215
|
+
const parsed = parseFrontMatter(text);
|
|
1216
|
+
const fileRel = rel(file).replace(/\\/g, "/");
|
|
1217
|
+
const headings = extractHeadings(parsed.body);
|
|
1218
|
+
const stat = fs.statSync(file);
|
|
1219
|
+
const doc = {
|
|
1220
|
+
path: fileRel,
|
|
1221
|
+
title: parsed.data.title || headings[0]?.text || path.basename(file, ".md"),
|
|
1222
|
+
type: parsed.data.type || null,
|
|
1223
|
+
status: parsed.data.status || null,
|
|
1224
|
+
owner: parsed.data.owner || null,
|
|
1225
|
+
tags: Array.isArray(parsed.data.tags) ? parsed.data.tags : [],
|
|
1226
|
+
updated: parsed.data.updated || null,
|
|
1227
|
+
sha256: sha256(text),
|
|
1228
|
+
mtimeMs: stat.mtimeMs,
|
|
1229
|
+
headings
|
|
1230
|
+
};
|
|
1231
|
+
documents.push(doc);
|
|
1232
|
+
for (const chunk of chunkByHeading(fileRel, parsed.body)) chunks.push(chunk);
|
|
1233
|
+
for (const link of extractMarkdownLinks(parsed.body)) edges.push({ from: fileRel, to: link, type: "markdown-link" });
|
|
1234
|
+
for (const link of extractWikiLinks(parsed.body)) edges.push({ from: fileRel, to: link, type: "wiki-link" });
|
|
1235
|
+
for (const tag of doc.tags) edges.push({ from: fileRel, to: String(tag), type: "has-tag" });
|
|
1236
|
+
if (doc.owner) edges.push({ from: fileRel, to: String(doc.owner), type: "owned-by" });
|
|
1237
|
+
if (doc.status) edges.push({ from: fileRel, to: String(doc.status), type: "has-status" });
|
|
1238
|
+
}
|
|
1239
|
+
return { documents, chunks, graph: { nodes: graphNodes(documents, edges), edges } };
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
function exactSearch(query) {
|
|
1243
|
+
const rg = checkCommand("rg", ["--version"]);
|
|
1244
|
+
if (rg.ok) {
|
|
1245
|
+
const result = spawnSync("rg", ["-n", "--glob", "*.md", query, "knowledge"], { cwd: ROOT, encoding: "utf8" });
|
|
1246
|
+
if (result.status === 0) return result.stdout.trim().split(/\r?\n/).filter(Boolean).map(parseRgLine);
|
|
1247
|
+
if (result.status !== 1) fail(sanitizeGit(result.stderr || result.stdout));
|
|
1248
|
+
return [];
|
|
1249
|
+
}
|
|
1250
|
+
const matches = [];
|
|
1251
|
+
for (const file of markdownFiles(KNOWLEDGE_DIR)) {
|
|
1252
|
+
const lines = fs.readFileSync(file, "utf8").split(/\r?\n/);
|
|
1253
|
+
lines.forEach((line, index) => {
|
|
1254
|
+
if (line.toLowerCase().includes(query.toLowerCase())) matches.push({ path: rel(file), line: index + 1, text: line });
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
return matches;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
function createLocalPatch() {
|
|
1261
|
+
ensureDir(PATCH_DIR);
|
|
1262
|
+
const target = path.join(PATCH_DIR, `local-${timestamp()}.patch`);
|
|
1263
|
+
const diff = git(["diff", "--", "knowledge", "docs", "skills", "scripts", "bin", "package.json"], { allowFail: true });
|
|
1264
|
+
fs.writeFileSync(target, diff.stdout || "", "utf8");
|
|
1265
|
+
return target;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
function createConflictPackage({ reason, patchPath, pathName, base, local, remote }) {
|
|
1269
|
+
ensureDir(CONFLICT_DIR);
|
|
1270
|
+
const id = `conflict-${timestamp()}`;
|
|
1271
|
+
const data = {
|
|
1272
|
+
id,
|
|
1273
|
+
createdAt: new Date().toISOString(),
|
|
1274
|
+
reason,
|
|
1275
|
+
path: pathName,
|
|
1276
|
+
patchPath: patchPath ? rel(path.resolve(ROOT, patchPath)) : null,
|
|
1277
|
+
base,
|
|
1278
|
+
local,
|
|
1279
|
+
remote,
|
|
1280
|
+
nextActions: [
|
|
1281
|
+
"Ask TMLBrain Skill to explain the conflict.",
|
|
1282
|
+
"Confirm a merged Markdown result.",
|
|
1283
|
+
"Run tmlbrain conflict resolve <id> --merged-file <path>."
|
|
1284
|
+
]
|
|
1285
|
+
};
|
|
1286
|
+
const file = path.join(CONFLICT_DIR, `${id}.json`);
|
|
1287
|
+
writeJson(file, data);
|
|
1288
|
+
return file;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
function snapshotBase() {
|
|
1292
|
+
if (!fs.existsSync(KNOWLEDGE_DIR)) return;
|
|
1293
|
+
ensureDir(BASE_DIR);
|
|
1294
|
+
for (const file of markdownFiles(KNOWLEDGE_DIR)) {
|
|
1295
|
+
const target = path.join(BASE_DIR, rel(file));
|
|
1296
|
+
ensureDir(path.dirname(target));
|
|
1297
|
+
fs.copyFileSync(file, target);
|
|
1298
|
+
}
|
|
1299
|
+
const state = readState();
|
|
1300
|
+
const head = git(["rev-parse", "HEAD"], { allowFail: true });
|
|
1301
|
+
state.lastSyncedRevision = head.ok ? head.stdout.trim() : null;
|
|
1302
|
+
state.lastSyncedAt = new Date().toISOString();
|
|
1303
|
+
state.files = {};
|
|
1304
|
+
for (const file of markdownFiles(KNOWLEDGE_DIR)) state.files[rel(file)] = { sha256: sha256(fs.readFileSync(file, "utf8")) };
|
|
1305
|
+
writeJson(STATE_FILE, state);
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
function hasRemoteChanges() {
|
|
1309
|
+
const upstream = git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], { allowFail: true });
|
|
1310
|
+
if (!upstream.ok) return false;
|
|
1311
|
+
const count = git(["rev-list", "--count", `HEAD..${upstream.stdout.trim()}`], { allowFail: true });
|
|
1312
|
+
return count.ok && Number(count.stdout.trim()) > 0;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
function ensureGit({ install = false, dryRun = false } = {}) {
|
|
1316
|
+
const gitInfo = checkCommand("git", ["--version"]);
|
|
1317
|
+
if (gitInfo.ok) return gitInfo;
|
|
1318
|
+
if (!install) fail(gitInstallMessage());
|
|
1319
|
+
if (dryRun) {
|
|
1320
|
+
console.log(gitInstallMessage());
|
|
1321
|
+
return { ok: false };
|
|
1322
|
+
}
|
|
1323
|
+
const installResult = installGit();
|
|
1324
|
+
if (!installResult.ok) fail(installResult.message);
|
|
1325
|
+
return checkCommand("git", ["--version"]);
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
function installGit() {
|
|
1329
|
+
if (process.platform === "win32" && checkCommand("winget", ["--version"]).ok) {
|
|
1330
|
+
const result = spawnSync("winget", ["install", "--id", "Git.Git", "-e", "--source", "winget"], { stdio: "inherit" });
|
|
1331
|
+
return { ok: result.status === 0, message: "winget Git installation failed." };
|
|
1332
|
+
}
|
|
1333
|
+
if (process.platform === "darwin") {
|
|
1334
|
+
if (checkCommand("brew", ["--version"]).ok) {
|
|
1335
|
+
const result = spawnSync("brew", ["install", "git"], { stdio: "inherit" });
|
|
1336
|
+
return { ok: result.status === 0, message: "Homebrew Git installation failed." };
|
|
1337
|
+
}
|
|
1338
|
+
return { ok: false, message: "Install Xcode Command Line Tools with: xcode-select --install" };
|
|
1339
|
+
}
|
|
1340
|
+
if (process.platform === "linux") {
|
|
1341
|
+
return { ok: false, message: "Install Git with your distribution package manager, then rerun tmlbrain install." };
|
|
1342
|
+
}
|
|
1343
|
+
return { ok: false, message: gitInstallMessage() };
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
function gitInstallMessage() {
|
|
1347
|
+
return "Git is required for TMLBrain server, backup, and legacy Git migration flows. HTTP-only clients can use TMLBrain without Git.";
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
function installSkillToCodex({ dryRun = false } = {}) {
|
|
1351
|
+
const packagedSource = path.join(PACKAGE_ROOT, "skills", "tmlbrain");
|
|
1352
|
+
const workspaceSource = path.join(ROOT, "skills", "tmlbrain");
|
|
1353
|
+
const source = fs.existsSync(packagedSource) ? packagedSource : workspaceSource;
|
|
1354
|
+
const target = path.join(ROOT, ".codex", "skills", "tmlbrain");
|
|
1355
|
+
if (!fs.existsSync(source) || dryRun) return;
|
|
1356
|
+
copyDir(source, target);
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
function detectGraphRuntime() {
|
|
1360
|
+
return {
|
|
1361
|
+
available: fs.existsSync(path.join(LOCAL_DIR, "runtime")),
|
|
1362
|
+
uv: checkCommand("uv", ["--version"]).ok,
|
|
1363
|
+
python: checkCommand("python", ["--version"]).ok
|
|
1364
|
+
};
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
function folderForType(type) {
|
|
1368
|
+
return {
|
|
1369
|
+
project: "knowledge/10-projects",
|
|
1370
|
+
area: "knowledge/20-areas",
|
|
1371
|
+
resource: "knowledge/30-resources",
|
|
1372
|
+
reference: "knowledge/40-references",
|
|
1373
|
+
meeting: "knowledge/00-inbox",
|
|
1374
|
+
decision: "knowledge/10-projects"
|
|
1375
|
+
}[type] || "knowledge/00-inbox";
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
function looksLikeFilePath(value) {
|
|
1379
|
+
return /[\\/]/.test(value) || /\.[A-Za-z0-9]{1,8}$/.test(value);
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
function parseArgs(args) {
|
|
1383
|
+
const opts = { _: [] };
|
|
1384
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
1385
|
+
const arg = args[i];
|
|
1386
|
+
if (!arg.startsWith("--")) {
|
|
1387
|
+
opts._.push(arg);
|
|
1388
|
+
continue;
|
|
1389
|
+
}
|
|
1390
|
+
const key = arg.slice(2);
|
|
1391
|
+
const next = args[i + 1];
|
|
1392
|
+
if (next && !next.startsWith("--")) {
|
|
1393
|
+
opts[key] = next;
|
|
1394
|
+
i += 1;
|
|
1395
|
+
} else {
|
|
1396
|
+
opts[key] = true;
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
return opts;
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
function promptText(label, defaultValue = "") {
|
|
1403
|
+
const suffix = defaultValue ? ` [${defaultValue}]` : "";
|
|
1404
|
+
return promptLine(`${label}${suffix}: `).then((answer) => {
|
|
1405
|
+
const value = answer.trim();
|
|
1406
|
+
return value || defaultValue || "";
|
|
1407
|
+
});
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
function promptSecret(label, defaultValue = "") {
|
|
1411
|
+
const suffix = defaultValue ? " [configured, press Enter to keep]" : "";
|
|
1412
|
+
return promptLine(`${label}${suffix}: `).then((answer) => {
|
|
1413
|
+
const value = answer.trim();
|
|
1414
|
+
return value || defaultValue || "";
|
|
1415
|
+
});
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
function promptYesNo(label, defaultValue = false) {
|
|
1419
|
+
const suffix = defaultValue ? " [Y/n]" : " [y/N]";
|
|
1420
|
+
return promptLine(`${label}${suffix}: `).then((answer) => {
|
|
1421
|
+
const value = answer.trim().toLowerCase();
|
|
1422
|
+
if (!value) return Boolean(defaultValue);
|
|
1423
|
+
return ["y", "yes", "true", "1"].includes(value);
|
|
1424
|
+
});
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
function promptLine(prompt) {
|
|
1428
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1429
|
+
return new Promise((resolve) => {
|
|
1430
|
+
rl.question(prompt, (answer) => {
|
|
1431
|
+
rl.close();
|
|
1432
|
+
resolve(answer);
|
|
1433
|
+
});
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
function output(value, opts) {
|
|
1438
|
+
if (opts.json) {
|
|
1439
|
+
console.log(JSON.stringify(value, null, 2));
|
|
1440
|
+
} else if (Array.isArray(value.matches)) {
|
|
1441
|
+
for (const match of value.matches) console.log(`${match.path}:${match.line}: ${match.text}`);
|
|
1442
|
+
if (value.matches.length === 0) console.log("No matches.");
|
|
1443
|
+
} else {
|
|
1444
|
+
console.log(JSON.stringify(value, null, 2));
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
function parseFrontMatter(text) {
|
|
1449
|
+
if (!text.startsWith("---\n") && !text.startsWith("---\r\n")) return { data: {}, body: text };
|
|
1450
|
+
const lines = text.split(/\r?\n/);
|
|
1451
|
+
const end = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
|
|
1452
|
+
if (end < 0) return { data: {}, body: text };
|
|
1453
|
+
return { data: parseSimpleYaml(lines.slice(1, end)), body: lines.slice(end + 1).join("\n") };
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
function parseSimpleYaml(lines) {
|
|
1457
|
+
const data = {};
|
|
1458
|
+
let current = null;
|
|
1459
|
+
for (const line of lines) {
|
|
1460
|
+
const list = line.match(/^\s*-\s+(.*)$/);
|
|
1461
|
+
if (list && current) {
|
|
1462
|
+
if (!Array.isArray(data[current])) data[current] = [];
|
|
1463
|
+
data[current].push(unquote(list[1].trim()));
|
|
1464
|
+
continue;
|
|
1465
|
+
}
|
|
1466
|
+
const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
1467
|
+
if (!match) continue;
|
|
1468
|
+
current = match[1];
|
|
1469
|
+
const raw = match[2].trim();
|
|
1470
|
+
data[current] = raw === "[]" ? [] : unquote(raw);
|
|
1471
|
+
}
|
|
1472
|
+
return data;
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
function unquote(value) {
|
|
1476
|
+
return value.replace(/^["']|["']$/g, "");
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
function markdownFiles(dir) {
|
|
1480
|
+
if (!fs.existsSync(dir)) return [];
|
|
1481
|
+
return walk(dir).filter((file) => file.endsWith(".md"));
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
function walk(dir) {
|
|
1485
|
+
const out = [];
|
|
1486
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
1487
|
+
const full = path.join(dir, entry.name);
|
|
1488
|
+
if (entry.isDirectory()) {
|
|
1489
|
+
if ([".git", ".tmlbrain", "node_modules"].includes(entry.name)) continue;
|
|
1490
|
+
out.push(...walk(full));
|
|
1491
|
+
} else {
|
|
1492
|
+
out.push(full);
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
return out;
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
function extractHeadings(markdown) {
|
|
1499
|
+
return markdown.split(/\r?\n/).map((line, index) => ({ line, index: index + 1 }))
|
|
1500
|
+
.filter((item) => /^#{1,6}\s+/.test(item.line))
|
|
1501
|
+
.map((item) => {
|
|
1502
|
+
const match = item.line.match(/^(#{1,6})\s+(.*)$/);
|
|
1503
|
+
return { level: match[1].length, text: match[2].trim(), line: item.index, anchor: slugify(match[2].trim()) };
|
|
1504
|
+
});
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
function chunkByHeading(fileRel, markdown) {
|
|
1508
|
+
const lines = markdown.split(/\r?\n/);
|
|
1509
|
+
const chunks = [];
|
|
1510
|
+
let current = { heading: null, startLine: 1, lines: [] };
|
|
1511
|
+
lines.forEach((line, index) => {
|
|
1512
|
+
const heading = line.match(/^(#{1,6})\s+(.*)$/);
|
|
1513
|
+
if (heading && current.lines.length > 0) {
|
|
1514
|
+
chunks.push(makeChunk(fileRel, current));
|
|
1515
|
+
current = { heading: heading[2].trim(), startLine: index + 1, lines: [line] };
|
|
1516
|
+
} else {
|
|
1517
|
+
if (heading && !current.heading) current.heading = heading[2].trim();
|
|
1518
|
+
current.lines.push(line);
|
|
1519
|
+
}
|
|
1520
|
+
});
|
|
1521
|
+
if (current.lines.length > 0) chunks.push(makeChunk(fileRel, current));
|
|
1522
|
+
return chunks;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
function makeChunk(fileRel, chunk) {
|
|
1526
|
+
const text = chunk.lines.join("\n").trim();
|
|
1527
|
+
return { id: sha256(`${fileRel}:${chunk.startLine}:${text}`).slice(0, 16), path: fileRel, heading: chunk.heading, startLine: chunk.startLine, endLine: chunk.startLine + chunk.lines.length - 1, sha256: sha256(text), text };
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
function extractMarkdownLinks(markdown) {
|
|
1531
|
+
const links = [];
|
|
1532
|
+
const re = /\[[^\]]+\]\(([^)]+)\)/g;
|
|
1533
|
+
let match;
|
|
1534
|
+
while ((match = re.exec(markdown))) links.push(match[1]);
|
|
1535
|
+
return links;
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
function extractWikiLinks(markdown) {
|
|
1539
|
+
const links = [];
|
|
1540
|
+
const re = /\[\[([^\]]+)\]\]/g;
|
|
1541
|
+
let match;
|
|
1542
|
+
while ((match = re.exec(markdown))) links.push(match[1]);
|
|
1543
|
+
return links;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
function graphNodes(documents, edges) {
|
|
1547
|
+
const nodes = new Map();
|
|
1548
|
+
for (const doc of documents) nodes.set(doc.path, { id: doc.path, type: "document", title: doc.title });
|
|
1549
|
+
for (const edge of edges) if (!nodes.has(edge.to)) nodes.set(edge.to, { id: edge.to, type: edge.type.includes("link") ? "target" : "metadata" });
|
|
1550
|
+
return Array.from(nodes.values());
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
function parseRgLine(line) {
|
|
1554
|
+
const match = line.match(/^(.+?):(\d+):(.*)$/);
|
|
1555
|
+
if (!match) return { path: "", line: 0, text: line };
|
|
1556
|
+
return { path: match[1], line: Number(match[2]), text: match[3] };
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
function readState() {
|
|
1560
|
+
return fs.existsSync(STATE_FILE) ? JSON.parse(fs.readFileSync(STATE_FILE, "utf8")) : {};
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
function ensureLocalDirs() {
|
|
1564
|
+
[LOCAL_DIR, INDEX_DIR, BASE_DIR, CONFLICT_DIR, PATCH_DIR, LOG_DIR].forEach(ensureDir);
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
function writeJson(file, data) {
|
|
1568
|
+
ensureDir(path.dirname(file));
|
|
1569
|
+
fs.writeFileSync(file, JSON.stringify(data, null, 2) + "\n", "utf8");
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
function ensureDir(dir) {
|
|
1573
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
function copyDir(source, target) {
|
|
1577
|
+
ensureDir(target);
|
|
1578
|
+
for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
|
|
1579
|
+
const sourcePath = path.join(source, entry.name);
|
|
1580
|
+
const targetPath = path.join(target, entry.name);
|
|
1581
|
+
if (entry.isDirectory()) {
|
|
1582
|
+
copyDir(sourcePath, targetPath);
|
|
1583
|
+
} else if (entry.isFile()) {
|
|
1584
|
+
fs.copyFileSync(sourcePath, targetPath);
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
function sha256(value) {
|
|
1590
|
+
return crypto.createHash("sha256").update(value).digest("hex");
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
function slugify(value) {
|
|
1594
|
+
return String(value).trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "untitled";
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
function today() {
|
|
1598
|
+
const now = new Date();
|
|
1599
|
+
const year = now.getFullYear();
|
|
1600
|
+
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
1601
|
+
const day = String(now.getDate()).padStart(2, "0");
|
|
1602
|
+
return `${year}-${month}-${day}`;
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
function timestamp() {
|
|
1606
|
+
return new Date().toISOString().replace(/[-:T.Z]/g, "").slice(0, 14);
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
function checkCommand(command, args = []) {
|
|
1610
|
+
if (process.env.TMLBRAIN_SIMULATE_MISSING_GIT === "1" && command === "git") {
|
|
1611
|
+
return { ok: false, version: null };
|
|
1612
|
+
}
|
|
1613
|
+
const result = spawnSync(command, args, { encoding: "utf8" });
|
|
1614
|
+
return { ok: result.status === 0, version: result.stdout ? result.stdout.split(/\r?\n/)[0] : null };
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
function git(args, opts = {}) {
|
|
1618
|
+
const result = spawnSync("git", args, { cwd: ROOT, encoding: "utf8" });
|
|
1619
|
+
const out = { ok: result.status === 0, stdout: result.stdout || "", stderr: result.stderr || "" };
|
|
1620
|
+
if (!out.ok && !opts.allowFail) fail(sanitizeGit(out.stderr || out.stdout));
|
|
1621
|
+
return out;
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
function sanitizeGit(value) {
|
|
1625
|
+
return String(value || "").trim().replace(/\r/g, "");
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
function rel(file) {
|
|
1629
|
+
return path.relative(ROOT, file).replace(/\\/g, "/");
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
function findRoot(start) {
|
|
1633
|
+
let dir = start;
|
|
1634
|
+
while (dir && dir !== path.dirname(dir)) {
|
|
1635
|
+
if (fs.existsSync(path.join(dir, "knowledge")) || fs.existsSync(path.join(dir, ".git"))) return dir;
|
|
1636
|
+
dir = path.dirname(dir);
|
|
1637
|
+
}
|
|
1638
|
+
return start;
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
function resolveConflictFile(id, files) {
|
|
1642
|
+
const name = id.endsWith(".json") ? id : `${id}.json`;
|
|
1643
|
+
const file = path.join(CONFLICT_DIR, name);
|
|
1644
|
+
if (fs.existsSync(file)) return file;
|
|
1645
|
+
const found = files.find((candidate) => candidate.startsWith(id));
|
|
1646
|
+
if (found) return path.join(CONFLICT_DIR, found);
|
|
1647
|
+
fail(`Conflict package not found: ${id}`);
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
function fail(message) {
|
|
1651
|
+
console.error(message);
|
|
1652
|
+
process.exit(1);
|
|
1653
|
+
}
|