@series-a/cli 0.1.0 → 0.6.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/CHANGELOG.md +11 -0
- package/LICENSE +3 -2
- package/README.md +17 -2
- package/package.json +4 -3
- package/src/api.mjs +2 -1
- package/src/commands/insights.mjs +1034 -0
- package/src/commands/knowledge-sync.mjs +519 -0
- package/src/commands/resources.mjs +1222 -23
- package/src/index.mjs +38 -3
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
// AdvisorPro → Obsidian vault: ONE-WAY PUSH (pull into the vault).
|
|
2
|
+
//
|
|
3
|
+
// One vault per workspace. The platform is the source of truth; the vault is a
|
|
4
|
+
// read-only mirror of the workspace Knowledge Base and its knowledge graph:
|
|
5
|
+
// plain Markdown notes with YAML frontmatter and [[wikilinks]].
|
|
6
|
+
//
|
|
7
|
+
// Nothing written inside Obsidian ever flows back to AdvisorPro. Local edits are
|
|
8
|
+
// overwritten on the next pull unless --preserve-local is passed, in which case
|
|
9
|
+
// the fresh remote copy lands beside the note as <slug>.remote.md.
|
|
10
|
+
//
|
|
11
|
+
// Sync state lives in <vault>/.advisoros/state.json.
|
|
12
|
+
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
15
|
+
import { existsSync } from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { call } from "../api.mjs";
|
|
18
|
+
import { emit, fail, ctxFrom, withCommonOpts, table, truncate } from "../ui.mjs";
|
|
19
|
+
|
|
20
|
+
const STATE_DIR = ".advisoros";
|
|
21
|
+
const STATE_FILE = "state.json";
|
|
22
|
+
const TRASH_DIR = "trash";
|
|
23
|
+
const ENTITY_DIR = "Entities";
|
|
24
|
+
|
|
25
|
+
/* ------------------------------------------------------------------ utils */
|
|
26
|
+
|
|
27
|
+
const sha256 = (s) => createHash("sha256").update(s ?? "", "utf8").digest("hex");
|
|
28
|
+
|
|
29
|
+
function slugify(s) {
|
|
30
|
+
return String(s || "untitled")
|
|
31
|
+
.normalize("NFKD")
|
|
32
|
+
.replace(/[^\w\s-]/g, "")
|
|
33
|
+
.trim()
|
|
34
|
+
.replace(/\s+/g, "-")
|
|
35
|
+
.slice(0, 80)
|
|
36
|
+
.toLowerCase() || "untitled";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function titleCase(s) {
|
|
40
|
+
return String(s || "General")
|
|
41
|
+
.split(/[-_\s]+/)
|
|
42
|
+
.filter(Boolean)
|
|
43
|
+
.map((w) => w[0].toUpperCase() + w.slice(1))
|
|
44
|
+
.join(" ");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/* ------------------------------------------------- frontmatter (tiny YAML) */
|
|
48
|
+
|
|
49
|
+
function parseFrontmatter(text) {
|
|
50
|
+
if (!text.startsWith("---")) return { data: {}, body: text };
|
|
51
|
+
const end = text.indexOf("\n---", 3);
|
|
52
|
+
if (end === -1) return { data: {}, body: text };
|
|
53
|
+
const raw = text.slice(text.indexOf("\n") + 1, end);
|
|
54
|
+
const body = text.slice(end + 4).replace(/^\r?\n/, "");
|
|
55
|
+
const data = {};
|
|
56
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
57
|
+
const m = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/);
|
|
58
|
+
if (!m) continue;
|
|
59
|
+
let [, key, value] = m;
|
|
60
|
+
value = value.trim();
|
|
61
|
+
if (value.startsWith("[") && value.endsWith("]")) {
|
|
62
|
+
data[key] = value.slice(1, -1).split(",").map((v) => v.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
|
|
63
|
+
} else {
|
|
64
|
+
data[key] = value.replace(/^["']|["']$/g, "");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return { data, body };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function serializeNote(data, body) {
|
|
71
|
+
const lines = ["---"];
|
|
72
|
+
for (const [k, v] of Object.entries(data)) {
|
|
73
|
+
if (v === undefined || v === null || v === "") continue;
|
|
74
|
+
if (Array.isArray(v)) lines.push(`${k}: [${v.map((x) => String(x)).join(", ")}]`);
|
|
75
|
+
else lines.push(`${k}: ${String(v).replace(/\n/g, " ")}`);
|
|
76
|
+
}
|
|
77
|
+
lines.push("---", "");
|
|
78
|
+
return lines.join("\n") + (body ?? "").replace(/^\n+/, "") + "\n";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* -------------------------------------------------------------- rendering */
|
|
82
|
+
|
|
83
|
+
function linkSection(heading, titles) {
|
|
84
|
+
if (!titles?.length) return "";
|
|
85
|
+
return ["", `## ${heading}`, ...titles.map((t) => `- [[${t}]]`), ""].join("\n");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function remoteToNote(item, titleById, entityTitlesByItem) {
|
|
89
|
+
const bodyRaw = item.extracted_text ?? item.summary ?? "";
|
|
90
|
+
const links = Array.isArray(item.metadata?.links) ? item.metadata.links : [];
|
|
91
|
+
const linkedTitles = links
|
|
92
|
+
.map((l) => titleById.get(l.id) ?? l.title ?? l.id)
|
|
93
|
+
.filter(Boolean);
|
|
94
|
+
const entityTitles = entityTitlesByItem.get(item.id) ?? [];
|
|
95
|
+
|
|
96
|
+
let body = bodyRaw;
|
|
97
|
+
if (linkedTitles.length && !/\[\[[^\]]+\]\]/.test(bodyRaw ?? "")) {
|
|
98
|
+
body += "\n" + linkSection("Linked", linkedTitles);
|
|
99
|
+
}
|
|
100
|
+
if (entityTitles.length) body += "\n" + linkSection("Entities", entityTitles);
|
|
101
|
+
|
|
102
|
+
const rel = path.join(titleCase(item.category || "General"), `${slugify(item.name)}.md`);
|
|
103
|
+
const fm = {
|
|
104
|
+
advisoros_id: item.id,
|
|
105
|
+
workspace: item.workspace_id,
|
|
106
|
+
title: item.name,
|
|
107
|
+
category: item.category || "general",
|
|
108
|
+
tags: item.tags ?? [],
|
|
109
|
+
source_url: item.content_url ?? "",
|
|
110
|
+
updated_at: item.updated_at,
|
|
111
|
+
content_hash: sha256(body.trim()),
|
|
112
|
+
source: "advisorpro (read-only mirror)",
|
|
113
|
+
};
|
|
114
|
+
return { rel, text: serializeNote(fm, body), hash: sha256(body.trim()) };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function entityToNote(entity, ctxIndex) {
|
|
118
|
+
const type = titleCase(entity.entity_type || "Concept");
|
|
119
|
+
const rel = path.join(ENTITY_DIR, type, `${slugify(entity.name)}.md`);
|
|
120
|
+
|
|
121
|
+
const lines = [];
|
|
122
|
+
if (entity.description) lines.push(entity.description.trim(), "");
|
|
123
|
+
|
|
124
|
+
const rels = ctxIndex.edgesByEntity.get(entity.id) ?? [];
|
|
125
|
+
if (rels.length) {
|
|
126
|
+
lines.push("## Relationships");
|
|
127
|
+
for (const r of rels) {
|
|
128
|
+
const other = ctxIndex.entityNameById.get(r.otherId);
|
|
129
|
+
if (!other) continue;
|
|
130
|
+
lines.push(`- ${r.direction === "out" ? "" : "← "}${r.edge_type} → [[${other}]]`);
|
|
131
|
+
}
|
|
132
|
+
lines.push("");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const docs = ctxIndex.itemTitlesByEntity.get(entity.id) ?? [];
|
|
136
|
+
if (docs.length) {
|
|
137
|
+
lines.push("## Mentioned in");
|
|
138
|
+
for (const d of docs) lines.push(`- [[${d}]]`);
|
|
139
|
+
lines.push("");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const body = lines.join("\n");
|
|
143
|
+
const fm = {
|
|
144
|
+
advisoros_entity_id: entity.id,
|
|
145
|
+
workspace: entity.workspace_id ?? ctxIndex.workspaceId,
|
|
146
|
+
title: entity.name,
|
|
147
|
+
type: entity.entity_type || "concept",
|
|
148
|
+
aliases: Array.isArray(entity.aliases) ? entity.aliases : [],
|
|
149
|
+
mention_count: entity.mention_count ?? 0,
|
|
150
|
+
updated_at: entity.updated_at,
|
|
151
|
+
content_hash: sha256(body.trim()),
|
|
152
|
+
source: "advisorpro (read-only mirror)",
|
|
153
|
+
};
|
|
154
|
+
return { rel, text: serializeNote(fm, body), hash: sha256(body.trim()) };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/* ------------------------------------------------------------------ state */
|
|
158
|
+
|
|
159
|
+
const statePath = (vault) => path.join(vault, STATE_DIR, STATE_FILE);
|
|
160
|
+
|
|
161
|
+
async function readState(vault) {
|
|
162
|
+
try {
|
|
163
|
+
return JSON.parse(await readFile(statePath(vault), "utf8"));
|
|
164
|
+
} catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function writeState(vault, state) {
|
|
170
|
+
await mkdir(path.join(vault, STATE_DIR), { recursive: true });
|
|
171
|
+
await writeFile(statePath(vault), JSON.stringify(state, null, 2) + "\n", "utf8");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function requireLinkedVault(vaultArg, cmdWorkspace) {
|
|
175
|
+
const vault = path.resolve(vaultArg || ".");
|
|
176
|
+
if (!existsSync(vault)) throw new Error(`Vault not found: ${vault}`);
|
|
177
|
+
const state = await readState(vault);
|
|
178
|
+
if (!state) {
|
|
179
|
+
throw new Error(`Vault is not linked. Run: advisoros kb link "${vault}" --workspace <id>`);
|
|
180
|
+
}
|
|
181
|
+
if (cmdWorkspace && state.workspace_id !== cmdWorkspace) {
|
|
182
|
+
throw new Error(
|
|
183
|
+
`Vault is linked to workspace ${state.workspace_id}, but --workspace ${cmdWorkspace} was given. Refusing to sync.`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
return { vault, state };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/* ------------------------------------------------------------ vault scans */
|
|
190
|
+
|
|
191
|
+
async function walkMarkdown(dir, base = dir, acc = []) {
|
|
192
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
193
|
+
for (const e of entries) {
|
|
194
|
+
if (e.name.startsWith(".")) continue;
|
|
195
|
+
const full = path.join(dir, e.name);
|
|
196
|
+
if (e.isDirectory()) await walkMarkdown(full, base, acc);
|
|
197
|
+
else if (e.isFile() && e.name.endsWith(".md") && !e.name.endsWith(".remote.md")) {
|
|
198
|
+
acc.push(path.relative(base, full));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return acc;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function localHash(vault, rel) {
|
|
205
|
+
try {
|
|
206
|
+
const text = await readFile(path.join(vault, rel), "utf8");
|
|
207
|
+
const { body } = parseFrontmatter(text);
|
|
208
|
+
return sha256(body.trim());
|
|
209
|
+
} catch {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function trash(vault, rel, report) {
|
|
215
|
+
const src = path.join(vault, rel);
|
|
216
|
+
if (!existsSync(src)) return;
|
|
217
|
+
const dest = path.join(vault, STATE_DIR, TRASH_DIR, rel.replace(/[\\/]/g, "__"));
|
|
218
|
+
await mkdir(path.dirname(dest), { recursive: true });
|
|
219
|
+
await writeFile(dest, await readFile(src, "utf8"), "utf8");
|
|
220
|
+
await rm(src);
|
|
221
|
+
report.trashed.push(rel);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/* ------------------------------------------------------------------- pull */
|
|
225
|
+
|
|
226
|
+
async function fetchGraph(ctx) {
|
|
227
|
+
const entities = [];
|
|
228
|
+
let edges = [];
|
|
229
|
+
let mentions = [];
|
|
230
|
+
let offset = 0;
|
|
231
|
+
for (;;) {
|
|
232
|
+
const page = await call("knowledge", "graph_sync_list", { limit: 1000, offset }, ctx);
|
|
233
|
+
entities.push(...(page.entities ?? []));
|
|
234
|
+
if (offset === 0) {
|
|
235
|
+
edges = page.edges ?? [];
|
|
236
|
+
mentions = page.mentions ?? [];
|
|
237
|
+
}
|
|
238
|
+
if (!page.next_offset) break;
|
|
239
|
+
offset = page.next_offset;
|
|
240
|
+
}
|
|
241
|
+
return { entities, edges, mentions };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function runPull(vault, state, ctx, opts) {
|
|
245
|
+
const report = {
|
|
246
|
+
workspace_id: state.workspace_id,
|
|
247
|
+
vault,
|
|
248
|
+
pulled: [], entities: [], preserved: [], unchanged: 0, trashed: [], skipped: [],
|
|
249
|
+
dry_run: !!opts.dryRun,
|
|
250
|
+
direction: "advisorpro → obsidian (one-way)",
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
// ---- fetch remote knowledge items --------------------------------------
|
|
254
|
+
const remote = [];
|
|
255
|
+
let offset = 0;
|
|
256
|
+
for (;;) {
|
|
257
|
+
const page = await call("knowledge", "sync_list", { limit: 500, offset }, ctx);
|
|
258
|
+
remote.push(...(page.items ?? []));
|
|
259
|
+
if (!page.next_offset) break;
|
|
260
|
+
offset = page.next_offset;
|
|
261
|
+
}
|
|
262
|
+
const titleById = new Map(remote.map((r) => [r.id, r.name]));
|
|
263
|
+
|
|
264
|
+
// ---- fetch the graph (best effort: an older backend may not expose it) --
|
|
265
|
+
let graph = { entities: [], edges: [], mentions: [] };
|
|
266
|
+
if (opts.graph !== false) {
|
|
267
|
+
try {
|
|
268
|
+
graph = await fetchGraph(ctx);
|
|
269
|
+
} catch (e) {
|
|
270
|
+
report.skipped.push({ path: "Entities/", reason: `graph unavailable — ${e.message}` });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const entityNameById = new Map(graph.entities.map((e) => [e.id, e.name]));
|
|
275
|
+
const entityTitlesByItem = new Map();
|
|
276
|
+
const itemTitlesByEntity = new Map();
|
|
277
|
+
for (const m of graph.mentions) {
|
|
278
|
+
const name = entityNameById.get(m.entity_id);
|
|
279
|
+
const docTitle = titleById.get(m.item_id);
|
|
280
|
+
if (name) {
|
|
281
|
+
const arr = entityTitlesByItem.get(m.item_id) ?? [];
|
|
282
|
+
if (!arr.includes(name)) arr.push(name);
|
|
283
|
+
entityTitlesByItem.set(m.item_id, arr);
|
|
284
|
+
}
|
|
285
|
+
if (docTitle) {
|
|
286
|
+
const arr = itemTitlesByEntity.get(m.entity_id) ?? [];
|
|
287
|
+
if (!arr.includes(docTitle)) arr.push(docTitle);
|
|
288
|
+
itemTitlesByEntity.set(m.entity_id, arr);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const edgesByEntity = new Map();
|
|
292
|
+
const addEdge = (id, rec) => {
|
|
293
|
+
const arr = edgesByEntity.get(id) ?? [];
|
|
294
|
+
arr.push(rec);
|
|
295
|
+
edgesByEntity.set(id, arr);
|
|
296
|
+
};
|
|
297
|
+
for (const e of graph.edges) {
|
|
298
|
+
if (e.source_entity_id && e.target_entity_id) {
|
|
299
|
+
addEdge(e.source_entity_id, { edge_type: e.edge_type, otherId: e.target_entity_id, direction: "out" });
|
|
300
|
+
addEdge(e.target_entity_id, { edge_type: e.edge_type, otherId: e.source_entity_id, direction: "in" });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const ctxIndex = {
|
|
304
|
+
entityNameById, itemTitlesByEntity, edgesByEntity, workspaceId: state.workspace_id,
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
// ---- writer ------------------------------------------------------------
|
|
308
|
+
const tracked = state.notes ?? {};
|
|
309
|
+
const trackedEntities = state.entities ?? {};
|
|
310
|
+
|
|
311
|
+
const writeMirror = async (key, store, rendered, remoteUpdatedAt) => {
|
|
312
|
+
const known = store[key];
|
|
313
|
+
const currentPath = known?.path ?? rendered.rel;
|
|
314
|
+
const onDisk = await localHash(vault, currentPath);
|
|
315
|
+
|
|
316
|
+
if (onDisk && known && onDisk === known.hash && known.hash === rendered.hash &&
|
|
317
|
+
known.updated_at === remoteUpdatedAt && currentPath === rendered.rel) {
|
|
318
|
+
report.unchanged++;
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const locallyModified = !!onDisk && !!known && onDisk !== known.hash;
|
|
323
|
+
let target = rendered.rel;
|
|
324
|
+
if (locallyModified && opts.preserveLocal) {
|
|
325
|
+
target = rendered.rel.replace(/\.md$/, ".remote.md");
|
|
326
|
+
report.preserved.push(currentPath);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (!opts.dryRun) {
|
|
330
|
+
// The note moved (renamed / recategorised) → drop the stale copy.
|
|
331
|
+
if (known?.path && known.path !== target && existsSync(path.join(vault, known.path)) &&
|
|
332
|
+
!(locallyModified && opts.preserveLocal)) {
|
|
333
|
+
await rm(path.join(vault, known.path));
|
|
334
|
+
}
|
|
335
|
+
const full = path.join(vault, target);
|
|
336
|
+
await mkdir(path.dirname(full), { recursive: true });
|
|
337
|
+
await writeFile(full, rendered.text, "utf8");
|
|
338
|
+
if (!(locallyModified && opts.preserveLocal)) {
|
|
339
|
+
store[key] = { path: target, hash: rendered.hash, updated_at: remoteUpdatedAt };
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return target;
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
// ---- knowledge items ---------------------------------------------------
|
|
346
|
+
const remoteIds = new Set();
|
|
347
|
+
for (const item of remote) {
|
|
348
|
+
if (item.is_active === false) {
|
|
349
|
+
const known = tracked[item.id];
|
|
350
|
+
if (known?.path && !opts.dryRun) await trash(vault, known.path, report);
|
|
351
|
+
else if (known?.path) report.trashed.push(known.path);
|
|
352
|
+
delete tracked[item.id];
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
remoteIds.add(item.id);
|
|
356
|
+
const rendered = remoteToNote(item, titleById, entityTitlesByItem);
|
|
357
|
+
const written = await writeMirror(item.id, tracked, rendered, item.updated_at);
|
|
358
|
+
if (written) report.pulled.push(written);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Items that vanished from the workspace entirely.
|
|
362
|
+
for (const [id, meta] of Object.entries(tracked)) {
|
|
363
|
+
if (remoteIds.has(id)) continue;
|
|
364
|
+
if (!opts.dryRun) await trash(vault, meta.path, report);
|
|
365
|
+
else report.trashed.push(meta.path);
|
|
366
|
+
delete tracked[id];
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ---- entities ----------------------------------------------------------
|
|
370
|
+
const entityIds = new Set();
|
|
371
|
+
for (const entity of graph.entities) {
|
|
372
|
+
entityIds.add(entity.id);
|
|
373
|
+
const rendered = entityToNote(entity, ctxIndex);
|
|
374
|
+
const written = await writeMirror(entity.id, trackedEntities, rendered, entity.updated_at);
|
|
375
|
+
if (written) report.entities.push(written);
|
|
376
|
+
}
|
|
377
|
+
if (graph.entities.length || Object.keys(trackedEntities).length) {
|
|
378
|
+
for (const [id, meta] of Object.entries(trackedEntities)) {
|
|
379
|
+
if (entityIds.has(id)) continue;
|
|
380
|
+
if (!opts.dryRun) await trash(vault, meta.path, report);
|
|
381
|
+
else report.trashed.push(meta.path);
|
|
382
|
+
delete trackedEntities[id];
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (!opts.dryRun) {
|
|
387
|
+
state.notes = tracked;
|
|
388
|
+
state.entities = trackedEntities;
|
|
389
|
+
state.last_sync_at = new Date().toISOString();
|
|
390
|
+
state.direction = "pull";
|
|
391
|
+
await writeState(vault, state);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
report.summary = {
|
|
395
|
+
notes: report.pulled.length,
|
|
396
|
+
entities: report.entities.length,
|
|
397
|
+
unchanged: report.unchanged,
|
|
398
|
+
preserved: report.preserved.length,
|
|
399
|
+
trashed: report.trashed.length,
|
|
400
|
+
skipped: report.skipped.length,
|
|
401
|
+
};
|
|
402
|
+
return report;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function renderReport(r) {
|
|
406
|
+
const s = r.summary;
|
|
407
|
+
process.stdout.write(
|
|
408
|
+
`AdvisorPro → Obsidian (one-way mirror). Local edits are overwritten` +
|
|
409
|
+
` unless --preserve-local is used.\n\n`,
|
|
410
|
+
);
|
|
411
|
+
process.stdout.write(
|
|
412
|
+
table(
|
|
413
|
+
["Notes", "Entities", "Unchanged", "Preserved", "Trashed", "Skipped"],
|
|
414
|
+
[[s.notes, s.entities, s.unchanged, s.preserved, s.trashed, s.skipped]],
|
|
415
|
+
) + "\n",
|
|
416
|
+
);
|
|
417
|
+
if (r.dry_run) process.stdout.write("(dry run — nothing was written)\n");
|
|
418
|
+
if (r.preserved.length) {
|
|
419
|
+
process.stdout.write("\nLocally modified (remote copy written as *.remote.md):\n");
|
|
420
|
+
for (const p of r.preserved) process.stdout.write(` • ${p}\n`);
|
|
421
|
+
}
|
|
422
|
+
if (r.skipped.length) {
|
|
423
|
+
process.stdout.write("\nSkipped:\n");
|
|
424
|
+
for (const x of r.skipped) process.stdout.write(` • ${truncate(x.path, 60)} — ${x.reason}\n`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/* -------------------------------------------------------------- commands */
|
|
429
|
+
|
|
430
|
+
export function registerVaultSync(program) {
|
|
431
|
+
const kb = program.command("kb")
|
|
432
|
+
.description("Mirror the workspace Knowledge Base into an Obsidian vault (one-way)");
|
|
433
|
+
|
|
434
|
+
withCommonOpts(kb.command("link <vault>"))
|
|
435
|
+
.description("Link a local Obsidian vault to a workspace (one vault per workspace)")
|
|
436
|
+
.action(async (vaultArg, _o, cmd) => {
|
|
437
|
+
try {
|
|
438
|
+
const ctx = ctxFrom(cmd);
|
|
439
|
+
const vault = path.resolve(vaultArg);
|
|
440
|
+
await mkdir(vault, { recursive: true });
|
|
441
|
+
const existing = await readState(vault);
|
|
442
|
+
const me = await call("workspaces", "list", {}, ctx).catch(() => null);
|
|
443
|
+
const wsId = ctx.workspaceId ||
|
|
444
|
+
process.env.ADVISOROS_WORKSPACE_ID ||
|
|
445
|
+
existing?.workspace_id ||
|
|
446
|
+
me?.workspaces?.[0]?.id;
|
|
447
|
+
if (!wsId) throw new Error("Pass --workspace <id> to link this vault.");
|
|
448
|
+
if (existing && existing.workspace_id !== wsId) {
|
|
449
|
+
throw new Error(
|
|
450
|
+
`Vault already linked to ${existing.workspace_id}. Run \`advisoros kb unlink\` first.`,
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
const state = existing ??
|
|
454
|
+
{ workspace_id: wsId, notes: {}, entities: {}, created_at: new Date().toISOString() };
|
|
455
|
+
state.workspace_id = wsId;
|
|
456
|
+
state.entities = state.entities ?? {};
|
|
457
|
+
await writeState(vault, state);
|
|
458
|
+
emit(cmd, { vault, workspace_id: wsId }, (d) =>
|
|
459
|
+
process.stdout.write(
|
|
460
|
+
`✔ Vault linked: ${d.vault} → workspace ${d.workspace_id}\n` +
|
|
461
|
+
` This vault is a read-only mirror. Edits made in Obsidian are not sent back.\n`,
|
|
462
|
+
),
|
|
463
|
+
);
|
|
464
|
+
} catch (e) { fail(e); }
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
const pullOpts = (c) =>
|
|
468
|
+
withCommonOpts(c)
|
|
469
|
+
.option("--dry-run", "show what would change without writing anything")
|
|
470
|
+
.option("--preserve-local", "never overwrite a locally edited note; write <name>.remote.md instead")
|
|
471
|
+
.option("--no-graph", "skip the Entities/ graph mirror, notes only");
|
|
472
|
+
|
|
473
|
+
pullOpts(kb.command("pull [vault]"))
|
|
474
|
+
.description("Mirror the workspace Knowledge Base and graph into the vault (one-way)")
|
|
475
|
+
.action(async (vaultArg, o, cmd) => {
|
|
476
|
+
try {
|
|
477
|
+
const ctx = ctxFrom(cmd);
|
|
478
|
+
const { vault, state } = await requireLinkedVault(vaultArg, ctx.workspaceId);
|
|
479
|
+
const report = await runPull(vault, state, { ...ctx, workspaceId: state.workspace_id }, o);
|
|
480
|
+
emit(cmd, report, renderReport);
|
|
481
|
+
} catch (e) { fail(e); }
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
pullOpts(kb.command("sync [vault]"))
|
|
485
|
+
.description("Alias of `kb pull` (sync is one-way: AdvisorPro → Obsidian)")
|
|
486
|
+
.action(async (vaultArg, o, cmd) => {
|
|
487
|
+
try {
|
|
488
|
+
const ctx = ctxFrom(cmd);
|
|
489
|
+
const { vault, state } = await requireLinkedVault(vaultArg, ctx.workspaceId);
|
|
490
|
+
const report = await runPull(vault, state, { ...ctx, workspaceId: state.workspace_id }, o);
|
|
491
|
+
emit(cmd, report, renderReport);
|
|
492
|
+
} catch (e) { fail(e); }
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
withCommonOpts(kb.command("status [vault]"))
|
|
496
|
+
.description("Preview what the next pull would change (same as pull --dry-run)")
|
|
497
|
+
.action(async (vaultArg, _o, cmd) => {
|
|
498
|
+
try {
|
|
499
|
+
const ctx = ctxFrom(cmd);
|
|
500
|
+
const { vault, state } = await requireLinkedVault(vaultArg, ctx.workspaceId);
|
|
501
|
+
const report = await runPull(
|
|
502
|
+
vault, state, { ...ctx, workspaceId: state.workspace_id }, { dryRun: true },
|
|
503
|
+
);
|
|
504
|
+
emit(cmd, report, renderReport);
|
|
505
|
+
} catch (e) { fail(e); }
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
kb.command("unlink [vault]")
|
|
509
|
+
.description("Remove the sync state from a vault (notes are left untouched)")
|
|
510
|
+
.action(async (vaultArg) => {
|
|
511
|
+
try {
|
|
512
|
+
const vault = path.resolve(vaultArg || ".");
|
|
513
|
+
const p = statePath(vault);
|
|
514
|
+
if (!existsSync(p)) throw new Error("Vault is not linked.");
|
|
515
|
+
await rm(p);
|
|
516
|
+
process.stdout.write(`✔ Unlinked ${vault}\n`);
|
|
517
|
+
} catch (e) { fail(e); }
|
|
518
|
+
});
|
|
519
|
+
}
|