sdtk-wiki-kit 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.
@@ -0,0 +1,111 @@
1
+ "use strict";
2
+
3
+ const crypto = require("crypto");
4
+ const fs = require("fs");
5
+ const path = require("path");
6
+ const {
7
+ assertWikiWorkspaceWritePath,
8
+ getWikiQueriesPath,
9
+ resolveProjectPath,
10
+ } = require("./wiki-paths");
11
+
12
+ function toPosix(value) {
13
+ return String(value || "").replace(/\\/g, "/");
14
+ }
15
+
16
+ function shortHash(value, length = 12) {
17
+ return crypto.createHash("sha256").update(String(value || "")).digest("hex").slice(0, length);
18
+ }
19
+
20
+ function timestampParts(date = new Date()) {
21
+ const iso = date.toISOString();
22
+ const stamp = iso.slice(0, 19).replace(/[-:]/g, "").replace("T", "-");
23
+ return {
24
+ iso,
25
+ stamp,
26
+ };
27
+ }
28
+
29
+ function projectFingerprint(projectPath) {
30
+ return shortHash(path.resolve(projectPath), 16);
31
+ }
32
+
33
+ function summarizeAnswer(answer, citations) {
34
+ const answerLength = String(answer || "").length;
35
+ const citationCount = Array.isArray(citations) ? citations.length : 0;
36
+ return `Redacted answer summary; answer length ${answerLength} characters with ${citationCount} source reference(s).`;
37
+ }
38
+
39
+ function sourceRefs(citations) {
40
+ if (!Array.isArray(citations)) return [];
41
+ return citations
42
+ .map((citation) => {
43
+ if (typeof citation === "string") return citation;
44
+ if (citation && typeof citation === "object") {
45
+ return citation.path || citation.sourcePath || citation.id || citation.title || "";
46
+ }
47
+ return "";
48
+ })
49
+ .filter(Boolean)
50
+ .map(toPosix);
51
+ }
52
+
53
+ function relativeGraphPath(result, projectPath) {
54
+ const graphPath = result && result.graphPath ? String(result.graphPath) : "";
55
+ if (!graphPath) return ".sdtk/wiki/graph";
56
+ const relative = path.relative(resolveProjectPath(projectPath), graphPath);
57
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
58
+ return ".sdtk/wiki/graph";
59
+ }
60
+ return toPosix(relative);
61
+ }
62
+
63
+ function buildQueryRecord({ projectPath, question, result, createdAt = new Date() }) {
64
+ const time = timestampParts(createdAt);
65
+ const refs = sourceRefs(result.citations);
66
+ const queryId = `query-${shortHash(`${time.iso}:${question}:${result.graphPath || ""}`)}`;
67
+ return {
68
+ schema_version: 1,
69
+ product: "SDTK-WIKI",
70
+ record_type: "sdtk_wiki_query",
71
+ query_id: queryId,
72
+ created_at: time.iso,
73
+ command_surface: "sdtk-wiki ask",
74
+ project_path_fingerprint: projectFingerprint(projectPath),
75
+ question_redacted: "[redacted]",
76
+ answer_summary: summarizeAnswer(result.answer, refs),
77
+ source_refs: refs,
78
+ graph_path: relativeGraphPath(result, projectPath),
79
+ runtime: {
80
+ capability: "wiki.ask",
81
+ confidence: result.confidence || null,
82
+ },
83
+ model: null,
84
+ privacy_mode: "redacted",
85
+ retention_policy: "Project-local redacted query record. Full question and full answer are not stored by default.",
86
+ status: "answered",
87
+ review_status: "unreviewed",
88
+ };
89
+ }
90
+
91
+ function saveQueryHistoryRecord({ projectPath, question, result }) {
92
+ const resolvedProjectPath = resolveProjectPath(projectPath);
93
+ const queriesPath = getWikiQueriesPath(resolvedProjectPath);
94
+ assertWikiWorkspaceWritePath(queriesPath, resolvedProjectPath);
95
+ const record = buildQueryRecord({ projectPath: resolvedProjectPath, question, result });
96
+ const time = timestampParts(new Date(record.created_at));
97
+ const fileName = `${time.stamp}-${shortHash(record.query_id)}.json`;
98
+ const recordPath = path.join(queriesPath, fileName);
99
+ assertWikiWorkspaceWritePath(recordPath, resolvedProjectPath);
100
+ fs.mkdirSync(queriesPath, { recursive: true });
101
+ fs.writeFileSync(recordPath, `${JSON.stringify(record, null, 2)}\n`, "utf-8");
102
+ return {
103
+ record,
104
+ recordPath,
105
+ };
106
+ }
107
+
108
+ module.exports = {
109
+ buildQueryRecord,
110
+ saveQueryHistoryRecord,
111
+ };
@@ -0,0 +1,373 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const http = require("http");
5
+ const https = require("https");
6
+ const net = require("net");
7
+ const path = require("path");
8
+ const { spawn, execFile } = require("child_process");
9
+ const { CliError, DependencyError } = require("./errors");
10
+ const { resolveBuilderPath } = require("./wiki-config");
11
+ const { openBrowser } = require("./browser-open");
12
+
13
+ const HEALTH_CHECK_RETRIES = 20;
14
+ const HEALTH_CHECK_INTERVAL_MS = 300;
15
+
16
+ function findPython() {
17
+ return new Promise((resolve, reject) => {
18
+ const candidates = process.platform === "win32"
19
+ ? ["python", "python3"]
20
+ : ["python3", "python"];
21
+
22
+ let idx = 0;
23
+ function tryNext() {
24
+ if (idx >= candidates.length) {
25
+ reject(
26
+ new DependencyError(
27
+ "Python not found. Install Python 3.8+ and ensure it is in PATH.\n" +
28
+ "SDTK-WIKI graph build requires Python to run the local builder."
29
+ )
30
+ );
31
+ return;
32
+ }
33
+
34
+ const candidate = candidates[idx++];
35
+ execFile(candidate, ["--version"], { timeout: 5000 }, (err) => {
36
+ if (!err) resolve(candidate);
37
+ else tryNext();
38
+ });
39
+ }
40
+ tryNext();
41
+ });
42
+ }
43
+
44
+ async function runBuild(config) {
45
+ const builderPath = resolveBuilderPath();
46
+ if (!fs.existsSync(builderPath)) {
47
+ throw new DependencyError(
48
+ `SDTK-WIKI builder not found: ${builderPath}\n` +
49
+ "This is a packaging error. Reinstall sdtk-wiki-kit."
50
+ );
51
+ }
52
+
53
+ const pythonExe = await findPython();
54
+ const args = [
55
+ builderPath,
56
+ "--project-root",
57
+ config.projectPath,
58
+ "--output-dir",
59
+ config.outputDir,
60
+ ];
61
+
62
+ for (const sr of config.scanRoots) {
63
+ args.push("--scan-root", sr);
64
+ }
65
+ for (const ex of config.excludes) {
66
+ args.push("--exclude", ex);
67
+ }
68
+ if (config.verbose) {
69
+ args.push("--verbose");
70
+ }
71
+
72
+ return new Promise((resolve, reject) => {
73
+ const child = spawn(pythonExe, args, {
74
+ stdio: ["ignore", "pipe", "pipe"],
75
+ });
76
+
77
+ let stdout = "";
78
+ let stderr = "";
79
+
80
+ child.stdout.on("data", (chunk) => {
81
+ const text = chunk.toString();
82
+ stdout += text;
83
+ process.stdout.write(text);
84
+ });
85
+
86
+ child.stderr.on("data", (chunk) => {
87
+ const text = chunk.toString();
88
+ stderr += text;
89
+ process.stderr.write(text);
90
+ });
91
+
92
+ child.on("error", (err) => {
93
+ if (err.code === "ENOENT") {
94
+ reject(
95
+ new DependencyError(
96
+ `Python executable not found: ${pythonExe}\n` +
97
+ "Install Python 3.8+ and ensure it is in PATH."
98
+ )
99
+ );
100
+ } else {
101
+ reject(new CliError(`Failed to start SDTK-WIKI builder: ${err.message}`));
102
+ }
103
+ });
104
+
105
+ child.on("close", (code) => {
106
+ if (code !== 0) {
107
+ reject(
108
+ new CliError(
109
+ `SDTK-WIKI build failed (exit code ${code}).\n` +
110
+ (stderr ? `Builder error output:\n${stderr}` : "See output above for details.")
111
+ )
112
+ );
113
+ return;
114
+ }
115
+
116
+ const resultLine = stdout.split("\n").find((l) => l.startsWith("[atlas:result] "));
117
+ if (resultLine) {
118
+ try {
119
+ const result = JSON.parse(resultLine.replace("[atlas:result] ", ""));
120
+ resolve({
121
+ docCount: result.doc_count || 0,
122
+ nodeCount: result.node_count || 0,
123
+ edgeCount: result.edge_count || 0,
124
+ generated: result.generated || "",
125
+ pageCount: result.page_count || 0,
126
+ pagesRoot: result.pages_root || "",
127
+ pageIndexPath: result.page_index_path || "",
128
+ provenancePath: result.provenance_path || "",
129
+ changesPath: result.changes_path || "",
130
+ });
131
+ return;
132
+ } catch (_) {
133
+ // fall through
134
+ }
135
+ }
136
+
137
+ resolve({
138
+ docCount: 0,
139
+ nodeCount: 0,
140
+ edgeCount: 0,
141
+ generated: "",
142
+ pageCount: 0,
143
+ pagesRoot: "",
144
+ pageIndexPath: "",
145
+ provenancePath: "",
146
+ changesPath: "",
147
+ });
148
+ });
149
+ });
150
+ }
151
+
152
+ function isPortOpen(host, port) {
153
+ return new Promise((resolve) => {
154
+ const socket = net.createConnection({ host, port, timeout: 500 });
155
+ socket.once("connect", () => {
156
+ socket.destroy();
157
+ resolve(true);
158
+ });
159
+ socket.once("error", () => resolve(false));
160
+ socket.once("timeout", () => {
161
+ socket.destroy();
162
+ resolve(false);
163
+ });
164
+ });
165
+ }
166
+
167
+ async function waitForServer(host, port) {
168
+ for (let i = 0; i < HEALTH_CHECK_RETRIES; i++) {
169
+ const ok = await isPortOpen(host, port);
170
+ if (ok) return;
171
+ await new Promise((r) => setTimeout(r, HEALTH_CHECK_INTERVAL_MS));
172
+ }
173
+ throw new CliError(
174
+ `SDTK-WIKI viewer server did not start on http://${host}:${port}\n` +
175
+ "Try passing a different --port if the port is occupied."
176
+ );
177
+ }
178
+
179
+ function probeUrl(url) {
180
+ return new Promise((resolve) => {
181
+ let target;
182
+ try {
183
+ target = new URL(url);
184
+ } catch (_) {
185
+ resolve({ ok: false, statusCode: 0, body: "" });
186
+ return;
187
+ }
188
+
189
+ const transport = target.protocol === "https:" ? https : http;
190
+ const req = transport.request(
191
+ target,
192
+ { method: "GET", timeout: 1200 },
193
+ (res) => {
194
+ let body = "";
195
+ res.setEncoding("utf8");
196
+ res.on("data", (chunk) => {
197
+ if (body.length < 512) {
198
+ body += chunk.slice(0, 512 - body.length);
199
+ }
200
+ });
201
+ res.on("end", () => {
202
+ const statusCode = res.statusCode || 0;
203
+ resolve({
204
+ ok: statusCode >= 200 && statusCode < 300,
205
+ statusCode,
206
+ body,
207
+ });
208
+ });
209
+ }
210
+ );
211
+
212
+ req.on("timeout", () => {
213
+ req.destroy();
214
+ resolve({ ok: false, statusCode: 0, body: "" });
215
+ });
216
+ req.on("error", () => resolve({ ok: false, statusCode: 0, body: "" }));
217
+ req.end();
218
+ });
219
+ }
220
+
221
+ async function probeExistingWikiServer(host, port, viewerUrl) {
222
+ const health = await probeUrl(`http://${host}:${port}/api/health`);
223
+ const viewer = await probeUrl(`${viewerUrl}?embedded=1&probe=1`);
224
+ return {
225
+ reusable: health.ok && viewer.ok,
226
+ health: { ok: health.ok, statusCode: health.statusCode },
227
+ viewer: { ok: viewer.ok, statusCode: viewer.statusCode },
228
+ };
229
+ }
230
+
231
+ function startWikiServer(host, port, outputDir, projectPath) {
232
+ return new Promise((resolve, reject) => {
233
+ const MIME_TYPES = {
234
+ ".html": "text/html; charset=utf-8",
235
+ ".js": "application/javascript; charset=utf-8",
236
+ ".json": "application/json; charset=utf-8",
237
+ ".css": "text/css; charset=utf-8",
238
+ ".md": "text/plain; charset=utf-8",
239
+ ".txt": "text/plain; charset=utf-8",
240
+ };
241
+
242
+ const server = http.createServer((req, res) => {
243
+ const url = req.url || "/";
244
+
245
+ if (url === "/api/health" || url === "/api/health/") {
246
+ res.writeHead(200, { "Content-Type": "application/json" });
247
+ res.end(JSON.stringify({ ok: true, product: "SDTK-WIKI" }));
248
+ return;
249
+ }
250
+
251
+ if (url.startsWith("/api/note")) {
252
+ const qs = new URL(url, `http://${host}:${port}`).searchParams;
253
+ const notePath = qs.get("path");
254
+ if (!notePath) {
255
+ res.writeHead(400, { "Content-Type": "application/json" });
256
+ res.end(JSON.stringify({ error: "Missing path parameter" }));
257
+ return;
258
+ }
259
+
260
+ const projectRoot = projectPath || outputDir;
261
+ const resolved = path.resolve(projectRoot, notePath);
262
+ if (!resolved.startsWith(projectRoot + path.sep) && resolved !== projectRoot) {
263
+ res.writeHead(403, { "Content-Type": "application/json" });
264
+ res.end(JSON.stringify({ error: "Access denied" }));
265
+ return;
266
+ }
267
+
268
+ if (!fs.existsSync(resolved)) {
269
+ res.writeHead(404, { "Content-Type": "application/json" });
270
+ res.end(JSON.stringify({ error: "Not found" }));
271
+ return;
272
+ }
273
+
274
+ try {
275
+ const content = fs.readFileSync(resolved, "utf-8");
276
+ res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
277
+ res.end(content);
278
+ } catch (_) {
279
+ res.writeHead(500, { "Content-Type": "application/json" });
280
+ res.end(JSON.stringify({ error: "Read error" }));
281
+ }
282
+ return;
283
+ }
284
+
285
+ let filePath = url.split("?")[0];
286
+ if (filePath === "/" || filePath === "") {
287
+ filePath = "/viewer.html";
288
+ }
289
+
290
+ const resolved = path.resolve(outputDir, filePath.replace(/^\/+/, ""));
291
+ if (!resolved.startsWith(outputDir + path.sep) && resolved !== outputDir) {
292
+ res.writeHead(403, { "Content-Type": "text/plain" });
293
+ res.end("403 Forbidden");
294
+ return;
295
+ }
296
+
297
+ if (!fs.existsSync(resolved) || fs.statSync(resolved).isDirectory()) {
298
+ res.writeHead(404, { "Content-Type": "text/plain" });
299
+ res.end("404 Not Found");
300
+ return;
301
+ }
302
+
303
+ const ext = path.extname(resolved).toLowerCase();
304
+ const contentType = MIME_TYPES[ext] || "application/octet-stream";
305
+
306
+ try {
307
+ const data = fs.readFileSync(resolved);
308
+ res.writeHead(200, { "Content-Type": contentType });
309
+ res.end(data);
310
+ } catch (_) {
311
+ res.writeHead(500, { "Content-Type": "text/plain" });
312
+ res.end("500 Internal Server Error");
313
+ }
314
+ });
315
+
316
+ server.once("error", (err) => {
317
+ if (err.code === "EADDRINUSE") {
318
+ reject(
319
+ new CliError(
320
+ `Port ${port} is already in use on ${host}.\n` +
321
+ "Pass --port <number> to use a different port, or stop the process using that port."
322
+ )
323
+ );
324
+ } else {
325
+ reject(new CliError(`SDTK-WIKI server error: ${err.message}`));
326
+ }
327
+ });
328
+
329
+ server.listen(port, host, () => resolve(server));
330
+ });
331
+ }
332
+
333
+ async function openViewer(config, noOpen = false) {
334
+ const { host, port, outputDir } = config;
335
+ const viewerUrl = `http://${host}:${port}/viewer.html`;
336
+ const alreadyRunning = await isPortOpen(host, port);
337
+
338
+ let server = null;
339
+ if (!alreadyRunning) {
340
+ console.log(`[wiki] Starting local SDTK-WIKI viewer on http://${host}:${port} ...`);
341
+ server = await startWikiServer(host, port, outputDir, config.projectPath);
342
+ await waitForServer(host, port);
343
+ console.log(`[wiki] Viewer server ready: ${viewerUrl}`);
344
+ } else {
345
+ const probe = await probeExistingWikiServer(host, port, viewerUrl);
346
+ if (!probe.reusable) {
347
+ throw new CliError(
348
+ `Port ${port} is already occupied by an incompatible viewer server.\n` +
349
+ ` Health endpoint status: ${probe.health.statusCode || "unreachable"}\n` +
350
+ ` Viewer endpoint status: ${probe.viewer.statusCode || "unreachable"}\n` +
351
+ "Stop the existing process on that port, then rerun the command, or pass --port <number>."
352
+ );
353
+ }
354
+ console.log(`[wiki] Reusing existing server at http://${host}:${port}`);
355
+ }
356
+
357
+ if (!noOpen) {
358
+ console.log("[wiki] Opening viewer in default browser...");
359
+ await openBrowser(viewerUrl);
360
+ } else {
361
+ console.log(`[wiki] Viewer URL: ${viewerUrl}`);
362
+ console.log("[wiki] --no-open specified; skipping browser launch.");
363
+ }
364
+
365
+ return { url: viewerUrl, server };
366
+ }
367
+
368
+ module.exports = {
369
+ findPython,
370
+ openViewer,
371
+ runBuild,
372
+ startWikiServer,
373
+ };
@@ -0,0 +1,144 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const {
6
+ assertPathInsideOrEqual,
7
+ getLegacyAtlasPath,
8
+ getWikiGraphPath,
9
+ getWikiLogsPath,
10
+ getWikiManifestPath,
11
+ getWikiPagesPath,
12
+ getWikiRawDescriptorsPath,
13
+ getWikiRawPath,
14
+ getWikiRawSourcesPath,
15
+ getWikiProvenanceIngestEventsPath,
16
+ getWikiProvenancePath,
17
+ getWikiProvenanceSourcesPath,
18
+ getWikiQueriesPath,
19
+ getWikiReportsPath,
20
+ getWikiWorkspacePath,
21
+ } = require("./wiki-paths");
22
+
23
+ const WIKI_WORKSPACE_SCHEMA_VERSION = 1;
24
+
25
+ const RESERVED_DIRECTORIES = [
26
+ "graph",
27
+ "pages",
28
+ "raw",
29
+ "provenance",
30
+ "queries",
31
+ "reports",
32
+ "logs",
33
+ ];
34
+
35
+ function nowIso() {
36
+ return new Date().toISOString();
37
+ }
38
+
39
+ function describeWorkspace(projectPath, graphRoot) {
40
+ return {
41
+ workspaceRoot: getWikiWorkspacePath(projectPath),
42
+ manifestPath: getWikiManifestPath(projectPath),
43
+ graphRoot: graphRoot || getWikiGraphPath(projectPath),
44
+ defaultGraphRoot: getWikiGraphPath(projectPath),
45
+ pagesRoot: getWikiPagesPath(projectPath),
46
+ rawRoot: getWikiRawPath(projectPath),
47
+ rawDescriptorsRoot: getWikiRawDescriptorsPath(projectPath),
48
+ rawSourcesPath: getWikiRawSourcesPath(projectPath),
49
+ provenanceRoot: getWikiProvenancePath(projectPath),
50
+ provenanceIngestEventsPath: getWikiProvenanceIngestEventsPath(projectPath),
51
+ provenanceSourcesPath: getWikiProvenanceSourcesPath(projectPath),
52
+ queriesRoot: getWikiQueriesPath(projectPath),
53
+ reportsRoot: getWikiReportsPath(projectPath),
54
+ logsRoot: getWikiLogsPath(projectPath),
55
+ legacyAtlasRoot: getLegacyAtlasPath(projectPath),
56
+ };
57
+ }
58
+
59
+ function assertInsideWorkspace(targetPath, workspaceRoot) {
60
+ return assertPathInsideOrEqual(
61
+ targetPath,
62
+ workspaceRoot,
63
+ "Refusing to write outside SDTK-WIKI workspace"
64
+ );
65
+ }
66
+
67
+ function readWorkspaceManifest(projectPath) {
68
+ const manifestPath = getWikiManifestPath(projectPath);
69
+ if (!fs.existsSync(manifestPath)) return null;
70
+ try {
71
+ return JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
72
+ } catch (_) {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ function buildManifest(projectPath, graphRoot, existingManifest) {
78
+ const workspace = describeWorkspace(projectPath, graphRoot);
79
+ const timestamp = nowIso();
80
+ const createdAt =
81
+ existingManifest && typeof existingManifest.createdAt === "string"
82
+ ? existingManifest.createdAt
83
+ : timestamp;
84
+
85
+ return {
86
+ schemaVersion: WIKI_WORKSPACE_SCHEMA_VERSION,
87
+ product: "SDTK-WIKI",
88
+ workspaceRoot: workspace.workspaceRoot,
89
+ graphRoot: workspace.graphRoot,
90
+ legacyAtlasRoot: workspace.legacyAtlasRoot,
91
+ createdAt,
92
+ updatedAt: timestamp,
93
+ features: {
94
+ graph: true,
95
+ pages: false,
96
+ raw: true,
97
+ queries: false,
98
+ reports: false,
99
+ },
100
+ };
101
+ }
102
+
103
+ function ensureWorkspace(projectPath, graphRoot) {
104
+ const workspace = describeWorkspace(projectPath, graphRoot);
105
+ fs.mkdirSync(workspace.workspaceRoot, { recursive: true });
106
+
107
+ for (const dirName of RESERVED_DIRECTORIES) {
108
+ const target = path.join(workspace.workspaceRoot, dirName);
109
+ assertInsideWorkspace(target, workspace.workspaceRoot);
110
+ fs.mkdirSync(target, { recursive: true });
111
+ }
112
+
113
+ const existingManifest = readWorkspaceManifest(projectPath);
114
+ const manifest = buildManifest(projectPath, graphRoot, existingManifest);
115
+ assertInsideWorkspace(workspace.manifestPath, workspace.workspaceRoot);
116
+ fs.writeFileSync(workspace.manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf-8");
117
+
118
+ return {
119
+ manifest,
120
+ workspace,
121
+ };
122
+ }
123
+
124
+ function getWorkspaceStatus(projectPath) {
125
+ const workspace = describeWorkspace(projectPath);
126
+ const manifest = readWorkspaceManifest(projectPath);
127
+ return {
128
+ exists: fs.existsSync(workspace.workspaceRoot),
129
+ manifestExists: fs.existsSync(workspace.manifestPath),
130
+ schemaVersion: manifest ? manifest.schemaVersion : null,
131
+ manifest,
132
+ workspace,
133
+ };
134
+ }
135
+
136
+ module.exports = {
137
+ RESERVED_DIRECTORIES,
138
+ WIKI_WORKSPACE_SCHEMA_VERSION,
139
+ buildManifest,
140
+ describeWorkspace,
141
+ ensureWorkspace,
142
+ getWorkspaceStatus,
143
+ readWorkspaceManifest,
144
+ };