@omgbase/sync 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/src/bin.d.ts +2 -0
- package/dist/src/bin.js +49 -0
- package/dist/src/coordinator.d.ts +43 -0
- package/dist/src/coordinator.js +95 -0
- package/dist/src/engine-client.d.ts +48 -0
- package/dist/src/engine-client.js +40 -0
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.js +9 -0
- package/dist/src/mcp-engine-client.d.ts +39 -0
- package/dist/src/mcp-engine-client.js +86 -0
- package/dist/src/mirror.d.ts +18 -0
- package/dist/src/mirror.js +57 -0
- package/package.json +66 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Brendan Baldwin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/src/bin.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { runFsMirror } from "./mirror.js";
|
|
5
|
+
// `omgbase-sync` — the standalone synchronizer (ADR-014). A thin wrapper over
|
|
6
|
+
// `runFsMirror`: mirror a filesystem directory against an omgbase repo served
|
|
7
|
+
// over MCP (default server `omg mcp -C <root>`). Identical to `omg sync
|
|
8
|
+
// --server <cmd>` for callers that don't ship the full `omg` CLI.
|
|
9
|
+
//
|
|
10
|
+
// omgbase-sync --root ./vault # ingest the tree into the served repo
|
|
11
|
+
// omgbase-sync --root ./vault --out # also export engine-authored changes back
|
|
12
|
+
// omgbase-sync --root ./vault --watch # stay live: mirror edits as they land
|
|
13
|
+
// omgbase-sync --root ./vault --server "omg mcp -C ./vault" # custom / remote server command
|
|
14
|
+
function usage() {
|
|
15
|
+
process.stderr.write("usage: omgbase-sync --root <dir> [--server \"<cmd>\"] [--out] [--watch]\n" +
|
|
16
|
+
" --root <dir> filesystem directory to mirror (required)\n" +
|
|
17
|
+
" --server <cmd> MCP server command to spawn (default: omg mcp -C <root>)\n" +
|
|
18
|
+
" --out also export engine-authored changes back to the directory\n" +
|
|
19
|
+
" --watch stay live and mirror edits as they land\n");
|
|
20
|
+
}
|
|
21
|
+
async function main() {
|
|
22
|
+
const { values } = parseArgs({
|
|
23
|
+
options: {
|
|
24
|
+
root: { type: "string" },
|
|
25
|
+
server: { type: "string" },
|
|
26
|
+
out: { type: "boolean" },
|
|
27
|
+
watch: { type: "boolean" },
|
|
28
|
+
help: { type: "boolean" },
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
if (values.help || !values.root) {
|
|
32
|
+
usage();
|
|
33
|
+
process.exit(values.help ? 0 : 2);
|
|
34
|
+
}
|
|
35
|
+
const root = resolve(values.root);
|
|
36
|
+
const server = (values.server ?? `omg mcp -C ${root}`).split(/\s+/).filter(Boolean);
|
|
37
|
+
await runFsMirror({
|
|
38
|
+
server,
|
|
39
|
+
root,
|
|
40
|
+
...(values.watch ? { watch: true } : {}),
|
|
41
|
+
...(values.out ? { out: true } : {}),
|
|
42
|
+
log: (msg) => process.stderr.write(`[omgbase-sync] ${msg}\n`),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
main().catch((err) => {
|
|
46
|
+
process.stderr.write(`[omgbase-sync] fatal: ${String(err)}\n`);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
});
|
|
49
|
+
//# sourceMappingURL=bin.js.map
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { SyncSource, SourceWatch } from "@omgbase/core";
|
|
2
|
+
import type { EngineClient } from "./engine-client.js";
|
|
3
|
+
export interface SyncInSummary {
|
|
4
|
+
ingested: string[];
|
|
5
|
+
suppressed: string[];
|
|
6
|
+
conflicted: string[];
|
|
7
|
+
deleted: string[];
|
|
8
|
+
}
|
|
9
|
+
export interface SyncOutSummary {
|
|
10
|
+
cursor: number;
|
|
11
|
+
written: string[];
|
|
12
|
+
removed: string[];
|
|
13
|
+
}
|
|
14
|
+
export declare class Coordinator {
|
|
15
|
+
private readonly engine;
|
|
16
|
+
private readonly source;
|
|
17
|
+
constructor(engine: EngineClient, source: SyncSource);
|
|
18
|
+
/** source → engine: ingest the source's full current scope (initial sync). */
|
|
19
|
+
syncIn(): Promise<SyncInSummary>;
|
|
20
|
+
/**
|
|
21
|
+
* source → engine: reconcile a set of changed paths (e.g. a watch batch). A
|
|
22
|
+
* path the source no longer has (`fetch` → null) is mirrored as a deletion.
|
|
23
|
+
*/
|
|
24
|
+
reconcile(paths: string[]): Promise<SyncInSummary>;
|
|
25
|
+
/**
|
|
26
|
+
* engine → source: write engine-authored changes out to the source. Walks the
|
|
27
|
+
* commit feed from `cursor` (paging through `truncated`), skipping
|
|
28
|
+
* observed-origin commits (they originated from a source — §6), and writes
|
|
29
|
+
* each changed doc's current bytes to the source (or removes a tombstoned one).
|
|
30
|
+
* Requires a write-through source; a read-only source exports nothing.
|
|
31
|
+
*/
|
|
32
|
+
syncOut(cursor?: number): Promise<SyncOutSummary>;
|
|
33
|
+
/**
|
|
34
|
+
* Live source→engine sync: subscribe to the source's change batches and
|
|
35
|
+
* reconcile each. Returns the subscription (call `.stop()`), or null if the
|
|
36
|
+
* source cannot watch. Reconcile runs off the batch callback; `onSummary`
|
|
37
|
+
* observes each batch's result, `onError` any failure.
|
|
38
|
+
*/
|
|
39
|
+
watchIn(handlers?: {
|
|
40
|
+
onSummary?: (s: SyncInSummary) => void;
|
|
41
|
+
onError?: (err: unknown) => void;
|
|
42
|
+
}): Promise<SourceWatch | null>;
|
|
43
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
export class Coordinator {
|
|
2
|
+
engine;
|
|
3
|
+
source;
|
|
4
|
+
constructor(engine, source) {
|
|
5
|
+
this.engine = engine;
|
|
6
|
+
this.source = source;
|
|
7
|
+
}
|
|
8
|
+
/** source → engine: ingest the source's full current scope (initial sync). */
|
|
9
|
+
async syncIn() {
|
|
10
|
+
const entries = await this.source.enumerate();
|
|
11
|
+
return this.reconcile(entries.map((e) => e.path));
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* source → engine: reconcile a set of changed paths (e.g. a watch batch). A
|
|
15
|
+
* path the source no longer has (`fetch` → null) is mirrored as a deletion.
|
|
16
|
+
*/
|
|
17
|
+
async reconcile(paths) {
|
|
18
|
+
const files = [];
|
|
19
|
+
const gonePaths = [];
|
|
20
|
+
for (const path of paths) {
|
|
21
|
+
const item = await this.source.fetch(path);
|
|
22
|
+
if (item)
|
|
23
|
+
files.push({ path, content: item.content });
|
|
24
|
+
else
|
|
25
|
+
gonePaths.push(path);
|
|
26
|
+
}
|
|
27
|
+
const summary = { ingested: [], suppressed: [], conflicted: [], deleted: [] };
|
|
28
|
+
if (files.length > 0) {
|
|
29
|
+
for (const r of await this.engine.observeMany(files)) {
|
|
30
|
+
if (r.echo)
|
|
31
|
+
summary.suppressed.push(r.path);
|
|
32
|
+
else if (r.conflicted)
|
|
33
|
+
summary.conflicted.push(r.path);
|
|
34
|
+
else
|
|
35
|
+
summary.ingested.push(r.path);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
for (const path of gonePaths) {
|
|
39
|
+
const d = await this.engine.observeDelete(path);
|
|
40
|
+
if (d.deleted)
|
|
41
|
+
summary.deleted.push(path);
|
|
42
|
+
}
|
|
43
|
+
return summary;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* engine → source: write engine-authored changes out to the source. Walks the
|
|
47
|
+
* commit feed from `cursor` (paging through `truncated`), skipping
|
|
48
|
+
* observed-origin commits (they originated from a source — §6), and writes
|
|
49
|
+
* each changed doc's current bytes to the source (or removes a tombstoned one).
|
|
50
|
+
* Requires a write-through source; a read-only source exports nothing.
|
|
51
|
+
*/
|
|
52
|
+
async syncOut(cursor) {
|
|
53
|
+
const written = [];
|
|
54
|
+
const removed = [];
|
|
55
|
+
let cur = cursor ?? 0;
|
|
56
|
+
if (!this.source.write)
|
|
57
|
+
return { cursor: cur, written, removed }; // read-only source
|
|
58
|
+
for (;;) {
|
|
59
|
+
const page = await this.engine.changesSince(cur);
|
|
60
|
+
for (const digest of page.digests) {
|
|
61
|
+
if (digest.origin === "observed")
|
|
62
|
+
continue; // came from a source; don't echo back
|
|
63
|
+
for (const rev of digest.revisions) {
|
|
64
|
+
const doc = await this.engine.readDoc(rev.path);
|
|
65
|
+
if (doc) {
|
|
66
|
+
await this.source.write(rev.path, doc.content);
|
|
67
|
+
written.push(rev.path);
|
|
68
|
+
}
|
|
69
|
+
else if (this.source.remove) {
|
|
70
|
+
await this.source.remove(rev.path);
|
|
71
|
+
removed.push(rev.path);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
cur = page.cursor;
|
|
76
|
+
if (!page.truncated)
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
return { cursor: cur, written, removed };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Live source→engine sync: subscribe to the source's change batches and
|
|
83
|
+
* reconcile each. Returns the subscription (call `.stop()`), or null if the
|
|
84
|
+
* source cannot watch. Reconcile runs off the batch callback; `onSummary`
|
|
85
|
+
* observes each batch's result, `onError` any failure.
|
|
86
|
+
*/
|
|
87
|
+
async watchIn(handlers = {}) {
|
|
88
|
+
if (!this.source.watch)
|
|
89
|
+
return null;
|
|
90
|
+
return this.source.watch((paths) => {
|
|
91
|
+
this.reconcile(paths).then((s) => handlers.onSummary?.(s), (err) => handlers.onError?.(err));
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
//# sourceMappingURL=coordinator.js.map
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type Store, type ObserveResult, type ObserveDeleteResult, type CommitDigest } from "@omgbase/core";
|
|
2
|
+
export interface ChangesPage {
|
|
3
|
+
digests: CommitDigest[];
|
|
4
|
+
cursor: number;
|
|
5
|
+
truncated: boolean;
|
|
6
|
+
/** the repo's current max commit seq — cursor > head means the cursor came from another repo/server */
|
|
7
|
+
head?: number;
|
|
8
|
+
}
|
|
9
|
+
export interface DocBytes {
|
|
10
|
+
content: string;
|
|
11
|
+
/** hex of the doc's current rendered/file hash (matches changes_since contentHash). */
|
|
12
|
+
contentHash: string;
|
|
13
|
+
}
|
|
14
|
+
export interface EngineClient {
|
|
15
|
+
/** Push whole-file bytes in as observed commits (echo-suppressed engine-side). */
|
|
16
|
+
observeMany(files: {
|
|
17
|
+
path: string;
|
|
18
|
+
content: string;
|
|
19
|
+
}[]): Promise<ObserveResult[]>;
|
|
20
|
+
/** Mirror a source-side deletion: tombstone the doc (observed). */
|
|
21
|
+
observeDelete(path: string): Promise<ObserveDeleteResult>;
|
|
22
|
+
/** The repo-wide commit feed after a cursor (for the export direction). */
|
|
23
|
+
changesSince(cursor?: number, opts?: {
|
|
24
|
+
origin?: "api" | "observed" | "import";
|
|
25
|
+
limit?: number;
|
|
26
|
+
}): Promise<ChangesPage>;
|
|
27
|
+
/** Current whole-file bytes + content hash for a path, or null if absent. */
|
|
28
|
+
readDoc(path: string): Promise<DocBytes | null>;
|
|
29
|
+
close(): Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
/** In-process EngineClient: calls @omgbase/core directly against an open Store.
|
|
32
|
+
* Used by the local watcher/sync (no self-MCP-loop) and by tests. */
|
|
33
|
+
export declare class InProcessEngineClient implements EngineClient {
|
|
34
|
+
private readonly store;
|
|
35
|
+
private readonly repoId;
|
|
36
|
+
constructor(store: Store, repoId: string);
|
|
37
|
+
observeMany(files: {
|
|
38
|
+
path: string;
|
|
39
|
+
content: string;
|
|
40
|
+
}[]): Promise<ObserveResult[]>;
|
|
41
|
+
observeDelete(path: string): Promise<ObserveDeleteResult>;
|
|
42
|
+
changesSince(cursor?: number, opts?: {
|
|
43
|
+
origin?: "api" | "observed" | "import";
|
|
44
|
+
limit?: number;
|
|
45
|
+
}): Promise<ChangesPage>;
|
|
46
|
+
readDoc(path: string): Promise<DocBytes | null>;
|
|
47
|
+
close(): Promise<void>;
|
|
48
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { observeMany, observeDelete, changesSince, docsRead, } from "@omgbase/core";
|
|
2
|
+
/** In-process EngineClient: calls @omgbase/core directly against an open Store.
|
|
3
|
+
* Used by the local watcher/sync (no self-MCP-loop) and by tests. */
|
|
4
|
+
export class InProcessEngineClient {
|
|
5
|
+
store;
|
|
6
|
+
repoId;
|
|
7
|
+
constructor(store, repoId) {
|
|
8
|
+
this.store = store;
|
|
9
|
+
this.repoId = repoId;
|
|
10
|
+
}
|
|
11
|
+
observeMany(files) {
|
|
12
|
+
return Promise.resolve(observeMany(this.store, this.repoId, files));
|
|
13
|
+
}
|
|
14
|
+
observeDelete(path) {
|
|
15
|
+
return Promise.resolve(observeDelete(this.store, this.repoId, path));
|
|
16
|
+
}
|
|
17
|
+
changesSince(cursor, opts) {
|
|
18
|
+
return Promise.resolve(changesSince(this.store, this.repoId, {
|
|
19
|
+
...(cursor !== undefined ? { cursor } : {}),
|
|
20
|
+
...(opts?.origin ? { origin: opts.origin } : {}),
|
|
21
|
+
...(opts?.limit !== undefined ? { limit: opts.limit } : {}),
|
|
22
|
+
}));
|
|
23
|
+
}
|
|
24
|
+
readDoc(path) {
|
|
25
|
+
const row = this.store.db
|
|
26
|
+
.prepare("SELECT doc_id, file_hash FROM docs WHERE repo_id = ? AND path = ? AND deleted_commit IS NULL")
|
|
27
|
+
.get(this.repoId, path);
|
|
28
|
+
if (!row)
|
|
29
|
+
return Promise.resolve(null);
|
|
30
|
+
const res = docsRead(this.store, row.doc_id);
|
|
31
|
+
if (!res)
|
|
32
|
+
return Promise.resolve(null);
|
|
33
|
+
return Promise.resolve({ content: res.content, contentHash: row.file_hash?.toString("hex") ?? "" });
|
|
34
|
+
}
|
|
35
|
+
close() {
|
|
36
|
+
// The store's lifetime is owned by the caller (it may serve other work).
|
|
37
|
+
return Promise.resolve();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=engine-client.js.map
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { type EngineClient, type ChangesPage, type DocBytes, InProcessEngineClient } from "./engine-client.js";
|
|
2
|
+
export { McpEngineClient, connectStdioEngine, connectHttpEngine } from "./mcp-engine-client.js";
|
|
3
|
+
export { Coordinator, type SyncInSummary, type SyncOutSummary } from "./coordinator.js";
|
|
4
|
+
export { runFsMirror, type FsMirrorOptions } from "./mirror.js";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// @omgbase/sync — a standalone store-to-store synchronizer (ADR-014).
|
|
2
|
+
// The Coordinator reconciles a SyncSource (external store, via the adapter
|
|
3
|
+
// protocol) with an omgbase repo reached through an EngineClient seam:
|
|
4
|
+
// InProcessEngineClient (local, direct Store) or McpEngineClient (remote, MCP).
|
|
5
|
+
export { InProcessEngineClient } from "./engine-client.js";
|
|
6
|
+
export { McpEngineClient, connectStdioEngine, connectHttpEngine } from "./mcp-engine-client.js";
|
|
7
|
+
export { Coordinator } from "./coordinator.js";
|
|
8
|
+
export { runFsMirror } from "./mirror.js";
|
|
9
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
|
+
import { type ObserveResult, type ObserveDeleteResult } from "@omgbase/core";
|
|
3
|
+
import type { EngineClient, ChangesPage, DocBytes } from "./engine-client.js";
|
|
4
|
+
/** Wraps a connected MCP SDK Client, calling the engine's sync tools. */
|
|
5
|
+
export declare class McpEngineClient implements EngineClient {
|
|
6
|
+
private readonly client;
|
|
7
|
+
constructor(client: Client);
|
|
8
|
+
/** Call any MCP tool and return its parsed JSON result. Throws on a tool
|
|
9
|
+
* error (the message carries the engine error code) or non-JSON output. The
|
|
10
|
+
* general-purpose call the CLI's `--server` mode uses for arbitrary tools. */
|
|
11
|
+
callTool<T>(name: string, args: Record<string, unknown>): Promise<T>;
|
|
12
|
+
observeMany(files: {
|
|
13
|
+
path: string;
|
|
14
|
+
content: string;
|
|
15
|
+
}[]): Promise<ObserveResult[]>;
|
|
16
|
+
observeDelete(path: string): Promise<ObserveDeleteResult>;
|
|
17
|
+
changesSince(cursor?: number, opts?: {
|
|
18
|
+
origin?: "api" | "observed" | "import";
|
|
19
|
+
limit?: number;
|
|
20
|
+
}): Promise<ChangesPage>;
|
|
21
|
+
readDoc(path: string): Promise<DocBytes | null>;
|
|
22
|
+
close(): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
/** Connect to an omgbase MCP server over stdio by spawning `command args`
|
|
25
|
+
* (e.g. `omg mcp -C /vault`). Returns a ready EngineClient. */
|
|
26
|
+
export declare function connectStdioEngine(spec: {
|
|
27
|
+
command: string;
|
|
28
|
+
args?: string[];
|
|
29
|
+
env?: Record<string, string>;
|
|
30
|
+
}): Promise<McpEngineClient>;
|
|
31
|
+
/** Connect to an omgbase MCP server over Streamable HTTP at `url`. The full URL
|
|
32
|
+
* (including any base path) is used verbatim, so a secret-prefixed endpoint
|
|
33
|
+
* like `https://host/k/<secret>/mcp` authenticates by the path alone. Extra
|
|
34
|
+
* `headers` are sent on every request (for hosts that need auth beyond the
|
|
35
|
+
* path). Returns a ready EngineClient. */
|
|
36
|
+
export declare function connectHttpEngine(spec: {
|
|
37
|
+
url: string;
|
|
38
|
+
headers?: Record<string, string>;
|
|
39
|
+
}): Promise<McpEngineClient>;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
3
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
4
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
5
|
+
import { VERSION } from "@omgbase/core";
|
|
6
|
+
/** Wraps a connected MCP SDK Client, calling the engine's sync tools. */
|
|
7
|
+
export class McpEngineClient {
|
|
8
|
+
client;
|
|
9
|
+
constructor(client) {
|
|
10
|
+
this.client = client;
|
|
11
|
+
}
|
|
12
|
+
/** Call any MCP tool and return its parsed JSON result. Throws on a tool
|
|
13
|
+
* error (the message carries the engine error code) or non-JSON output. The
|
|
14
|
+
* general-purpose call the CLI's `--server` mode uses for arbitrary tools. */
|
|
15
|
+
async callTool(name, args) {
|
|
16
|
+
const res = (await this.client.callTool({ name, arguments: args }));
|
|
17
|
+
const text = res.content?.find((c) => c.type === "text")?.text ?? "";
|
|
18
|
+
let body;
|
|
19
|
+
try {
|
|
20
|
+
body = JSON.parse(text);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
throw new Error(`tool ${name} returned non-JSON: ${text.slice(0, 200)}`);
|
|
24
|
+
}
|
|
25
|
+
if (res.isError) {
|
|
26
|
+
const err = body;
|
|
27
|
+
throw new Error(`tool ${name} failed: ${err.error ?? "error"} — ${err.message ?? text.slice(0, 200)}`);
|
|
28
|
+
}
|
|
29
|
+
return body;
|
|
30
|
+
}
|
|
31
|
+
observeMany(files) {
|
|
32
|
+
return this.callTool("observe_many", { files });
|
|
33
|
+
}
|
|
34
|
+
observeDelete(path) {
|
|
35
|
+
return this.callTool("observe_delete", { path });
|
|
36
|
+
}
|
|
37
|
+
changesSince(cursor, opts) {
|
|
38
|
+
return this.callTool("changes_since", {
|
|
39
|
+
...(cursor !== undefined ? { cursor } : {}),
|
|
40
|
+
...(opts?.origin ? { origin: opts.origin } : {}),
|
|
41
|
+
...(opts?.limit !== undefined ? { limit: opts.limit } : {}),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
async readDoc(path) {
|
|
45
|
+
try {
|
|
46
|
+
const res = await this.callTool("docs_read", { path });
|
|
47
|
+
return { content: res.content, contentHash: createHash("sha256").update(res.content, "utf8").digest("hex") };
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// doc_missing (or any read failure) → treat as absent.
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async close() {
|
|
55
|
+
await this.client.close();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Connect to an omgbase MCP server over stdio by spawning `command args`
|
|
59
|
+
* (e.g. `omg mcp -C /vault`). Returns a ready EngineClient. */
|
|
60
|
+
export async function connectStdioEngine(spec) {
|
|
61
|
+
const client = new Client({ name: "omgbase-sync", version: VERSION });
|
|
62
|
+
const transport = new StdioClientTransport({
|
|
63
|
+
command: spec.command,
|
|
64
|
+
args: spec.args ?? [],
|
|
65
|
+
...(spec.env ? { env: spec.env } : {}),
|
|
66
|
+
});
|
|
67
|
+
await client.connect(transport);
|
|
68
|
+
return new McpEngineClient(client);
|
|
69
|
+
}
|
|
70
|
+
/** Connect to an omgbase MCP server over Streamable HTTP at `url`. The full URL
|
|
71
|
+
* (including any base path) is used verbatim, so a secret-prefixed endpoint
|
|
72
|
+
* like `https://host/k/<secret>/mcp` authenticates by the path alone. Extra
|
|
73
|
+
* `headers` are sent on every request (for hosts that need auth beyond the
|
|
74
|
+
* path). Returns a ready EngineClient. */
|
|
75
|
+
export async function connectHttpEngine(spec) {
|
|
76
|
+
const client = new Client({ name: "omgbase-sync", version: VERSION });
|
|
77
|
+
// The transport's `sessionId` getter is `string | undefined`, which trips the
|
|
78
|
+
// Transport interface's optional `sessionId?: string` under
|
|
79
|
+
// exactOptionalPropertyTypes; the value is only ever read by the SDK.
|
|
80
|
+
const transport = new StreamableHTTPClientTransport(new URL(spec.url), {
|
|
81
|
+
...(spec.headers && Object.keys(spec.headers).length > 0 ? { requestInit: { headers: spec.headers } } : {}),
|
|
82
|
+
});
|
|
83
|
+
await client.connect(transport);
|
|
84
|
+
return new McpEngineClient(client);
|
|
85
|
+
}
|
|
86
|
+
//# sourceMappingURL=mcp-engine-client.js.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface FsMirrorOptions {
|
|
2
|
+
/** MCP server command argv, e.g. ["omg","mcp","-C","/vault"]. */
|
|
3
|
+
server: string[];
|
|
4
|
+
/** Filesystem directory to mirror (absolute). */
|
|
5
|
+
root: string;
|
|
6
|
+
/** Stay live after the initial sync, mirroring edits as they land. */
|
|
7
|
+
watch?: boolean;
|
|
8
|
+
/** Also export engine-authored changes back to the directory. */
|
|
9
|
+
out?: boolean;
|
|
10
|
+
/** Diagnostics sink (stderr-style); defaults to no-op. */
|
|
11
|
+
log?: (msg: string) => void;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Run one filesystem↔engine mirror over MCP. Resolves when the initial sync (+
|
|
15
|
+
* optional export) completes; in `watch` mode it resolves only after SIGINT/
|
|
16
|
+
* SIGTERM, having torn down the source + engine. Throws on connect/spawn failure.
|
|
17
|
+
*/
|
|
18
|
+
export declare function runFsMirror(opts: FsMirrorOptions): Promise<void>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { execPath } from "node:process";
|
|
2
|
+
import { createExternalSource } from "@omgbase/core";
|
|
3
|
+
import { fsAdapterBinPath } from "@omgbase/fs-adapter";
|
|
4
|
+
import { connectStdioEngine } from "./mcp-engine-client.js";
|
|
5
|
+
import { Coordinator } from "./coordinator.js";
|
|
6
|
+
/**
|
|
7
|
+
* Run one filesystem↔engine mirror over MCP. Resolves when the initial sync (+
|
|
8
|
+
* optional export) completes; in `watch` mode it resolves only after SIGINT/
|
|
9
|
+
* SIGTERM, having torn down the source + engine. Throws on connect/spawn failure.
|
|
10
|
+
*/
|
|
11
|
+
export async function runFsMirror(opts) {
|
|
12
|
+
const log = opts.log ?? (() => { });
|
|
13
|
+
const engine = await connectStdioEngine({ command: opts.server[0], args: opts.server.slice(1) });
|
|
14
|
+
const source = await createExternalSource({ command: execPath, args: [fsAdapterBinPath(), "--root", opts.root] });
|
|
15
|
+
const coord = new Coordinator(engine, source);
|
|
16
|
+
const shutdown = async () => {
|
|
17
|
+
try {
|
|
18
|
+
await source.close();
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
await engine.close();
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
try {
|
|
25
|
+
const s = await coord.syncIn();
|
|
26
|
+
log(`in: +${s.ingested.length} ingested, =${s.suppressed.length} unchanged, !${s.conflicted.length} conflicted, -${s.deleted.length} deleted`);
|
|
27
|
+
if (opts.out) {
|
|
28
|
+
const o = await coord.syncOut();
|
|
29
|
+
log(`out: →${o.written.length} written, ✗${o.removed.length} removed`);
|
|
30
|
+
}
|
|
31
|
+
if (opts.watch) {
|
|
32
|
+
const sub = await coord.watchIn({
|
|
33
|
+
onSummary: (w) => log(`watch: +${w.ingested.length} =${w.suppressed.length} !${w.conflicted.length} -${w.deleted.length}`),
|
|
34
|
+
onError: (err) => log(`watch error: ${String(err)}`),
|
|
35
|
+
});
|
|
36
|
+
if (!sub) {
|
|
37
|
+
log("source cannot watch — nothing to do (did the initial sync above)");
|
|
38
|
+
await shutdown();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
log("watching — Ctrl-C to stop");
|
|
42
|
+
await new Promise((resolve) => {
|
|
43
|
+
const stop = () => {
|
|
44
|
+
void sub.stop().then(shutdown).then(resolve);
|
|
45
|
+
};
|
|
46
|
+
process.once("SIGINT", stop);
|
|
47
|
+
process.once("SIGTERM", stop);
|
|
48
|
+
});
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
if (!opts.watch)
|
|
54
|
+
await shutdown();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=mirror.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@omgbase/sync",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Open Markdown Graph Base — standalone store-to-store synchronizer: a coordinator over an EngineClient seam (in-process or MCP-client) and source adapters",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/omgbase/omgbase.git",
|
|
9
|
+
"directory": "packages/sync"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/omgbase/omgbase#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/omgbase/omgbase/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"markdown",
|
|
17
|
+
"knowledge-graph",
|
|
18
|
+
"sqlite",
|
|
19
|
+
"mcp",
|
|
20
|
+
"model-context-protocol",
|
|
21
|
+
"obsidian",
|
|
22
|
+
"agents",
|
|
23
|
+
"omgbase",
|
|
24
|
+
"sync",
|
|
25
|
+
"mirror",
|
|
26
|
+
"coordinator"
|
|
27
|
+
],
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=22"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist",
|
|
36
|
+
"!dist/**/*.test.*",
|
|
37
|
+
"!dist/corpus/**",
|
|
38
|
+
"!dist/**/*.map",
|
|
39
|
+
"!dist/**/*.tsbuildinfo",
|
|
40
|
+
"LICENSE"
|
|
41
|
+
],
|
|
42
|
+
"type": "module",
|
|
43
|
+
"main": "./dist/src/index.js",
|
|
44
|
+
"types": "./dist/src/index.d.ts",
|
|
45
|
+
"bin": {
|
|
46
|
+
"omgbase-sync": "./dist/src/bin.js"
|
|
47
|
+
},
|
|
48
|
+
"exports": {
|
|
49
|
+
".": {
|
|
50
|
+
"import": "./dist/src/index.js",
|
|
51
|
+
"types": "./dist/src/index.d.ts"
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
56
|
+
"@omgbase/core": "^0.1.0",
|
|
57
|
+
"@omgbase/fs-adapter": "^0.1.0"
|
|
58
|
+
},
|
|
59
|
+
"scripts": {
|
|
60
|
+
"build": "tsc -p tsconfig.json",
|
|
61
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
62
|
+
"test": "vitest run",
|
|
63
|
+
"lint": "eslint .",
|
|
64
|
+
"lint:fix": "eslint . --fix"
|
|
65
|
+
}
|
|
66
|
+
}
|