Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,20 @@ its agent-runtime patterns (the local memory daemon was deliberately ported from
`AGENTAGE_NO_DAEMON=1`, or fork blocked)
- `src/daemon/` + `src/daemon-entry.ts` - the local daemon (node:http, 127.0.0.1 only): one
in-process engine serialises vault mutations; `agentage daemon start|stop|status`
- `src/sync/` - git sync (M4): the daemon acts on per-vault `origin[]` (external remotes only),
debounced commit+push / pull-rebase per `interval` (SECONDS; 0 = manual-only). Conflicts keep
both sides (`<file>.conflict.md` = remote copy, zero lost writes); `ignore` rides on
`.git/info/exclude` (defaults `.obsidian/` + `data.json`, a set value REPLACES them, `[]` = all).
`spawn git` directly (memory-core's createGit is private); no new deps. `vault sync [name]`
forces a cycle via the daemon (`/api/sync/run`) or in-process when it is down
- `src/sync/` - channel-based sync; one subfolder per channel, no shared root logic:
- `src/sync/git/` - git channel (M4): `manager` (scheduler + status), `cycle` (one commit+push /
pull-rebase round), `planner` (targets + interval math), `git-exec` (spawn git, error classify),
`remote-url` (allowlist + redact), `conflict` (`<file>.conflict.md` naming). The daemon acts on
per-vault `origin[]` (external remotes only), debounced per `interval` (SECONDS; 0 = manual-only);
conflicts keep both sides (zero lost writes); `ignore` rides on `.git/info/exclude` (defaults
`.obsidian/` + `data.json`, a set value REPLACES them, `[]` = all). `spawn git` directly
(memory-core's createGit is private); no new deps
- `src/sync/couch/` - couch channel (account vaults): `manager` (thin composition), `manager.types`
(shared shapes), `cycle` / `wire` / `push-on-write` (the sync-on-save + scheduling seams),
`mutation-target` (verb -> vault+path), `local-commit`, `discovery`, `file-store`, `state-store`,
`targets`
- `src/sync/discover/` - `watcher` (auto-discovers new vaults, provisions account vaults)
- `vault sync [name]` forces a git cycle via the daemon (`/api/sync/run`) or in-process when down
- `src/package-guard.test.ts` - CI guard: no agent-runtime remnants (express/ws/sqlite/
core/platform/supabase), runtime deps stay exactly `@agentage/memory-core +
@agentage/server-memory + @modelcontextprotocol/sdk + chalk + commander + open`
Expand Down
4 changes: 2 additions & 2 deletions src/commands/vault-sync.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { describe, expect, it, vi } from 'vitest';
import { type VaultsConfig } from '@agentage/memory-core';
import { type SyncResult } from '../sync/cycle.js';
import { type SyncResult } from '../sync/git/cycle.js';
import { type CouchSyncResult } from '../sync/couch/manager.js';
import { type SyncTarget } from '../sync/planner.js';
import { type SyncTarget } from '../sync/git/planner.js';
import { runVaultSync, type VaultSyncDeps } from './vault-sync.js';

const result = (over: Partial<SyncResult> = {}): SyncResult => ({
Expand Down
6 changes: 3 additions & 3 deletions src/commands/vault-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import { health, syncRun, type SyncRunResult } from '../lib/daemon-client.js';
import { daemonDisabled } from '../lib/daemon-pref.js';
import { loadVaultsConfig } from '../lib/vaults.js';
import { resolvePort } from '../daemon/lifecycle.js';
import { runSyncCycle, type SyncResult } from '../sync/cycle.js';
import { runSyncCycle, type SyncResult } from '../sync/git/cycle.js';
import { createCouchSyncManager, type CouchSyncResult } from '../sync/couch/manager.js';
import { couchTargets } from '../sync/couch/targets.js';
import { syncTargets, type SyncTarget } from '../sync/planner.js';
import { redactRemoteUrl } from '../sync/remote-url.js';
import { syncTargets, type SyncTarget } from '../sync/git/planner.js';
import { redactRemoteUrl } from '../sync/git/remote-url.js';

export interface VaultSyncDeps {
loadConfig: () => VaultsConfig;
Expand Down
2 changes: 1 addition & 1 deletion src/commands/vault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
type ProvisionResult,
} from '../lib/provision.js';
import { loadVaultsConfig, mutateVaultsConfig, type LoadedVaults } from '../lib/vaults.js';
import { assertSafeRemoteUrl, redactRemoteUrl } from '../sync/remote-url.js';
import { assertSafeRemoteUrl, redactRemoteUrl } from '../sync/git/remote-url.js';
import { defaultVaultSyncDeps, runVaultSync } from './vault-sync.js';

export interface VaultDeps {
Expand Down
2 changes: 1 addition & 1 deletion src/daemon-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { loadLocalMemoryServer } from './mcp/local-server.js';
import { loadVaultsConfig, vaultsJsonPath } from './lib/vaults.js';
import { createCouchSyncManager } from './sync/couch/manager.js';
import { createDiscoverWatcher } from './sync/discover/watcher.js';
import { createSyncManager } from './sync/manager.js';
import { createSyncManager } from './sync/git/manager.js';
import { VERSION } from './utils/version.js';

export const isEaddrinuse = (err: unknown): boolean =>
Expand Down
4 changes: 2 additions & 2 deletions src/daemon/server.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
import { type McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { type MemoryClient } from '../lib/memory-client.js';
import { type SyncResult } from '../sync/cycle.js';
import { type SyncResult } from '../sync/git/cycle.js';
import { type CouchSyncResult } from '../sync/couch/manager.js';
import { type SyncStatus } from '../sync/manager.js';
import { type SyncStatus } from '../sync/git/manager.js';
import { dispatchMemory, isMemoryVerb, type MemoryVerb } from './actions.js';
import { isAllowedHost, isAllowedOrigin, loopbackHosts } from './guards.js';
import { handleMcp } from './mcp-http.js';
Expand Down
4 changes: 2 additions & 2 deletions src/lib/daemon-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ import {
type WriteResult,
} from '@agentage/memory-core';
import { EADDRINUSE_EXIT_CODE, readDaemonToken, resolvePort } from '../daemon/lifecycle.js';
import { type SyncResult } from '../sync/cycle.js';
import { type SyncResult } from '../sync/git/cycle.js';
import { type CouchSyncResult } from '../sync/couch/manager.js';
import { type SyncStatus } from '../sync/manager.js';
import { type SyncStatus } from '../sync/git/manager.js';
import { VERSION } from '../utils/version.js';

// One vault syncs on exactly one channel; /api/sync/run yields whichever result fits the vault.
Expand Down
2 changes: 1 addition & 1 deletion src/lib/vault-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
type VaultEntry,
type VaultsConfig,
} from '@agentage/memory-core';
import { redactRemoteUrl } from '../sync/remote-url.js';
import { redactRemoteUrl } from '../sync/git/remote-url.js';
import { isValidVaultName } from './vaults.schema.js';

// Offline registry operations over the unified vaults.json (object map keyed by name). No
Expand Down
60 changes: 60 additions & 0 deletions src/sync/couch/cycle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { type CouchRuntime, type CouchSyncResult, type TargetState } from './manager.types.js';
import { ensureWire, pendingCount } from './wire.js';

// One couch cycle: commit dirty local truth first, drain queued pushes/deletions, then push+pull.
// Every failure is caught and recorded (lastError / paused); it never throws to the caller.
export const runCouchCycle = async (
rt: CouchRuntime,
st: TargetState
): Promise<CouchSyncResult> => {
const vault = st.target.vault;
const build = (extra: Partial<CouchSyncResult>): CouchSyncResult => ({
vault,
channel: 'couch',
ok: true,
committed: false,
pulled: false,
pendingCount: pendingCount(st),
...extra,
});
if (st.running) return build({});
st.running = true;
try {
const bearer = await rt.getBearer();
if (!bearer) {
st.paused = 'signed out';
st.lastError = undefined;
return build({ paused: 'signed out' });
}
const decision = await rt.discovery.channelFor(vault, bearer);
if (decision.kind === 'paused') {
st.paused = decision.reason;
st.lastError = undefined;
return build({ paused: decision.reason });
}
st.paused = undefined;
const couch = await ensureWire(rt, st, decision);
const pre = await rt.commitDirty(st.target.path, `sync: ${rt.nowIso()}`);
await couch.flushPending(); // drain queued pushes AND queued deletions first
const res = await couch.syncNow(); // pushAll + reconcile deletions, then pullOnce
const post = await rt.commitDirty(st.target.path, `sync: couch ${rt.nowIso()}`);
if (res.error) {
st.lastError = res.error;
return build({
ok: false,
committed: pre.committed,
pulled: post.committed,
error: res.error,
});
}
st.lastSync = rt.nowIso();
st.lastError = undefined;
return build({ committed: pre.committed, pulled: post.committed });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
st.lastError = msg;
return build({ ok: false, error: msg });
} finally {
st.running = false;
}
};
22 changes: 22 additions & 0 deletions src/sync/couch/local-commit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { createSyncGit, GitError } from '../git/git-exec.js';
import { type CommitOutcome } from './manager.types.js';

// The default local-git commit: stage everything and make one commit when the tree is dirty. An
// index.lock collision (the engine mid-mutation) is a clean skip - the change stays for next cycle.
export const gitCommitDirty = async (path: string, message: string): Promise<CommitOutcome> => {
if (!existsSync(path)) return { committed: false, skipped: false };
const git = createSyncGit(path);
try {
if (!existsSync(join(path, '.git'))) await git.run(['init', '-b', 'main']);
await git.run(['add', '-A']);
if ((await git.exec(['diff', '--cached', '--quiet'])).code === 0)
return { committed: false, skipped: false };
await git.run(['commit', '-m', message]);
return { committed: true, skipped: false };
} catch (err) {
if (err instanceof GitError && err.kind === 'lock') return { committed: false, skipped: true };
throw err;
}
};
63 changes: 63 additions & 0 deletions src/sync/couch/manager.fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { vi } from 'vitest';
import { type FileStore, type VaultsConfig } from '@agentage/memory-core';
import { type ChannelDecision, type Discovery } from './discovery.js';
import { createCouchSyncManager, type CouchSyncManagerDeps } from './manager.js';

export const config: VaultsConfig = {
version: 1,
default: 'acct',
vaults: {
acct: { path: '/tmp/acct', origin: [{ remote: 'agentage', interval: 0 }] },
git: { path: '/tmp/git', origin: [{ remote: 'git@h:g.git' }] },
local: { path: '/tmp/local' },
},
};

export const autoConfig: VaultsConfig = {
version: 1,
default: 'acct',
vaults: {
acct: { path: '/tmp/acct', origin: [{ remote: 'agentage', interval: 300 }] },
two: { path: '/tmp/two', origin: [{ remote: 'agentage', interval: 300 }] },
},
};

export const noopStore = (): FileStore => ({
listMarkdown: async () => [],
read: async () => null,
write: async () => {},
remove: async () => {},
});

export const couchDecision: ChannelDecision = {
kind: 'couch',
endpoint: 'https://couch.test',
db: 'mem_acct',
tokenUrl: 'https://auth.test/couch-token',
};

export const makeManager = (over: Partial<CouchSyncManagerDeps> = {}) => {
const couch = {
pushFileLive: vi.fn(async () => {}),
removeFile: vi.fn(async () => {}),
flushPending: vi.fn(async () => {}),
syncNow: vi.fn(async () => ({ pushed: true, pulled: true })),
};
const discovery: Discovery = {
channelFor: vi.fn(async () => couchDecision),
reset: vi.fn(),
};
const mgr = createCouchSyncManager({
getConfig: () => config,
configDir: () => '/tmp/cfg',
getBearer: async () => 'tok',
discovery,
makeCouchSync: () => couch,
makeFileStore: noopStore,
makeStatePersistence: () => ({ load: async () => null, save: async () => {} }),
commitDirty: async () => ({ committed: false, skipped: false }),
now: () => '2026-01-01T00:00:00Z',
...over,
});
return { mgr, couch, discovery };
};
Loading