@agentage/cli 0.0.3 → 0.25.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.
Files changed (160) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +79 -23
  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} +2 -2
  14. package/dist/commands/status/status.js +88 -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 +99 -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/credentials.d.ts +12 -0
  44. package/dist/lib/auth/credentials.js +50 -0
  45. package/dist/lib/auth/env-match.d.ts +9 -0
  46. package/dist/lib/auth/env-match.js +17 -0
  47. package/dist/lib/auth/introspect.d.ts +7 -0
  48. package/dist/lib/auth/introspect.js +89 -0
  49. package/dist/lib/{oauth.d.ts → auth/oauth.d.ts} +6 -2
  50. package/dist/lib/{oauth.js → auth/oauth.js} +26 -7
  51. package/dist/lib/auth/provision.d.ts +14 -0
  52. package/dist/lib/auth/provision.js +71 -0
  53. package/dist/lib/daemon/daemon-client.d.ts +39 -0
  54. package/dist/lib/daemon/daemon-client.js +134 -0
  55. package/dist/lib/daemon/daemon-pref.d.ts +2 -0
  56. package/dist/lib/daemon/daemon-pref.js +10 -0
  57. package/dist/lib/{config.d.ts → fs/config.d.ts} +2 -1
  58. package/dist/lib/fs/config.js +55 -0
  59. package/dist/lib/fs/file-lock.d.ts +3 -0
  60. package/dist/lib/fs/file-lock.js +163 -0
  61. package/dist/lib/memory/memory-client.d.ts +30 -0
  62. package/dist/lib/memory/memory-client.js +98 -0
  63. package/dist/lib/net/http.d.ts +6 -0
  64. package/dist/lib/net/http.js +32 -0
  65. package/dist/lib/{origins.d.ts → net/origins.d.ts} +1 -1
  66. package/dist/lib/net/origins.js +39 -0
  67. package/dist/lib/net/user-agent.d.ts +6 -0
  68. package/dist/lib/net/user-agent.js +7 -0
  69. package/dist/lib/status/format.d.ts +1 -0
  70. package/dist/lib/status/format.js +12 -0
  71. package/dist/lib/status/status-info.d.ts +46 -0
  72. package/dist/lib/status/status-info.js +109 -0
  73. package/dist/lib/status/vaults-format.d.ts +2 -0
  74. package/dist/lib/status/vaults-format.js +44 -0
  75. package/dist/lib/status/vaults-status.d.ts +12 -0
  76. package/dist/lib/status/vaults-status.js +53 -0
  77. package/dist/lib/update/update-cache.d.ts +14 -0
  78. package/dist/lib/update/update-cache.js +63 -0
  79. package/dist/lib/update/update-check.d.ts +27 -0
  80. package/dist/lib/update/update-check.js +50 -0
  81. package/dist/lib/update/update-lock.d.ts +2 -0
  82. package/dist/lib/update/update-lock.js +109 -0
  83. package/dist/lib/vault/vault-registry.d.ts +12 -0
  84. package/dist/lib/vault/vault-registry.js +82 -0
  85. package/dist/lib/vault/vaults.d.ts +10 -0
  86. package/dist/lib/vault/vaults.js +66 -0
  87. package/dist/lib/vault/vaults.schema.d.ts +2 -0
  88. package/dist/lib/vault/vaults.schema.js +10 -0
  89. package/dist/mcp/local-server.d.ts +4 -0
  90. package/dist/mcp/local-server.js +26 -0
  91. package/dist/sync/couch/cycle.d.ts +2 -0
  92. package/dist/sync/couch/cycle.js +58 -0
  93. package/dist/sync/couch/discovery.d.ts +22 -0
  94. package/dist/sync/couch/discovery.js +34 -0
  95. package/dist/sync/couch/file-store.d.ts +2 -0
  96. package/dist/sync/couch/file-store.js +47 -0
  97. package/dist/sync/couch/local-commit.d.ts +2 -0
  98. package/dist/sync/couch/local-commit.js +24 -0
  99. package/dist/sync/couch/manager.d.ts +4 -0
  100. package/dist/sync/couch/manager.fixtures.d.ts +20 -0
  101. package/dist/sync/couch/manager.fixtures.js +56 -0
  102. package/dist/sync/couch/manager.js +126 -0
  103. package/dist/sync/couch/manager.types.d.ts +81 -0
  104. package/dist/sync/couch/manager.types.js +1 -0
  105. package/dist/sync/couch/mutation-target.d.ts +5 -0
  106. package/dist/sync/couch/mutation-target.js +33 -0
  107. package/dist/sync/couch/push-on-write.d.ts +3 -0
  108. package/dist/sync/couch/push-on-write.js +33 -0
  109. package/dist/sync/couch/state-store.d.ts +3 -0
  110. package/dist/sync/couch/state-store.js +28 -0
  111. package/dist/sync/couch/targets.d.ts +9 -0
  112. package/dist/sync/couch/targets.js +23 -0
  113. package/dist/sync/couch/wire.d.ts +6 -0
  114. package/dist/sync/couch/wire.js +16 -0
  115. package/dist/sync/discover/watcher.d.ts +26 -0
  116. package/dist/sync/discover/watcher.js +159 -0
  117. package/dist/sync/git/conflict.d.ts +1 -0
  118. package/dist/sync/git/conflict.js +16 -0
  119. package/dist/sync/git/cycle.d.ts +18 -0
  120. package/dist/sync/git/cycle.js +158 -0
  121. package/dist/sync/git/git-exec.d.ts +22 -0
  122. package/dist/sync/git/git-exec.js +56 -0
  123. package/dist/sync/git/manager.d.ts +30 -0
  124. package/dist/sync/git/manager.js +114 -0
  125. package/dist/sync/git/planner.d.ts +15 -0
  126. package/dist/sync/git/planner.js +46 -0
  127. package/dist/sync/git/remote-url.d.ts +3 -0
  128. package/dist/sync/git/remote-url.js +30 -0
  129. package/dist/utils/version.d.ts +0 -1
  130. package/dist/utils/version.js +0 -1
  131. package/package.json +20 -7
  132. package/dist/cli.d.ts.map +0 -1
  133. package/dist/cli.js.map +0 -1
  134. package/dist/commands/setup.d.ts.map +0 -1
  135. package/dist/commands/setup.js.map +0 -1
  136. package/dist/commands/status.d.ts.map +0 -1
  137. package/dist/commands/status.js +0 -36
  138. package/dist/commands/status.js.map +0 -1
  139. package/dist/index.d.ts.map +0 -1
  140. package/dist/index.js.map +0 -1
  141. package/dist/lib/api.d.ts +0 -12
  142. package/dist/lib/api.d.ts.map +0 -1
  143. package/dist/lib/api.js +0 -46
  144. package/dist/lib/api.js.map +0 -1
  145. package/dist/lib/callback-server.d.ts.map +0 -1
  146. package/dist/lib/callback-server.js.map +0 -1
  147. package/dist/lib/config.d.ts.map +0 -1
  148. package/dist/lib/config.js +0 -34
  149. package/dist/lib/config.js.map +0 -1
  150. package/dist/lib/oauth.d.ts.map +0 -1
  151. package/dist/lib/oauth.js.map +0 -1
  152. package/dist/lib/origins.d.ts.map +0 -1
  153. package/dist/lib/origins.js +0 -26
  154. package/dist/lib/origins.js.map +0 -1
  155. package/dist/lib/status-info.d.ts +0 -18
  156. package/dist/lib/status-info.d.ts.map +0 -1
  157. package/dist/lib/status-info.js +0 -39
  158. package/dist/lib/status-info.js.map +0 -1
  159. package/dist/utils/version.d.ts.map +0 -1
  160. package/dist/utils/version.js.map +0 -1
@@ -0,0 +1,53 @@
1
+ import { isAccountVault } from '@agentage/memory-core';
2
+ import { loadVaultsConfig } from '../vault/vaults.js';
3
+ // Config alone decides the channel: an `agentage` origin is the cloud (couch) channel, any other
4
+ // origin is an external git remote, and no origin at all is a local-only vault (nothing to sync).
5
+ const channelOf = (entry) => {
6
+ if (isAccountVault(entry))
7
+ return 'cloud';
8
+ return entry.origin?.some((o) => o.remote.trim() && o.remote.trim() !== 'agentage')
9
+ ? 'git'
10
+ : 'local';
11
+ };
12
+ // Live state from the daemon wins; a local-only vault is `idle` (nothing to sync), and any synced
13
+ // vault with no daemon report is `unknown` (daemon down or the vault not yet scheduled).
14
+ const stateFrom = (channel, live, daemonUp) => {
15
+ if (channel === 'local')
16
+ return 'idle';
17
+ if (!daemonUp)
18
+ return 'unknown';
19
+ if (!live)
20
+ return 'unknown';
21
+ if (live.lastError)
22
+ return 'error';
23
+ if (live.running)
24
+ return 'syncing';
25
+ return live.lastRun ? 'ok' : 'idle';
26
+ };
27
+ // Index the daemon's per-vault reports by name across both channels into one lookup.
28
+ const indexLive = (sync) => {
29
+ const map = new Map();
30
+ for (const v of sync?.vaults ?? [])
31
+ map.set(v.vault, { running: v.running, lastError: v.lastError, lastRun: v.lastRun });
32
+ for (const c of sync?.couch ?? [])
33
+ map.set(c.vault, { running: c.running, lastError: c.lastError, lastRun: c.lastSync });
34
+ return map;
35
+ };
36
+ // The full per-vault picture: every configured vault (so local-only vaults are never hidden),
37
+ // classified by channel and annotated with the daemon's live sync state when available.
38
+ export const buildVaultStatuses = (sync, daemonUp, config = loadVaultsConfig().config) => {
39
+ const live = indexLive(sync);
40
+ return Object.entries(config.vaults ?? {})
41
+ .map(([name, entry]) => {
42
+ const channel = channelOf(entry);
43
+ const l = live.get(name);
44
+ return {
45
+ name,
46
+ channel,
47
+ status: stateFrom(channel, l, daemonUp),
48
+ lastRun: l?.lastRun,
49
+ lastError: l?.lastError,
50
+ };
51
+ })
52
+ .sort((a, b) => a.name.localeCompare(b.name));
53
+ };
@@ -0,0 +1,14 @@
1
+ export interface UpdateCache {
2
+ checkedAt: number;
3
+ latest: string | null;
4
+ }
5
+ export declare const readUpdateCache: () => UpdateCache | null;
6
+ export declare const updateHint: () => string | null;
7
+ export declare const fetchLatestVersion: (timeoutMs: number, url?: string) => Promise<string | null>;
8
+ export interface RefreshDeps {
9
+ now?: () => number;
10
+ read?: () => UpdateCache | null;
11
+ fetch?: () => Promise<string | null>;
12
+ write?: (latest: string | null, now: number) => void;
13
+ }
14
+ export declare const refreshUpdateCache: (deps?: RefreshDeps) => Promise<void>;
@@ -0,0 +1,63 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { ensureConfigDir, getConfigDir } from '../fs/config.js';
4
+ import { fetchJsonUnref } from '../net/http.js';
5
+ import { compareVersions, INSTALL_HINT, REGISTRY_URL } from './update-check.js';
6
+ import { VERSION } from '../../utils/version.js';
7
+ const CACHE_FILE = 'update-check.json';
8
+ const TTL_MS = 60 * 60 * 1000; // passive checks refresh at most once an hour
9
+ const BG_TIMEOUT_MS = 3000; // hard cap on the whole background attempt, connect included
10
+ const cachePath = () => join(getConfigDir(), CACHE_FILE);
11
+ export const readUpdateCache = () => {
12
+ try {
13
+ const c = JSON.parse(readFileSync(cachePath(), 'utf-8'));
14
+ return typeof c.checkedAt === 'number' ? c : null;
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ };
20
+ const writeUpdateCache = (latest, now) => {
21
+ ensureConfigDir();
22
+ writeFileSync(cachePath(), JSON.stringify({ checkedAt: now, latest }) + '\n', 'utf-8');
23
+ };
24
+ // The one dim hint line printed after a command, read purely from the cache (never the network),
25
+ // only when the cached latest is newer than us. null = print nothing.
26
+ export const updateHint = () => {
27
+ const c = readUpdateCache();
28
+ if (!c?.latest)
29
+ return null;
30
+ return compareVersions(VERSION, c.latest) < 0
31
+ ? `update available: ${c.latest} -> ${INSTALL_HINT}`
32
+ : null;
33
+ };
34
+ // Shares fetchJsonUnref (node:https, unref'd timer) so an offline background check never stalls
35
+ // process exit the way an aborted global fetch does.
36
+ export const fetchLatestVersion = async (timeoutMs, url = REGISTRY_URL) => {
37
+ const res = await fetchJsonUnref(url, timeoutMs);
38
+ if (!res?.ok)
39
+ return null;
40
+ const v = res.json?.version;
41
+ return typeof v === 'string' ? v : null;
42
+ };
43
+ // Fire-and-forget refresh, throttled by the TTL. checkedAt advances on FAILURE too (keeping any
44
+ // previously known version), so an offline machine retries once an hour, not on every command.
45
+ export const refreshUpdateCache = async (deps = {}) => {
46
+ const now = (deps.now ?? Date.now)();
47
+ const cached = (deps.read ?? readUpdateCache)();
48
+ if (cached && now - cached.checkedAt < TTL_MS)
49
+ return;
50
+ let version = null;
51
+ try {
52
+ version = await (deps.fetch ?? (() => fetchLatestVersion(BG_TIMEOUT_MS)))();
53
+ }
54
+ catch {
55
+ version = null;
56
+ }
57
+ try {
58
+ (deps.write ?? writeUpdateCache)(version ?? cached?.latest ?? null, now);
59
+ }
60
+ catch {
61
+ // total silence: a background check never disrupts a command
62
+ }
63
+ };
@@ -0,0 +1,27 @@
1
+ export declare const INSTALL_HINT = "npm i -g @agentage/cli@latest";
2
+ export declare const REGISTRY_URL = "https://registry.npmjs.org/@agentage/cli/latest";
3
+ export interface CliLatest {
4
+ version: string | null;
5
+ minSupported: string;
6
+ message: string | null;
7
+ }
8
+ export type UpdateStatus = {
9
+ kind: 'current';
10
+ } | {
11
+ kind: 'update-available';
12
+ latest: string;
13
+ } | {
14
+ kind: 'unsupported';
15
+ latest: string | null;
16
+ minSupported: string;
17
+ } | {
18
+ kind: 'unknown';
19
+ };
20
+ export interface UpdateInfo {
21
+ status: UpdateStatus;
22
+ message: string | null;
23
+ }
24
+ export declare const compareVersions: (a: string, b: string) => number;
25
+ export declare const fetchCliLatest: (timeoutMs?: number) => Promise<CliLatest | null>;
26
+ export declare const evaluateUpdate: (installed: string, latest: CliLatest | null) => UpdateInfo;
27
+ export declare const checkForUpdate: (installed: string) => Promise<UpdateInfo>;
@@ -0,0 +1,50 @@
1
+ // Self-update check. Reads the latest published version straight from the public npm registry
2
+ // (the canonical source of truth for a published package). Never throws: an unreachable registry
3
+ // or any malformed payload yields 'unknown'.
4
+ import { fetchJsonUnref } from '../net/http.js';
5
+ export const INSTALL_HINT = 'npm i -g @agentage/cli@latest';
6
+ export const REGISTRY_URL = 'https://registry.npmjs.org/@agentage/cli/latest';
7
+ // Compare dotted numeric versions (major.minor.patch); prerelease/build suffixes are
8
+ // ignored - good enough for an update hint. Returns -1 / 0 / 1.
9
+ export const compareVersions = (a, b) => {
10
+ const parts = (v) => v
11
+ .split('-')[0]
12
+ .split('.')
13
+ .map((n) => Number.parseInt(n, 10) || 0);
14
+ const pa = parts(a);
15
+ const pb = parts(b);
16
+ for (let i = 0; i < 3; i++) {
17
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
18
+ if (d !== 0)
19
+ return d < 0 ? -1 : 1;
20
+ }
21
+ return 0;
22
+ };
23
+ export const fetchCliLatest = async (timeoutMs = 5000) => {
24
+ // node:https via fetchJsonUnref, not global fetch: undici's ref'd connect timer stalls exit.
25
+ const res = await fetchJsonUnref(REGISTRY_URL, timeoutMs);
26
+ if (!res?.ok)
27
+ return null;
28
+ const body = res.json;
29
+ const version = typeof body?.version === 'string' ? body.version : null;
30
+ if (!version)
31
+ return null;
32
+ // The registry carries no support floor or notice, so neither gates an update hint.
33
+ return { version, minSupported: '0.0.0', message: null };
34
+ };
35
+ export const evaluateUpdate = (installed, latest) => {
36
+ if (!latest)
37
+ return { status: { kind: 'unknown' }, message: null };
38
+ const message = latest.message;
39
+ if (compareVersions(installed, latest.minSupported) < 0) {
40
+ return {
41
+ status: { kind: 'unsupported', latest: latest.version, minSupported: latest.minSupported },
42
+ message,
43
+ };
44
+ }
45
+ if (latest.version && compareVersions(installed, latest.version) < 0) {
46
+ return { status: { kind: 'update-available', latest: latest.version }, message };
47
+ }
48
+ return { status: { kind: 'current' }, message };
49
+ };
50
+ export const checkForUpdate = async (installed) => evaluateUpdate(installed, await fetchCliLatest());
@@ -0,0 +1,2 @@
1
+ export declare const acquireUpdateLock: (now?: number) => boolean;
2
+ export declare const releaseUpdateLock: () => void;
@@ -0,0 +1,109 @@
1
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { ensureConfigDir, getConfigDir } from '../fs/config.js';
4
+ const LOCK_FILE = 'update.lock';
5
+ const LOCK_TTL_MS = 10 * 60 * 1000; // a lock older than this whose holder is gone is a crashed run
6
+ // Past this age a holder is taken over even if its pid still resolves (pid reuse after a crash); far
7
+ // beyond any real run or scheduler pause, so a live-but-slow holder is never mistaken for crashed.
8
+ const STALE_HARD_MS = 30 * 60 * 1000;
9
+ const lockPath = () => join(getConfigDir(), LOCK_FILE);
10
+ // The lock file holds "<pid> <timestamp>": pid gates crash-takeover, the trailing token is the
11
+ // acquisition time. A legacy single-token file parses as pid 0 (unknown holder), aging out on TTL.
12
+ const holderOf = (path) => {
13
+ try {
14
+ const parts = readFileSync(path, 'utf-8').trim().split(/\s+/);
15
+ const at = Number.parseInt(parts[parts.length - 1] ?? '', 10);
16
+ if (Number.isNaN(at))
17
+ return null;
18
+ const pid = parts.length >= 2 ? Number.parseInt(parts[0] ?? '', 10) : 0;
19
+ return { pid: Number.isNaN(pid) ? 0 : pid, at };
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ };
25
+ // Same-host liveness probe. ESRCH = the holder is gone (crashed); EPERM = alive but owned by another
26
+ // user. pid 0/unknown counts as gone so a legacy or malformed lock still ages out on the TTL.
27
+ const holderAlive = (pid) => {
28
+ if (pid <= 0)
29
+ return false;
30
+ try {
31
+ process.kill(pid, 0);
32
+ return true;
33
+ }
34
+ catch (err) {
35
+ return err.code === 'EPERM';
36
+ }
37
+ };
38
+ // A holder is safe to take over only once it is past the TTL AND provably gone: a dead pid, our own
39
+ // leftover pid, or so old that pid reuse is the only explanation. A live holder merely starved of
40
+ // CPU is never stolen - stealing it would let two writers run concurrently.
41
+ const isCrashed = (holder, now) => {
42
+ const age = now - holder.at;
43
+ if (age < LOCK_TTL_MS)
44
+ return false;
45
+ if (age >= STALE_HARD_MS)
46
+ return true;
47
+ return holder.pid === process.pid || !holderAlive(holder.pid);
48
+ };
49
+ const unlinkQuiet = (path) => {
50
+ try {
51
+ unlinkSync(path);
52
+ }
53
+ catch {
54
+ // already gone
55
+ }
56
+ };
57
+ // Serialize stale-lock takeover behind its own exclusive create, so no taker can ever delete a
58
+ // lock that turned FRESH mid-takeover (the ABA race: reader sees the stale timestamp, a winner
59
+ // re-creates the lock, the reader deletes the winner's file). Under the guard the staleness is
60
+ // re-checked and only a genuinely stale lock is cleared; losers simply refuse. A guard left by a
61
+ // crashed taker expires like the lock itself. Returns whether to re-race the create.
62
+ const takeOverStale = (now) => {
63
+ const guard = `${lockPath()}.takeover`;
64
+ const guardHeld = holderOf(guard);
65
+ if (guardHeld !== null && isCrashed(guardHeld, now))
66
+ unlinkQuiet(guard);
67
+ try {
68
+ writeFileSync(guard, `${process.pid} ${now}`, { flag: 'wx' });
69
+ }
70
+ catch {
71
+ return false; // another taker is mid-takeover; let it win
72
+ }
73
+ try {
74
+ const holder = holderOf(lockPath());
75
+ if (holder !== null && !isCrashed(holder, now))
76
+ return false; // fresh or a live-but-slow holder
77
+ unlinkQuiet(lockPath());
78
+ return true;
79
+ }
80
+ finally {
81
+ unlinkQuiet(guard);
82
+ }
83
+ };
84
+ // Acquire the single-writer update lock. The O_EXCL ('wx') create IS the mutex - concurrent runs
85
+ // cannot both win it, unlike a check-then-write. A fresh holder (younger than the TTL) refuses;
86
+ // a stale one (crashed run) is cleared under the takeover guard and re-raced once. `now` is
87
+ // injectable.
88
+ export const acquireUpdateLock = (now = Date.now()) => {
89
+ ensureConfigDir();
90
+ for (let attempt = 0; attempt < 2; attempt++) {
91
+ try {
92
+ writeFileSync(lockPath(), `${process.pid} ${now}`, { flag: 'wx' });
93
+ return true;
94
+ }
95
+ catch {
96
+ const holder = holderOf(lockPath());
97
+ if (holder !== null && !isCrashed(holder, now))
98
+ return false;
99
+ if (!takeOverStale(now))
100
+ return false;
101
+ }
102
+ }
103
+ return false;
104
+ };
105
+ export const releaseUpdateLock = () => {
106
+ const path = lockPath();
107
+ if (existsSync(path))
108
+ unlinkSync(path);
109
+ };
@@ -0,0 +1,12 @@
1
+ import { type VaultEntry, type VaultsConfig } from '@agentage/memory-core';
2
+ export type VaultType = 'account' | 'git' | 'local' | 'remote';
3
+ export declare const vaultType: (entry: VaultEntry) => VaultType;
4
+ export declare const ensureVaultDir: (path: string) => void;
5
+ export declare const formatVaultLine: (name: string, entry: VaultEntry) => string;
6
+ export declare const redactEntry: (entry: VaultEntry) => VaultEntry;
7
+ export declare const addVault: (config: VaultsConfig, name: string, entry: VaultEntry) => VaultsConfig;
8
+ export declare const removeVault: (config: VaultsConfig, name: string) => VaultsConfig;
9
+ export declare const appendDiscoverIgnore: (config: VaultsConfig, name: string, vaultPath: string) => {
10
+ config: VaultsConfig;
11
+ root: string;
12
+ } | null;
@@ -0,0 +1,82 @@
1
+ import { existsSync, mkdirSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { expandPath, isAccountVault, validateConfig, } from '@agentage/memory-core';
4
+ import { redactRemoteUrl } from '../../sync/git/remote-url.js';
5
+ import { isValidVaultName } from './vaults.schema.js';
6
+ // The human-facing type of an entry: an agentage origin is an account vault (local mirror +
7
+ // cloud channel); otherwise a path with an external origin is git, a bare path is local, and an
8
+ // origin without a path is remote.
9
+ export const vaultType = (entry) => {
10
+ if (isAccountVault(entry))
11
+ return 'account';
12
+ if (entry.path)
13
+ return entry.origin?.length ? 'git' : 'local';
14
+ return 'remote';
15
+ };
16
+ // A local vault's markdown directory is created on `vault add` (~ is expanded).
17
+ export const ensureVaultDir = (path) => {
18
+ const dir = expandPath(path);
19
+ if (!existsSync(dir))
20
+ mkdirSync(dir, { recursive: true });
21
+ };
22
+ export const formatVaultLine = (name, entry) => {
23
+ const kind = vaultType(entry);
24
+ const rawWhere = entry.path ? expandPath(entry.path) : (entry.origin?.[0]?.remote ?? '');
25
+ const where = entry.path ? rawWhere : redactRemoteUrl(rawWhere);
26
+ // Only an external git remote is worth echoing; the account channel is implied by the type.
27
+ const remote = kind === 'git' && entry.origin?.length
28
+ ? ` <- ${redactRemoteUrl(entry.origin[0].remote)}`
29
+ : '';
30
+ return `${name.padEnd(16)} ${kind.padEnd(8)} ${where}${remote}`;
31
+ };
32
+ // A copy of `entry` with every origin remote redacted (credentials stripped) for display/JSON;
33
+ // the on-disk value is never mutated.
34
+ export const redactEntry = (entry) => entry.origin?.length
35
+ ? { ...entry, origin: entry.origin.map((o) => ({ ...o, remote: redactRemoteUrl(o.remote) })) }
36
+ : entry;
37
+ // Add an entry under `name`; the first vault added also becomes the `default`.
38
+ export const addVault = (config, name, entry) => {
39
+ if (!isValidVaultName(name))
40
+ throw new Error(`invalid vault name: ${JSON.stringify(name)}`);
41
+ const vaults = config.vaults ?? {};
42
+ if (vaults[name])
43
+ throw new Error(`vault '${name}' already exists`);
44
+ return validateConfig({
45
+ ...config,
46
+ default: config.default ?? name,
47
+ vaults: { ...vaults, [name]: entry },
48
+ });
49
+ };
50
+ // Remove an entry; if it was the `default`, reassign to a remaining vault or drop the field.
51
+ export const removeVault = (config, name) => {
52
+ const vaults = config.vaults ?? {};
53
+ if (!vaults[name])
54
+ throw new Error(`vault '${name}' not found`);
55
+ const rest = { ...vaults };
56
+ delete rest[name];
57
+ const next = { ...config, vaults: rest };
58
+ if (config.default === name) {
59
+ const fallback = Object.keys(rest)[0];
60
+ if (fallback)
61
+ next.default = fallback;
62
+ else
63
+ delete next.default;
64
+ }
65
+ return validateConfig(next);
66
+ };
67
+ // V8: when a removed vault sat directly under a discover root, append its name to that root's
68
+ // `ignore` so the next scan does not re-add it. Returns the updated config plus the matched root
69
+ // path (for the message), or null when the path is not directly under any discover root.
70
+ export const appendDiscoverIgnore = (config, name, vaultPath) => {
71
+ const parent = resolve(expandPath(vaultPath), '..');
72
+ const roots = config.discover ?? [];
73
+ const idx = roots.findIndex((r) => resolve(expandPath(r.path)) === parent);
74
+ if (idx === -1)
75
+ return null;
76
+ const root = roots[idx];
77
+ if (root.ignore?.includes(name))
78
+ return { config, root: root.path };
79
+ const next = [...roots];
80
+ next[idx] = { ...root, ignore: [...(root.ignore ?? []), name] };
81
+ return { config: validateConfig({ ...config, discover: next }), root: root.path };
82
+ };
@@ -0,0 +1,10 @@
1
+ import { type VaultsConfig } from '@agentage/memory-core';
2
+ export declare const vaultsJsonPath: () => string;
3
+ export interface LoadedVaults {
4
+ config: VaultsConfig;
5
+ source: string | null;
6
+ }
7
+ export declare const loadVaultsConfig: () => LoadedVaults;
8
+ export declare const ensureVaultsConfig: () => void;
9
+ export declare const saveVaultsConfig: (config: VaultsConfig) => string;
10
+ export declare const mutateVaultsConfig: (fn: (config: VaultsConfig) => VaultsConfig | null | void) => Promise<VaultsConfig>;
@@ -0,0 +1,66 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { chmodSync, existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { validateConfig } from '@agentage/memory-core';
5
+ import { ensureConfigDir, getConfigDir } from '../fs/config.js';
6
+ import { withFileLock } from '../fs/file-lock.js';
7
+ import { VAULTS_SCHEMA_URL } from './vaults.schema.js';
8
+ export const vaultsJsonPath = () => join(getConfigDir(), 'vaults.json');
9
+ const EMPTY = { version: 1, vaults: {} };
10
+ // JSON only (one format everywhere; the standalone @agentage/server-memory reads the same file
11
+ // and parses only JSON). A missing file yields an empty config; a bad shape throws ConfigError.
12
+ export const loadVaultsConfig = () => {
13
+ const path = vaultsJsonPath();
14
+ if (!existsSync(path))
15
+ return { config: EMPTY, source: null };
16
+ const raw = JSON.parse(readFileSync(path, 'utf-8'));
17
+ return { config: validateConfig(raw), source: path };
18
+ };
19
+ // Scaffold an empty, $schema-linked vaults.json when none exists yet, so a fresh machine always
20
+ // has an editable file with editor autocomplete. Leaves any existing file untouched.
21
+ export const ensureVaultsConfig = () => {
22
+ if (existsSync(vaultsJsonPath()))
23
+ return;
24
+ saveVaultsConfig(EMPTY);
25
+ };
26
+ // Atomic 0600 write. Re-injects the canonical $schema first and preserves every other field.
27
+ export const saveVaultsConfig = (config) => {
28
+ ensureConfigDir();
29
+ const path = vaultsJsonPath();
30
+ const out = { $schema: VAULTS_SCHEMA_URL, version: config.version };
31
+ if (config.vaultsDir !== undefined)
32
+ out.vaultsDir = config.vaultsDir;
33
+ if (config.autodiscover !== undefined)
34
+ out.autodiscover = config.autodiscover;
35
+ if (config.autoInit !== undefined)
36
+ out.autoInit = config.autoInit;
37
+ if (config.default !== undefined)
38
+ out.default = config.default;
39
+ if (config.discover !== undefined)
40
+ out.discover = config.discover;
41
+ out.vaults = config.vaults ?? {};
42
+ // A per-save tmp name: two concurrent savers can never clobber each other's tmp file.
43
+ const tmp = `${path}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
44
+ writeFileSync(tmp, JSON.stringify(out, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });
45
+ renameSync(tmp, path);
46
+ chmodSync(path, 0o600);
47
+ return path;
48
+ };
49
+ // Cross-process-safe read-modify-write on vaults.json (issue #231). Under the advisory lock, re-read
50
+ // FRESH from disk, apply `fn`, then atomic-save - so a concurrent `vault add` or the daemon's
51
+ // discovery write-back can never clobber each other's update. `fn` may return a new config, mutate
52
+ // the fresh one in place and return void, or return null to skip the write entirely (a no-op scan
53
+ // must not re-touch the file, else the daemon's config watcher would loop). Returns the config on
54
+ // disk after the call. A plain read stays lock-free via loadVaultsConfig.
55
+ export const mutateVaultsConfig = async (fn) => {
56
+ ensureConfigDir();
57
+ return withFileLock(vaultsJsonPath(), () => {
58
+ const fresh = loadVaultsConfig().config;
59
+ const next = fn(fresh);
60
+ if (next === null)
61
+ return fresh; // explicit no-op: leave the file untouched
62
+ const result = next ?? fresh;
63
+ saveVaultsConfig(result);
64
+ return result;
65
+ });
66
+ };
@@ -0,0 +1,2 @@
1
+ export declare const VAULTS_SCHEMA_URL = "https://agentage.io/schemas/vaults.schema.json";
2
+ export { isValidVaultName, VAULT_NAME_PATTERN } from '@agentage/memory-core';
@@ -0,0 +1,10 @@
1
+ // The vaults.json format is owned by @agentage/memory-core (validateConfig / VaultsConfig).
2
+ // This module keeps the one CLI-surface piece memory-core does not: the editor-tooling $schema
3
+ // pointer written on create. Name validation re-exports memory-core's single source of truth.
4
+ // The public JSON Schema location (served by the landing). Written into the file on create so
5
+ // editors get autocomplete + inline validation; accepted-and-ignored on load. A fixed
6
+ // production URL by design: editor tooling fetches it regardless of the CLI's target FQDN.
7
+ export const VAULTS_SCHEMA_URL = 'https://agentage.io/schemas/vaults.schema.json';
8
+ // ADR-013 AA-4: every name stays a valid cloud path segment (`<sub>/<name>.git`). memory-core's
9
+ // schema accepts any string key, so the CLI guards the name at the add surface with its allowlist.
10
+ export { isValidVaultName, VAULT_NAME_PATTERN } from '@agentage/memory-core';
@@ -0,0 +1,4 @@
1
+ import { type VaultRegistry } from '@agentage/memory-core';
2
+ import { type McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ export declare const createLocalMemoryServer: (registry: VaultRegistry) => McpServer;
4
+ export declare const loadLocalMemoryServer: () => Promise<McpServer>;
@@ -0,0 +1,26 @@
1
+ import { createRegistry } from '@agentage/memory-core';
2
+ import { createMemoryServer } from '@agentage/server-memory';
3
+ import { loadVaultsConfig } from '../lib/vault/vaults.js';
4
+ import { VERSION } from '../utils/version.js';
5
+ // Restrict a registry to its offline (git working-copy) vaults, so the local MCP surface exposes
6
+ // exactly what the DirectClient does - never a network-backed vault over the no-auth loopback.
7
+ const localOnly = (registry) => {
8
+ const local = registry.list().filter((h) => h.backend.capabilities().kind === 'local');
9
+ const byId = new Map(local.map((h) => [h.id, h]));
10
+ const def = registry.default();
11
+ const defaultHandle = def && byId.has(def.id) ? def : undefined;
12
+ return {
13
+ list: () => local,
14
+ get: (id) => byId.get(id),
15
+ default: () => defaultHandle,
16
+ surfaced: () => local,
17
+ watch: () => () => { },
18
+ close: async () => { },
19
+ };
20
+ };
21
+ // Build the frozen 6-tool MCP server over one registry, reusing @agentage/server-memory's contract
22
+ // layer verbatim (descriptions, schemas, annotations, per-connection instructions) - zero drift.
23
+ export const createLocalMemoryServer = (registry) => createMemoryServer(localOnly(registry), { scope: 'local', version: VERSION });
24
+ // The whole local stack: read this machine's vaults.json (the same config the daemon + CLI verbs
25
+ // load) -> registry -> an MCP server over the surfaced local vaults.
26
+ export const loadLocalMemoryServer = async () => createLocalMemoryServer(await createRegistry(loadVaultsConfig().config));
@@ -0,0 +1,2 @@
1
+ import { type CouchRuntime, type CouchSyncResult, type TargetState } from './manager.types.js';
2
+ export declare const runCouchCycle: (rt: CouchRuntime, st: TargetState) => Promise<CouchSyncResult>;
@@ -0,0 +1,58 @@
1
+ import { ensureWire, pendingCount } from './wire.js';
2
+ // One couch cycle: commit dirty local truth first, drain queued pushes/deletions, then push+pull.
3
+ // Every failure is caught and recorded (lastError / paused); it never throws to the caller.
4
+ export const runCouchCycle = async (rt, st) => {
5
+ const vault = st.target.vault;
6
+ const build = (extra) => ({
7
+ vault,
8
+ channel: 'couch',
9
+ ok: true,
10
+ committed: false,
11
+ pulled: false,
12
+ pendingCount: pendingCount(st),
13
+ ...extra,
14
+ });
15
+ if (st.running)
16
+ return build({});
17
+ st.running = true;
18
+ try {
19
+ const bearer = await rt.getBearer();
20
+ if (!bearer) {
21
+ st.paused = 'signed out';
22
+ st.lastError = undefined;
23
+ return build({ paused: 'signed out' });
24
+ }
25
+ const decision = await rt.discovery.channelFor(vault, bearer);
26
+ if (decision.kind === 'paused') {
27
+ st.paused = decision.reason;
28
+ st.lastError = undefined;
29
+ return build({ paused: decision.reason });
30
+ }
31
+ st.paused = undefined;
32
+ const couch = await ensureWire(rt, st, decision);
33
+ const pre = await rt.commitDirty(st.target.path, `sync: ${rt.nowIso()}`);
34
+ await couch.flushPending(); // drain queued pushes AND queued deletions first
35
+ const res = await couch.syncNow(); // pushAll + reconcile deletions, then pullOnce
36
+ const post = await rt.commitDirty(st.target.path, `sync: couch ${rt.nowIso()}`);
37
+ if (res.error) {
38
+ st.lastError = res.error;
39
+ return build({
40
+ ok: false,
41
+ committed: pre.committed,
42
+ pulled: post.committed,
43
+ error: res.error,
44
+ });
45
+ }
46
+ st.lastSync = rt.nowIso();
47
+ st.lastError = undefined;
48
+ return build({ committed: pre.committed, pulled: post.committed });
49
+ }
50
+ catch (err) {
51
+ const msg = err instanceof Error ? err.message : String(err);
52
+ st.lastError = msg;
53
+ return build({ ok: false, error: msg });
54
+ }
55
+ finally {
56
+ st.running = false;
57
+ }
58
+ };
@@ -0,0 +1,22 @@
1
+ import { type FetchJson } from '@agentage/memory-core';
2
+ import { type ProvisionResult } from '../../lib/auth/provision.js';
3
+ export type ChannelDecision = {
4
+ kind: 'couch';
5
+ endpoint: string;
6
+ db: string;
7
+ tokenUrl: string;
8
+ } | {
9
+ kind: 'paused';
10
+ reason: string;
11
+ };
12
+ export interface DiscoveryDeps {
13
+ bootstrapHost: string;
14
+ fetchJson: FetchJson;
15
+ provision: (vault: string) => Promise<ProvisionResult>;
16
+ now?: () => number;
17
+ }
18
+ export interface Discovery {
19
+ channelFor(vault: string, token: string): Promise<ChannelDecision>;
20
+ reset(): void;
21
+ }
22
+ export declare const createDiscovery: (deps: DiscoveryDeps) => Discovery;