@omgbase/core 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.
Files changed (214) hide show
  1. package/LICENSE +21 -0
  2. package/dist/src/cli/eval-matcher.d.ts +13 -0
  3. package/dist/src/cli/eval-matcher.js +36 -0
  4. package/dist/src/core/attach.d.ts +5 -0
  5. package/dist/src/core/attach.js +38 -0
  6. package/dist/src/core/cursor.d.ts +11 -0
  7. package/dist/src/core/cursor.js +35 -0
  8. package/dist/src/core/hash.d.ts +23 -0
  9. package/dist/src/core/hash.js +82 -0
  10. package/dist/src/core/ids.d.ts +6 -0
  11. package/dist/src/core/ids.js +26 -0
  12. package/dist/src/core/index.d.ts +1 -0
  13. package/dist/src/core/index.js +2 -0
  14. package/dist/src/core/ingest.d.ts +43 -0
  15. package/dist/src/core/ingest.js +287 -0
  16. package/dist/src/core/order-key.d.ts +8 -0
  17. package/dist/src/core/order-key.js +72 -0
  18. package/dist/src/core/parse/parse.d.ts +3 -0
  19. package/dist/src/core/parse/parse.js +104 -0
  20. package/dist/src/core/parse/render.d.ts +3 -0
  21. package/dist/src/core/parse/render.js +20 -0
  22. package/dist/src/core/parse/tree.d.ts +8 -0
  23. package/dist/src/core/parse/tree.js +43 -0
  24. package/dist/src/core/parse/types.d.ts +52 -0
  25. package/dist/src/core/parse/types.js +9 -0
  26. package/dist/src/core/read/document.d.ts +107 -0
  27. package/dist/src/core/read/document.js +191 -0
  28. package/dist/src/core/read/nodes.d.ts +47 -0
  29. package/dist/src/core/read/nodes.js +102 -0
  30. package/dist/src/core/read/outline.d.ts +12 -0
  31. package/dist/src/core/read/outline.js +76 -0
  32. package/dist/src/core/read/reader.d.ts +125 -0
  33. package/dist/src/core/read/reader.js +206 -0
  34. package/dist/src/core/read/refs.d.ts +15 -0
  35. package/dist/src/core/read/refs.js +51 -0
  36. package/dist/src/core/store/edges.d.ts +26 -0
  37. package/dist/src/core/store/edges.js +101 -0
  38. package/dist/src/core/store/fts.d.ts +10 -0
  39. package/dist/src/core/store/fts.js +22 -0
  40. package/dist/src/core/store/gc.d.ts +18 -0
  41. package/dist/src/core/store/gc.js +69 -0
  42. package/dist/src/core/store/nodes.d.ts +5 -0
  43. package/dist/src/core/store/nodes.js +57 -0
  44. package/dist/src/core/store/properties.d.ts +38 -0
  45. package/dist/src/core/store/properties.js +177 -0
  46. package/dist/src/core/store/rebuild.d.ts +3 -0
  47. package/dist/src/core/store/rebuild.js +29 -0
  48. package/dist/src/core/store/schema.d.ts +9 -0
  49. package/dist/src/core/store/schema.js +386 -0
  50. package/dist/src/core/store/sections.d.ts +2 -0
  51. package/dist/src/core/store/sections.js +29 -0
  52. package/dist/src/core/store/store.d.ts +24 -0
  53. package/dist/src/core/store/store.js +164 -0
  54. package/dist/src/core/store/writers.d.ts +52 -0
  55. package/dist/src/core/store/writers.js +89 -0
  56. package/dist/src/core/vec.d.ts +10 -0
  57. package/dist/src/core/vec.js +33 -0
  58. package/dist/src/format/adapter.d.ts +97 -0
  59. package/dist/src/format/adapter.js +14 -0
  60. package/dist/src/format/index.d.ts +6 -0
  61. package/dist/src/format/index.js +15 -0
  62. package/dist/src/format/json.d.ts +3 -0
  63. package/dist/src/format/json.js +324 -0
  64. package/dist/src/format/markdown.d.ts +5 -0
  65. package/dist/src/format/markdown.js +217 -0
  66. package/dist/src/format/registry.d.ts +6 -0
  67. package/dist/src/format/registry.js +24 -0
  68. package/dist/src/format/yaml.d.ts +5 -0
  69. package/dist/src/format/yaml.js +413 -0
  70. package/dist/src/graph/extract.d.ts +38 -0
  71. package/dist/src/graph/extract.js +242 -0
  72. package/dist/src/graph/history.d.ts +103 -0
  73. package/dist/src/graph/history.js +210 -0
  74. package/dist/src/graph/inbound-links.d.ts +33 -0
  75. package/dist/src/graph/inbound-links.js +130 -0
  76. package/dist/src/graph/link-destinations.d.ts +22 -0
  77. package/dist/src/graph/link-destinations.js +84 -0
  78. package/dist/src/graph/link-health.d.ts +79 -0
  79. package/dist/src/graph/link-health.js +146 -0
  80. package/dist/src/graph/links.d.ts +23 -0
  81. package/dist/src/graph/links.js +42 -0
  82. package/dist/src/index.d.ts +60 -0
  83. package/dist/src/index.js +70 -0
  84. package/dist/src/mcp/errors.d.ts +17 -0
  85. package/dist/src/mcp/errors.js +18 -0
  86. package/dist/src/mcp/graph.d.ts +63 -0
  87. package/dist/src/mcp/graph.js +212 -0
  88. package/dist/src/mcp/reference.d.ts +1 -0
  89. package/dist/src/mcp/reference.js +227 -0
  90. package/dist/src/mcp/server.d.ts +30 -0
  91. package/dist/src/mcp/server.js +1076 -0
  92. package/dist/src/mcp/stdio.d.ts +13 -0
  93. package/dist/src/mcp/stdio.js +24 -0
  94. package/dist/src/migrate/mrplex.d.ts +27 -0
  95. package/dist/src/migrate/mrplex.js +29 -0
  96. package/dist/src/mutate/apply.d.ts +83 -0
  97. package/dist/src/mutate/apply.js +302 -0
  98. package/dist/src/mutate/doc-store.d.ts +49 -0
  99. package/dist/src/mutate/doc-store.js +71 -0
  100. package/dist/src/mutate/docs.d.ts +60 -0
  101. package/dist/src/mutate/docs.js +232 -0
  102. package/dist/src/mutate/known-ids.d.ts +12 -0
  103. package/dist/src/mutate/known-ids.js +154 -0
  104. package/dist/src/mutate/load.d.ts +3 -0
  105. package/dist/src/mutate/load.js +66 -0
  106. package/dist/src/mutate/lower.d.ts +32 -0
  107. package/dist/src/mutate/lower.js +232 -0
  108. package/dist/src/mutate/macros.d.ts +91 -0
  109. package/dist/src/mutate/macros.js +230 -0
  110. package/dist/src/mutate/ops.d.ts +40 -0
  111. package/dist/src/mutate/ops.js +513 -0
  112. package/dist/src/mutate/opset.d.ts +92 -0
  113. package/dist/src/mutate/opset.js +79 -0
  114. package/dist/src/mutate/plan-update.d.ts +51 -0
  115. package/dist/src/mutate/plan-update.js +187 -0
  116. package/dist/src/mutate/tree.d.ts +46 -0
  117. package/dist/src/mutate/tree.js +167 -0
  118. package/dist/src/oqx/run.d.ts +1 -0
  119. package/dist/src/oqx/run.js +6 -0
  120. package/dist/src/oqx-js/context.d.ts +23 -0
  121. package/dist/src/oqx-js/context.js +564 -0
  122. package/dist/src/oqx-js/planner.d.ts +10 -0
  123. package/dist/src/oqx-js/planner.js +91 -0
  124. package/dist/src/oqx-js/run.d.ts +41 -0
  125. package/dist/src/oqx-js/run.js +332 -0
  126. package/dist/src/oqx-js/sql/translate.d.ts +27 -0
  127. package/dist/src/oqx-js/sql/translate.js +290 -0
  128. package/dist/src/reconcile/crossdoc.d.ts +34 -0
  129. package/dist/src/reconcile/crossdoc.js +76 -0
  130. package/dist/src/reconcile/eval/generator.d.ts +21 -0
  131. package/dist/src/reconcile/eval/generator.js +129 -0
  132. package/dist/src/reconcile/eval/metrics.d.ts +32 -0
  133. package/dist/src/reconcile/eval/metrics.js +74 -0
  134. package/dist/src/reconcile/flatten.d.ts +12 -0
  135. package/dist/src/reconcile/flatten.js +37 -0
  136. package/dist/src/reconcile/phase5.d.ts +8 -0
  137. package/dist/src/reconcile/phase5.js +167 -0
  138. package/dist/src/reconcile/phase6a.d.ts +2 -0
  139. package/dist/src/reconcile/phase6a.js +159 -0
  140. package/dist/src/reconcile/phases.d.ts +28 -0
  141. package/dist/src/reconcile/phases.js +0 -0
  142. package/dist/src/reconcile/reconcile.d.ts +21 -0
  143. package/dist/src/reconcile/reconcile.js +115 -0
  144. package/dist/src/reconcile/similarity.d.ts +7 -0
  145. package/dist/src/reconcile/similarity.js +41 -0
  146. package/dist/src/reconcile/types.d.ts +46 -0
  147. package/dist/src/reconcile/types.js +19 -0
  148. package/dist/src/search/cel/ast.d.ts +74 -0
  149. package/dist/src/search/cel/ast.js +3 -0
  150. package/dist/src/search/cel/compile.d.ts +60 -0
  151. package/dist/src/search/cel/compile.js +836 -0
  152. package/dist/src/search/cel/lexer.d.ts +9 -0
  153. package/dist/src/search/cel/lexer.js +139 -0
  154. package/dist/src/search/cel/parser.d.ts +7 -0
  155. package/dist/src/search/cel/parser.js +219 -0
  156. package/dist/src/search/drain.d.ts +39 -0
  157. package/dist/src/search/drain.js +97 -0
  158. package/dist/src/search/embeddings.d.ts +131 -0
  159. package/dist/src/search/embeddings.js +263 -0
  160. package/dist/src/search/external.d.ts +13 -0
  161. package/dist/src/search/external.js +177 -0
  162. package/dist/src/search/fts-query.d.ts +1 -0
  163. package/dist/src/search/fts-query.js +64 -0
  164. package/dist/src/search/provider.d.ts +17 -0
  165. package/dist/src/search/provider.js +13 -0
  166. package/dist/src/search/resolve.d.ts +18 -0
  167. package/dist/src/search/resolve.js +23 -0
  168. package/dist/src/search/rrf.d.ts +36 -0
  169. package/dist/src/search/rrf.js +84 -0
  170. package/dist/src/search/tasks.d.ts +12 -0
  171. package/dist/src/search/tasks.js +131 -0
  172. package/dist/src/search/text.d.ts +16 -0
  173. package/dist/src/search/text.js +21 -0
  174. package/dist/src/search/vector.d.ts +18 -0
  175. package/dist/src/search/vector.js +49 -0
  176. package/dist/src/sync/admin.d.ts +59 -0
  177. package/dist/src/sync/admin.js +53 -0
  178. package/dist/src/sync/attach.d.ts +13 -0
  179. package/dist/src/sync/attach.js +29 -0
  180. package/dist/src/sync/checkpoint.d.ts +21 -0
  181. package/dist/src/sync/checkpoint.js +52 -0
  182. package/dist/src/sync/driver.d.ts +25 -0
  183. package/dist/src/sync/driver.js +80 -0
  184. package/dist/src/sync/external-source.d.ts +14 -0
  185. package/dist/src/sync/external-source.js +137 -0
  186. package/dist/src/sync/freshness.d.ts +42 -0
  187. package/dist/src/sync/freshness.js +152 -0
  188. package/dist/src/sync/fs-util.d.ts +12 -0
  189. package/dist/src/sync/fs-util.js +55 -0
  190. package/dist/src/sync/git-heuristics.d.ts +6 -0
  191. package/dist/src/sync/git-heuristics.js +16 -0
  192. package/dist/src/sync/observe.d.ts +63 -0
  193. package/dist/src/sync/observe.js +84 -0
  194. package/dist/src/sync/plugin.d.ts +54 -0
  195. package/dist/src/sync/plugin.js +12 -0
  196. package/dist/src/sync/reconciling-ingest.d.ts +12 -0
  197. package/dist/src/sync/reconciling-ingest.js +209 -0
  198. package/dist/src/sync/recovery.d.ts +6 -0
  199. package/dist/src/sync/recovery.js +34 -0
  200. package/dist/src/sync/settings.d.ts +18 -0
  201. package/dist/src/sync/settings.js +58 -0
  202. package/dist/src/sync/sources.d.ts +37 -0
  203. package/dist/src/sync/sources.js +75 -0
  204. package/dist/src/sync/tombstone.d.ts +9 -0
  205. package/dist/src/sync/tombstone.js +55 -0
  206. package/dist/src/sync/watch-lease.d.ts +14 -0
  207. package/dist/src/sync/watch-lease.js +91 -0
  208. package/dist/src/sync/watcher.d.ts +19 -0
  209. package/dist/src/sync/watcher.js +34 -0
  210. package/dist/src/sync/workspace.d.ts +34 -0
  211. package/dist/src/sync/workspace.js +120 -0
  212. package/dist/src/sync/writer-lock.d.ts +16 -0
  213. package/dist/src/sync/writer-lock.js +121 -0
  214. package/package.json +80 -0
@@ -0,0 +1,80 @@
1
+ import { mintId } from "../core/ids.js";
2
+ import { ingestFile } from "../core/ingest.js";
3
+ import { ensureRepo } from "../core/attach.js";
4
+ import { makeReconcilingResolver } from "./reconciling-ingest.js";
5
+ import { observeOne } from "./observe.js";
6
+ import { sweepResurrectionPool } from "../core/store/gc.js";
7
+ import { tombstoneObservedDeletion } from "./tombstone.js";
8
+ /**
9
+ * Reconcile a batch of changed members against a repo as one checkpoint.
10
+ * `source.fetch(path)` supplies current bytes (or null = left the scope). The
11
+ * engine hashes the bytes itself: a hash equal to the stored file_hash is an
12
+ * echo (no commit); otherwise the member is ingested as an observed commit.
13
+ */
14
+ export async function reconcileChanges(store, repoId, source, changes, opts = {}) {
15
+ const ts = opts.ts ?? new Date().toISOString();
16
+ const checkpointId = mintId("cp");
17
+ const ingested = [];
18
+ const suppressed = [];
19
+ const deleted = [];
20
+ const conflicted = [];
21
+ const fileEntries = [];
22
+ for (const change of changes) {
23
+ const item = await source.fetch(change.path);
24
+ if (item === null) {
25
+ // Member left the source scope (deleted/moved out): tombstone the live doc
26
+ // (drop FTS, tombstone blocks + doc, pool blocks) so it stops being served.
27
+ const existing = store.db
28
+ .prepare("SELECT doc_id, file_hash FROM docs WHERE repo_id = ? AND path = ? AND deleted_commit IS NULL")
29
+ .get(repoId, change.path);
30
+ if (existing) {
31
+ tombstoneObservedDeletion(store, repoId, existing.doc_id, ts);
32
+ deleted.push(change.path);
33
+ }
34
+ fileEntries.push([change.path, existing?.file_hash?.toString("hex") ?? null, null]);
35
+ continue;
36
+ }
37
+ // Reconcile the fetched bytes through the shared observe primitive — the one
38
+ // implementation of echo gate + identity threading + conflict flagging (D2).
39
+ // (v1 sources are identity-inferred; borne identity is deferred, ADR-010/D4.)
40
+ const r = observeOne(store, repoId, change.path, item.content, ts);
41
+ if (r.echo)
42
+ suppressed.push(change.path);
43
+ else if (r.conflicted)
44
+ conflicted.push(change.path);
45
+ else
46
+ ingested.push(change.path);
47
+ fileEntries.push([change.path, r.oldHashHex, r.newHashHex]);
48
+ }
49
+ store.db
50
+ .prepare("INSERT INTO checkpoints (id, repo_id, ts, files, git_head) VALUES (?, ?, ?, ?, ?)")
51
+ .run(checkpointId, repoId, ts, JSON.stringify(fileEntries), opts.gitHead ?? null);
52
+ sweepResurrectionPool(store, ts);
53
+ return { checkpointId, ingested, suppressed, deleted, conflicted };
54
+ }
55
+ /**
56
+ * Attach a source scope as a repo: create/reuse the repo row, then ingest every
57
+ * member the source enumerates (identity-inferred sources thread the reconciling
58
+ * resolver so edges + identity are established on the initial walk).
59
+ */
60
+ export async function attachSource(store, slug, rootPath, source) {
61
+ const repoId = ensureRepo(store, slug, rootPath);
62
+ const inferred = source.capabilities().identity === "inferred";
63
+ const ts = new Date().toISOString();
64
+ let fileCount = 0;
65
+ let allConverged = true;
66
+ for (const entry of await source.enumerate()) {
67
+ const item = await source.fetch(entry.path);
68
+ if (!item)
69
+ continue;
70
+ const res = ingestFile(store, repoId, entry.path, item.content, {
71
+ ts,
72
+ ...(inferred ? { resolveIds: makeReconcilingResolver(store, repoId, { ts, path: entry.path }) } : {}),
73
+ });
74
+ fileCount++;
75
+ if (!res.converged)
76
+ allConverged = false;
77
+ }
78
+ return { repoId, fileCount, allConverged };
79
+ }
80
+ //# sourceMappingURL=driver.js.map
@@ -0,0 +1,14 @@
1
+ import type { SyncSource } from "./plugin.js";
2
+ export interface ExternalSourceSpec {
3
+ /** Adapter command (argv[0]). */
4
+ command: string;
5
+ /** Rendered flags from the source config (13 §3.1). */
6
+ args: string[];
7
+ /** Extra environment (secrets; 13 §3.2). Merged over process.env. */
8
+ env?: Record<string, string>;
9
+ }
10
+ /**
11
+ * Spawn an adapter and connect it as a SyncSource. Throws on spawn/handshake
12
+ * failure. Callers MUST call close() to terminate the process.
13
+ */
14
+ export declare function createExternalSource(spec: ExternalSourceSpec): Promise<SyncSource>;
@@ -0,0 +1,137 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createInterface } from "node:readline";
3
+ /**
4
+ * Spawn an adapter and connect it as a SyncSource. Throws on spawn/handshake
5
+ * failure. Callers MUST call close() to terminate the process.
6
+ */
7
+ export async function createExternalSource(spec) {
8
+ let child;
9
+ try {
10
+ child = spawn(spec.command, spec.args, {
11
+ stdio: ["pipe", "pipe", "inherit"],
12
+ env: { ...process.env, ...(spec.env ?? {}) },
13
+ });
14
+ }
15
+ catch (err) {
16
+ throw new Error(`sync adapter '${spec.command}' failed to spawn: ${err.message}`);
17
+ }
18
+ const rl = createInterface({ input: child.stdout });
19
+ let watchListener = null;
20
+ const router = messageRouter(rl, (paths) => watchListener?.(paths));
21
+ const died = new Promise((_, reject) => {
22
+ child.once("error", (err) => reject(new Error(`sync adapter '${spec.command}' failed to spawn: ${err.message}`)));
23
+ child.once("exit", (code) => reject(new Error(`sync adapter '${spec.command}' exited early (code ${code ?? "?"})`)));
24
+ });
25
+ // Handshake: the first line declares protocol + capabilities.
26
+ const handshakeRaw = await Promise.race([router.nextResponse(), died]);
27
+ let caps;
28
+ try {
29
+ const hs = JSON.parse(handshakeRaw);
30
+ const c = hs.capabilities ?? {};
31
+ caps = {
32
+ identity: c.identity === "borne" ? "borne" : "inferred",
33
+ writeThrough: Boolean(c.writeThrough),
34
+ watch: Boolean(c.watch),
35
+ };
36
+ }
37
+ catch {
38
+ throw new Error(`sync adapter '${spec.command}' sent an invalid handshake: ${handshakeRaw.slice(0, 120)}`);
39
+ }
40
+ let nextId = 1;
41
+ const call = async (method, params = {}) => {
42
+ const id = nextId++;
43
+ child.stdin.write(JSON.stringify({ id, method, params }) + "\n");
44
+ for (;;) {
45
+ const raw = await Promise.race([router.nextResponse(), died]);
46
+ const msg = JSON.parse(raw);
47
+ if (msg.id !== id)
48
+ continue; // response to another in-flight call
49
+ if (msg.error)
50
+ throw new Error(`sync adapter error (${method}): ${msg.error}`);
51
+ return msg.result;
52
+ }
53
+ };
54
+ const source = {
55
+ capabilities: () => caps,
56
+ async enumerate() {
57
+ const r = (await call("enumerate"));
58
+ return r.entries ?? [];
59
+ },
60
+ async fetch(path) {
61
+ const r = (await call("fetch", { path }));
62
+ return r.item ?? null;
63
+ },
64
+ ...(caps.watch
65
+ ? {
66
+ async watch(onBatch) {
67
+ watchListener = onBatch;
68
+ await call("watch");
69
+ return {
70
+ async stop() {
71
+ watchListener = null;
72
+ try {
73
+ await call("unwatch");
74
+ }
75
+ catch {
76
+ // process may already be exiting; ignore.
77
+ }
78
+ },
79
+ };
80
+ },
81
+ }
82
+ : {}),
83
+ ...(caps.writeThrough
84
+ ? {
85
+ async write(path, content) {
86
+ await call("write", { path, content });
87
+ },
88
+ async remove(path) {
89
+ await call("remove", { path });
90
+ },
91
+ }
92
+ : {}),
93
+ async close() {
94
+ rl.close();
95
+ child.stdin.end();
96
+ if (!child.killed)
97
+ child.kill("SIGTERM");
98
+ },
99
+ };
100
+ return source;
101
+ }
102
+ // Demultiplex adapter stdout: {"event":"batch"} lines feed the watch listener;
103
+ // every other line is an id-addressed response buffered for nextResponse().
104
+ function messageRouter(rl, onBatch) {
105
+ const buffered = [];
106
+ const waiters = [];
107
+ rl.on("line", (line) => {
108
+ const trimmed = line.trim();
109
+ if (!trimmed)
110
+ return;
111
+ try {
112
+ const peek = JSON.parse(trimmed);
113
+ if (peek.event === "batch") {
114
+ onBatch(peek.paths ?? []);
115
+ return;
116
+ }
117
+ }
118
+ catch {
119
+ // Non-JSON on stdout is a protocol violation; surface it as a response so
120
+ // the awaiting call() rejects with a parse error rather than hanging.
121
+ }
122
+ const w = waiters.shift();
123
+ if (w)
124
+ w(trimmed);
125
+ else
126
+ buffered.push(trimmed);
127
+ });
128
+ return {
129
+ nextResponse() {
130
+ const b = buffered.shift();
131
+ if (b !== undefined)
132
+ return Promise.resolve(b);
133
+ return new Promise((resolve) => waiters.push(resolve));
134
+ },
135
+ };
136
+ }
137
+ //# sourceMappingURL=external-source.js.map
@@ -0,0 +1,42 @@
1
+ import type { Store } from "../core/store/store.js";
2
+ import { type CheckpointResult } from "./checkpoint.js";
3
+ export interface SweepResult extends CheckpointResult {
4
+ scanned: number;
5
+ candidates: number;
6
+ changed: boolean;
7
+ }
8
+ /** Record/refresh a file's stat cache row. Call after any ingest of the file. */
9
+ export declare function recordFileStat(store: Store, repoId: string, path: string, abs: string, hash: Buffer): void;
10
+ /**
11
+ * Run the freshness sweep for a repo. Returns the checkpoint result plus scan
12
+ * counters. Idempotent; safe alongside a live watcher (hash-based echo
13
+ * suppression makes a double ingest a no-op).
14
+ */
15
+ export declare function freshnessSweep(store: Store, repoId: string, rootPath: string): SweepResult;
16
+ /**
17
+ * Counts of ways the DB disagrees with the current filesystem. All fields are
18
+ * "not yet reconciled" states a freshnessSweep/ingest would resolve.
19
+ */
20
+ export interface DiskDrift {
21
+ /** non-deleted docs whose on-disk content hash != the doc's stored file_hash (drifted edits). */
22
+ changed: number;
23
+ /** non-deleted docs whose file is no longer on disk (deletes not yet ingested). */
24
+ deleted: number;
25
+ /** *.md files on disk with no corresponding non-deleted doc (new files not yet ingested). */
26
+ untracked: number;
27
+ }
28
+ /**
29
+ * READ-ONLY disk-drift detector. Mirrors freshnessSweep's staged cheap approach
30
+ * — stat-compare against file_stats first, hash only the stat-mismatched
31
+ * candidates — but never ingests, commits, or touches file_stats. It answers a
32
+ * single question: does the DB still agree with what's on disk right now?
33
+ *
34
+ * `changed` is decided by comparing a candidate's content hash to the doc's
35
+ * stored `file_hash` (the same sha256(bytes) checkpoint writes), so a stat
36
+ * change without a content change is NOT counted as drift. At the envelope
37
+ * (≤10⁴ docs) the clean case is a directory walk plus stats (tens of ms);
38
+ * hashing is bounded to files whose (mtime_ns, size) moved.
39
+ */
40
+ export declare function detectDiskDrift(store: Store, repoId: string, rootPath: string): DiskDrift;
41
+ /** Rebuild file_stats from scratch by re-statting + re-hashing every file. */
42
+ export declare function rebuildFileStats(store: Store, repoId: string, rootPath: string): number;
@@ -0,0 +1,152 @@
1
+ import { statSync, readFileSync, existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { sha256 } from "../core/hash.js";
4
+ import { processCheckpoint } from "./checkpoint.js";
5
+ import { walkMarkdown } from "./fs-util.js";
6
+ /** Record/refresh a file's stat cache row. Call after any ingest of the file. */
7
+ export function recordFileStat(store, repoId, path, abs, hash) {
8
+ if (!existsSync(abs)) {
9
+ store.db.prepare("DELETE FROM file_stats WHERE repo_id = ? AND path = ?").run(repoId, path);
10
+ return;
11
+ }
12
+ const st = statSync(abs, { bigint: true });
13
+ store.db
14
+ .prepare(`INSERT INTO file_stats (repo_id, path, mtime_ns, size, hash) VALUES (?, ?, ?, ?, ?)
15
+ ON CONFLICT(repo_id, path) DO UPDATE SET mtime_ns = excluded.mtime_ns, size = excluded.size, hash = excluded.hash`)
16
+ .run(repoId, path, st.mtimeNs.toString(), Number(st.size), hash);
17
+ }
18
+ /**
19
+ * Run the freshness sweep for a repo. Returns the checkpoint result plus scan
20
+ * counters. Idempotent; safe alongside a live watcher (hash-based echo
21
+ * suppression makes a double ingest a no-op).
22
+ */
23
+ export function freshnessSweep(store, repoId, rootPath) {
24
+ const cache = new Map();
25
+ for (const row of store.db
26
+ .prepare("SELECT path, mtime_ns, size, hash FROM file_stats WHERE repo_id = ?")
27
+ .all(repoId)) {
28
+ cache.set(row.path, { mtime_ns: BigInt(row.mtime_ns), size: row.size, hash: row.hash });
29
+ }
30
+ const paths = walkMarkdown(rootPath);
31
+ const seen = new Set(paths);
32
+ // Candidates: files whose (mtime_ns, size) differs from cache, or are new.
33
+ const candidates = [];
34
+ const freshStats = new Map();
35
+ for (const path of paths) {
36
+ const st = statSync(join(rootPath, path), { bigint: true });
37
+ freshStats.set(path, { mtimeNs: st.mtimeNs, size: Number(st.size) });
38
+ const cached = cache.get(path);
39
+ if (!cached || cached.mtime_ns !== st.mtimeNs || cached.size !== Number(st.size)) {
40
+ candidates.push(path);
41
+ }
42
+ }
43
+ // Deletions: cached paths no longer on disk.
44
+ const deletedPaths = [...cache.keys()].filter((p) => !seen.has(p));
45
+ // Hash candidates; only those whose content hash actually differs from the
46
+ // cached hash need ingesting (mtime can change without content changing).
47
+ const changedPaths = [];
48
+ const candHashes = new Map();
49
+ for (const path of candidates) {
50
+ const hash = sha256(readFileSync(join(rootPath, path), "utf8"));
51
+ candHashes.set(path, hash);
52
+ const cached = cache.get(path);
53
+ if (!cached || !cached.hash.equals(hash))
54
+ changedPaths.push(path);
55
+ else {
56
+ // Content unchanged; refresh the stat cache so we don't re-hash next time.
57
+ const fs = freshStats.get(path);
58
+ store.db
59
+ .prepare("UPDATE file_stats SET mtime_ns = ?, size = ? WHERE repo_id = ? AND path = ?")
60
+ .run(fs.mtimeNs.toString(), fs.size, repoId, path);
61
+ }
62
+ }
63
+ const toIngest = [...changedPaths, ...deletedPaths];
64
+ const result = processCheckpoint(store, repoId, rootPath, toIngest.map((path) => ({ path })));
65
+ // Refresh stat cache for everything we touched (ingested or echo-suppressed).
66
+ for (const path of changedPaths) {
67
+ recordFileStat(store, repoId, path, join(rootPath, path), candHashes.get(path));
68
+ }
69
+ for (const path of deletedPaths) {
70
+ store.db.prepare("DELETE FROM file_stats WHERE repo_id = ? AND path = ?").run(repoId, path);
71
+ }
72
+ return {
73
+ ...result,
74
+ scanned: paths.length,
75
+ candidates: candidates.length,
76
+ changed: result.ingested.length > 0 || result.deleted.length > 0 || result.conflicted.length > 0,
77
+ };
78
+ }
79
+ /**
80
+ * READ-ONLY disk-drift detector. Mirrors freshnessSweep's staged cheap approach
81
+ * — stat-compare against file_stats first, hash only the stat-mismatched
82
+ * candidates — but never ingests, commits, or touches file_stats. It answers a
83
+ * single question: does the DB still agree with what's on disk right now?
84
+ *
85
+ * `changed` is decided by comparing a candidate's content hash to the doc's
86
+ * stored `file_hash` (the same sha256(bytes) checkpoint writes), so a stat
87
+ * change without a content change is NOT counted as drift. At the envelope
88
+ * (≤10⁴ docs) the clean case is a directory walk plus stats (tens of ms);
89
+ * hashing is bounded to files whose (mtime_ns, size) moved.
90
+ */
91
+ export function detectDiskDrift(store, repoId, rootPath) {
92
+ // Stat cache keyed by path, for cheap change detection.
93
+ const cache = new Map();
94
+ for (const row of store.db
95
+ .prepare("SELECT path, mtime_ns, size, hash FROM file_stats WHERE repo_id = ?")
96
+ .all(repoId)) {
97
+ cache.set(row.path, { mtime_ns: BigInt(row.mtime_ns), size: row.size, hash: row.hash });
98
+ }
99
+ // Live docs keyed by path, with their durable file_hash (the convergence
100
+ // signal). This is the authority for changed/deleted/untracked, independent
101
+ // of the derived file_stats cache.
102
+ const docs = new Map();
103
+ for (const row of store.db
104
+ .prepare("SELECT path, file_hash FROM docs WHERE repo_id = ? AND deleted_commit IS NULL")
105
+ .all(repoId)) {
106
+ docs.set(row.path, row.file_hash);
107
+ }
108
+ const paths = walkMarkdown(rootPath);
109
+ const seen = new Set(paths);
110
+ // Candidates whose (mtime_ns, size) differs from the stat cache (or are new).
111
+ const candidates = [];
112
+ for (const path of paths) {
113
+ const st = statSync(join(rootPath, path), { bigint: true });
114
+ const cached = cache.get(path);
115
+ if (!cached || cached.mtime_ns !== st.mtimeNs || cached.size !== Number(st.size)) {
116
+ candidates.push(path);
117
+ }
118
+ }
119
+ let changed = 0;
120
+ let untracked = 0;
121
+ for (const path of candidates) {
122
+ const doc = docs.get(path);
123
+ if (doc === undefined) {
124
+ // No non-deleted doc for an on-disk *.md ⇒ untracked (new file).
125
+ untracked += 1;
126
+ continue;
127
+ }
128
+ const hash = sha256(readFileSync(join(rootPath, path), "utf8"));
129
+ // Genuinely drifted only if content hash differs from the doc's file_hash.
130
+ if (!doc || !doc.equals(hash))
131
+ changed += 1;
132
+ }
133
+ // A doc present in the DB whose path isn't on disk anymore is a pending delete.
134
+ let deleted = 0;
135
+ for (const path of docs.keys()) {
136
+ if (!seen.has(path))
137
+ deleted += 1;
138
+ }
139
+ return { changed, deleted, untracked };
140
+ }
141
+ /** Rebuild file_stats from scratch by re-statting + re-hashing every file. */
142
+ export function rebuildFileStats(store, repoId, rootPath) {
143
+ store.db.prepare("DELETE FROM file_stats WHERE repo_id = ?").run(repoId);
144
+ const paths = walkMarkdown(rootPath);
145
+ for (const path of paths) {
146
+ const abs = join(rootPath, path);
147
+ const hash = sha256(readFileSync(abs, "utf8"));
148
+ recordFileStat(store, repoId, path, abs, hash);
149
+ }
150
+ return paths.length;
151
+ }
152
+ //# sourceMappingURL=freshness.js.map
@@ -0,0 +1,12 @@
1
+ /** Walk `root` for *.md files, returning repo-relative canonical paths. */
2
+ export declare function walkMarkdown(root: string): string[];
3
+ /**
4
+ * Async variant of {@link walkMarkdown}: identical result, but yields to the
5
+ * event loop between directories and invokes `onFound(count)` as matches
6
+ * accumulate. This lets a caller drive a live progress counter (e.g. beside an
7
+ * interactive prompt) while the scan runs. `signal` lets the caller stop early
8
+ * once a decision has been made — the partial result is still returned.
9
+ */
10
+ export declare function walkMarkdownAsync(root: string, onFound?: (count: number) => void, signal?: {
11
+ aborted: boolean;
12
+ }): Promise<string[]>;
@@ -0,0 +1,55 @@
1
+ import { readdirSync, statSync } from "node:fs";
2
+ import { readdir, stat } from "node:fs/promises";
3
+ import { join, relative, sep } from "node:path";
4
+ // Shared filesystem walk for the in-process one-shot/reconcile paths (attach,
5
+ // checkpoint, freshness). This is NOT the live watcher — chokidar lives only in
6
+ // the external @omgbase/fs-adapter (sync-plugins). node:fs (a builtin) reads
7
+ // are retained here for the one-shot ingest/freshness fast-path.
8
+ const IGNORED_DIRS = new Set([".omgbase", ".git", "node_modules"]);
9
+ /** Walk `root` for *.md files, returning repo-relative canonical paths. */
10
+ export function walkMarkdown(root) {
11
+ const out = [];
12
+ const recur = (dir) => {
13
+ for (const entry of readdirSync(dir)) {
14
+ if (IGNORED_DIRS.has(entry))
15
+ continue;
16
+ const full = join(dir, entry);
17
+ if (statSync(full).isDirectory())
18
+ recur(full);
19
+ else if (entry.endsWith(".md"))
20
+ out.push(relative(root, full).split(sep).join("/"));
21
+ }
22
+ };
23
+ recur(root);
24
+ return out;
25
+ }
26
+ /**
27
+ * Async variant of {@link walkMarkdown}: identical result, but yields to the
28
+ * event loop between directories and invokes `onFound(count)` as matches
29
+ * accumulate. This lets a caller drive a live progress counter (e.g. beside an
30
+ * interactive prompt) while the scan runs. `signal` lets the caller stop early
31
+ * once a decision has been made — the partial result is still returned.
32
+ */
33
+ export async function walkMarkdownAsync(root, onFound, signal) {
34
+ const out = [];
35
+ const recur = async (dir) => {
36
+ if (signal?.aborted)
37
+ return;
38
+ for (const entry of await readdir(dir)) {
39
+ if (signal?.aborted)
40
+ return;
41
+ if (IGNORED_DIRS.has(entry))
42
+ continue;
43
+ const full = join(dir, entry);
44
+ if ((await stat(full)).isDirectory())
45
+ await recur(full);
46
+ else if (entry.endsWith(".md")) {
47
+ out.push(relative(root, full).split(sep).join("/"));
48
+ onFound?.(out.length);
49
+ }
50
+ }
51
+ };
52
+ await recur(root);
53
+ return out;
54
+ }
55
+ //# sourceMappingURL=fs-util.js.map
@@ -0,0 +1,6 @@
1
+ export declare function hasConflictMarkers(content: string): boolean;
2
+ export interface RenameCandidate {
3
+ docId: string;
4
+ fileHashHex: string;
5
+ }
6
+ export declare function detectRename(newFileHashHex: string, deletedCandidates: RenameCandidate[]): string | null;
@@ -0,0 +1,16 @@
1
+ // Git-related heuristics (01 §6, 07 task 7.1). Conflict-marker detection flags a
2
+ // document `conflicted` (mutations refused until clean); whole-file hash match
3
+ // against a deleted/moved document detects a rename. These are hints layered on
4
+ // the checkpoint model — a checkout arrives as one large checkpoint that
5
+ // hash-locking makes cheap.
6
+ // Git conflict markers: <<<<<<< … ======= … >>>>>>> at line starts.
7
+ export function hasConflictMarkers(content) {
8
+ // Require both an opening and a closing marker to avoid false positives on
9
+ // prose that happens to start a line with '======='.
10
+ return /^<{7}/m.test(content) && /^>{7}/m.test(content);
11
+ }
12
+ export function detectRename(newFileHashHex, deletedCandidates) {
13
+ const match = deletedCandidates.find((c) => c.fileHashHex === newFileHashHex);
14
+ return match ? match.docId : null;
15
+ }
16
+ //# sourceMappingURL=git-heuristics.js.map
@@ -0,0 +1,63 @@
1
+ import type { Store } from "../core/store/store.js";
2
+ export interface ObserveResult {
3
+ docId: string;
4
+ path: string;
5
+ /** null on an echo (bytes already matched the stored revision — no commit). */
6
+ rev: string | null;
7
+ commitId: string | null;
8
+ /** convergence: sha256(bytes) === the committed revision's rendered_hash. */
9
+ converged: boolean;
10
+ /** true when the bytes already matched the stored revision (no new commit). */
11
+ echo: boolean;
12
+ /** true when the bytes carry git conflict markers (doc flagged conflicted). */
13
+ conflicted: boolean;
14
+ /** disposition kind → count for the commit this observation produced. */
15
+ dispositions: {
16
+ kind: string;
17
+ count: number;
18
+ }[];
19
+ }
20
+ /** `observeOne` plus the change-tracking hashes batch callers need for their
21
+ * checkpoint file-entries: the prior `file_hash` and the new bytes' hash. */
22
+ export interface ObserveOneResult extends ObserveResult {
23
+ /** prior stored file_hash (hex), or null if the doc was new/absent. */
24
+ oldHashHex: string | null;
25
+ /** sha256(content) (hex) — the new file hash. */
26
+ newHashHex: string;
27
+ }
28
+ /**
29
+ * The reconcile primitive: echo-gate `content` for `path` and, when it differs
30
+ * from the stored revision, ingest it as an observed commit (reconciling block
31
+ * identity against the prior revision). Does NOT sweep the resurrection pool —
32
+ * the caller does that once per batch. Does NOT write a file.
33
+ */
34
+ export declare function observeOne(store: Store, repoId: string, path: string, content: string, ts: string): ObserveOneResult;
35
+ /** Observe one file's authoritative bytes as an observed commit (echo-suppressed). */
36
+ export declare function observeFile(store: Store, repoId: string, path: string, content: string, opts?: {
37
+ ts?: string;
38
+ }): ObserveResult;
39
+ /** Observe a batch of files under one timestamp + one resurrection sweep. The
40
+ * batch form of `observeFile`; each item is echo-gated independently. */
41
+ export declare function observeMany(store: Store, repoId: string, items: {
42
+ path: string;
43
+ content: string;
44
+ }[], opts?: {
45
+ ts?: string;
46
+ }): ObserveResult[];
47
+ export interface ObserveDeleteResult {
48
+ docId: string | null;
49
+ path: string;
50
+ /** true when a live doc existed at `path` and was tombstoned. */
51
+ deleted: boolean;
52
+ }
53
+ /**
54
+ * Observe that `path` left the source scope: tombstone the live doc as an
55
+ * OBSERVED deletion (blocks pooled for resurrection; no file removed — the file
56
+ * is already gone from the source). The deletion counterpart to `observeFile`,
57
+ * for a synchronizer mirroring an external delete. Idempotent: a path with no
58
+ * live doc is a no-op (`deleted:false`). Distinct from `docs_delete`, which is
59
+ * an api-origin, intentional, non-pooled removal that also unlinks the file.
60
+ */
61
+ export declare function observeDelete(store: Store, repoId: string, path: string, opts?: {
62
+ ts?: string;
63
+ }): ObserveDeleteResult;
@@ -0,0 +1,84 @@
1
+ import { sha256 } from "../core/hash.js";
2
+ import { ingestFile } from "../core/ingest.js";
3
+ import { makeReconcilingResolver } from "./reconciling-ingest.js";
4
+ import { hasConflictMarkers } from "./git-heuristics.js";
5
+ import { tombstoneObservedDeletion } from "./tombstone.js";
6
+ import { sweepResurrectionPool } from "../core/store/gc.js";
7
+ /**
8
+ * The reconcile primitive: echo-gate `content` for `path` and, when it differs
9
+ * from the stored revision, ingest it as an observed commit (reconciling block
10
+ * identity against the prior revision). Does NOT sweep the resurrection pool —
11
+ * the caller does that once per batch. Does NOT write a file.
12
+ */
13
+ export function observeOne(store, repoId, path, content, ts) {
14
+ const existing = store.db
15
+ .prepare("SELECT doc_id, file_hash FROM docs WHERE repo_id = ? AND path = ? AND deleted_commit IS NULL")
16
+ .get(repoId, path);
17
+ const oldHashHex = existing?.file_hash?.toString("hex") ?? null;
18
+ const diskHash = sha256(content);
19
+ const newHashHex = diskHash.toString("hex");
20
+ // Echo: the stored revision already equals these bytes → no commit. The
21
+ // loop-breaker the coordinator relies on (§6): re-observing what the engine
22
+ // already holds is a no-op.
23
+ if (existing && existing.file_hash && existing.file_hash.equals(diskHash)) {
24
+ return { docId: existing.doc_id, path, rev: null, commitId: null, converged: true, echo: true, conflicted: false, dispositions: [], oldHashHex, newHashHex };
25
+ }
26
+ const conflicted = hasConflictMarkers(content);
27
+ const resolveIds = makeReconcilingResolver(store, repoId, { ts, path });
28
+ const res = ingestFile(store, repoId, path, content, { ts, origin: "observed", resolveIds });
29
+ store.db.prepare("UPDATE docs SET conflicted = ? WHERE repo_id = ? AND path = ?").run(conflicted ? 1 : 0, repoId, path);
30
+ const rows = store.db
31
+ .prepare("SELECT kind, count(*) n FROM dispositions WHERE commit_id = ? GROUP BY kind ORDER BY kind")
32
+ .all(res.commitId);
33
+ return {
34
+ docId: res.docId,
35
+ path,
36
+ rev: res.revId,
37
+ commitId: res.commitId,
38
+ converged: res.converged,
39
+ echo: false,
40
+ conflicted,
41
+ dispositions: rows.map((r) => ({ kind: r.kind, count: r.n })),
42
+ oldHashHex,
43
+ newHashHex,
44
+ };
45
+ }
46
+ function toPublic(r) {
47
+ const { oldHashHex: _o, newHashHex: _n, ...pub } = r;
48
+ return pub;
49
+ }
50
+ /** Observe one file's authoritative bytes as an observed commit (echo-suppressed). */
51
+ export function observeFile(store, repoId, path, content, opts = {}) {
52
+ const ts = opts.ts ?? new Date().toISOString();
53
+ const r = observeOne(store, repoId, path, content, ts);
54
+ sweepResurrectionPool(store, ts);
55
+ return toPublic(r);
56
+ }
57
+ /** Observe a batch of files under one timestamp + one resurrection sweep. The
58
+ * batch form of `observeFile`; each item is echo-gated independently. */
59
+ export function observeMany(store, repoId, items, opts = {}) {
60
+ const ts = opts.ts ?? new Date().toISOString();
61
+ const out = items.map((it) => toPublic(observeOne(store, repoId, it.path, it.content, ts)));
62
+ sweepResurrectionPool(store, ts);
63
+ return out;
64
+ }
65
+ /**
66
+ * Observe that `path` left the source scope: tombstone the live doc as an
67
+ * OBSERVED deletion (blocks pooled for resurrection; no file removed — the file
68
+ * is already gone from the source). The deletion counterpart to `observeFile`,
69
+ * for a synchronizer mirroring an external delete. Idempotent: a path with no
70
+ * live doc is a no-op (`deleted:false`). Distinct from `docs_delete`, which is
71
+ * an api-origin, intentional, non-pooled removal that also unlinks the file.
72
+ */
73
+ export function observeDelete(store, repoId, path, opts = {}) {
74
+ const ts = opts.ts ?? new Date().toISOString();
75
+ const existing = store.db
76
+ .prepare("SELECT doc_id FROM docs WHERE repo_id = ? AND path = ? AND deleted_commit IS NULL")
77
+ .get(repoId, path);
78
+ if (!existing)
79
+ return { docId: null, path, deleted: false };
80
+ tombstoneObservedDeletion(store, repoId, existing.doc_id, ts);
81
+ sweepResurrectionPool(store, ts);
82
+ return { docId: existing.doc_id, path, deleted: true };
83
+ }
84
+ //# sourceMappingURL=observe.js.map