@geml/logseq-sync 2.0.5 → 2.0.6

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 CHANGED
@@ -20,7 +20,8 @@ Two settings, and only the first one usually needs touching:
20
20
  - đŸ“Ļ **A plain-text copy that stays yours** — every page a readable file, not a
21
21
  database dump, in a folder you chose
22
22
  - 🔁 **Continuous, not one-shot** — edit in Logseq, and seconds later the file
23
- on disk has caught up
23
+ on disk has caught up; with `--two-way`, edit the file and the graph
24
+ catches up the same way
24
25
  - â†Šī¸ **A way back** — `logseq-sync restore` imports the vault into a graph,
25
26
  merging by block uuid. Files you can read are worth more when they are also
26
27
  files you can return
@@ -150,6 +151,7 @@ found and what is missing, and exits non-zero when the setup cannot sync:
150
151
  | `--once` | sync once and exit, instead of watching |
151
152
  | `--git-commit` | commit, creating the vault repository if there is none |
152
153
  | `--no-git-commit` | never touch git |
154
+ | `--two-way` | also import vault edits back, every cycle — conflicts held, deletions never imported (needs the app CLI) |
153
155
  | `--mirror` | delete vault files for pages removed from the graph |
154
156
  | `--markdown <dir>` | also write a lossy Markdown copy there, for other tools |
155
157
  | `--interval <seconds>` | heartbeat between signals (default 10) |
@@ -199,11 +201,58 @@ reads them back from. *Debounce (seconds)* — quiet
199
201
  period after the last change before the watcher is signalled (default 5; syncs
200
202
  feed git commits, so this is deliberately calmer than UI-style debounce).
201
203
 
204
+ ### Editing the vault from outside
205
+
206
+ The vault is ordinary text, and that is the point: agents, scripts and plain
207
+ `sed` all work on it, and none of them needs to know Logseq exists. With
208
+ `--two-way` running, an edit imports on the next cycle; without it, run
209
+ `logseq-sync restore` when you are ready.
210
+
211
+ **An agent (Claude, or anything speaking MCP)** gets addressed, validated
212
+ block edits from the [`geml` MCP server](https://github.com/geml-spec/geml):
213
+
214
+ ```sh
215
+ npm i -g @geml/geml
216
+ geml mcp --root <your-vault-dir> --no-history
217
+ ```
218
+
219
+ `--no-history` matters here: git is this vault's history, and without the flag
220
+ every MCP write also saves a `.gemlhistory` sidecar revision beside the file.
221
+ (If you want those too, drop the flag — the sync ignores sidecars either way
222
+ and never commits them.)
223
+
224
+ **A one-liner** reads or edits one block by its address — every block carries
225
+ its uuid:
226
+
227
+ ```sh
228
+ geml find "that phrase" <vault-dir> # → pages/foo.geml #<uuid>
229
+ geml get <vault-dir>/pages/foo.geml '#<uuid>'
230
+ printf 'new text' | geml set <vault-dir>/pages/foo.geml '#<uuid>' --in - -o <same-file>
231
+ ```
232
+
233
+ **Bulk refactoring** is whatever your shell already does — the result is
234
+ re-imported by uuid, so identity survives the edit:
235
+
236
+ ```sh
237
+ grep -rl "old-tag" <vault-dir>/pages | xargs sed -i 's/old-tag/new-tag/g'
238
+ logseq-sync restore <vault-dir> --yes # or let --two-way pick it up
239
+ ```
240
+
241
+ A `geml check <file>` after a bulk edit is cheap insurance: it names a mangled
242
+ block before the import carries it into the graph, while the diff is still in
243
+ front of you.
244
+
202
245
  ## Honesty corner
203
246
 
204
- - The **continuous** direction is graph → files. Going back is a deliberate
205
- command (`restore`), not a background loop — two live writers over one graph
206
- is a merge problem this does not pretend to have solved.
247
+ - The **default** continuous direction is graph → files; going back is a
248
+ deliberate command (`restore`). `--two-way` makes the return trip continuous
249
+ too — every cycle imports what changed in the vault — under three rules that
250
+ say what it does NOT pretend to solve: a file changed on **both** sides
251
+ since the last sync is a conflict, held exactly as you left it (not
252
+ imported, not overwritten, named in the toolbar status until you merge it);
253
+ deletions are **never** imported; and a graph backup is taken before the
254
+ first import and every tenth after. The sync tells its own writes from
255
+ yours by content hash, so nothing echoes.
207
256
  - Files the sync did not write are never touched: a manifest tracks what it
208
257
  owns, and `--mirror` only ever removes files from that list.
209
258
  - **The app's lock is the thing to know about.** A running Logseq holds
@@ -14,11 +14,83 @@ import {
14
14
  } from "node:fs";
15
15
  import { join, dirname, relative, resolve, sep, isAbsolute } from "node:path";
16
16
  import { execFileSync } from "node:child_process";
17
- import { randomUUID } from "node:crypto";
17
+ import { randomUUID, createHash } from "node:crypto";
18
18
  import { ednToGemlFiles, gemlFilesToEdn } from "./mapping.mjs";
19
19
 
20
20
  const MANIFEST_FILE = ".geml-manifest.json";
21
21
 
22
+ const sha256 = (s) => createHash("sha256").update(s).digest("hex");
23
+
24
+ /**
25
+ * Read the sync manifest in either of its two shapes.
26
+ * v1 was a sorted array of paths — enough to know which files the sync owns.
27
+ * v2 ({ version: 2, files: { rel: sha256 } }) also records the content the
28
+ * sync last wrote or saw, which is what lets two-way sync tell an external
29
+ * edit from its own echo: a file whose hash matches the manifest is the
30
+ * watcher's own last write, not something a person or agent changed.
31
+ * @returns {{ known: boolean, hashed: boolean, files: Map<string, string|null> }}
32
+ */
33
+ function readManifest(targetDir) {
34
+ const manifestPath = join(targetDir, MANIFEST_FILE);
35
+ if (!existsSync(manifestPath)) return { known: false, hashed: false, files: new Map() };
36
+ try {
37
+ const parsed = JSON.parse(readFileSync(manifestPath, "utf8"));
38
+ if (Array.isArray(parsed)) {
39
+ return { known: true, hashed: false, files: new Map(parsed.map((p) => [p, null])) };
40
+ }
41
+ if (parsed && parsed.version === 2 && parsed.files && typeof parsed.files === "object") {
42
+ return { known: true, hashed: true, files: new Map(Object.entries(parsed.files)) };
43
+ }
44
+ } catch {}
45
+ return { known: false, hashed: false, files: new Map() };
46
+ }
47
+
48
+ /**
49
+ * What changed in the vault since the sync last touched it — the read side of
50
+ * the two-way bridge. Baselines come from the v2 manifest hashes; a v1
51
+ * manifest (or none) knows which files exist but not what they held, so it
52
+ * reports nothing rather than guessing: `baselineKnown: false` means "sync
53
+ * once first".
54
+ *
55
+ * With `graphFiles` (the current export, as ednToGemlFiles returns it) the
56
+ * vault-modified files are split further: one the GRAPH also moved since the
57
+ * last sync is a `conflict` — importing it would clobber the graph's edit,
58
+ * exporting over it would clobber the person's, so two-way sync does neither
59
+ * and a person merges.
60
+ * @param {string} targetDir
61
+ * @param {{ graphFiles?: Map<string, string> }} [opts]
62
+ * @returns {{ baselineKnown: boolean, modified: string[], added: string[], missing: string[], conflicts: string[] }}
63
+ */
64
+ export function detectExternalEdits(targetDir, opts = {}) {
65
+ const manifest = readManifest(targetDir);
66
+ const onDisk = readGemlFilesFromDisk(targetDir);
67
+ const modified = [];
68
+ const added = [];
69
+ const missing = [];
70
+ const conflicts = [];
71
+ if (!manifest.hashed) return { baselineKnown: false, modified, added, missing, conflicts };
72
+ for (const [rel, hash] of manifest.files) {
73
+ const content = onDisk.get(rel);
74
+ if (content === undefined) missing.push(rel);
75
+ else if (hash !== null && sha256(content) !== hash) {
76
+ const graphContent = opts.graphFiles?.get(rel);
77
+ const graphMoved =
78
+ graphContent !== undefined && sha256(normalizeEol(graphContent)) !== hash;
79
+ (graphMoved ? conflicts : modified).push(rel);
80
+ }
81
+ }
82
+ for (const rel of onDisk.keys()) {
83
+ if (!manifest.files.has(rel)) added.push(rel);
84
+ }
85
+ return {
86
+ baselineKnown: true,
87
+ modified: modified.sort(),
88
+ added: added.sort(),
89
+ missing: missing.sort(),
90
+ conflicts: conflicts.sort(),
91
+ };
92
+ }
93
+
22
94
  /**
23
95
  * Normalize line endings to LF, handling CRLF (\r\n) and lone CR (\r).
24
96
  */
@@ -100,27 +172,27 @@ export function readGemlFilesFromDisk(dir, baseDir = dir) {
100
172
  * @param {string} targetDir Local destination directory.
101
173
  * @param {object} [opts]
102
174
  * @param {boolean} [opts.deleteOrphans=false] Whether to delete previous-sync .geml files no longer in graph.
103
- * @returns {{ written: string[], orphaned: string[], unchanged: string[], deleted: string[] }}
175
+ * @param {string[]} [opts.preserve] Files NOT to overwrite even when the graph
176
+ * differs — the conflicted files of a two-way cycle. Their manifest entry
177
+ * keeps its previous hash, so they stay flagged until a person resolves them.
178
+ * @returns {{ written: string[], orphaned: string[], unchanged: string[], deleted: string[], preserved: string[] }}
104
179
  */
105
180
  export function writeGemlFilesToDisk(gemlFiles, targetDir, opts = {}) {
106
181
  const deleteOrphans = opts.deleteOrphans ?? false;
182
+ const preserve = new Set(opts.preserve ?? []);
107
183
  const written = [];
108
184
  const unchanged = [];
109
185
  const orphaned = [];
110
186
  const deleted = [];
187
+ const preserved = [];
111
188
 
112
189
  mkdirSync(targetDir, { recursive: true });
113
190
  const existingFiles = readGemlFilesFromDisk(targetDir);
114
191
 
115
192
  // Load previous sync manifest to know which files belong to sync vs user-authored files
116
193
  const manifestPath = join(targetDir, MANIFEST_FILE);
117
- let lastManifest = new Set();
118
- if (existsSync(manifestPath)) {
119
- try {
120
- const parsed = JSON.parse(readFileSync(manifestPath, "utf8"));
121
- if (Array.isArray(parsed)) lastManifest = new Set(parsed);
122
- } catch {}
123
- }
194
+ const previous = readManifest(targetDir);
195
+ const lastManifest = new Set(previous.files.keys());
124
196
 
125
197
  // Write new or updated files atomically with CRLF normalization
126
198
  for (const [rel, newContent] of gemlFiles) {
@@ -128,7 +200,10 @@ export function writeGemlFilesToDisk(gemlFiles, targetDir, opts = {}) {
128
200
  const normNew = normalizeEol(newContent);
129
201
  const existingContent = existingFiles.get(rel);
130
202
 
131
- if (existingContent === undefined || existingContent !== normNew) {
203
+ if (preserve.has(rel)) {
204
+ if (existingContent !== normNew) preserved.push(rel);
205
+ else unchanged.push(rel);
206
+ } else if (existingContent === undefined || existingContent !== normNew) {
132
207
  atomicWriteFileSync(fullPath, normNew);
133
208
  written.push(rel);
134
209
  } else {
@@ -153,17 +228,32 @@ export function writeGemlFilesToDisk(gemlFiles, targetDir, opts = {}) {
153
228
  }
154
229
  }
155
230
 
156
- // Save updated manifest of managed sync files:
157
- // includes all current gemlFiles, plus any existing files on disk that were in lastManifest and not deleted.
231
+ // Save updated manifest of managed sync files: all current gemlFiles, plus
232
+ // any existing files on disk that were in lastManifest and not deleted.
233
+ // v2 records each file's content hash AS OF THIS SYNC — the baseline
234
+ // detectExternalEdits() compares against, so the watcher's own writes never
235
+ // read as someone else's edits.
158
236
  const currentManifest = new Set(gemlFiles.keys());
159
237
  for (const rel of lastManifest) {
160
238
  if (existingFiles.has(rel) && !deleted.includes(rel)) {
161
239
  currentManifest.add(rel);
162
240
  }
163
241
  }
164
- atomicWriteFileSync(manifestPath, JSON.stringify([...currentManifest].sort(), null, 1) + "\n");
242
+ const manifestFiles = {};
243
+ for (const rel of [...currentManifest].sort()) {
244
+ if (preserved.includes(rel)) {
245
+ // A conflicted file keeps its OLD baseline: recording what sits on disk
246
+ // now would make the person's unmerged edit read as "already synced" on
247
+ // the next cycle, and the conflict would be silently forgotten.
248
+ manifestFiles[rel] = previous.files.get(rel) ?? null;
249
+ continue;
250
+ }
251
+ const content = gemlFiles.has(rel) ? normalizeEol(gemlFiles.get(rel)) : existingFiles.get(rel);
252
+ manifestFiles[rel] = content === undefined ? null : sha256(content);
253
+ }
254
+ atomicWriteFileSync(manifestPath, JSON.stringify({ version: 2, files: manifestFiles }, null, 1) + "\n");
165
255
 
166
- return { written, orphaned, unchanged, deleted };
256
+ return { written, orphaned, unchanged, deleted, preserved };
167
257
  }
168
258
 
169
259
  /**
@@ -344,12 +434,16 @@ export async function syncEdnToDisk(ednText, targetDir, opts = {}) {
344
434
 
345
435
  /**
346
436
  * Full Sync Pipeline from disk back to EDN string.
347
- *
437
+ *
348
438
  * @param {string} targetDir Local folder containing .geml files.
349
439
  * @param {object} lib Parser library containing { parse, addressedUnits, sliceUnit }.
440
+ * @param {{ exclude?: string[] }} [opts] Files to leave OUT of the import —
441
+ * the conflicted files of a two-way cycle: absent from the EDN means the
442
+ * graph's version stays untouched (import merges by uuid, it never deletes).
350
443
  * @returns {string} EDN string ready for logseq import-edn.
351
444
  */
352
- export function syncDiskToEdn(targetDir, lib) {
445
+ export function syncDiskToEdn(targetDir, lib, opts = {}) {
353
446
  const files = readGemlFilesFromDisk(targetDir);
447
+ for (const rel of opts.exclude ?? []) files.delete(rel);
354
448
  return gemlFilesToEdn(files, lib);
355
449
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geml/logseq-sync",
3
- "version": "2.0.5",
3
+ "version": "2.0.6",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -9,7 +9,8 @@ import {
9
9
  import { join, resolve, dirname, basename, sep } from "node:path";
10
10
  import { tmpdir, homedir } from "node:os";
11
11
  import { randomUUID, createHash } from "node:crypto";
12
- import { syncEdnToDisk, syncDiskToEdn, atomicWriteFileSync } from "../../core/src/sync-engine.mjs";
12
+ import { syncEdnToDisk, syncDiskToEdn, atomicWriteFileSync, detectExternalEdits } from "../../core/src/sync-engine.mjs";
13
+ import { ednToGemlFiles } from "../../core/src/mapping.mjs";
13
14
  import { STATUS_FILE } from "../../core/src/bridge.mjs";
14
15
  import { parse as parseGeml, addressedUnits, sliceUnit, gemlToMd } from "@geml/geml";
15
16
 
@@ -39,6 +40,12 @@ to override it.
39
40
 
40
41
  Flags:
41
42
  --once Sync once and exit (default: keep watching)
43
+ --two-way Also import vault edits back into the graph, checked
44
+ on every cycle. A file changed on BOTH sides is a
45
+ conflict: neither imported nor overwritten, reported
46
+ until you merge it. Deletions are never imported.
47
+ Takes a graph backup before the first import and
48
+ every 10th after. Needs the app CLI.
42
49
  --git-commit Commit, creating the vault repository if there is none
43
50
  (default: commit only when the vault ALREADY is a repository)
44
51
  --no-git-commit Never touch git
@@ -64,6 +71,7 @@ const args = process.argv.slice(2);
64
71
  const positional = [];
65
72
  const flags = {
66
73
  once: false,
74
+ twoWay: false,
67
75
  gitCommit: "auto",
68
76
  mirror: false,
69
77
  markdown: null,
@@ -102,6 +110,8 @@ for (let i = 0; i < args.length; i++) {
102
110
  flags.yes = true;
103
111
  } else if (arg === "--no-backup") {
104
112
  flags.backup = false;
113
+ } else if (arg === "--two-way") {
114
+ flags.twoWay = true;
105
115
  } else if (arg === "--mirror") {
106
116
  flags.mirror = true;
107
117
  } else if (arg === "--markdown") {
@@ -436,6 +446,14 @@ const signalPath = resolveSignalPath();
436
446
  const watchMode = !flags.once;
437
447
  const gitCommit = subcommand === "restore" ? false : resolveGitCommit();
438
448
 
449
+ if (flags.twoWay && !appCli) {
450
+ console.error(
451
+ "Error: --two-way needs the Logseq desktop app's CLI (it performs the imports). " +
452
+ "Install Logseq, or pass --app-cli <path>. Run `logseq-sync doctor` for the full picture."
453
+ );
454
+ process.exit(2);
455
+ }
456
+
439
457
  function isGitRepo(dir) {
440
458
  try {
441
459
  execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
@@ -599,21 +617,86 @@ async function restore() {
599
617
 
600
618
  let lastEdnHash = null;
601
619
 
620
+ // ⑤'s bookkeeping: a graph backup before the session's first import, then
621
+ // every BACKUP_EVERY imports after — enough that an import gone wrong always
622
+ // has a recent restore point, without one backup per keystroke.
623
+ let sessionBackupTaken = false;
624
+ let importsSinceBackup = 0;
625
+ const BACKUP_EVERY = 10;
626
+
627
+ // Export the graph as EDN into tempPath — the one exporter, used once per
628
+ // cycle, twice when a two-way import changed the graph mid-cycle.
629
+ // With a token the CLI goes through the running app's HTTP API server and
630
+ // exports whatever graph the app has OPEN — the graph name is not part of
631
+ // that request, so -a REPLACES -g rather than joining it. Without a token
632
+ // the CLI opens the named graph's sqlite directly, which only works while
633
+ // the app does not hold the lock on it.
634
+ function exportGraphEdn(tempPath) {
635
+ if (appCli) {
636
+ runAppCli(tempPath);
637
+ } else {
638
+ const exportSource = apiServerToken ? ["-a", apiServerToken] : ["-g", graphName];
639
+ runLogseqCli("export-edn", ...exportSource, "-f", tempPath);
640
+ }
641
+ }
642
+
643
+ // The import half of --two-way, run before the export lands on disk: whatever
644
+ // a person or agent changed in the vault goes back into the graph first, so
645
+ // the write that follows holds the merged state and re-baselines the
646
+ // manifest. Deletions are reported, never imported (the vault's stance, now
647
+ // in both directions); a file changed on BOTH sides since the last sync is a
648
+ // conflict — importing it would clobber the graph's edit, exporting over it
649
+ // would clobber the person's, so two-way does neither and says so until a
650
+ // person merges.
651
+ async function importExternalEdits(ednText) {
652
+ const graphFiles = ednToGemlFiles(ednText);
653
+ const edits = detectExternalEdits(targetDir, { graphFiles });
654
+ if (!edits.baselineKnown) {
655
+ // A v1 manifest (or none) has no content baseline — the sync about to run
656
+ // writes one, and the NEXT cycle can start importing.
657
+ return { imported: 0, conflicts: [], missing: [] };
658
+ }
659
+ const importable = [...edits.modified, ...edits.added];
660
+ const result = { imported: 0, conflicts: edits.conflicts, missing: edits.missing };
661
+ if (edits.missing.length > 0) {
662
+ console.log(
663
+ ` two-way: ${edits.missing.length} vault file(s) deleted on disk — deletions are never imported; ` +
664
+ `delete the page in Logseq if you mean it.`
665
+ );
666
+ }
667
+ if (importable.length === 0) return result;
668
+
669
+ if (!sessionBackupTaken || importsSinceBackup >= BACKUP_EVERY) {
670
+ appCliRun("graph", "backup", "create", "--graph", graphName);
671
+ sessionBackupTaken = true;
672
+ importsSinceBackup = 0;
673
+ }
674
+
675
+ const tmpEdn = join(tmpdir(), `geml-twoway-${process.pid}-${randomUUID()}.edn`);
676
+ try {
677
+ atomicWriteFileSync(
678
+ tmpEdn,
679
+ syncDiskToEdn(targetDir, { parse: parseGeml, addressedUnits, sliceUnit }, { exclude: edits.conflicts })
680
+ );
681
+ appCliRun("graph", "import", "--graph", graphName, "--type", "edn", "--input", tmpEdn);
682
+ } finally {
683
+ if (existsSync(tmpEdn)) { try { unlinkSync(tmpEdn); } catch {} }
684
+ }
685
+ importsSinceBackup += 1;
686
+ result.imported = importable.length;
687
+ console.log(
688
+ `[${new Date().toLocaleTimeString()}] two-way: imported ${importable.length} vault edit(s) into "${graphName}"` +
689
+ (edits.conflicts.length ? `; ${edits.conflicts.length} conflict(s) held` : "") +
690
+ `.`
691
+ );
692
+ return result;
693
+ }
694
+
602
695
  async function performSync() {
603
696
  const tempEdnPath = join(tmpdir(), `logseq-export-${process.pid}-${Date.now()}-${randomUUID()}.edn`);
604
697
  try {
605
698
  // 1. Export from Logseq DB via official CLI.
606
- // With a token the CLI goes through the running app's HTTP API server and
607
- // exports whatever graph the app has OPEN — the graph name is not part of
608
- // that request, so -a REPLACES -g rather than joining it. Without a token
609
- // the CLI opens the named graph's sqlite directly, which only works while
610
- // the app does not hold the lock on it.
611
- if (appCli) {
612
- runAppCli(tempEdnPath);
613
- } else {
614
- const exportSource = apiServerToken ? ["-a", apiServerToken] : ["-g", graphName];
615
- runLogseqCli("export-edn", ...exportSource, "-f", tempEdnPath);
616
- }
699
+ exportGraphEdn(tempEdnPath);
617
700
 
618
701
  if (!existsSync(tempEdnPath)) {
619
702
  throw new Error(`Export failed: ${tempEdnPath} was not created.`);
@@ -624,11 +707,26 @@ async function performSync() {
624
707
  throw new Error(`Export produced an empty (0 byte) EDN file.`);
625
708
  }
626
709
 
627
- const ednText = readFileSync(tempEdnPath, "utf8");
710
+ let ednText = readFileSync(tempEdnPath, "utf8");
711
+
712
+ // 1.5 Two-way import, BEFORE the unchanged-export short-circuit below:
713
+ // the graph being unchanged says nothing about the vault.
714
+ let twoWay = null;
715
+ if (flags.twoWay) {
716
+ twoWay = await importExternalEdits(ednText);
717
+ if (twoWay.imported > 0) {
718
+ // The graph just absorbed the vault edits — export again, so the disk
719
+ // write and the manifest baseline hold the merged state.
720
+ exportGraphEdn(tempEdnPath);
721
+ ednText = readFileSync(tempEdnPath, "utf8");
722
+ }
723
+ }
724
+ const twoWayActivity =
725
+ twoWay !== null && (twoWay.imported > 0 || twoWay.conflicts.length > 0 || twoWay.missing.length > 0);
628
726
 
629
727
  // 2. Efficiency: In watch mode, skip disk scanning if export content is bit-for-bit identical
630
728
  const currentHash = createHash("sha256").update(ednText).digest("hex");
631
- if (watchMode && currentHash === lastEdnHash) {
729
+ if (watchMode && currentHash === lastEdnHash && !twoWayActivity) {
632
730
  return;
633
731
  }
634
732
 
@@ -636,6 +734,7 @@ async function performSync() {
636
734
  const res = await syncEdnToDisk(ednText, targetDir, {
637
735
  autoCommit: gitCommit,
638
736
  deleteOrphans: flags.mirror,
737
+ preserve: twoWay?.conflicts ?? [],
639
738
  markdownDir: flags.markdown ? resolve(expandHome(flags.markdown)) : null,
640
739
  gemlToMd: gemlSourceToMd,
641
740
  commitMessage: flags.message || `logseq-geml: sync graph "${graphName}" (${new Date().toISOString()})`,
@@ -650,18 +749,29 @@ async function performSync() {
650
749
  unchanged: res.unchanged.length,
651
750
  orphaned: res.orphaned.length,
652
751
  deleted: res.deleted.length,
752
+ imported: twoWay?.imported ?? 0,
753
+ conflicts: twoWay?.conflicts ?? [],
653
754
  });
654
755
 
655
756
  const timestamp = new Date().toLocaleTimeString();
656
757
  const parts = [`${res.written.length} written`, `${res.unchanged.length} unchanged`];
758
+ if (twoWay && twoWay.imported > 0) {
759
+ parts.unshift(`${twoWay.imported} imported`);
760
+ }
657
761
  if (res.orphaned && res.orphaned.length > 0) {
658
762
  parts.push(`${res.orphaned.length} orphaned/absent from export (preserved safely)`);
659
763
  }
660
764
  if (res.deleted && res.deleted.length > 0) {
661
765
  parts.push(`${res.deleted.length} deleted`);
662
766
  }
767
+ if (twoWay && twoWay.conflicts.length > 0) {
768
+ console.error(
769
+ ` ⚠ conflict(s), changed in BOTH the vault and the graph since the last sync — ` +
770
+ `held as you left them, not imported, not overwritten: ${twoWay.conflicts.join(", ")}`
771
+ );
772
+ }
663
773
 
664
- if (res.written.length > 0 || res.deleted.length > 0) {
774
+ if (res.written.length > 0 || res.deleted.length > 0 || twoWayActivity) {
665
775
  console.log(`[${timestamp}] Synced: ${parts.join(", ")}.`);
666
776
  if (res.gitResult && res.gitResult.committed) {
667
777
  console.log(` Git: ${res.gitResult.output}`);