logseq-graph-living-atlas 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/.env.example +29 -0
- package/CHANGELOG.md +38 -0
- package/CODE_OF_CONDUCT.md +33 -0
- package/CONTRIBUTING.md +116 -0
- package/GOVERNANCE.md +40 -0
- package/LICENSE +21 -0
- package/MAINTAINERS.md +34 -0
- package/README.md +370 -0
- package/ROADMAP.md +38 -0
- package/SECURITY.md +43 -0
- package/SUPPORT.md +31 -0
- package/dist/assets/AtlasCanvas-p-Pb_C3p.js +172 -0
- package/dist/assets/index-Cb0dgkF5.css +1 -0
- package/dist/assets/index-D-LUf-q7.js +12 -0
- package/dist/assets/three-DQCgX68K.js +3947 -0
- package/dist/index.html +13 -0
- package/docs/ADAPTERS.md +48 -0
- package/docs/API.md +145 -0
- package/docs/ARCHITECTURE.md +93 -0
- package/docs/CONCEPTS.md +62 -0
- package/docs/MCP.md +74 -0
- package/docs/RELEASE.md +65 -0
- package/docs/REPO_GUIDE.md +92 -0
- package/docs/TROUBLESHOOTING.md +138 -0
- package/docs/assets/living-atlas-demo.png +0 -0
- package/docs/assets/living-atlas-pathfinder.png +0 -0
- package/docs/assets/living-atlas-radar.png +0 -0
- package/docs/assets/living-atlas-source-detail.png +0 -0
- package/package.json +102 -0
- package/server/brain-service.mjs +201 -0
- package/server/contracts.mjs +346 -0
- package/server/fixture/create-fixture-graph.mjs +115 -0
- package/server/graph/pathfinding.mjs +155 -0
- package/server/graph/quality.mjs +11 -0
- package/server/graph/utils.mjs +25 -0
- package/server/graph-index.mjs +920 -0
- package/server/logseq/parser.mjs +121 -0
- package/server/logseq/source-adapter.mjs +147 -0
- package/server/redaction.mjs +89 -0
- package/server/service.mjs +777 -0
- package/server/source-adapter-contract.d.ts +46 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
const WIKILINK_RE = /\[\[([^\]]+?)\]\]/g;
|
|
4
|
+
const PROP_RE = /^([a-zA-Z][\w-]*?)::\s*(.*?)\s*$/;
|
|
5
|
+
const TYPED_LINK_RE = /^\s*(?:[-*]\s*)?(?:\*\*)?([a-zA-Z][\w\s-]{1,40}?):(?:\*\*)?\s*\[\[([^\]]+?)\]\]/gm;
|
|
6
|
+
const CODE_FENCE_RE = /```[\s\S]*?```/g;
|
|
7
|
+
const INLINE_CODE_RE = /`[^`]*`/g;
|
|
8
|
+
|
|
9
|
+
export function slugify(name) {
|
|
10
|
+
return String(name || "")
|
|
11
|
+
.normalize("NFC")
|
|
12
|
+
.trim()
|
|
13
|
+
.replace(/\.md$/i, "")
|
|
14
|
+
.replace(/\//g, "___")
|
|
15
|
+
.toLowerCase();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function displayNameFromFile(filePath, root = "") {
|
|
19
|
+
const normalizedRoot = root ? path.resolve(root) : "";
|
|
20
|
+
const normalizedFile = path.resolve(filePath);
|
|
21
|
+
if (!normalizedRoot) return path.basename(filePath, ".md").replace(/___/g, "/");
|
|
22
|
+
const relative = path.relative(normalizedRoot, normalizedFile);
|
|
23
|
+
const parts = relative.split(path.sep);
|
|
24
|
+
if (parts[0] === "pages" || parts[0] === "journals") parts.shift();
|
|
25
|
+
return parts.join("/").replace(/\.md$/i, "").replace(/___/g, "/");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function stripCode(text) {
|
|
29
|
+
return String(text || "").replace(CODE_FENCE_RE, "").replace(INLINE_CODE_RE, "");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function extractWikilinks(text) {
|
|
33
|
+
const clean = stripCode(text);
|
|
34
|
+
const links = new Set();
|
|
35
|
+
for (const match of clean.matchAll(WIKILINK_RE)) {
|
|
36
|
+
const target = match[1].trim();
|
|
37
|
+
if (!target || /^https?:/i.test(target)) continue;
|
|
38
|
+
links.add(slugify(target));
|
|
39
|
+
}
|
|
40
|
+
return [...links];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function parseProperties(text) {
|
|
44
|
+
const props = {};
|
|
45
|
+
for (const line of String(text || "").split(/\r?\n/)) {
|
|
46
|
+
const match = line.match(PROP_RE);
|
|
47
|
+
if (!match) continue;
|
|
48
|
+
props[match[1].toLowerCase()] = match[2].trim();
|
|
49
|
+
}
|
|
50
|
+
return props;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function parsePageRecord(filePath, text, stat = undefined, options = {}) {
|
|
54
|
+
const root = typeof options === "string" ? options : options.root || "";
|
|
55
|
+
const name = displayNameFromFile(filePath, root);
|
|
56
|
+
const props = parseProperties(text);
|
|
57
|
+
const type = props.type || "note";
|
|
58
|
+
const tags = parseTags(props.tags || "");
|
|
59
|
+
const out = extractWikilinks(text).filter((target) => target !== slugify(name));
|
|
60
|
+
const mtimeMs = stat?.mtimeMs ?? Date.now();
|
|
61
|
+
const relations = extractTypedRelations(text, props);
|
|
62
|
+
return {
|
|
63
|
+
id: slugify(name),
|
|
64
|
+
name,
|
|
65
|
+
path: filePath,
|
|
66
|
+
type,
|
|
67
|
+
tags,
|
|
68
|
+
status: props.status || "",
|
|
69
|
+
source: props.source || "",
|
|
70
|
+
confidence: props.confidence || "",
|
|
71
|
+
lastContacted: props["last-contacted"] || "",
|
|
72
|
+
updatedAt: new Date(mtimeMs).toISOString(),
|
|
73
|
+
mtimeMs,
|
|
74
|
+
out,
|
|
75
|
+
relations,
|
|
76
|
+
props
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function extractTypedRelations(text, props = {}) {
|
|
81
|
+
const relations = [];
|
|
82
|
+
for (const [key, value] of Object.entries(props || {})) {
|
|
83
|
+
const links = extractWikilinks(String(value || ""));
|
|
84
|
+
for (const target of links) {
|
|
85
|
+
relations.push({ kind: normalizeRelationKind(key), target, evidence: `${key}:: ${value}` });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const clean = stripCode(text);
|
|
89
|
+
for (const match of clean.matchAll(TYPED_LINK_RE)) {
|
|
90
|
+
const kind = normalizeRelationKind(match[1]);
|
|
91
|
+
const target = slugify(match[2]);
|
|
92
|
+
if (!kind || !target) continue;
|
|
93
|
+
relations.push({ kind, target, evidence: match[0].trim() });
|
|
94
|
+
}
|
|
95
|
+
const seen = new Set();
|
|
96
|
+
return relations.filter((relation) => {
|
|
97
|
+
const key = `${relation.kind}:${relation.target}`;
|
|
98
|
+
if (seen.has(key)) return false;
|
|
99
|
+
seen.add(key);
|
|
100
|
+
return true;
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function parseTags(raw) {
|
|
105
|
+
if (!raw) return [];
|
|
106
|
+
const tags = [];
|
|
107
|
+
for (const wikilink of raw.matchAll(WIKILINK_RE)) tags.push(wikilink[1].trim());
|
|
108
|
+
const cleaned = raw.replace(WIKILINK_RE, " ");
|
|
109
|
+
for (const token of cleaned.split(/[\s,]+/)) {
|
|
110
|
+
const value = token.trim();
|
|
111
|
+
if (value) tags.push(value);
|
|
112
|
+
}
|
|
113
|
+
return [...new Set(tags)];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function normalizeRelationKind(value) {
|
|
117
|
+
return String(value || "")
|
|
118
|
+
.replace(/\s+/g, " ")
|
|
119
|
+
.trim()
|
|
120
|
+
.toLowerCase();
|
|
121
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { parsePageRecord } from "./parser.mjs";
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_LOGSEQ_ROOT = process.env.LOGSEQ_ROOT || "";
|
|
8
|
+
const LOGSEQ_SOURCE_DIRS = ["pages", "journals"];
|
|
9
|
+
const DEFAULT_MAX_MARKDOWN_FILES = 250000;
|
|
10
|
+
const DEFAULT_MAX_MARKDOWN_FILE_BYTES = 2 * 1024 * 1024;
|
|
11
|
+
|
|
12
|
+
export function createLogseqSourceAdapter(root = DEFAULT_LOGSEQ_ROOT) {
|
|
13
|
+
return {
|
|
14
|
+
kind: "logseq-markdown",
|
|
15
|
+
root,
|
|
16
|
+
readManifest: () => readGraphManifest(root),
|
|
17
|
+
readRecords: () => readPageRecords(root),
|
|
18
|
+
watchDirectories: () => LOGSEQ_SOURCE_DIRS
|
|
19
|
+
.map((sourceDir) => ({ sourceDir, path: path.join(root, sourceDir) }))
|
|
20
|
+
.filter((entry) => fs.existsSync(entry.path))
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function readPageRecords(root = DEFAULT_LOGSEQ_ROOT) {
|
|
25
|
+
if (!root) throw new Error("LOGSEQ_ROOT or --root is required. Point it at a Logseq graph folder containing pages/.");
|
|
26
|
+
const records = [];
|
|
27
|
+
for (const filePath of readLogseqMarkdownFiles(root)) {
|
|
28
|
+
const stat = fs.statSync(filePath);
|
|
29
|
+
const text = fs.readFileSync(filePath, "utf8");
|
|
30
|
+
records.push(parsePageRecord(filePath, text, stat, { root }));
|
|
31
|
+
}
|
|
32
|
+
assertUniqueRecordIds(records);
|
|
33
|
+
records.sort((a, b) => a.name.localeCompare(b.name));
|
|
34
|
+
return records;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function readGraphManifest(root = DEFAULT_LOGSEQ_ROOT) {
|
|
38
|
+
if (!root) throw new Error("LOGSEQ_ROOT or --root is required. Point it at a Logseq graph folder containing pages/.");
|
|
39
|
+
const files = [];
|
|
40
|
+
for (const filePath of readLogseqMarkdownFiles(root)) {
|
|
41
|
+
const stat = fs.statSync(filePath);
|
|
42
|
+
files.push({
|
|
43
|
+
name: path.relative(root, filePath),
|
|
44
|
+
size: stat.size,
|
|
45
|
+
mtimeMs: Math.round(stat.mtimeMs),
|
|
46
|
+
contentHash: fileContentHash(filePath)
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
files.sort((a, b) => a.name.localeCompare(b.name));
|
|
50
|
+
const fingerprint = crypto.createHash("sha256").update(JSON.stringify(files)).digest("hex");
|
|
51
|
+
return {
|
|
52
|
+
pages: files.length,
|
|
53
|
+
graphId: graphIdentity(root),
|
|
54
|
+
fingerprint,
|
|
55
|
+
maxMtimeMs: Math.max(0, ...files.map((file) => file.mtimeMs))
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function readLogseqMarkdownFiles(root = DEFAULT_LOGSEQ_ROOT) {
|
|
60
|
+
if (!root) throw new Error("LOGSEQ_ROOT or --root is required. Point it at a Logseq graph folder containing pages/.");
|
|
61
|
+
const pagesDir = path.join(root, "pages");
|
|
62
|
+
if (!fs.existsSync(pagesDir)) {
|
|
63
|
+
throw new Error("Logseq pages directory not found under the configured graph root.");
|
|
64
|
+
}
|
|
65
|
+
const files = [];
|
|
66
|
+
for (const sourceDir of LOGSEQ_SOURCE_DIRS) {
|
|
67
|
+
const directory = path.join(root, sourceDir);
|
|
68
|
+
if (!fs.existsSync(directory)) continue;
|
|
69
|
+
collectMarkdownFiles(directory, files, root);
|
|
70
|
+
}
|
|
71
|
+
const maxFiles = positiveInteger(process.env.LIVING_ATLAS_MAX_FILES, DEFAULT_MAX_MARKDOWN_FILES);
|
|
72
|
+
if (files.length > maxFiles) {
|
|
73
|
+
throw new Error(`Logseq graph has ${files.length} markdown files, above LIVING_ATLAS_MAX_FILES=${maxFiles}.`);
|
|
74
|
+
}
|
|
75
|
+
files.sort((a, b) => path.relative(root, a).localeCompare(path.relative(root, b)));
|
|
76
|
+
return files;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function collectMarkdownFiles(directory, files, root) {
|
|
80
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
81
|
+
const filePath = path.join(directory, entry.name);
|
|
82
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
83
|
+
if (entry.isDirectory()) {
|
|
84
|
+
collectMarkdownFiles(filePath, files, root);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
88
|
+
const maxBytes = positiveInteger(process.env.LIVING_ATLAS_MAX_FILE_BYTES, DEFAULT_MAX_MARKDOWN_FILE_BYTES);
|
|
89
|
+
const stat = fs.statSync(filePath);
|
|
90
|
+
if (stat.size > maxBytes) {
|
|
91
|
+
throw new Error(`Logseq markdown file is too large for indexing: ${path.relative(root, filePath)}. Set LIVING_ATLAS_MAX_FILE_BYTES to override or use --debug-paths for full diagnostics.`);
|
|
92
|
+
}
|
|
93
|
+
files.push(filePath);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function assertUniqueRecordIds(records) {
|
|
99
|
+
const seen = new Map();
|
|
100
|
+
const duplicates = [];
|
|
101
|
+
for (const record of records) {
|
|
102
|
+
const previous = seen.get(record.id);
|
|
103
|
+
if (previous) duplicates.push(`${previous.name} <-> ${record.name}`);
|
|
104
|
+
seen.set(record.id, record);
|
|
105
|
+
}
|
|
106
|
+
if (duplicates.length) {
|
|
107
|
+
throw new Error(`Duplicate Logseq page identities found: ${duplicates.slice(0, 5).join(", ")}. Rename or merge duplicate namespace pages before indexing.`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function positiveInteger(value, fallback) {
|
|
112
|
+
const parsed = Number(value || 0);
|
|
113
|
+
if (Number.isFinite(parsed) && parsed > 0) return Math.floor(parsed);
|
|
114
|
+
return fallback;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function graphIdentity(root) {
|
|
118
|
+
const realRoot = fs.realpathSync(root);
|
|
119
|
+
const stat = fs.statSync(realRoot);
|
|
120
|
+
return crypto
|
|
121
|
+
.createHmac("sha256", installSecret())
|
|
122
|
+
.update(`${realRoot}:${stat.dev}:${stat.ino}`)
|
|
123
|
+
.digest("hex")
|
|
124
|
+
.slice(0, 32);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function installSecret() {
|
|
128
|
+
const secretPath = path.join(defaultCacheDirectory(), "install-secret");
|
|
129
|
+
if (fs.existsSync(secretPath)) return fs.readFileSync(secretPath, "utf8").trim();
|
|
130
|
+
fs.mkdirSync(path.dirname(secretPath), { recursive: true, mode: 0o700 });
|
|
131
|
+
const installKey = crypto.randomBytes(32).toString("hex");
|
|
132
|
+
fs.writeFileSync(secretPath, installKey, { mode: 0o600 });
|
|
133
|
+
return installKey;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function defaultCacheDirectory() {
|
|
137
|
+
if (process.env.XDG_CACHE_HOME) return path.join(process.env.XDG_CACHE_HOME, "logseq-graph-living-atlas");
|
|
138
|
+
if (process.platform === "darwin") return path.join(os.homedir(), "Library", "Caches", "logseq-graph-living-atlas");
|
|
139
|
+
if (process.platform === "win32") {
|
|
140
|
+
return path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"), "logseq-graph-living-atlas");
|
|
141
|
+
}
|
|
142
|
+
return path.join(os.homedir(), ".cache", "logseq-graph-living-atlas");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function fileContentHash(filePath) {
|
|
146
|
+
return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
|
|
147
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
const SENSITIVE_KEYS = new Set([
|
|
2
|
+
"id",
|
|
3
|
+
"name",
|
|
4
|
+
"label",
|
|
5
|
+
"title",
|
|
6
|
+
"detail",
|
|
7
|
+
"target",
|
|
8
|
+
"source",
|
|
9
|
+
"cluster",
|
|
10
|
+
"clusterLabel",
|
|
11
|
+
"nodeId",
|
|
12
|
+
"nodeIds",
|
|
13
|
+
"graphId",
|
|
14
|
+
"fingerprint",
|
|
15
|
+
"nodeName",
|
|
16
|
+
"sourceId",
|
|
17
|
+
"targetId",
|
|
18
|
+
"linkId",
|
|
19
|
+
"tags",
|
|
20
|
+
"relativePath",
|
|
21
|
+
"preview",
|
|
22
|
+
"query",
|
|
23
|
+
"evidence",
|
|
24
|
+
"rationale",
|
|
25
|
+
"nextStep"
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
const PRESERVED_KEYS = new Set([
|
|
29
|
+
"type",
|
|
30
|
+
"kind",
|
|
31
|
+
"severity",
|
|
32
|
+
"status",
|
|
33
|
+
"confidence",
|
|
34
|
+
"updatedAt",
|
|
35
|
+
"generatedAt",
|
|
36
|
+
"observedAt",
|
|
37
|
+
"actor",
|
|
38
|
+
"reason",
|
|
39
|
+
"ok",
|
|
40
|
+
"version"
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
export function redactPayload(payload) {
|
|
44
|
+
const labels = new Map();
|
|
45
|
+
let next = 0;
|
|
46
|
+
const labelFor = (value, parentKey = "") => {
|
|
47
|
+
if (!labels.has(value)) {
|
|
48
|
+
next += 1;
|
|
49
|
+
const prefix = parentKey.toLowerCase().includes("path") ? "path" : "entity";
|
|
50
|
+
labels.set(value, `${prefix}-${String(next).padStart(4, "0")}`);
|
|
51
|
+
}
|
|
52
|
+
return labels.get(value);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const visit = (value, parentKey = "") => {
|
|
56
|
+
if (Array.isArray(value)) return value.map((item) => visit(item, parentKey));
|
|
57
|
+
if (!value || typeof value !== "object") {
|
|
58
|
+
if (typeof value === "string" && SENSITIVE_KEYS.has(parentKey)) return labelFor(value, parentKey);
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
const output = {};
|
|
62
|
+
for (const [key, item] of Object.entries(value)) {
|
|
63
|
+
if (PRESERVED_KEYS.has(key)) {
|
|
64
|
+
output[key] = item;
|
|
65
|
+
} else if (SENSITIVE_KEYS.has(key)) {
|
|
66
|
+
output[key] = typeof item === "string" ? labelFor(item, key) : visit(item, key);
|
|
67
|
+
} else if (key === "properties" || key === "provenance" || key === "action") {
|
|
68
|
+
output[key] = redactPropertyBag(item, labelFor);
|
|
69
|
+
} else {
|
|
70
|
+
output[key] = visit(item, key);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return output;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
return visit(payload);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function redactPropertyBag(value, labelFor) {
|
|
80
|
+
if (Array.isArray(value)) return value.map((item) => redactPropertyBag(item, labelFor));
|
|
81
|
+
if (!value || typeof value !== "object") {
|
|
82
|
+
return typeof value === "string" ? labelFor(value) : value;
|
|
83
|
+
}
|
|
84
|
+
const output = {};
|
|
85
|
+
for (const [key, item] of Object.entries(value)) {
|
|
86
|
+
output[key] = redactPropertyBag(item, labelFor);
|
|
87
|
+
}
|
|
88
|
+
return output;
|
|
89
|
+
}
|