@omgbase/core 0.1.0 → 0.3.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 (84) hide show
  1. package/README.md +172 -0
  2. package/dist/src/cli/eval-matcher.d.ts +6 -1
  3. package/dist/src/cli/eval-matcher.js +14 -2
  4. package/dist/src/core/clock.d.ts +9 -0
  5. package/dist/src/core/clock.js +46 -0
  6. package/dist/src/core/hash.d.ts +25 -1
  7. package/dist/src/core/hash.js +110 -10
  8. package/dist/src/core/ids.d.ts +12 -0
  9. package/dist/src/core/ids.js +43 -1
  10. package/dist/src/core/ingest.d.ts +22 -2
  11. package/dist/src/core/ingest.js +92 -18
  12. package/dist/src/core/parse/parse.d.ts +1 -1
  13. package/dist/src/core/parse/parse.js +31 -9
  14. package/dist/src/core/store/nodes.d.ts +13 -0
  15. package/dist/src/core/store/nodes.js +24 -0
  16. package/dist/src/core/store/writers.d.ts +6 -0
  17. package/dist/src/core/store/writers.js +1 -0
  18. package/dist/src/core/utf8.d.ts +29 -0
  19. package/dist/src/core/utf8.js +96 -0
  20. package/dist/src/format/adapter.d.ts +7 -1
  21. package/dist/src/format/markdown.js +36 -41
  22. package/dist/src/graph/extract.d.ts +41 -1
  23. package/dist/src/graph/extract.js +59 -1
  24. package/dist/src/graph/history.d.ts +39 -1
  25. package/dist/src/graph/history.js +154 -20
  26. package/dist/src/index.d.ts +10 -6
  27. package/dist/src/index.js +11 -6
  28. package/dist/src/mcp/graph.js +8 -4
  29. package/dist/src/mcp/reference.d.ts +1 -1
  30. package/dist/src/mcp/reference.js +17 -6
  31. package/dist/src/mcp/server.js +15 -10
  32. package/dist/src/mutate/apply.d.ts +9 -0
  33. package/dist/src/mutate/apply.js +24 -11
  34. package/dist/src/mutate/docs.d.ts +2 -0
  35. package/dist/src/mutate/docs.js +10 -6
  36. package/dist/src/mutate/known-ids.js +11 -3
  37. package/dist/src/mutate/macros.js +9 -2
  38. package/dist/src/mutate/ops.d.ts +5 -4
  39. package/dist/src/mutate/ops.js +62 -15
  40. package/dist/src/mutate/plan-update.d.ts +7 -1
  41. package/dist/src/mutate/plan-update.js +3 -1
  42. package/dist/src/oqx-js/context.js +57 -7
  43. package/dist/src/reconcile/eval/generator.d.ts +24 -1
  44. package/dist/src/reconcile/eval/generator.js +248 -0
  45. package/dist/src/reconcile/eval/metrics.d.ts +33 -9
  46. package/dist/src/reconcile/eval/metrics.js +55 -32
  47. package/dist/src/reconcile/flatten.js +7 -5
  48. package/dist/src/reconcile/phase5.d.ts +2 -1
  49. package/dist/src/reconcile/phase5.js +27 -10
  50. package/dist/src/reconcile/phase6a.js +17 -5
  51. package/dist/src/reconcile/phases.d.ts +44 -6
  52. package/dist/src/reconcile/phases.js +0 -0
  53. package/dist/src/reconcile/reconcile.js +16 -9
  54. package/dist/src/reconcile/types.d.ts +4 -1
  55. package/dist/src/reconcile/types.js +11 -2
  56. package/dist/src/search/external.d.ts +10 -0
  57. package/dist/src/search/external.js +18 -1
  58. package/dist/src/search/provider.d.ts +7 -3
  59. package/dist/src/search/provider.js +8 -7
  60. package/dist/src/search/rrf.js +4 -0
  61. package/dist/src/search/tasks.d.ts +28 -0
  62. package/dist/src/search/tasks.js +36 -23
  63. package/dist/src/search/vector.js +30 -14
  64. package/dist/src/sync/attach.d.ts +7 -2
  65. package/dist/src/sync/attach.js +10 -8
  66. package/dist/src/sync/checkpoint.d.ts +18 -1
  67. package/dist/src/sync/checkpoint.js +31 -29
  68. package/dist/src/sync/driver.d.ts +15 -4
  69. package/dist/src/sync/driver.js +18 -43
  70. package/dist/src/sync/external-source.js +7 -1
  71. package/dist/src/sync/freshness.d.ts +73 -15
  72. package/dist/src/sync/freshness.js +128 -97
  73. package/dist/src/sync/fs-util.d.ts +19 -0
  74. package/dist/src/sync/fs-util.js +24 -1
  75. package/dist/src/sync/observe.d.ts +53 -5
  76. package/dist/src/sync/observe.js +162 -21
  77. package/dist/src/sync/reconciling-ingest.d.ts +42 -2
  78. package/dist/src/sync/reconciling-ingest.js +87 -13
  79. package/dist/src/sync/recovery.d.ts +5 -1
  80. package/dist/src/sync/recovery.js +8 -8
  81. package/dist/src/sync/workspace.d.ts +11 -3
  82. package/dist/src/sync/workspace.js +39 -30
  83. package/dist/src/sync/writer-lock.js +16 -26
  84. package/package.json +3 -3
@@ -1,12 +1,52 @@
1
1
  import type { Database } from "better-sqlite3";
2
2
  import type { Store } from "../core/store/store.js";
3
- import type { IdResolver } from "../core/ingest.js";
3
+ import type { BlockTree } from "../core/parse/types.js";
4
+ import { type IdResolver } from "../core/ingest.js";
5
+ import { type ResurrectionCandidate, type DocReconcileResult } from "../reconcile/reconcile.js";
4
6
  import { type MatchBlock, type ReconcileConfig } from "../reconcile/types.js";
5
7
  export declare function loadOldMatchBlocks(db: Database, docId: string): MatchBlock[];
8
+ /** The live resurrection pool for a repo (rows not yet expired at `ts`). A batch
9
+ * caller loads it ONCE per checkpoint and threads a shared consumed-set through
10
+ * `prepareReconcile`, so two documents reconciled before either commits cannot
11
+ * both resurrect the same pooled id. */
12
+ export declare function loadPool(db: Database, repoId: string, ts: string): ResurrectionCandidate[];
13
+ /** One document parsed + reconciled but NOT committed: the first pass of a
14
+ * checkpoint (reconciliation-spec §8). The cross-document phase reads
15
+ * `oldBlocks`/`newBlocks`/`result` across every prepared doc, rewrites `result`
16
+ * in place (`applyCrossDocMatches`) and records the ids it carried in; the
17
+ * second pass commits by handing this to `makeReconcilingResolver({ prepared })`
18
+ * + `ingestFile({ parsed: tree })`. */
19
+ export interface PreparedReconcile {
20
+ path: string;
21
+ content: string;
22
+ /** the doc row at `path` (live or tombstoned), or null when the path is new —
23
+ * exactly the docId ingestFile will pass the resolver. */
24
+ docId: string | null;
25
+ tree: BlockTree;
26
+ oldBlocks: MatchBlock[];
27
+ newBlocks: MatchBlock[];
28
+ result: DocReconcileResult;
29
+ /** ids carried into this document by the cross-doc phase (filled by it). */
30
+ crossDocIds: string[];
31
+ }
32
+ /**
33
+ * Pass 1 of an observed ingest: parse `content`, load the document's current
34
+ * tree, and reconcile — without writing. `pool`/`consumed` let a batch share one
35
+ * pool snapshot: candidates already in `consumed` are hidden from this document,
36
+ * and the ids this document resurrects are added to it.
37
+ */
38
+ export declare function prepareReconcile(store: Store, repoId: string, path: string, content: string, opts?: {
39
+ ts?: string;
40
+ config?: ReconcileConfig;
41
+ pool?: ResurrectionCandidate[];
42
+ consumed?: Set<string>;
43
+ }): PreparedReconcile;
6
44
  /** Build an IdResolver bound to this store + repo, reconciling against the
7
- * document's current revision. */
45
+ * document's current revision. With `prepared` (from `prepareReconcile`, after
46
+ * the cross-doc phase) the resolver skips the reconcile and applies that result. */
8
47
  export declare function makeReconcilingResolver(store: Store, repoId: string, opts?: {
9
48
  ts?: string;
10
49
  config?: ReconcileConfig;
11
50
  path?: string;
51
+ prepared?: PreparedReconcile;
12
52
  }): IdResolver;
@@ -1,7 +1,8 @@
1
+ import { parseForIngest } from "../core/ingest.js";
1
2
  import { mintId } from "../core/ids.js";
2
3
  import { extractFromBlock, extractFromFrontmatter, resolveRelativePath } from "../graph/extract.js";
3
4
  import { resolveExternal, resolveDocPath } from "../core/store/edges.js";
4
- import { sha256, normalizeVisibleText } from "../core/hash.js";
5
+ import { sha256, childQuoteDepth, visibleText } from "../core/hash.js";
5
6
  import { reconcileDocument } from "../reconcile/reconcile.js";
6
7
  import { flatten } from "../reconcile/flatten.js";
7
8
  import { DEFAULT_CONFIG } from "../reconcile/types.js";
@@ -35,11 +36,41 @@ export function loadOldMatchBlocks(db, docId) {
35
36
  parentKeyOf.set(r.block_id, parentKey);
36
37
  return key;
37
38
  };
39
+ // `text` follows spec/format §4.1, which needs the tree: containers compose
40
+ // from their children and nested raws lose up to <blockquote-depth> `> `
41
+ // prefixes. Rebuild the shape (children by parent, in ordinal order) and
42
+ // compute text top-down with the same helper ingest used to store it.
38
43
  const blobBytes = db.prepare("SELECT bytes FROM blobs WHERE hash = ?");
44
+ const rawOf = new Map();
45
+ for (const r of rows) {
46
+ rawOf.set(r.block_id, blobBytes.get(r.raw_hash)?.bytes.toString("utf8") ?? "");
47
+ }
48
+ const childrenOf = new Map();
49
+ for (const r of rows) {
50
+ const parent = r.parent_block && byId.has(r.parent_block) ? r.parent_block : null;
51
+ let list = childrenOf.get(parent);
52
+ if (!list)
53
+ childrenOf.set(parent, (list = []));
54
+ list.push(r);
55
+ }
56
+ for (const list of childrenOf.values())
57
+ list.sort((a, b) => a.ordinal - b.ordinal);
58
+ const toTree = (r) => ({
59
+ type: r.type,
60
+ raw: rawOf.get(r.block_id) ?? "",
61
+ children: (childrenOf.get(r.block_id) ?? []).map(toTree),
62
+ });
63
+ const textOf = new Map();
64
+ const walk = (list, quoteDepth) => {
65
+ for (const r of list) {
66
+ textOf.set(r.block_id, visibleText(toTree(r), quoteDepth));
67
+ walk(childrenOf.get(r.block_id) ?? [], childQuoteDepth(r.type, quoteDepth));
68
+ }
69
+ };
70
+ walk(childrenOf.get(null) ?? [], 0);
39
71
  return rows.map((r) => {
40
72
  const key = resolveKey(r);
41
- const raw = blobBytes.get(r.raw_hash)?.bytes.toString("utf8") ?? "";
42
- const text = normalizeVisibleText(raw, r.type);
73
+ const text = textOf.get(r.block_id) ?? "";
43
74
  return {
44
75
  blockId: r.block_id,
45
76
  type: r.type,
@@ -53,7 +84,11 @@ export function loadOldMatchBlocks(db, docId) {
53
84
  };
54
85
  });
55
86
  }
56
- function loadPool(db, repoId, ts) {
87
+ /** The live resurrection pool for a repo (rows not yet expired at `ts`). A batch
88
+ * caller loads it ONCE per checkpoint and threads a shared consumed-set through
89
+ * `prepareReconcile`, so two documents reconciled before either commits cannot
90
+ * both resurrect the same pooled id. */
91
+ export function loadPool(db, repoId, ts) {
57
92
  const rows = db
58
93
  .prepare("SELECT block_id, raw_hash, norm_hash, type FROM resurrection_pool WHERE repo_id = ? AND expires_ts > ?")
59
94
  .all(repoId, ts);
@@ -70,12 +105,41 @@ function assignFromMap(blocks, assignment) {
70
105
  const walk = (list, parentKey) => list.map((b, index) => {
71
106
  const key = `${parentKey ?? ""}/${index}`;
72
107
  const blockId = assignment.get(key) ?? mintId("b");
73
- return { blockId, type: b.type, raw: b.raw, trivia: b.trivia, attrs: b.attrs, children: walk(b.children, key) };
108
+ return { blockId, type: b.type, raw: b.raw, trivia: b.trivia, attrs: b.attrs, children: walk(b.children, key), span: b.span };
74
109
  });
75
110
  return walk(blocks, null);
76
111
  }
112
+ /**
113
+ * Pass 1 of an observed ingest: parse `content`, load the document's current
114
+ * tree, and reconcile — without writing. `pool`/`consumed` let a batch share one
115
+ * pool snapshot: candidates already in `consumed` are hidden from this document,
116
+ * and the ids this document resurrects are added to it.
117
+ */
118
+ export function prepareReconcile(store, repoId, path, content, opts = {}) {
119
+ const db = store.db;
120
+ const ts = opts.ts ?? new Date().toISOString();
121
+ const config = opts.config ?? DEFAULT_CONFIG;
122
+ // Same lookup ingestFile does (a tombstoned row still owns the path's identity).
123
+ const doc = db.prepare("SELECT doc_id FROM docs WHERE repo_id = ? AND path = ?").get(repoId, path);
124
+ const docId = doc?.doc_id ?? null;
125
+ const { tree, rest } = parseForIngest(path, content);
126
+ const oldBlocks = docId ? loadOldMatchBlocks(db, docId) : [];
127
+ const newBlocks = flatten(rest.map(toFlatSource));
128
+ let pool = [];
129
+ if (docId) {
130
+ pool = opts.pool ?? loadPool(db, repoId, ts);
131
+ if (opts.consumed)
132
+ pool = pool.filter((c) => !opts.consumed.has(c.blockId));
133
+ }
134
+ const result = reconcileDocument(oldBlocks, newBlocks, { config, pool });
135
+ if (opts.consumed)
136
+ for (const id of result.consumedPool)
137
+ opts.consumed.add(id);
138
+ return { path, content, docId, tree, oldBlocks, newBlocks, result, crossDocIds: [] };
139
+ }
77
140
  /** Build an IdResolver bound to this store + repo, reconciling against the
78
- * document's current revision. */
141
+ * document's current revision. With `prepared` (from `prepareReconcile`, after
142
+ * the cross-doc phase) the resolver skips the reconcile and applies that result. */
79
143
  export function makeReconcilingResolver(store, repoId, opts = {}) {
80
144
  const ts = opts.ts ?? new Date().toISOString();
81
145
  const config = opts.config ?? DEFAULT_CONFIG;
@@ -83,10 +147,18 @@ export function makeReconcilingResolver(store, repoId, opts = {}) {
83
147
  const docDir = opts.path ? opts.path.replace(/[^/]*$/, "") : "";
84
148
  return (rest, docId) => {
85
149
  const db = store.db;
86
- const oldBlocks = docId ? loadOldMatchBlocks(db, docId) : [];
87
- const newBlocks = flatten(rest.map(toFlatSource));
88
- const pool = docId ? loadPool(db, repoId, ts) : [];
89
- const result = reconcileDocument(oldBlocks, newBlocks, { config, pool });
150
+ let result;
151
+ let crossDocIds = [];
152
+ if (opts.prepared) {
153
+ result = opts.prepared.result;
154
+ crossDocIds = opts.prepared.crossDocIds;
155
+ }
156
+ else {
157
+ const oldBlocks = docId ? loadOldMatchBlocks(db, docId) : [];
158
+ const newBlocks = flatten(rest.map(toFlatSource));
159
+ const pool = docId ? loadPool(db, repoId, ts) : [];
160
+ result = reconcileDocument(oldBlocks, newBlocks, { config, pool });
161
+ }
90
162
  const assigned = assignFromMap(rest, result.assignment);
91
163
  const dispositions = result.dispositions.map((d) => ({
92
164
  blockId: d.blockId,
@@ -124,7 +196,7 @@ export function makeReconcilingResolver(store, repoId, opts = {}) {
124
196
  }
125
197
  return out;
126
198
  };
127
- return { assigned, dispositions, deleted: result.deleted, consumedPool: result.consumedPool, extractEdges };
199
+ return { assigned, dispositions, deleted: result.deleted, consumedPool: result.consumedPool, crossDocIds, extractEdges };
128
200
  };
129
201
  }
130
202
  // Resolve an extracted edge's target to a node id (external mint, doc path or
@@ -188,11 +260,13 @@ function resolveAdapterEdge(db, repoId, srcDoc, e, docDir) {
188
260
  provenance: e.provenance,
189
261
  };
190
262
  }
191
- // Collect RawBlock-shaped objects from the id-assigned tree for adapter edge extraction.
263
+ // Collect RawBlock-shaped objects from the id-assigned tree for adapter edge
264
+ // extraction. The parsed span rides along so the adapter can blank a
265
+ // container's children (spec/graph §1 own text).
192
266
  function collectRawBlocks(blocks) {
193
267
  return blocks.map((b) => ({
194
268
  type: b.type,
195
- span: { start: 0, end: 0 },
269
+ span: b.span ?? { start: 0, end: 0 },
196
270
  raw: b.raw,
197
271
  text: "",
198
272
  attrs: b.attrs,
@@ -1,6 +1,10 @@
1
1
  import type { Store } from "../core/store/store.js";
2
+ import { type SyncFs } from "./fs-util.js";
2
3
  export interface RecoveryResult {
3
4
  healed: string[];
4
5
  missing: string[];
5
6
  }
6
- export declare function recoverRepo(store: Store, repoId: string, rootPath: string): RecoveryResult;
7
+ export declare function recoverRepo(store: Store, repoId: string, rootPath: string, opts?: {
8
+ ts?: string;
9
+ fs?: SyncFs;
10
+ }): RecoveryResult;
@@ -1,9 +1,9 @@
1
- import { readFileSync, existsSync } from "node:fs";
2
- import { join } from "node:path";
3
1
  import { sha256 } from "../core/hash.js";
4
2
  import { ingestFile } from "../core/ingest.js";
5
3
  import { makeReconcilingResolver } from "./reconciling-ingest.js";
6
- export function recoverRepo(store, repoId, rootPath) {
4
+ import { nodeFs } from "./fs-util.js";
5
+ export function recoverRepo(store, repoId, rootPath, opts = {}) {
6
+ const fs = opts.fs ?? nodeFs;
7
7
  const docs = store.db
8
8
  .prepare(`SELECT d.doc_id, d.path, d.file_hash, r.rendered_hash
9
9
  FROM docs d LEFT JOIN revisions r ON r.rev_id = d.current_rev
@@ -11,18 +11,18 @@ export function recoverRepo(store, repoId, rootPath) {
11
11
  .all(repoId);
12
12
  const healed = [];
13
13
  const missing = [];
14
- const ts = new Date().toISOString();
14
+ const ts = opts.ts ?? new Date().toISOString();
15
15
  for (const doc of docs) {
16
- const abs = join(rootPath, doc.path);
17
- if (!existsSync(abs)) {
16
+ const bytes = fs.read(rootPath, doc.path);
17
+ if (bytes === null) {
18
18
  missing.push(doc.path);
19
19
  continue;
20
20
  }
21
- const onDisk = sha256(readFileSync(abs, "utf8"));
21
+ const onDisk = sha256(bytes);
22
22
  const recorded = doc.rendered_hash ?? doc.file_hash;
23
23
  if (!recorded || !onDisk.equals(recorded)) {
24
24
  // Divergence: the file moved ahead of (or behind) the store. Re-ingest.
25
- ingestFile(store, repoId, doc.path, readFileSync(abs, "utf8"), {
25
+ ingestFile(store, repoId, doc.path, bytes, {
26
26
  ts,
27
27
  resolveIds: makeReconcilingResolver(store, repoId, { ts, path: doc.path }),
28
28
  });
@@ -20,13 +20,21 @@ export declare class Workspace {
20
20
  repos(): RepoRow[];
21
21
  repoBySlug(slug: string): RepoRow | null;
22
22
  /**
23
- * Select the active repo. With an explicit slug, that repo (or throws
24
- * repo_not_found). Otherwise the repo whose root_path contains `cwd`; if none
25
- * or several match, throws AmbiguousRepo with the candidate slugs.
23
+ * Select the active repo (spec/sync §1 "Repo selection"): {@link selectRepo}
24
+ * over this workspace's repos.
26
25
  */
27
26
  selectRepo(cwd: string, slug?: string): RepoRow;
28
27
  close(): void;
29
28
  }
29
+ /**
30
+ * Repo selection (spec/sync §1) over a plain `repos` list. With an explicit
31
+ * slug, that repo (or `repo_not_found` with the candidate slugs). Else with
32
+ * exactly one repo, that repo. Else the repos whose root path contains `cwd`
33
+ * (path-prefix containment after resolving both; a sourceless repo never
34
+ * matches): exactly one → it; none → `repo_not_found`; several (nested roots)
35
+ * → the one with the longest resolved root path.
36
+ */
37
+ export declare function selectRepo(repos: RepoRow[], cwd: string, slug?: string): RepoRow;
30
38
  export declare class RepoSelectionError extends Error {
31
39
  readonly code: "repo_not_found";
32
40
  readonly candidates: string[];
@@ -70,43 +70,52 @@ export class Workspace {
70
70
  return this.repos().find((r) => r.slug === slug) ?? null;
71
71
  }
72
72
  /**
73
- * Select the active repo. With an explicit slug, that repo (or throws
74
- * repo_not_found). Otherwise the repo whose root_path contains `cwd`; if none
75
- * or several match, throws AmbiguousRepo with the candidate slugs.
73
+ * Select the active repo (spec/sync §1 "Repo selection"): {@link selectRepo}
74
+ * over this workspace's repos.
76
75
  */
77
76
  selectRepo(cwd, slug) {
78
- const repos = this.repos();
79
- if (slug) {
80
- const found = repos.find((r) => r.slug === slug);
81
- if (!found) {
82
- throw new RepoSelectionError("repo_not_found", `no repo with slug '${slug}'`, repos.map((r) => r.slug));
83
- }
84
- return found;
85
- }
86
- if (repos.length === 1)
87
- return repos[0];
88
- const here = resolve(cwd);
89
- // Only filesystem-backed repos (a derived rootPath) can contain the cwd; a
90
- // sourceless/headless repo has no location, so it never matches by cwd.
91
- const containing = repos.filter((r) => {
92
- if (r.rootPath == null)
93
- return false;
94
- const rel = relative(resolve(r.rootPath), here);
95
- return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
96
- });
97
- if (containing.length === 1)
98
- return containing[0];
99
- if (containing.length === 0) {
100
- throw new RepoSelectionError("repo_not_found", `no repo contains ${here}; select one with --repo`, repos.map((r) => r.slug));
101
- }
102
- // Several contain cwd (nested roots): pick the deepest (longest root path).
103
- containing.sort((a, b) => resolve(b.rootPath).length - resolve(a.rootPath).length);
104
- return containing[0];
77
+ return selectRepo(this.repos(), cwd, slug);
105
78
  }
106
79
  close() {
107
80
  this.store.close();
108
81
  }
109
82
  }
83
+ /**
84
+ * Repo selection (spec/sync §1) over a plain `repos` list. With an explicit
85
+ * slug, that repo (or `repo_not_found` with the candidate slugs). Else with
86
+ * exactly one repo, that repo. Else the repos whose root path contains `cwd`
87
+ * (path-prefix containment after resolving both; a sourceless repo never
88
+ * matches): exactly one → it; none → `repo_not_found`; several (nested roots)
89
+ * → the one with the longest resolved root path.
90
+ */
91
+ export function selectRepo(repos, cwd, slug) {
92
+ if (slug) {
93
+ const found = repos.find((r) => r.slug === slug);
94
+ if (!found) {
95
+ throw new RepoSelectionError("repo_not_found", `no repo with slug '${slug}'`, repos.map((r) => r.slug));
96
+ }
97
+ return found;
98
+ }
99
+ if (repos.length === 1)
100
+ return repos[0];
101
+ const here = resolve(cwd);
102
+ // Only filesystem-backed repos (a derived rootPath) can contain the cwd; a
103
+ // sourceless/headless repo has no location, so it never matches by cwd.
104
+ const containing = repos.filter((r) => {
105
+ if (r.rootPath == null)
106
+ return false;
107
+ const rel = relative(resolve(r.rootPath), here);
108
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
109
+ });
110
+ if (containing.length === 1)
111
+ return containing[0];
112
+ if (containing.length === 0) {
113
+ throw new RepoSelectionError("repo_not_found", `no repo contains ${here}; select one with --repo`, repos.map((r) => r.slug));
114
+ }
115
+ // Several contain cwd (nested roots): pick the deepest (longest root path).
116
+ containing.sort((a, b) => resolve(b.rootPath).length - resolve(a.rootPath).length);
117
+ return containing[0];
118
+ }
110
119
  export class RepoSelectionError extends Error {
111
120
  code;
112
121
  candidates;
@@ -8,12 +8,12 @@ import { join } from "node:path";
8
8
  // Node core exposes no flock(2), and the design forbids new runtime deps (11
9
9
  // §8). We implement the same advisory contract with an O_EXCL lockfile: exclusive
10
10
  // creation is atomic on local filesystems, the holder writes its pid for
11
- // liveness, and a lock whose pid is dead is stolen (no daemon, no lease sweeper —
12
- // the same "probe by trying" spirit as the watch lease in §3.4). This is an
13
- // as-built substitution for flock; the file-CAS + ingest-and-replay behavior in
14
- // the write protocol is unchanged.
11
+ // liveness, and a lock whose pid is dead (or whose body does not parse) is
12
+ // stolen — never one whose holder is alive, however old the record (spec/sync
13
+ // §7; no daemon, no lease sweeper — the same "probe by trying" spirit as the
14
+ // watch lease in §3.4). This is an as-built substitution for flock; the
15
+ // file-CAS + ingest-and-replay behavior in the write protocol is unchanged.
15
16
  const LOCK_NAME = "writer.lock";
16
- const STALE_MS = 30_000;
17
17
  function pidAlive(pid) {
18
18
  try {
19
19
  process.kill(pid, 0);
@@ -40,20 +40,9 @@ function tryAcquire(lockPath) {
40
40
  catch (err) {
41
41
  if (err.code !== "EEXIST")
42
42
  throw err;
43
- // Lock exists: steal it if the holder is dead or the record is stale.
44
- let holderPid = null;
45
- let ts = 0;
46
- try {
47
- const rec = JSON.parse(readFileSync(lockPath, "utf8"));
48
- holderPid = rec.pid ?? null;
49
- ts = rec.ts ?? 0;
50
- }
51
- catch {
52
- // Unreadable/partial lock record — treat as stale.
53
- }
54
- const dead = holderPid != null && !pidAlive(holderPid);
55
- const stale = Date.now() - ts > STALE_MS;
56
- if (dead || stale) {
43
+ // Lock exists: steal it only if the holder is dead or the record is unreadable.
44
+ const holderPid = readHolderPid(lockPath);
45
+ if (holderPid == null || !pidAlive(holderPid)) {
57
46
  try {
58
47
  unlinkSync(lockPath);
59
48
  }
@@ -73,14 +62,15 @@ function readHolderPid(lockPath) {
73
62
  return null;
74
63
  }
75
64
  }
76
- // Busy-wait with a blocking sleep between polls. Writers hold the lock only for
77
- // the brief file-CAS + ingest window, so contention waits are short; a sync
78
- // spin keeps the lock API synchronous like the rest of the write path.
65
+ // Blocking sleep between polls. Writers hold the lock only for the brief
66
+ // file-CAS + ingest window, so contention waits are short, and the lock API
67
+ // stays synchronous like the rest of the write path. `Atomics.wait` parks the
68
+ // thread in the kernel for `ms` — unlike a `Date.now()` spin it burns no CPU,
69
+ // which matters precisely when several writers contend on a loaded machine
70
+ // (each spinning waiter would otherwise steal cycles from the lock holder).
71
+ const SLEEP_CELL = new Int32Array(new SharedArrayBuffer(4));
79
72
  function sleepSync(ms) {
80
- const until = Date.now() + ms;
81
- while (Date.now() < until) {
82
- /* spin */
83
- }
73
+ Atomics.wait(SLEEP_CELL, 0, 0, ms);
84
74
  }
85
75
  /**
86
76
  * Run fn while holding the workspace writer lock. Blocks (polling) until the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omgbase/core",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Open Markdown Graph Base — embedded engine",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -55,7 +55,7 @@
55
55
  },
56
56
  "dependencies": {
57
57
  "@modelcontextprotocol/sdk": "^1.30.0",
58
- "@omgbase/oqx": "^0.10.2",
58
+ "@omgbase/oqx": "^0.13.0",
59
59
  "better-sqlite3": "^13.0.3",
60
60
  "mdast-util-from-markdown": "^2.0.2",
61
61
  "mdast-util-frontmatter": "^2.0.1",
@@ -73,7 +73,7 @@
73
73
  "scripts": {
74
74
  "build": "tsc -p tsconfig.json",
75
75
  "typecheck": "tsc -p tsconfig.json --noEmit",
76
- "test": "vitest run",
76
+ "test": "vitest run --exclude src/core/perf.test.ts && vitest run src/core/perf.test.ts",
77
77
  "lint": "eslint .",
78
78
  "lint:fix": "eslint . --fix"
79
79
  }