@creativeaitools/agent-wiki 2.0.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,74 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { loadConfig, readJsonObject } from "./config.js";
5
+ import { doctorWiki } from "./lifecycle.js";
6
+ export function registryPath() {
7
+ return process.env.AGENT_WIKI_REGISTRY_PATH || join(homedir(), ".config", "agent-wiki", "registry.json");
8
+ }
9
+ export function loadRegistry() {
10
+ const data = readJsonObject(registryPath());
11
+ const rawWikis = data && typeof data.wikis === "object" && data.wikis !== null && !Array.isArray(data.wikis)
12
+ ? data.wikis
13
+ : {};
14
+ const wikis = {};
15
+ for (const [name, value] of Object.entries(rawWikis)) {
16
+ if (!value || typeof value !== "object" || Array.isArray(value))
17
+ continue;
18
+ const entry = value;
19
+ if (typeof entry.root !== "string")
20
+ continue;
21
+ const type = entry.type === "workspace" ? "workspace" : "vault";
22
+ wikis[name] = {
23
+ name,
24
+ root: resolve(entry.root),
25
+ type,
26
+ addedAt: typeof entry.addedAt === "string" ? entry.addedAt : new Date(0).toISOString()
27
+ };
28
+ }
29
+ return { schemaVersion: 1, wikis };
30
+ }
31
+ export function saveRegistry(registry) {
32
+ const path = registryPath();
33
+ mkdirSync(dirname(path), { recursive: true });
34
+ writeFileSync(path, `${JSON.stringify(registry, null, 2)}\n`, "utf8");
35
+ }
36
+ export function listRegistryEntries() {
37
+ return Object.values(loadRegistry().wikis).sort((a, b) => a.name.localeCompare(b.name));
38
+ }
39
+ export function getRegistryEntry(name) {
40
+ const entry = loadRegistry().wikis[name];
41
+ if (!entry)
42
+ throw new Error(`Unknown wiki: ${name}`);
43
+ return entry;
44
+ }
45
+ export function addRegistryEntry(name, rootInput, typeInput) {
46
+ validateName(name);
47
+ const root = resolve(rootInput);
48
+ if (!existsSync(root))
49
+ throw new Error(`Wiki root does not exist: ${root}`);
50
+ const config = loadConfig(root);
51
+ const type = typeInput === "workspace" ? "workspace" : typeInput === "vault" ? "vault" : config.wikiType;
52
+ const issues = doctorWiki(root, type);
53
+ const errors = issues.filter((issue) => issue.level === "error");
54
+ if (errors.length > 0)
55
+ throw new Error(`Not a valid Agent Wiki root: ${errors.map((issue) => issue.code).join(", ")}`);
56
+ const registry = loadRegistry();
57
+ const entry = { name, root, type, addedAt: new Date().toISOString() };
58
+ registry.wikis[name] = entry;
59
+ saveRegistry(registry);
60
+ return entry;
61
+ }
62
+ export function removeRegistryEntry(name) {
63
+ const registry = loadRegistry();
64
+ if (!registry.wikis[name])
65
+ return false;
66
+ delete registry.wikis[name];
67
+ saveRegistry(registry);
68
+ return true;
69
+ }
70
+ function validateName(name) {
71
+ if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(name)) {
72
+ throw new Error("Wiki name must start with a letter and contain only letters, numbers, underscores, or hyphens.");
73
+ }
74
+ }
@@ -0,0 +1,74 @@
1
+ import { listRegistryEntries } from "./registry.js";
2
+ const JOBS = new Set(["process-inbox", "extract-primitives", "update-overview"]);
3
+ export function schedulePrompt(args) {
4
+ const subcommand = Array.isArray(args._) ? String(args._[1] ?? "") : "";
5
+ if (subcommand !== "prompt")
6
+ throw new Error("schedule requires prompt");
7
+ const job = Array.isArray(args._) ? String(args._[2] ?? "") : "";
8
+ if (!JOBS.has(job))
9
+ throw new Error("schedule prompt requires process-inbox, extract-primitives, or update-overview");
10
+ const selected = selectedNames(args);
11
+ const entries = selected.length > 0
12
+ ? listRegistryEntries().filter((entry) => selected.includes(entry.name))
13
+ : listRegistryEntries();
14
+ const missing = selected.filter((name) => !entries.some((entry) => entry.name === name));
15
+ if (missing.length > 0)
16
+ throw new Error(`Unknown registered wiki: ${missing.join(", ")}`);
17
+ console.log(renderPrompt(job, entries));
18
+ return 0;
19
+ }
20
+ function selectedNames(args) {
21
+ const values = [];
22
+ const wiki = args.wiki;
23
+ if (typeof wiki === "string")
24
+ values.push(wiki);
25
+ else if (Array.isArray(wiki))
26
+ values.push(...wiki.map(String));
27
+ if (Array.isArray(args._))
28
+ values.push(...args._.slice(3).map(String));
29
+ return Array.from(new Set(values.filter(Boolean)));
30
+ }
31
+ function renderPrompt(job, entries) {
32
+ const header = {
33
+ "process-inbox": "Scheduled Agent Wiki job: process new inbox notes",
34
+ "extract-primitives": "Scheduled Agent Wiki job: extract knowledge primitives",
35
+ "update-overview": "Scheduled Agent Wiki job: compile and refresh overview"
36
+ }[job];
37
+ const skill = {
38
+ "process-inbox": "skills/process-inbox/SKILL.md",
39
+ "extract-primitives": "skills/extract-knowledge-primitives/SKILL.md",
40
+ "update-overview": "skills/update-overview/SKILL.md"
41
+ }[job];
42
+ const task = {
43
+ "process-inbox": "Run the local process-inbox workflow for raw files in `_inbox/`.",
44
+ "extract-primitives": "Run the local extract-knowledge-primitives workflow for source pages with `status: unprocessed`.",
45
+ "update-overview": "Run the local update-overview workflow, which compiles first and then refreshes `overview.md`."
46
+ }[job];
47
+ const empty = {
48
+ "process-inbox": "If `_inbox/` is empty or has no processable files, note it and continue.",
49
+ "extract-primitives": "If no unprocessed source pages exist, note it and continue.",
50
+ "update-overview": "If compile reports validation issues, report them and continue to the next wiki."
51
+ }[job];
52
+ const lines = [
53
+ header,
54
+ "",
55
+ "Use `agent-wiki list --json` to confirm the registered Agent Wiki roots before starting.",
56
+ "Process only registered Agent Wiki roots. Do not use hardcoded vault paths or unregistered folders.",
57
+ "If one wiki fails, log the error, summarize the failure, and continue to the next wiki.",
58
+ "Do not hand-edit `_system/config.json`, `_system/cache/`, or `_system/indexes/`.",
59
+ "",
60
+ `Task: ${task}`,
61
+ `Skill: ${skill}`,
62
+ "",
63
+ "Registered wiki targets for this run:"
64
+ ];
65
+ if (entries.length === 0) {
66
+ lines.push("- No registered wikis found. Report that there is nothing to run.");
67
+ }
68
+ else {
69
+ for (const entry of entries)
70
+ lines.push(`- ${entry.name}: ${entry.root}`);
71
+ }
72
+ lines.push("", "For each target wiki, in order:", "1. Run `agent-wiki --wiki <name> onboard --check` and review the JSON summary.", "2. Read that wiki's `AGENTS.md` and `WIKI.md` before editing.", `3. Follow that wiki's local \`${skill}\` instructions exactly.`, `4. ${empty}`, "5. Report a compact per-wiki result: processed, skipped, failed, and why.", "", "Act without asking unless the local skill requires an explicit operator decision.");
73
+ return `${lines.join("\n")}\n`;
74
+ }
@@ -0,0 +1,215 @@
1
+ import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { compileWiki } from "./compile.js";
5
+ import { renderIndexCommand } from "./catalog.js";
6
+ import { detectWikiType, doctorWiki, requiredFoldersForDoctor, writeLocalConfig } from "./lifecycle.js";
7
+ import { readJsonObject } from "./config.js";
8
+ import { writeJson } from "./wiki-utils.js";
9
+ const OBSOLETE_PATHS = [
10
+ "pyproject.toml",
11
+ "agent_wiki",
12
+ "_system/scripts/create-page.py",
13
+ "_system/scripts/index.py",
14
+ "_system/scripts/log.py",
15
+ "_system/scripts/migrate-refs-to-links.py",
16
+ "_system/scripts/onboard.py",
17
+ "_system/skills/compile-wiki/scripts/compile.py",
18
+ "_system/skills/import-link/scripts/uuid.py",
19
+ "skills/compile-wiki/scripts/compile.py",
20
+ "skills/import-link/scripts/uuid.py",
21
+ "tests/e2e_smoke.py",
22
+ "tests/test_lifecycle.py",
23
+ "tests/test_onboard.py",
24
+ "tests/test_workspace.py"
25
+ ];
26
+ const TEMPLATE_FILES = [
27
+ "AGENTS.md",
28
+ "WIKI.md",
29
+ "README.md",
30
+ "ONBOARD.md",
31
+ "INBOX.md",
32
+ "AGENT-WIKI-SPEC-v2.md",
33
+ "package.json",
34
+ "_system/config.example.json"
35
+ ];
36
+ const TEMPLATE_DIRS = ["skills"];
37
+ const REWRITES = [
38
+ [/AGENT-WIKI-SPEC-v1\.md/g, "AGENT-WIKI-SPEC-v2.md"],
39
+ [/AGENT-WIKI-SPEC-v1/g, "AGENT-WIKI-SPEC-v2"],
40
+ [/python3 -m agent_wiki\.cli/g, "agent-wiki"],
41
+ [/python -m agent_wiki\.cli/g, "agent-wiki"],
42
+ [/python3 _system\/scripts\/create-page\.py/g, "agent-wiki create-page"],
43
+ [/python _system\/scripts\/create-page\.py/g, "agent-wiki create-page"],
44
+ [/_system\/scripts\/create-page\.py/g, "agent-wiki create-page"],
45
+ [/create-page\.py/g, "agent-wiki create-page"],
46
+ [/python3 _system\/scripts\/onboard\.py/g, "agent-wiki onboard"],
47
+ [/python _system\/scripts\/onboard\.py/g, "agent-wiki onboard"],
48
+ [/_system\/scripts\/onboard\.py/g, "agent-wiki onboard"],
49
+ [/python3 _system\/scripts\/log\.py/g, "agent-wiki log"],
50
+ [/python _system\/scripts\/log\.py/g, "agent-wiki log"],
51
+ [/_system\/scripts\/log\.py/g, "agent-wiki log"],
52
+ [/python3 _system\/scripts\/index\.py/g, "agent-wiki index"],
53
+ [/python _system\/scripts\/index\.py/g, "agent-wiki index"],
54
+ [/_system\/scripts\/index\.py/g, "agent-wiki index"],
55
+ [/python3 _system\/scripts\/migrate-refs-to-links\.py/g, "agent-wiki migrate-refs-to-links"],
56
+ [/python _system\/scripts\/migrate-refs-to-links\.py/g, "agent-wiki migrate-refs-to-links"],
57
+ [/_system\/scripts\/migrate-refs-to-links\.py/g, "agent-wiki migrate-refs-to-links"],
58
+ [/python3 skills\/compile-wiki\/scripts\/compile\.py/g, "agent-wiki compile"],
59
+ [/python skills\/compile-wiki\/scripts\/compile\.py/g, "agent-wiki compile"],
60
+ [/_system\/skills\/compile-wiki\/scripts\/compile\.py/g, "agent-wiki compile"],
61
+ [/skills\/compile-wiki\/scripts\/compile\.py/g, "agent-wiki compile"],
62
+ [/skills\/import-link\/scripts\/uuid\.py/g, "agent-wiki uuid"],
63
+ [/_system\/skills\/import-link\/scripts\/uuid\.py/g, "agent-wiki uuid"],
64
+ [/scripts\/uuid\.py/g, "agent-wiki uuid"]
65
+ ];
66
+ export function migrateWiki(args) {
67
+ const from = String(args.from ?? "");
68
+ const check = Boolean(args.check);
69
+ const write = Boolean(args.write);
70
+ if (from !== "v1")
71
+ throw new Error("migrate requires --from v1");
72
+ if (check === write)
73
+ throw new Error("migrate requires exactly one of --check or --write");
74
+ const root = process.cwd();
75
+ const templateRoot = fileURLToPath(new URL("../..", import.meta.url));
76
+ const actions = planMigration(root, templateRoot);
77
+ const summary = {
78
+ schemaVersion: 1,
79
+ migration: "v1-to-v2",
80
+ mode: check ? "check" : "write",
81
+ root,
82
+ actions,
83
+ counts: countActions(actions)
84
+ };
85
+ if (write) {
86
+ const backupRoot = join(root, "_archive/migrations/v1-to-v2", timestamp());
87
+ mkdirSync(backupRoot, { recursive: true });
88
+ for (const action of actions) {
89
+ if (action.action === "remove" && action.path) {
90
+ backupPath(root, backupRoot, action.path);
91
+ rmSync(join(root, action.path), { recursive: true, force: true });
92
+ }
93
+ else if (action.action === "rewrite" && action.path) {
94
+ backupPath(root, backupRoot, action.path);
95
+ writeFileSync(join(root, action.path), rewriteText(readFileSync(join(root, action.path), "utf8")), "utf8");
96
+ }
97
+ else if (action.action === "copy" && action.path) {
98
+ copyTemplatePath(templateRoot, root, action.path);
99
+ }
100
+ else if (action.action === "mkdir" && action.path) {
101
+ mkdirSync(join(root, action.path), { recursive: true });
102
+ }
103
+ }
104
+ writeLocalConfig(root, detectWikiType(readJsonObject(join(root, "_system/config.json"))), null, "wiki");
105
+ writeJson(join(backupRoot, "migration-summary.json"), summary);
106
+ const doctorIssues = doctorWiki(root);
107
+ const originalLog = console.log;
108
+ try {
109
+ console.log = () => undefined;
110
+ compileWiki({});
111
+ renderIndexCommand({ write: true, "no-log": true });
112
+ }
113
+ finally {
114
+ console.log = originalLog;
115
+ }
116
+ summary.actions.push({ action: "doctor", message: `doctor completed with ${doctorIssues.length} issue(s)` });
117
+ summary.actions.push({ action: "compile", message: "compile completed" });
118
+ summary.actions.push({ action: "index", message: "index completed" });
119
+ }
120
+ console.log(JSON.stringify(summary, null, 2));
121
+ return 0;
122
+ }
123
+ function planMigration(root, templateRoot) {
124
+ const actions = [];
125
+ for (const path of OBSOLETE_PATHS) {
126
+ if (existsSync(join(root, path)))
127
+ actions.push({ action: "remove", path, message: `Remove obsolete Python-era path: ${path}` });
128
+ }
129
+ const wikiType = detectWikiType(readJsonObject(join(root, "_system/config.json")));
130
+ for (const path of requiredFoldersForDoctor(wikiType)) {
131
+ if (!existsSync(join(root, path)))
132
+ actions.push({ action: "mkdir", path, message: `Create missing required folder: ${path}` });
133
+ }
134
+ for (const path of [...TEMPLATE_FILES, ...templateFiles(templateRoot, TEMPLATE_DIRS)]) {
135
+ if (!existsSync(join(root, path)))
136
+ actions.push({ action: "copy", path, message: `Copy missing v2 template file: ${path}` });
137
+ }
138
+ for (const path of rewriteCandidateFiles(root)) {
139
+ const text = readFileSync(join(root, path), "utf8");
140
+ if (rewriteText(text) !== text)
141
+ actions.push({ action: "rewrite", path, message: `Rewrite old helper command references in ${path}` });
142
+ }
143
+ return actions.sort((a, b) => `${a.action}:${a.path ?? ""}`.localeCompare(`${b.action}:${b.path ?? ""}`));
144
+ }
145
+ function rewriteCandidateFiles(root) {
146
+ const dirs = [".", "skills", "_system/skills"];
147
+ const files = [];
148
+ for (const dir of dirs)
149
+ collectMarkdown(root, dir, files);
150
+ return Array.from(new Set(files)).sort();
151
+ }
152
+ function collectMarkdown(root, dir, files) {
153
+ const full = join(root, dir);
154
+ if (!existsSync(full) || !statSync(full).isDirectory())
155
+ return;
156
+ for (const entry of readdirSync(full, { withFileTypes: true })) {
157
+ if (entry.name.startsWith(".") || ["node_modules", "dist", "_system", "_archive"].includes(entry.name))
158
+ continue;
159
+ const rel = dir === "." ? entry.name : join(dir, entry.name);
160
+ const path = join(root, rel);
161
+ if (entry.isDirectory())
162
+ collectMarkdown(root, rel, files);
163
+ else if (entry.isFile() && /\.(md|json)$/i.test(entry.name))
164
+ files.push(rel.split("\\").join("/"));
165
+ }
166
+ }
167
+ function rewriteText(text) {
168
+ return REWRITES.reduce((current, [pattern, replacement]) => current.replace(pattern, replacement), text);
169
+ }
170
+ function templateFiles(templateRoot, dirs) {
171
+ const files = [];
172
+ for (const dir of dirs)
173
+ collectAllFiles(templateRoot, dir, files);
174
+ return files;
175
+ }
176
+ function collectAllFiles(root, dir, files) {
177
+ const full = join(root, dir);
178
+ if (!existsSync(full) || !statSync(full).isDirectory())
179
+ return;
180
+ for (const entry of readdirSync(full, { withFileTypes: true })) {
181
+ const rel = join(dir, entry.name);
182
+ if (entry.isDirectory())
183
+ collectAllFiles(root, rel, files);
184
+ else if (entry.isFile())
185
+ files.push(rel.split("\\").join("/"));
186
+ }
187
+ }
188
+ function copyTemplatePath(templateRoot, root, path) {
189
+ const source = join(templateRoot, path);
190
+ const destination = join(root, path);
191
+ mkdirSync(dirname(destination), { recursive: true });
192
+ copyFileSync(source, destination);
193
+ }
194
+ function backupPath(root, backupRoot, path) {
195
+ const source = join(root, path);
196
+ if (!existsSync(source))
197
+ return;
198
+ const destination = join(backupRoot, path);
199
+ mkdirSync(dirname(destination), { recursive: true });
200
+ if (statSync(source).isDirectory()) {
201
+ cpSync(source, destination, { recursive: true });
202
+ }
203
+ else {
204
+ copyFileSync(source, destination);
205
+ }
206
+ }
207
+ function countActions(actions) {
208
+ return actions.reduce((counts, action) => {
209
+ counts[action.action] = (counts[action.action] ?? 0) + 1;
210
+ return counts;
211
+ }, {});
212
+ }
213
+ function timestamp() {
214
+ return new Date().toISOString().replace(/[:.]/g, "-");
215
+ }
@@ -0,0 +1,112 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
2
+ import { dirname, join, relative } from "node:path";
3
+ import YAML from "yaml";
4
+ export function today() {
5
+ return new Date().toISOString().slice(0, 10);
6
+ }
7
+ export function nowIsoSeconds() {
8
+ return new Date().toISOString().replace(/\.\d{3}Z$/, "");
9
+ }
10
+ export function readText(path) {
11
+ return readFileSync(path, "utf8");
12
+ }
13
+ export function writeText(path, content) {
14
+ mkdirSync(dirname(path), { recursive: true });
15
+ writeFileSync(path, content, "utf8");
16
+ }
17
+ export function readJson(path) {
18
+ try {
19
+ return JSON.parse(readFileSync(path, "utf8"));
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ export function writeJson(path, value) {
26
+ writeText(path, `${JSON.stringify(value, null, 2)}\n`);
27
+ }
28
+ export function renderMarkdown(frontmatter, body) {
29
+ return `---\n${YAML.stringify(frontmatter).trimEnd()}\n---\n\n${body.trim()}\n`;
30
+ }
31
+ export function parseMarkdownPage(path, wikiRoot) {
32
+ const text = readFileSync(path, "utf8");
33
+ if (!text.startsWith("---\n")) {
34
+ return null;
35
+ }
36
+ const end = text.indexOf("\n---", 4);
37
+ if (end === -1) {
38
+ return null;
39
+ }
40
+ const raw = text.slice(4, end);
41
+ const parsed = YAML.parse(raw);
42
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
43
+ return null;
44
+ }
45
+ const meta = parsed;
46
+ const relPath = relative(wikiRoot, path).split("\\").join("/");
47
+ const body = text.slice(end + 4).replace(/^\s*\n/, "");
48
+ return {
49
+ id: String(meta.id ?? ""),
50
+ pageType: String(meta.pageType ?? ""),
51
+ title: String(meta.title ?? meta.id ?? relPath),
52
+ status: String(meta.status ?? ""),
53
+ createdAt: String(meta.createdAt ?? ""),
54
+ updatedAt: String(meta.updatedAt ?? meta.createdAt ?? ""),
55
+ path: relPath,
56
+ aliases: Array.isArray(meta.aliases) ? meta.aliases : [],
57
+ tags: Array.isArray(meta.tags) ? meta.tags : [],
58
+ meta,
59
+ body,
60
+ ...meta
61
+ };
62
+ }
63
+ export function walkMarkdownPages(wikiRoot) {
64
+ const skip = new Set([".git", ".obsidian", "_system", "skills", "_archive", "_inbox", "_attachments", "raw", "reports", "node_modules", "dist"]);
65
+ const pages = [];
66
+ function walk(dir) {
67
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
68
+ const path = join(dir, entry.name);
69
+ const relParts = relative(wikiRoot, path).split(/[\\/]/);
70
+ if (entry.isDirectory()) {
71
+ if (relParts.some((part) => skip.has(part)))
72
+ continue;
73
+ walk(path);
74
+ }
75
+ else if (entry.isFile() && entry.name.endsWith(".md")) {
76
+ const page = parseMarkdownPage(path, wikiRoot);
77
+ if (page)
78
+ pages.push(page);
79
+ }
80
+ }
81
+ }
82
+ if (existsSync(wikiRoot) && statSync(wikiRoot).isDirectory()) {
83
+ walk(wikiRoot);
84
+ }
85
+ return pages.sort((a, b) => a.path.localeCompare(b.path));
86
+ }
87
+ export function idToFilename(pageId) {
88
+ return pageId.startsWith("source.") ? `${pageId.slice("source.".length).replaceAll(".", "-")}.md` : `${pageId.replaceAll(".", "-")}.md`;
89
+ }
90
+ export function refToWikilink(value) {
91
+ const text = value.trim();
92
+ if (!text || text.startsWith("[["))
93
+ return text;
94
+ return `[[${idToFilename(text).slice(0, -3)}|${text}]]`;
95
+ }
96
+ export function pathToWikilink(value) {
97
+ const text = value.trim();
98
+ if (!text || text.startsWith("[["))
99
+ return text;
100
+ const target = text.endsWith(".md") ? text.slice(0, -3) : text;
101
+ return `[[${target}|${text}]]`;
102
+ }
103
+ export function writeOperationalLog(wikiRoot, message) {
104
+ if (!message.trim())
105
+ throw new Error("--message cannot be empty");
106
+ const logPath = join(wikiRoot, "_system/logs/log.md");
107
+ const timestamp = new Date().toISOString();
108
+ const entry = `## ${timestamp}\n\n${message.trim()}\n`;
109
+ const existing = existsSync(logPath) ? readFileSync(logPath, "utf8").trimStart() : "";
110
+ writeText(logPath, existing ? `${entry}\n${existing}` : entry);
111
+ return logPath;
112
+ }
@@ -0,0 +1,198 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
3
+ import { dirname, join, relative, resolve } from "node:path";
4
+ import { DEFAULT_WORKSPACE_WIKI_DIR, cleanWikiDir, isObject } from "./config.js";
5
+ export const STATE_PATH = "_system/state/workspace-sources.json";
6
+ export function defaultWorkspaceRoot(config, explicitRoot) {
7
+ if (explicitRoot) {
8
+ return resolve(explicitRoot);
9
+ }
10
+ if (config.workspaceRoot) {
11
+ return resolve(config.workspaceRoot);
12
+ }
13
+ if (config.wikiType === "workspace") {
14
+ return resolve(config.root);
15
+ }
16
+ return process.cwd();
17
+ }
18
+ export function wikiRootForWorkspace(workspaceRoot, wikiDir) {
19
+ return resolve(workspaceRoot, cleanWikiDir(wikiDir ?? DEFAULT_WORKSPACE_WIKI_DIR));
20
+ }
21
+ export function loadState(wikiRoot) {
22
+ const path = join(wikiRoot, STATE_PATH);
23
+ try {
24
+ const data = JSON.parse(readFileSync(path, "utf8"));
25
+ if (!isObject(data)) {
26
+ return emptyState();
27
+ }
28
+ if (!isObject(data.files)) {
29
+ data.files = {};
30
+ }
31
+ return data;
32
+ }
33
+ catch {
34
+ return emptyState();
35
+ }
36
+ }
37
+ export function writeState(wikiRoot, state) {
38
+ const path = join(wikiRoot, STATE_PATH);
39
+ mkdirSync(dirname(path), { recursive: true });
40
+ writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`, "utf8");
41
+ }
42
+ export function scanWorkspace(workspaceRootInput, wikiRootInput, scanConfig, options = {}) {
43
+ const workspaceRoot = resolve(workspaceRootInput);
44
+ const wikiRoot = resolve(wikiRootInput);
45
+ const filesState = isObject(options.state?.files) ? options.state.files : {};
46
+ const results = [];
47
+ for (const path of iterCandidatePaths(workspaceRoot, wikiRoot, scanConfig)) {
48
+ let stat;
49
+ try {
50
+ stat = statSync(path);
51
+ }
52
+ catch {
53
+ continue;
54
+ }
55
+ const modified = new Date(stat.mtimeMs);
56
+ if (options.since && modified < options.since) {
57
+ continue;
58
+ }
59
+ const relPath = relative(workspaceRoot, path).split("\\").join("/");
60
+ const digest = sha256File(path);
61
+ const previous = isObject(filesState[relPath]) ? filesState[relPath] : null;
62
+ const previousHash = typeof previous?.sha256 === "string" ? previous.sha256 : null;
63
+ const sourceId = typeof previous?.sourceId === "string" ? previous.sourceId : null;
64
+ const sourcePath = typeof previous?.sourcePath === "string" ? previous.sourcePath : null;
65
+ const reason = previousHash === null ? "new" : previousHash === digest ? "unchanged" : "changed";
66
+ results.push({
67
+ path,
68
+ relativePath: relPath,
69
+ modifiedAt: modified.toISOString(),
70
+ size: stat.size,
71
+ extension: extensionOf(path),
72
+ sha256: digest,
73
+ reason,
74
+ recommendedSourceType: recommendSourceType(path),
75
+ alreadySourced: sourceId !== null,
76
+ sourceId,
77
+ sourcePath
78
+ });
79
+ }
80
+ return results.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
81
+ }
82
+ export function updateStateFromScan(wikiRoot, files, state) {
83
+ const filesState = isObject(state.files) ? state.files : {};
84
+ const now = new Date().toISOString();
85
+ for (const item of files) {
86
+ const previous = (isObject(filesState[item.relativePath]) ? filesState[item.relativePath] : {});
87
+ filesState[item.relativePath] = {
88
+ path: item.path,
89
+ mtime: item.modifiedAt,
90
+ size: item.size,
91
+ sha256: item.sha256,
92
+ sourceId: typeof previous.sourceId === "string" ? previous.sourceId : null,
93
+ sourcePath: typeof previous.sourcePath === "string" ? previous.sourcePath : null,
94
+ lastSeenAt: now
95
+ };
96
+ }
97
+ state.schemaVersion = 1;
98
+ state.lastScanAt = now;
99
+ state.files = filesState;
100
+ writeState(wikiRoot, state);
101
+ return state;
102
+ }
103
+ export function markSourced(wikiRoot, state, options) {
104
+ const filesState = isObject(state.files) ? state.files : {};
105
+ const previous = (isObject(filesState[options.relativePath]) ? filesState[options.relativePath] : {});
106
+ filesState[options.relativePath] = {
107
+ ...previous,
108
+ sourceId: options.sourceId,
109
+ sourcePath: options.sourcePath,
110
+ mappedAt: new Date().toISOString()
111
+ };
112
+ state.schemaVersion = 1;
113
+ state.files = filesState;
114
+ writeState(wikiRoot, state);
115
+ return state;
116
+ }
117
+ export function filesToJson(files) {
118
+ return JSON.stringify(files, null, 2);
119
+ }
120
+ export function filesToText(files) {
121
+ if (files.length === 0) {
122
+ return "No workspace source candidates found.";
123
+ }
124
+ return files
125
+ .map((item) => {
126
+ const marker = item.alreadySourced ? "sourced" : item.reason;
127
+ return `${marker.padEnd(9)} ${item.relativePath} (${item.recommendedSourceType}, ${item.size} bytes)`;
128
+ })
129
+ .join("\n");
130
+ }
131
+ function emptyState() {
132
+ return { schemaVersion: 1, files: {} };
133
+ }
134
+ function iterCandidatePaths(workspaceRoot, wikiRoot, scanConfig) {
135
+ const results = [];
136
+ const excludedDirs = new Set(scanConfig.excludeDirs);
137
+ function walk(dir) {
138
+ let entries;
139
+ try {
140
+ entries = readdirSync(dir, { withFileTypes: true });
141
+ }
142
+ catch {
143
+ return;
144
+ }
145
+ for (const entry of entries) {
146
+ const path = join(dir, entry.name);
147
+ if (entry.isDirectory()) {
148
+ const relParts = relative(workspaceRoot, path).split(/[\\/]/);
149
+ if (isRelativeTo(path, wikiRoot) || relParts.some((part) => excludedDirs.has(part))) {
150
+ continue;
151
+ }
152
+ walk(path);
153
+ }
154
+ else if (entry.isFile()) {
155
+ const relParts = relative(workspaceRoot, path).split(/[\\/]/);
156
+ if (isRelativeTo(path, wikiRoot) || relParts.slice(0, -1).some((part) => excludedDirs.has(part))) {
157
+ continue;
158
+ }
159
+ if (scanConfig.excludeFileGlobs.some((pattern) => globMatch(entry.name, pattern))) {
160
+ continue;
161
+ }
162
+ if (!scanConfig.includeExtensions.includes(extensionOf(path))) {
163
+ continue;
164
+ }
165
+ results.push(resolve(path));
166
+ }
167
+ }
168
+ }
169
+ walk(workspaceRoot);
170
+ return results;
171
+ }
172
+ function isRelativeTo(path, parent) {
173
+ const rel = relative(resolve(parent), resolve(path));
174
+ return rel === "" || (!rel.startsWith("..") && !rel.startsWith("/") && !/^[A-Za-z]:/.test(rel));
175
+ }
176
+ function sha256File(path) {
177
+ return createHash("sha256").update(readFileSync(path)).digest("hex");
178
+ }
179
+ function recommendSourceType(path) {
180
+ const suffix = extensionOf(path);
181
+ if (suffix === ".pdf")
182
+ return "pdf";
183
+ if ([".csv", ".json", ".yaml", ".yml"].includes(suffix))
184
+ return "dataset";
185
+ if ([".md", ".markdown", ".txt", ".docx"].includes(suffix))
186
+ return "document";
187
+ return "other";
188
+ }
189
+ function extensionOf(path) {
190
+ const match = /(\.[^./\\]+)$/.exec(path);
191
+ return match ? match[1].toLowerCase() : "";
192
+ }
193
+ function globMatch(name, pattern) {
194
+ if (pattern.startsWith("*")) {
195
+ return name.endsWith(pattern.slice(1));
196
+ }
197
+ return name === pattern;
198
+ }