@agentage/cli 0.0.2 → 0.0.4

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.
Files changed (158) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +67 -25
  3. package/dist/cli.d.ts +0 -1
  4. package/dist/cli.js +44 -4
  5. package/dist/commands/{setup.d.ts → auth/setup.d.ts} +6 -3
  6. package/dist/commands/{setup.js → auth/setup.js} +56 -11
  7. package/dist/commands/daemon/daemon-cmd.d.ts +2 -0
  8. package/dist/commands/daemon/daemon-cmd.js +124 -0
  9. package/dist/commands/memory/memory-verbs.d.ts +21 -0
  10. package/dist/commands/memory/memory-verbs.js +98 -0
  11. package/dist/commands/memory/memory.d.ts +2 -0
  12. package/dist/commands/memory/memory.js +54 -0
  13. package/dist/commands/{status.d.ts → status/status.d.ts} +1 -2
  14. package/dist/commands/status/status.js +86 -0
  15. package/dist/commands/update/update.d.ts +23 -0
  16. package/dist/commands/update/update.js +89 -0
  17. package/dist/commands/vault/vault-sync.d.ts +15 -0
  18. package/dist/commands/vault/vault-sync.js +100 -0
  19. package/dist/commands/vault/vault.d.ts +22 -0
  20. package/dist/commands/vault/vault.js +128 -0
  21. package/dist/daemon/actions.d.ts +5 -0
  22. package/dist/daemon/actions.js +22 -0
  23. package/dist/daemon/client-provider.d.ts +2 -0
  24. package/dist/daemon/client-provider.js +16 -0
  25. package/dist/daemon/guards.d.ts +3 -0
  26. package/dist/daemon/guards.js +13 -0
  27. package/dist/daemon/lifecycle.d.ts +17 -0
  28. package/dist/daemon/lifecycle.js +151 -0
  29. package/dist/daemon/mcp-http.d.ts +3 -0
  30. package/dist/daemon/mcp-http.js +34 -0
  31. package/dist/daemon/server.d.ts +26 -0
  32. package/dist/daemon/server.js +136 -0
  33. package/dist/daemon-entry.d.ts +7 -0
  34. package/dist/daemon-entry.js +128 -0
  35. package/dist/index.d.ts +0 -1
  36. package/dist/index.js +0 -1
  37. package/dist/lib/auth/api.d.ts +8 -0
  38. package/dist/lib/auth/api.js +97 -0
  39. package/dist/lib/auth/auth-errors.d.ts +7 -0
  40. package/dist/lib/auth/auth-errors.js +22 -0
  41. package/dist/lib/{callback-server.d.ts → auth/callback-server.d.ts} +0 -1
  42. package/dist/lib/{callback-server.js → auth/callback-server.js} +2 -2
  43. package/dist/lib/auth/env-match.d.ts +9 -0
  44. package/dist/lib/auth/env-match.js +17 -0
  45. package/dist/lib/auth/introspect.d.ts +7 -0
  46. package/dist/lib/auth/introspect.js +89 -0
  47. package/dist/lib/{oauth.d.ts → auth/oauth.d.ts} +6 -2
  48. package/dist/lib/{oauth.js → auth/oauth.js} +26 -7
  49. package/dist/lib/auth/provision.d.ts +14 -0
  50. package/dist/lib/auth/provision.js +62 -0
  51. package/dist/lib/daemon/daemon-client.d.ts +39 -0
  52. package/dist/lib/daemon/daemon-client.js +134 -0
  53. package/dist/lib/daemon/daemon-pref.d.ts +2 -0
  54. package/dist/lib/daemon/daemon-pref.js +10 -0
  55. package/dist/lib/{config.d.ts → fs/config.d.ts} +1 -1
  56. package/dist/lib/fs/config.js +55 -0
  57. package/dist/lib/fs/file-lock.d.ts +3 -0
  58. package/dist/lib/fs/file-lock.js +163 -0
  59. package/dist/lib/memory/memory-client.d.ts +30 -0
  60. package/dist/lib/memory/memory-client.js +98 -0
  61. package/dist/lib/net/http.d.ts +6 -0
  62. package/dist/lib/net/http.js +32 -0
  63. package/dist/lib/{origins.d.ts → net/origins.d.ts} +1 -1
  64. package/dist/lib/net/origins.js +39 -0
  65. package/dist/lib/net/user-agent.d.ts +6 -0
  66. package/dist/lib/net/user-agent.js +7 -0
  67. package/dist/lib/status/format.d.ts +1 -0
  68. package/dist/lib/status/format.js +12 -0
  69. package/dist/lib/status/status-info.d.ts +45 -0
  70. package/dist/lib/status/status-info.js +107 -0
  71. package/dist/lib/status/vaults-format.d.ts +2 -0
  72. package/dist/lib/status/vaults-format.js +44 -0
  73. package/dist/lib/status/vaults-status.d.ts +12 -0
  74. package/dist/lib/status/vaults-status.js +53 -0
  75. package/dist/lib/update/update-cache.d.ts +14 -0
  76. package/dist/lib/update/update-cache.js +63 -0
  77. package/dist/lib/update/update-check.d.ts +27 -0
  78. package/dist/lib/update/update-check.js +50 -0
  79. package/dist/lib/update/update-lock.d.ts +2 -0
  80. package/dist/lib/update/update-lock.js +109 -0
  81. package/dist/lib/vault/vault-registry.d.ts +12 -0
  82. package/dist/lib/vault/vault-registry.js +82 -0
  83. package/dist/lib/vault/vaults.d.ts +10 -0
  84. package/dist/lib/vault/vaults.js +66 -0
  85. package/dist/lib/vault/vaults.schema.d.ts +2 -0
  86. package/dist/lib/vault/vaults.schema.js +10 -0
  87. package/dist/mcp/local-server.d.ts +4 -0
  88. package/dist/mcp/local-server.js +26 -0
  89. package/dist/sync/couch/cycle.d.ts +2 -0
  90. package/dist/sync/couch/cycle.js +58 -0
  91. package/dist/sync/couch/discovery.d.ts +22 -0
  92. package/dist/sync/couch/discovery.js +34 -0
  93. package/dist/sync/couch/file-store.d.ts +2 -0
  94. package/dist/sync/couch/file-store.js +47 -0
  95. package/dist/sync/couch/local-commit.d.ts +2 -0
  96. package/dist/sync/couch/local-commit.js +24 -0
  97. package/dist/sync/couch/manager.d.ts +4 -0
  98. package/dist/sync/couch/manager.fixtures.d.ts +20 -0
  99. package/dist/sync/couch/manager.fixtures.js +56 -0
  100. package/dist/sync/couch/manager.js +126 -0
  101. package/dist/sync/couch/manager.types.d.ts +81 -0
  102. package/dist/sync/couch/manager.types.js +1 -0
  103. package/dist/sync/couch/mutation-target.d.ts +5 -0
  104. package/dist/sync/couch/mutation-target.js +33 -0
  105. package/dist/sync/couch/push-on-write.d.ts +3 -0
  106. package/dist/sync/couch/push-on-write.js +33 -0
  107. package/dist/sync/couch/state-store.d.ts +3 -0
  108. package/dist/sync/couch/state-store.js +28 -0
  109. package/dist/sync/couch/targets.d.ts +9 -0
  110. package/dist/sync/couch/targets.js +23 -0
  111. package/dist/sync/couch/wire.d.ts +6 -0
  112. package/dist/sync/couch/wire.js +16 -0
  113. package/dist/sync/discover/watcher.d.ts +26 -0
  114. package/dist/sync/discover/watcher.js +159 -0
  115. package/dist/sync/git/conflict.d.ts +1 -0
  116. package/dist/sync/git/conflict.js +16 -0
  117. package/dist/sync/git/cycle.d.ts +18 -0
  118. package/dist/sync/git/cycle.js +158 -0
  119. package/dist/sync/git/git-exec.d.ts +22 -0
  120. package/dist/sync/git/git-exec.js +56 -0
  121. package/dist/sync/git/manager.d.ts +30 -0
  122. package/dist/sync/git/manager.js +114 -0
  123. package/dist/sync/git/planner.d.ts +15 -0
  124. package/dist/sync/git/planner.js +46 -0
  125. package/dist/sync/git/remote-url.d.ts +3 -0
  126. package/dist/sync/git/remote-url.js +30 -0
  127. package/dist/utils/version.d.ts +0 -1
  128. package/dist/utils/version.js +0 -1
  129. package/package.json +20 -7
  130. package/dist/cli.d.ts.map +0 -1
  131. package/dist/cli.js.map +0 -1
  132. package/dist/commands/setup.d.ts.map +0 -1
  133. package/dist/commands/setup.js.map +0 -1
  134. package/dist/commands/status.d.ts.map +0 -1
  135. package/dist/commands/status.js +0 -36
  136. package/dist/commands/status.js.map +0 -1
  137. package/dist/index.d.ts.map +0 -1
  138. package/dist/index.js.map +0 -1
  139. package/dist/lib/api.d.ts +0 -12
  140. package/dist/lib/api.d.ts.map +0 -1
  141. package/dist/lib/api.js +0 -43
  142. package/dist/lib/api.js.map +0 -1
  143. package/dist/lib/callback-server.d.ts.map +0 -1
  144. package/dist/lib/callback-server.js.map +0 -1
  145. package/dist/lib/config.d.ts.map +0 -1
  146. package/dist/lib/config.js +0 -34
  147. package/dist/lib/config.js.map +0 -1
  148. package/dist/lib/oauth.d.ts.map +0 -1
  149. package/dist/lib/oauth.js.map +0 -1
  150. package/dist/lib/origins.d.ts.map +0 -1
  151. package/dist/lib/origins.js +0 -26
  152. package/dist/lib/origins.js.map +0 -1
  153. package/dist/lib/status-info.d.ts +0 -18
  154. package/dist/lib/status-info.d.ts.map +0 -1
  155. package/dist/lib/status-info.js +0 -39
  156. package/dist/lib/status-info.js.map +0 -1
  157. package/dist/utils/version.d.ts.map +0 -1
  158. package/dist/utils/version.js.map +0 -1
@@ -0,0 +1,159 @@
1
+ import { statSync, watch as fsWatch } from 'node:fs';
2
+ import { expandPath, scanDiscoverRoots, } from '@agentage/memory-core';
3
+ import { defaultProvisionDeps, provisionAccountVault } from '../../lib/auth/provision.js';
4
+ import { addVault } from '../../lib/vault/vault-registry.js';
5
+ import { loadVaultsConfig, mutateVaultsConfig } from '../../lib/vault/vaults.js';
6
+ const msgOf = (err) => (err instanceof Error ? err.message : String(err));
7
+ // Floors: a 0/near-0 poll interval would busy-loop the daemon; a 0 debounce defeats coalescing.
8
+ const POLL_FLOOR_MS = 1000;
9
+ const DEBOUNCE_FLOOR_MS = 50;
10
+ const isDir = (path) => {
11
+ try {
12
+ return statSync(path).isDirectory();
13
+ }
14
+ catch {
15
+ return false;
16
+ }
17
+ };
18
+ const defaultWatch = (dir, onChange) => {
19
+ const w = fsWatch(dir, { persistent: false }, () => onChange());
20
+ w.on('error', () => { }); // an unreliable fs: the poll fallback still covers the root
21
+ return w;
22
+ };
23
+ export const createDiscoverWatcher = (deps = {}) => {
24
+ const getConfig = deps.getConfig ?? (() => loadVaultsConfig().config);
25
+ const mutate = deps.mutate ?? mutateVaultsConfig;
26
+ const scan = deps.scan ?? scanDiscoverRoots;
27
+ const provision = deps.provision ?? ((name) => provisionAccountVault(name, defaultProvisionDeps()));
28
+ const isDirectory = deps.isDirectory ?? isDir;
29
+ const watch = deps.watch ?? defaultWatch;
30
+ const debounceMs = Math.max(DEBOUNCE_FLOOR_MS, deps.debounceMs ?? 500);
31
+ const pollMs = Math.max(POLL_FLOOR_MS, deps.pollMs ?? 60_000);
32
+ const log = deps.log ?? (() => { });
33
+ const watchers = [];
34
+ let debounceTimer;
35
+ let pollTimer;
36
+ let scanning = false;
37
+ let queued = false;
38
+ const roots = (config) => (config.discover ?? []).map((r) => expandPath(r.path));
39
+ const runScan = async () => {
40
+ let candidates;
41
+ try {
42
+ candidates = scan(getConfig());
43
+ }
44
+ catch (err) {
45
+ log(`scan failed: ${msgOf(err)}`);
46
+ return [];
47
+ }
48
+ if (candidates.length === 0)
49
+ return [];
50
+ // Fold the candidates onto the FRESH on-disk config inside the cross-process lock (issue #231):
51
+ // re-read, register any still-absent-and-present folder, save - so a concurrent `vault add` (or
52
+ // a second daemon) is never clobbered. Return null when nothing changed so the file is left
53
+ // untouched (a redundant write would loop the daemon's own config watcher).
54
+ let added = [];
55
+ try {
56
+ await mutate((fresh) => {
57
+ added = [];
58
+ let next = fresh;
59
+ for (const c of candidates) {
60
+ if (next.vaults?.[c.name])
61
+ continue; // registered by a concurrent writer
62
+ if (!c.entry.path || !isDirectory(c.entry.path))
63
+ continue; // vanished before we wrote it
64
+ try {
65
+ next = addVault(next, c.name, c.entry);
66
+ added.push(c);
67
+ }
68
+ catch (err) {
69
+ log(`register '${c.name}' failed: ${msgOf(err)}`);
70
+ }
71
+ }
72
+ return added.length > 0 ? next : null;
73
+ });
74
+ }
75
+ catch (err) {
76
+ log(`save failed: ${msgOf(err)}`);
77
+ return [];
78
+ }
79
+ if (added.length === 0)
80
+ return [];
81
+ for (const c of added) {
82
+ log(`discovered account vault '${c.name}' -> ${c.entry.path}`);
83
+ void provision(c.name).catch(() => { }); // never fatal: the couch loop re-provisions
84
+ }
85
+ return added;
86
+ };
87
+ // Serialise scans against themselves; a change during an in-flight scan queues one more pass.
88
+ const scanNow = async () => {
89
+ if (scanning) {
90
+ queued = true;
91
+ return [];
92
+ }
93
+ scanning = true;
94
+ try {
95
+ return await runScan();
96
+ }
97
+ finally {
98
+ scanning = false;
99
+ if (queued) {
100
+ queued = false;
101
+ void scanNow();
102
+ }
103
+ }
104
+ };
105
+ const debounced = () => {
106
+ if (debounceTimer)
107
+ clearTimeout(debounceTimer);
108
+ debounceTimer = setTimeout(() => {
109
+ debounceTimer = undefined;
110
+ void scanNow();
111
+ }, debounceMs);
112
+ debounceTimer.unref?.();
113
+ };
114
+ const closeWatchers = () => {
115
+ for (const w of watchers.splice(0)) {
116
+ try {
117
+ w.close();
118
+ }
119
+ catch {
120
+ // already closed
121
+ }
122
+ }
123
+ };
124
+ const reschedule = () => {
125
+ closeWatchers();
126
+ const config = getConfig();
127
+ for (const dir of roots(config).filter(isDirectory)) {
128
+ try {
129
+ watchers.push(watch(dir, debounced));
130
+ }
131
+ catch (err) {
132
+ log(`watch ${dir} failed: ${msgOf(err)}`);
133
+ }
134
+ }
135
+ // The poll fallback runs while any root is configured (even one not yet a directory), so a root
136
+ // created after boot is still picked up where fs.watch is unreliable.
137
+ if (roots(config).length > 0 && !pollTimer) {
138
+ pollTimer = setInterval(() => void scanNow(), pollMs);
139
+ pollTimer.unref?.();
140
+ }
141
+ else if (roots(config).length === 0 && pollTimer) {
142
+ clearInterval(pollTimer);
143
+ pollTimer = undefined;
144
+ }
145
+ void scanNow(); // initial scan on boot, and a re-scan whenever the config changes
146
+ };
147
+ const stop = () => {
148
+ closeWatchers();
149
+ if (debounceTimer) {
150
+ clearTimeout(debounceTimer);
151
+ debounceTimer = undefined;
152
+ }
153
+ if (pollTimer) {
154
+ clearInterval(pollTimer);
155
+ pollTimer = undefined;
156
+ }
157
+ };
158
+ return { reschedule, scanNow, status: () => ({ roots: roots(getConfig()) }), stop };
159
+ };
@@ -0,0 +1 @@
1
+ export declare const conflictName: (path: string, exists: (candidate: string) => boolean) => string;
@@ -0,0 +1,16 @@
1
+ // The name for the file that preserves the remote side of a conflict: `x.md` -> `x.conflict.md`.
2
+ // An existing name is never clobbered - a numeric suffix is added instead (`x.conflict-1.md`).
3
+ export const conflictName = (path, exists) => {
4
+ const slash = path.lastIndexOf('/');
5
+ const dot = path.lastIndexOf('.');
6
+ const hasExt = dot > slash + 1; // a dot inside the basename, not a leading dot
7
+ const stem = hasExt ? path.slice(0, dot) : path;
8
+ const ext = hasExt ? path.slice(dot) : '';
9
+ const base = `${stem}.conflict${ext}`;
10
+ if (!exists(base))
11
+ return base;
12
+ let i = 1;
13
+ while (exists(`${stem}.conflict-${i}${ext}`))
14
+ i += 1;
15
+ return `${stem}.conflict-${i}${ext}`;
16
+ };
@@ -0,0 +1,18 @@
1
+ import { type GitErrorKind, type SyncGit } from './git-exec.js';
2
+ import { type SyncTarget } from './planner.js';
3
+ export interface SyncResult {
4
+ vault: string;
5
+ remote: string;
6
+ ok: boolean;
7
+ committed: boolean;
8
+ pushed: boolean;
9
+ conflicts: string[];
10
+ skipped?: 'lock' | 'busy' | 'invalid-remote';
11
+ reason?: GitErrorKind;
12
+ error?: string;
13
+ }
14
+ export interface CycleDeps {
15
+ git?: SyncGit;
16
+ now?: () => string;
17
+ }
18
+ export declare const runSyncCycle: (target: SyncTarget, deps?: CycleDeps) => Promise<SyncResult>;
@@ -0,0 +1,158 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { mkdir, writeFile } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
4
+ import { conflictName } from './conflict.js';
5
+ import { createSyncGit, GitError } from './git-exec.js';
6
+ import { isSafeRemoteUrl } from './remote-url.js';
7
+ const branchOf = async (git) => {
8
+ const out = await git.exec(['symbolic-ref', '--short', 'HEAD']);
9
+ return out.code === 0 && out.stdout.trim() ? out.stdout.trim() : 'main';
10
+ };
11
+ // Local, uncommitted, non-ignored changes -> one sync commit. Returns whether a commit was made.
12
+ const commitIfDirty = async (git, message) => {
13
+ await git.run(['add', '-A']);
14
+ const staged = await git.exec(['diff', '--cached', '--quiet']);
15
+ if (staged.code === 0)
16
+ return false;
17
+ await git.run(['commit', '-m', message]);
18
+ return true;
19
+ };
20
+ // The ignore rules ride on `.git/info/exclude` (local, uncommitted, gitignore syntax) rather than
21
+ // per-command pathspecs: it is the robust, idempotent mechanism - matching untracked files are
22
+ // never staged by `add -A`, and it leaves no committed `.gitignore` in the user's vault. An empty
23
+ // list clears it (sync everything).
24
+ const writeExclude = async (path, patterns) => {
25
+ const infoDir = join(path, '.git', 'info');
26
+ await mkdir(infoDir, { recursive: true });
27
+ await writeFile(join(infoDir, 'exclude'), patterns.length ? patterns.join('\n') + '\n' : '', 'utf8');
28
+ };
29
+ const ensureRemote = async (git, name, url) => {
30
+ const remotes = (await git.exec(['remote'])).stdout.split('\n').map((r) => r.trim());
31
+ if (remotes.includes(name))
32
+ await git.run(['remote', 'set-url', name, url]);
33
+ else
34
+ await git.run(['remote', 'add', name, url]);
35
+ };
36
+ // A previous cycle may have died mid-merge/rebase (daemon crash). A leftover MERGE_HEAD or rebase
37
+ // dir would let commitIfDirty mint a bogus half-merge and wedge every later cycle, so abort any
38
+ // interrupted operation before touching the tree.
39
+ const abortInterrupted = async (git, path) => {
40
+ const gitDir = join(path, '.git');
41
+ if (existsSync(join(gitDir, 'MERGE_HEAD'))) {
42
+ await git.exec(['merge', '--abort']);
43
+ // The abort refuses when the user edited a merge-touched file meanwhile ("entry not uptodate");
44
+ // complete the staged merge instead so MERGE_HEAD never leaks into commitIfDirty.
45
+ if (existsSync(join(gitDir, 'MERGE_HEAD')))
46
+ await git.exec(['commit', '--no-edit']);
47
+ }
48
+ if (existsSync(join(gitDir, 'rebase-merge')) || existsSync(join(gitDir, 'rebase-apply')))
49
+ await git.exec(['rebase', '--abort']);
50
+ };
51
+ // Reconcile a diverged history keeping BOTH sides. A rebase probes for a true conflict; on a clean
52
+ // replay history stays linear. On conflict the local files are kept as-is and each conflicted
53
+ // file's remote copy is written alongside as `<name>.conflict.md`. The `.conflict.md` copies are
54
+ // staged into the SAME merge commit (`merge --no-commit`), so no single commit boundary ever
55
+ // leaves the remote side merged-away yet unsurfaced. Returns the conflict-copy paths written.
56
+ const reconcile = async (git, cwd, ref, now) => {
57
+ const rebase = await git.exec(['rebase', ref]);
58
+ if (rebase.code === 0)
59
+ return [];
60
+ const conflicted = (await git.exec(['diff', '--name-only', '--diff-filter=U'])).stdout
61
+ .split('\n')
62
+ .map((p) => p.trim())
63
+ .filter(Boolean);
64
+ await git.exec(['rebase', '--abort']);
65
+ const remoteSides = new Map();
66
+ for (const p of conflicted) {
67
+ const show = await git.exec(['show', `${ref}:${p}`]);
68
+ if (show.code === 0)
69
+ remoteSides.set(p, show.stdout);
70
+ }
71
+ // Stage the merge WITHOUT committing (auto-resolving content conflicts to the local side); an
72
+ // unresolvable conflict (add/add, modify/delete) falls back to keeping ours. Everything stays
73
+ // staged so the conflict copies land in the very same commit.
74
+ const merge = await git.exec(['merge', '--no-commit', '-X', 'ours', '--no-edit', ref]);
75
+ if (merge.code !== 0) {
76
+ await git.exec(['checkout', '--ours', '--', '.']);
77
+ await git.run(['add', '-A']);
78
+ }
79
+ const written = [];
80
+ for (const [p, content] of remoteSides) {
81
+ const name = conflictName(p, (candidate) => existsSync(join(cwd, candidate)));
82
+ const abs = join(cwd, name);
83
+ await mkdir(dirname(abs), { recursive: true });
84
+ await writeFile(abs, content, 'utf8');
85
+ written.push(name);
86
+ }
87
+ await git.run(['add', '-A']);
88
+ // Self-sufficient close: nothing staged and no merge to conclude = nothing to commit (a bare
89
+ // `commit --no-edit` would abort on the empty message and wedge the cycle); an absent MERGE_MSG
90
+ // (recovered state) falls back to an explicit -m.
91
+ const staged = await git.exec(['diff', '--cached', '--quiet']);
92
+ const merging = existsSync(join(cwd, '.git', 'MERGE_HEAD'));
93
+ if (staged.code === 0 && !merging)
94
+ return written;
95
+ if (existsSync(join(cwd, '.git', 'MERGE_MSG')))
96
+ await git.run(['commit', '--no-edit']);
97
+ else
98
+ await git.run(['commit', '-m', `sync: merge ${ref} (${now})`]);
99
+ return written;
100
+ };
101
+ // One tolerant sync cycle for a single target: commit-if-dirty -> ensure remote -> fetch ->
102
+ // pull-rebase (keeping both sides on conflict) -> push. Every git failure is caught and classified
103
+ // so an unreachable remote or a held index.lock is a clean skip that the next cycle catches up.
104
+ export const runSyncCycle = async (target, deps = {}) => {
105
+ const git = deps.git ?? createSyncGit(target.path);
106
+ const now = (deps.now ?? (() => new Date().toISOString()))();
107
+ const base = {
108
+ vault: target.vault,
109
+ remote: target.remote,
110
+ ok: false,
111
+ committed: false,
112
+ pushed: false,
113
+ conflicts: [],
114
+ };
115
+ // Defense in depth: the planner already drops unsafe remotes, but never add/set-url one here.
116
+ if (!isSafeRemoteUrl(target.remote))
117
+ return { ...base, ok: true, skipped: 'invalid-remote' };
118
+ let committed = false;
119
+ let conflicts = [];
120
+ try {
121
+ if (!existsSync(target.path))
122
+ return { ...base, ok: true }; // nothing on disk yet
123
+ if (!existsSync(join(target.path, '.git')))
124
+ await git.run(['init', '-b', 'main']);
125
+ await abortInterrupted(git, target.path);
126
+ await writeExclude(target.path, target.ignore);
127
+ await ensureRemote(git, target.remoteName, target.remote);
128
+ committed = await commitIfDirty(git, `sync: ${now}`);
129
+ const branch = await branchOf(git);
130
+ const ref = `${target.remoteName}/${branch}`;
131
+ const fetch = await git.exec(['fetch', target.remoteName]);
132
+ if (fetch.code !== 0)
133
+ throw new GitError(fetch);
134
+ if ((await git.exec(['rev-parse', '--verify', ref])).code === 0) {
135
+ conflicts = await reconcile(git, target.path, ref, now);
136
+ }
137
+ const push = await git.exec(['push', target.remoteName, `HEAD:${branch}`]);
138
+ if (push.code !== 0)
139
+ throw new GitError(push);
140
+ return { ...base, ok: true, committed, pushed: true, conflicts };
141
+ }
142
+ catch (err) {
143
+ if (err instanceof GitError) {
144
+ // A concurrent engine mutation holding index.lock -> skip cleanly, retry next cycle.
145
+ if (err.kind === 'lock')
146
+ return { ...base, ok: true, committed, conflicts, skipped: 'lock', reason: 'lock' };
147
+ return { ...base, ok: false, committed, conflicts, reason: err.kind, error: err.message };
148
+ }
149
+ return {
150
+ ...base,
151
+ ok: false,
152
+ committed,
153
+ conflicts,
154
+ reason: 'other',
155
+ error: err instanceof Error ? err.message : String(err),
156
+ };
157
+ }
158
+ };
@@ -0,0 +1,22 @@
1
+ export type GitErrorKind = 'unreachable' | 'lock' | 'conflict' | 'other';
2
+ export interface GitRunResult {
3
+ code: number;
4
+ stdout: string;
5
+ stderr: string;
6
+ }
7
+ export declare const classifyGitError: (stderr: string) => GitErrorKind;
8
+ export declare class GitError extends Error {
9
+ readonly kind: GitErrorKind;
10
+ readonly stderr: string;
11
+ readonly code: number;
12
+ constructor(result: GitRunResult);
13
+ }
14
+ export interface SyncGit {
15
+ exec(args: string[], opts?: {
16
+ timeoutMs?: number;
17
+ }): Promise<GitRunResult>;
18
+ run(args: string[], opts?: {
19
+ timeoutMs?: number;
20
+ }): Promise<string>;
21
+ }
22
+ export declare const createSyncGit: (cwd: string) => SyncGit;
@@ -0,0 +1,56 @@
1
+ import { execFile } from 'node:child_process';
2
+ // Classify a git failure by its stderr so callers can react: an unreachable remote and a held
3
+ // index.lock are both skip-and-retry (not crashes); a conflict drives the keep-both-sides path.
4
+ export const classifyGitError = (stderr) => {
5
+ const s = stderr.toLowerCase();
6
+ if (/index\.lock|another git process|unable to create .*\.lock|cannot lock ref/.test(s))
7
+ return 'lock';
8
+ if (/conflict|could not apply|needs merge|automatic merge failed/.test(s))
9
+ return 'conflict';
10
+ if (/could not read from remote|connection refused|could not resolve host|repository .* not found|does not appear to be a git repository|unable to access|no route to host|network is unreachable|permission denied \(publickey\)|host key verification failed|does not exist|failed to connect|remote end hung up/.test(s))
11
+ return 'unreachable';
12
+ return 'other';
13
+ };
14
+ export class GitError extends Error {
15
+ kind;
16
+ stderr;
17
+ code;
18
+ constructor(result) {
19
+ super(result.stderr.trim() || `git exited with code ${result.code}`);
20
+ this.name = 'GitError';
21
+ this.kind = classifyGitError(result.stderr);
22
+ this.stderr = result.stderr;
23
+ this.code = result.code;
24
+ }
25
+ }
26
+ const IDENTITY = {
27
+ GIT_AUTHOR_NAME: 'agentage sync',
28
+ GIT_AUTHOR_EMAIL: 'sync@agentage.io',
29
+ GIT_COMMITTER_NAME: 'agentage sync',
30
+ GIT_COMMITTER_EMAIL: 'sync@agentage.io',
31
+ GIT_TERMINAL_PROMPT: '0',
32
+ GIT_SSH_COMMAND: 'ssh -oBatchMode=yes',
33
+ };
34
+ export const createSyncGit = (cwd) => {
35
+ const exec = (args, opts = {}) => new Promise((resolve) => {
36
+ execFile('git', args, {
37
+ cwd,
38
+ env: { ...process.env, ...IDENTITY },
39
+ encoding: 'utf8',
40
+ maxBuffer: 64 * 1024 * 1024,
41
+ timeout: opts.timeoutMs ?? 0,
42
+ }, (err, stdout, stderr) => {
43
+ const code = typeof err?.code === 'number' ? err.code : err ? 1 : 0;
44
+ resolve({ code, stdout: stdout ?? '', stderr: stderr ?? '' });
45
+ });
46
+ });
47
+ return {
48
+ exec,
49
+ async run(args, opts) {
50
+ const result = await exec(args, opts);
51
+ if (result.code !== 0)
52
+ throw new GitError(result);
53
+ return result.stdout;
54
+ },
55
+ };
56
+ };
@@ -0,0 +1,30 @@
1
+ import { type VaultsConfig } from '@agentage/memory-core';
2
+ import { type CouchTargetStatus } from '../couch/manager.js';
3
+ import { type DiscoverStatus } from '../discover/watcher.js';
4
+ import { type SyncResult } from './cycle.js';
5
+ import { type SyncTarget } from './planner.js';
6
+ export interface VaultSyncState {
7
+ vault: string;
8
+ remote: string;
9
+ intervalSeconds: number;
10
+ running: boolean;
11
+ lastRun?: string;
12
+ lastError?: string;
13
+ lastResult?: Pick<SyncResult, 'ok' | 'pushed' | 'committed' | 'conflicts' | 'skipped' | 'reason'>;
14
+ }
15
+ export interface SyncStatus {
16
+ vaults: VaultSyncState[];
17
+ couch?: CouchTargetStatus[];
18
+ discover?: DiscoverStatus;
19
+ }
20
+ export interface SyncManagerDeps {
21
+ getConfig?: () => VaultsConfig;
22
+ runCycle?: (target: SyncTarget) => Promise<SyncResult>;
23
+ }
24
+ export interface SyncManager {
25
+ reschedule(): void;
26
+ runNow(vault: string): Promise<SyncResult>;
27
+ status(): SyncStatus;
28
+ stop(): void;
29
+ }
30
+ export declare const createSyncManager: (deps?: SyncManagerDeps) => SyncManager;
@@ -0,0 +1,114 @@
1
+ import { loadVaultsConfig } from '../../lib/vault/vaults.js';
2
+ import { runSyncCycle } from './cycle.js';
3
+ import { autoSyncTargets, intervalMs, syncTargets } from './planner.js';
4
+ const busy = (t) => ({
5
+ vault: t.vault,
6
+ remote: t.remote,
7
+ ok: true,
8
+ committed: false,
9
+ pushed: false,
10
+ conflicts: [],
11
+ skipped: 'busy',
12
+ });
13
+ // The daemon-side scheduler: owns per-target timers, serialises each target against itself (an
14
+ // in-process running flag - no overlapping cycles), and records last-run/last-error for status.
15
+ export const createSyncManager = (deps = {}) => {
16
+ const getConfig = deps.getConfig ?? (() => loadVaultsConfig().config);
17
+ const runCycle = deps.runCycle ?? runSyncCycle;
18
+ const states = new Map();
19
+ const timers = new Map();
20
+ const keyOf = (t) => `${t.vault}::${t.remoteName}`;
21
+ const ensureState = (t) => {
22
+ const existing = states.get(keyOf(t));
23
+ if (existing) {
24
+ existing.remote = t.remote;
25
+ existing.intervalSeconds = t.intervalSeconds;
26
+ return existing;
27
+ }
28
+ const fresh = {
29
+ vault: t.vault,
30
+ remote: t.remote,
31
+ intervalSeconds: t.intervalSeconds,
32
+ running: false,
33
+ };
34
+ states.set(keyOf(t), fresh);
35
+ return fresh;
36
+ };
37
+ const runTarget = async (t) => {
38
+ const state = ensureState(t);
39
+ if (state.running)
40
+ return busy(t); // serialise: no overlapping cycles per target
41
+ state.running = true;
42
+ try {
43
+ const result = await runCycle(t);
44
+ state.lastRun = new Date().toISOString();
45
+ state.lastError = result.ok ? undefined : result.error;
46
+ state.lastResult = {
47
+ ok: result.ok,
48
+ pushed: result.pushed,
49
+ committed: result.committed,
50
+ conflicts: result.conflicts,
51
+ skipped: result.skipped,
52
+ reason: result.reason,
53
+ };
54
+ return result;
55
+ }
56
+ catch (err) {
57
+ const message = err instanceof Error ? err.message : String(err);
58
+ state.lastRun = new Date().toISOString();
59
+ state.lastError = message;
60
+ state.lastResult = {
61
+ ok: false,
62
+ pushed: false,
63
+ committed: false,
64
+ conflicts: [],
65
+ reason: 'other',
66
+ };
67
+ return {
68
+ vault: t.vault,
69
+ remote: t.remote,
70
+ ok: false,
71
+ committed: false,
72
+ pushed: false,
73
+ conflicts: [],
74
+ reason: 'other',
75
+ error: message,
76
+ };
77
+ }
78
+ finally {
79
+ state.running = false;
80
+ }
81
+ };
82
+ const runNow = async (vault) => {
83
+ const targets = syncTargets(getConfig()).filter((t) => t.vault === vault);
84
+ if (targets.length === 0)
85
+ throw new Error(`no sync origin configured for vault '${vault}'`);
86
+ let last = busy(targets[0]);
87
+ for (const t of targets)
88
+ last = await runTarget(t);
89
+ return last;
90
+ };
91
+ const reschedule = () => {
92
+ for (const timer of timers.values())
93
+ clearInterval(timer);
94
+ timers.clear();
95
+ const all = syncTargets(getConfig());
96
+ const live = new Set(all.map(keyOf));
97
+ for (const key of [...states.keys()])
98
+ if (!live.has(key))
99
+ states.delete(key);
100
+ for (const t of all)
101
+ ensureState(t);
102
+ for (const t of autoSyncTargets(getConfig())) {
103
+ const timer = setInterval(() => void runTarget(t), intervalMs(t.intervalSeconds));
104
+ timer.unref?.();
105
+ timers.set(keyOf(t), timer);
106
+ }
107
+ };
108
+ const stop = () => {
109
+ for (const timer of timers.values())
110
+ clearInterval(timer);
111
+ timers.clear();
112
+ };
113
+ return { reschedule, runNow, status: () => ({ vaults: [...states.values()] }), stop };
114
+ };
@@ -0,0 +1,15 @@
1
+ import { type VaultsConfig } from '@agentage/memory-core';
2
+ export declare const DEFAULT_INTERVAL_SECONDS = 300;
3
+ export declare const DEFAULT_IGNORE: readonly string[];
4
+ export interface SyncTarget {
5
+ vault: string;
6
+ path: string;
7
+ remoteName: string;
8
+ remote: string;
9
+ intervalSeconds: number;
10
+ ignore: string[];
11
+ }
12
+ export declare const resolveIgnore: (ignore: string[] | undefined) => string[];
13
+ export declare const intervalMs: (seconds: number) => number;
14
+ export declare const syncTargets: (config: VaultsConfig) => SyncTarget[];
15
+ export declare const autoSyncTargets: (config: VaultsConfig) => SyncTarget[];
@@ -0,0 +1,46 @@
1
+ import { expandPath } from '@agentage/memory-core';
2
+ import { isSafeRemoteUrl, redactRemoteUrl } from './remote-url.js';
3
+ // The unified schema stores interval as a bare non-negative integer; sync treats it as SECONDS
4
+ // (there is no minutes marker in the schema, so seconds is the least-surprising reading and keeps
5
+ // tests fast). 0 means manual-only (excluded from the daemon auto loop).
6
+ export const DEFAULT_INTERVAL_SECONDS = 300;
7
+ // When `ignore` is absent these editor/runtime files are excluded from sync; a set value REPLACES
8
+ // the defaults, and an empty array syncs everything.
9
+ export const DEFAULT_IGNORE = ['.obsidian/', 'data.json'];
10
+ // The reserved cloud channel is never synced over external git (that path is out of scope here).
11
+ const RESERVED_REMOTE = 'agentage';
12
+ export const resolveIgnore = (ignore) => ignore === undefined ? [...DEFAULT_IGNORE] : ignore;
13
+ export const intervalMs = (seconds) => Math.max(0, Math.floor(seconds)) * 1000;
14
+ // One vault may carry several origins; each gets a distinct remote name within its own repo.
15
+ const remoteNameFor = (index) => (index === 0 ? 'sync' : `sync-${index}`);
16
+ // Flatten (vault, origin) pairs into sync targets. A target needs a local `path` (the working
17
+ // copy to commit/push from) AND an external origin; origin-only entries (cloud remote backends)
18
+ // and the reserved cloud channel are skipped.
19
+ export const syncTargets = (config) => {
20
+ const out = [];
21
+ for (const [vault, entry] of Object.entries(config.vaults ?? {})) {
22
+ if (!entry.path || !entry.origin?.length)
23
+ continue;
24
+ entry.origin.forEach((origin, index) => {
25
+ const remote = origin.remote.trim();
26
+ if (!remote || remote === RESERVED_REMOTE)
27
+ return;
28
+ // One poisoned origin must not run code or kill the whole cycle: skip it with a warning.
29
+ if (!isSafeRemoteUrl(remote)) {
30
+ console.warn(`agentage: skipping unsafe remote for vault '${vault}': ${redactRemoteUrl(remote)}`);
31
+ return;
32
+ }
33
+ out.push({
34
+ vault,
35
+ path: expandPath(entry.path),
36
+ remoteName: remoteNameFor(index),
37
+ remote,
38
+ intervalSeconds: origin.interval ?? DEFAULT_INTERVAL_SECONDS,
39
+ ignore: resolveIgnore(origin.ignore),
40
+ });
41
+ });
42
+ }
43
+ return out;
44
+ };
45
+ // The targets the daemon auto-loop schedules: interval 0 is manual-only and excluded.
46
+ export const autoSyncTargets = (config) => syncTargets(config).filter((t) => t.intervalSeconds > 0);
@@ -0,0 +1,3 @@
1
+ export declare const isSafeRemoteUrl: (remote: string) => boolean;
2
+ export declare const assertSafeRemoteUrl: (remote: string) => void;
3
+ export declare const redactRemoteUrl: (remote: string) => string;