@omgbase/core 0.1.0 → 0.2.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 (76) 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/hash.d.ts +25 -1
  5. package/dist/src/core/hash.js +110 -10
  6. package/dist/src/core/ids.d.ts +12 -0
  7. package/dist/src/core/ids.js +43 -1
  8. package/dist/src/core/ingest.d.ts +22 -2
  9. package/dist/src/core/ingest.js +92 -18
  10. package/dist/src/core/parse/parse.d.ts +1 -1
  11. package/dist/src/core/parse/parse.js +31 -9
  12. package/dist/src/core/store/nodes.d.ts +13 -0
  13. package/dist/src/core/store/nodes.js +24 -0
  14. package/dist/src/core/utf8.d.ts +29 -0
  15. package/dist/src/core/utf8.js +96 -0
  16. package/dist/src/format/adapter.d.ts +7 -1
  17. package/dist/src/format/markdown.js +9 -18
  18. package/dist/src/graph/history.d.ts +6 -0
  19. package/dist/src/graph/history.js +13 -2
  20. package/dist/src/index.d.ts +8 -5
  21. package/dist/src/index.js +8 -5
  22. package/dist/src/mcp/graph.js +8 -4
  23. package/dist/src/mcp/reference.d.ts +1 -1
  24. package/dist/src/mcp/reference.js +17 -6
  25. package/dist/src/mcp/server.js +12 -7
  26. package/dist/src/mutate/apply.d.ts +7 -0
  27. package/dist/src/mutate/apply.js +17 -7
  28. package/dist/src/mutate/docs.d.ts +2 -0
  29. package/dist/src/mutate/docs.js +10 -6
  30. package/dist/src/mutate/known-ids.js +9 -1
  31. package/dist/src/mutate/macros.js +9 -2
  32. package/dist/src/mutate/ops.d.ts +2 -1
  33. package/dist/src/mutate/ops.js +24 -2
  34. package/dist/src/mutate/plan-update.d.ts +7 -1
  35. package/dist/src/mutate/plan-update.js +3 -1
  36. package/dist/src/oqx-js/context.js +43 -6
  37. package/dist/src/reconcile/eval/generator.d.ts +24 -1
  38. package/dist/src/reconcile/eval/generator.js +248 -0
  39. package/dist/src/reconcile/eval/metrics.d.ts +33 -9
  40. package/dist/src/reconcile/eval/metrics.js +55 -32
  41. package/dist/src/reconcile/flatten.js +7 -5
  42. package/dist/src/reconcile/phase5.d.ts +2 -1
  43. package/dist/src/reconcile/phase5.js +27 -10
  44. package/dist/src/reconcile/phase6a.js +17 -5
  45. package/dist/src/reconcile/phases.d.ts +44 -6
  46. package/dist/src/reconcile/phases.js +0 -0
  47. package/dist/src/reconcile/reconcile.js +16 -9
  48. package/dist/src/reconcile/types.d.ts +4 -1
  49. package/dist/src/reconcile/types.js +11 -2
  50. package/dist/src/search/external.d.ts +10 -0
  51. package/dist/src/search/external.js +18 -1
  52. package/dist/src/search/provider.d.ts +7 -3
  53. package/dist/src/search/provider.js +8 -7
  54. package/dist/src/search/rrf.js +3 -0
  55. package/dist/src/search/vector.js +21 -6
  56. package/dist/src/sync/attach.d.ts +7 -2
  57. package/dist/src/sync/attach.js +10 -8
  58. package/dist/src/sync/checkpoint.d.ts +18 -1
  59. package/dist/src/sync/checkpoint.js +31 -29
  60. package/dist/src/sync/driver.d.ts +15 -4
  61. package/dist/src/sync/driver.js +18 -43
  62. package/dist/src/sync/external-source.js +7 -1
  63. package/dist/src/sync/freshness.d.ts +65 -14
  64. package/dist/src/sync/freshness.js +107 -96
  65. package/dist/src/sync/fs-util.d.ts +19 -0
  66. package/dist/src/sync/fs-util.js +24 -1
  67. package/dist/src/sync/observe.d.ts +53 -5
  68. package/dist/src/sync/observe.js +162 -21
  69. package/dist/src/sync/reconciling-ingest.d.ts +42 -2
  70. package/dist/src/sync/reconciling-ingest.js +82 -10
  71. package/dist/src/sync/recovery.d.ts +5 -1
  72. package/dist/src/sync/recovery.js +8 -8
  73. package/dist/src/sync/workspace.d.ts +11 -3
  74. package/dist/src/sync/workspace.js +39 -30
  75. package/dist/src/sync/writer-lock.js +16 -26
  76. package/package.json +3 -3
@@ -1,16 +1,16 @@
1
1
  import { shingles, dice, tokenCount } from "./similarity.js";
2
2
  // score(o,n) = 0.55 text_sim + 0.15 neighbor_ctx + 0.10 parent_match
3
3
  // + 0.10 position_prior + 0.10 anchor_evidence
4
- function scorePair(o, n, state, oldCount, newCount) {
4
+ function scorePair(o, n, state, oldSiblings, newSiblings) {
5
5
  const textSimVal = dice(shingles(o.text), shingles(n.text));
6
6
  // neighbor_ctx: fraction of {prev,next} siblings that are matched pairs.
7
7
  const neighborCtx = neighborContext(o, n, state);
8
8
  // parent_match: 1 if parents are a matched pair (or both roots).
9
9
  const parentMatch = parentsMatched(o, n, state) ? 1 : 0;
10
- // position_prior: 1 - |rel_pos(o) - rel_pos(n)|
11
- const relO = oldCount > 1 ? o.index / (oldCount - 1) : 0;
12
- const relN = newCount > 1 ? n.index / (newCount - 1) : 0;
13
- const positionPrior = 1 - Math.abs(relO - relN);
10
+ // position_prior: 1 - |rel(o) - rel(n)| with rel(b) = b.index / (siblings - 1)
11
+ // over b's own sibling count (spec §5; m2.1 — m2.0 divided by the size of
12
+ // the whole flattened list, compressing the prior for nested blocks).
13
+ const positionPrior = 1 - Math.abs(relPos(o, oldSiblings) - relPos(n, newSiblings));
14
14
  // anchor_evidence: shared anchors / code-fence info / heading prefix.
15
15
  const anchorEvidence = sharedAnchorEvidence(o, n);
16
16
  return (0.55 * textSimVal +
@@ -19,6 +19,18 @@ function scorePair(o, n, state, oldCount, newCount) {
19
19
  0.1 * positionPrior +
20
20
  0.1 * anchorEvidence);
21
21
  }
22
+ function siblingCounts(blocks) {
23
+ const counts = new Map();
24
+ for (const b of blocks) {
25
+ const k = b.parentKey ?? "";
26
+ counts.set(k, (counts.get(k) ?? 0) + 1);
27
+ }
28
+ return counts;
29
+ }
30
+ function relPos(b, counts) {
31
+ const siblings = counts.get(b.parentKey ?? "") ?? 1;
32
+ return siblings > 1 ? b.index / (siblings - 1) : 0;
33
+ }
22
34
  function siblingAt(blocks, parentKey, index) {
23
35
  return blocks.find((b) => b.parentKey === parentKey && b.index === index);
24
36
  }
@@ -98,7 +110,8 @@ function unmatchedNew(state) {
98
110
  * Phase 5 — scored assignment. Greedy-by-score with R3 order constraints:
99
111
  * accept the highest-scoring candidate, discard conflicting candidates
100
112
  * (same old or new, or order-crossing with an already-accepted same-parent
101
- * pair), repeat. Accept while score ≥ θ (θ_small for tiny blocks).
113
+ * pair), repeat. Accept a candidate when score ≥ θ (θ_small for tiny blocks);
114
+ * a candidate below its θ is skipped, not a stopping point.
102
115
  */
103
116
  export function phase5Scored(state) {
104
117
  const olds = unmatchedOld(state);
@@ -108,11 +121,12 @@ export function phase5Scored(state) {
108
121
  // Bulk path guard: too many unmatched ⇒ skip (handled by give-up logic 2.5).
109
122
  if (olds.length + news.length > state.config.maxScoredBlocks * 2)
110
123
  return;
111
- const oldCount = state.old.length;
112
- const newCount = state.neu.length;
124
+ // Sibling counts once per tree (position_prior).
125
+ const oldSiblings = siblingCounts(state.old);
126
+ const newSiblings = siblingCounts(state.neu);
113
127
  const candidates = pruneCandidates(olds, news);
114
128
  for (const c of candidates)
115
- c.score = scorePair(c.old, c.neu, state, oldCount, newCount);
129
+ c.score = scorePair(c.old, c.neu, state, oldSiblings, newSiblings);
116
130
  // Deterministic order: score desc, then old key, then new key.
117
131
  candidates.sort((a, b) => b.score - a.score || cmp(a.old.key, b.old.key) || cmp(a.neu.key, b.neu.key));
118
132
  // All scored candidates per new key, for near-miss recording (R5).
@@ -125,8 +139,11 @@ export function phase5Scored(state) {
125
139
  if (state.usedOld.has(c.old.blockId) || state.usedNew.has(c.neu.key))
126
140
  continue;
127
141
  const threshold = tokenCount(c.neu.text) < state.config.smallBlockTokens ? state.config.thetaSmall : state.config.thetaAccept;
142
+ // Sorted desc, but θ differs per candidate (θ_small for tiny new blocks), so a
143
+ // sub-threshold tiny-block candidate must not end the walk for the regular
144
+ // candidates sorted below it (m2.1; spec §5 phase 5 step 2).
128
145
  if (c.score < threshold)
129
- break; // sorted desc: nothing below will qualify either
146
+ continue;
130
147
  // R3: within the same new parent, accepted pairs must not cross in order.
131
148
  if (c.old.parentKey === c.neu.parentKey) {
132
149
  const list = acceptedByParent.get(c.neu.parentKey) ?? [];
@@ -2,6 +2,9 @@ import { shingles, dice, tokenCount, tokenize } from "./similarity.js";
2
2
  // Phase 6a — compound classification (03 §4): split, merge, copy over the
3
3
  // still-unmatched. Split/merge use dominant-fragment inheritance (tunable via
4
4
  // split.dominant_share; set to 1.01 to disable). Copies never steal identity.
5
+ // Each pass proceeds in document order over the blocks that were unmatched when
6
+ // it began and are still unmatched when reached, resolving every split (merge)
7
+ // it finds (m2.1; m2.0 returned after the first).
5
8
  function unmatchedOld(state) {
6
9
  return state.old.filter((b) => !state.usedOld.has(b.blockId));
7
10
  }
@@ -44,11 +47,15 @@ function noteLineage(state, newB, kind, counterpartOldId) {
44
47
  function detectSplits(state) {
45
48
  const { splitCoverage, splitDominantShare } = state.config;
46
49
  for (const o of unmatchedOld(state)) {
50
+ if (state.usedOld.has(o.blockId))
51
+ continue; // consumed earlier in this pass
47
52
  const news = unmatchedNew(state)
48
53
  .filter((n) => n.parentKey === o.parentKey && n.type === o.type)
49
54
  .sort((a, b) => a.index - b.index);
50
- // find a contiguous run (by index) of length ≥2 covering O
51
- for (let start = 0; start < news.length; start++) {
55
+ // find a contiguous run (by index) of length ≥2 covering O; the first hit
56
+ // (start ascending, longest window first) is the split.
57
+ let found = false;
58
+ for (let start = 0; start < news.length && !found; start++) {
52
59
  for (let end = news.length; end > start + 1; end--) {
53
60
  const run = news.slice(start, end);
54
61
  if (!isContiguous(run))
@@ -76,7 +83,8 @@ function detectSplits(state) {
76
83
  for (const n of run)
77
84
  noteLineage(state, n, "split_from", o.blockId);
78
85
  }
79
- return; // one split per pass (conservative)
86
+ found = true;
87
+ break; // on to the next unmatched old block
80
88
  }
81
89
  }
82
90
  }
@@ -85,10 +93,13 @@ function detectSplits(state) {
85
93
  function detectMerges(state) {
86
94
  const { splitCoverage, splitDominantShare } = state.config;
87
95
  for (const n of unmatchedNew(state)) {
96
+ if (state.usedNew.has(n.key))
97
+ continue; // consumed earlier in this pass
88
98
  const olds = unmatchedOld(state)
89
99
  .filter((o) => o.parentKey === n.parentKey && o.type === n.type)
90
100
  .sort((a, b) => a.index - b.index);
91
- for (let start = 0; start < olds.length; start++) {
101
+ let found = false;
102
+ for (let start = 0; start < olds.length && !found; start++) {
92
103
  for (let end = olds.length; end > start + 1; end--) {
93
104
  const run = olds.slice(start, end);
94
105
  if (!isContiguous(run))
@@ -116,7 +127,8 @@ function detectMerges(state) {
116
127
  }
117
128
  noteLineage(state, n, "merged_into", first.blockId);
118
129
  }
119
- return;
130
+ found = true;
131
+ break; // on to the next unmatched new block
120
132
  }
121
133
  }
122
134
  }
@@ -18,11 +18,49 @@ export declare function phase2Normalized(state: PhaseState): void;
18
18
  /** Phase 3 — anchor lock: same authored ^ref, unique on both sides. */
19
19
  export declare function phase3Anchor(state: PhaseState): void;
20
20
  /**
21
- * Phase 4 — context propagation (03 §4). Matched siblings vouch for the
22
- * stranger between them: within a gap bounded by matched blocks, if exactly one
23
- * unmatched OLD child remains, pair it with its best-matching unmatched NEW
24
- * child in that same gap (text_sim ≥ floor, unique best). Insertions in the gap
25
- * do not block the carry — they simply lose to the better-matching candidate,
26
- * which is why an inserted block never steals an edited block's identity.
21
+ * Phase 4a — context propagation (spec/reconcile §5). Matched siblings vouch
22
+ * for the stranger between them: within a gap bounded by matched blocks, if
23
+ * exactly one unmatched OLD child remains, pair it with its best-matching
24
+ * unmatched NEW child in that same gap (text_sim ≥ floor, unique best).
25
+ * Insertions in the gap do not block the carry — they simply lose to the
26
+ * better-matching candidate, which is why an inserted block never steals an
27
+ * edited block's identity.
28
+ *
29
+ * Step 5, the singleton rule (m2.3): when the floor test fails (or there is
30
+ * no best), a carried container (not the root pair) with exactly one unmatched
31
+ * old child and exactly one unmatched new child of its type carries them
32
+ * without a text floor, provided the new child sits in the SAME SLOT — same
33
+ * index, or the siblings just before both (or just after both) are a carried
34
+ * pair — and anchors do not veto (if either block has anchors they must share
35
+ * one). Reason `context_unique`, confidence 0.75 + 0.2 × text_sim, whatever
36
+ * text_sim is (0 for a two-word item whose one word changed). Root-level
37
+ * singletons are excluded: an unrelated replacement paragraph is common there
38
+ * and paragraphs carry the block references R4 protects.
27
39
  */
28
40
  export declare function phase4Context(state: PhaseState): void;
41
+ /**
42
+ * Phase 4b — children vouch for their parent (03 §4). Phase 4 lets a matched
43
+ * parent vouch for the stranger among its children; this is the converse. For
44
+ * each unmatched OLD container, look at where its already-carried children
45
+ * landed. If a clear majority of them (≥ childrenVouchFrac of ALL its children)
46
+ * sit under one unmatched NEW block of the same type, and that block is the
47
+ * unique destination for them, and the old container is in turn the unique
48
+ * source of that block's carried children (mutual best by children), and the
49
+ * two containers' own parents do not contradict the pairing (a matched pair,
50
+ * both roots, or both still undecided), the containers pair. Reason
51
+ * `context_children`, confidence 0.75 + 0.2 × fraction.
52
+ *
53
+ * Text is deliberately not consulted: a container's visible text is its
54
+ * children's text, so a one-word-per-item list has no shingle evidence to give
55
+ * — its children's carried ids are the evidence. Returns true if any pair was
56
+ * made (phase4Propagate iterates to a fixed point).
57
+ */
58
+ export declare function phase4bChildren(state: PhaseState): boolean;
59
+ /**
60
+ * Phase 4 fixed point: context propagation (parents/siblings vouch for a
61
+ * child) and children vouching (children vouch for their parent) feed each
62
+ * other — a newly paired list unlocks its lone edited item; a newly paired
63
+ * inner list unlocks the item that contains it, then the outer list. Iterate
64
+ * until neither adds a pair. Terminates: every round consumes ≥ 1 old block.
65
+ */
66
+ export declare function phase4Propagate(state: PhaseState): void;
Binary file
@@ -1,6 +1,6 @@
1
1
  import { mintId } from "../core/ids.js";
2
2
  import { DEFAULT_CONFIG } from "./types.js";
3
- import { phase1Exact, phase2Normalized, phase3Anchor, phase4Context } from "./phases.js";
3
+ import { phase1Exact, phase2Normalized, phase3Anchor, phase4Propagate } from "./phases.js";
4
4
  import { phase5Scored } from "./phase5.js";
5
5
  import { phase6aCompound } from "./phase6a.js";
6
6
  function newState(old, neu, config) {
@@ -50,7 +50,7 @@ export function reconcileDocument(oldBlocks, newBlocks, opts = {}) {
50
50
  return bulkRewrite(state);
51
51
  }
52
52
  phase3Anchor(state);
53
- phase4Context(state);
53
+ phase4Propagate(state); // phases 4 + 4b to a fixed point
54
54
  phase5Scored(state);
55
55
  phase6aCompound(state);
56
56
  if (opts.pool && opts.pool.length > 0)
@@ -84,15 +84,24 @@ function finalize(state, consumedPool) {
84
84
  dispositions.push({ blockId: minted, kind: "inserted", confidence: null, reason: null, matcherV: state.config.matcherV, detail: {} });
85
85
  }
86
86
  }
87
- // Remaining old blocks (not carried) → deleted.
88
- const deleted = [];
87
+ // Remaining old blocks (not carried, not otherwise disposed) → deleted.
89
88
  for (const o of state.old) {
90
89
  if (!state.usedOld.has(o.blockId)) {
91
- deleted.push(o.blockId);
92
90
  dispositions.push({ blockId: o.blockId, kind: "deleted", confidence: null, reason: "tombstone", matcherV: state.config.matcherV, detail: {} });
93
91
  }
94
92
  }
95
- return { assignment, dispositions, deleted, consumedPool };
93
+ return { assignment, dispositions, deleted: deletedIds(state.old, dispositions), consumedPool };
94
+ }
95
+ /** `deleted` = every old id whose disposition kind is `deleted` — the phase 7
96
+ * tombstones and the phase 6a non-dominant split tombstones — in old document
97
+ * order (spec §2; m2.1 — m2.0 listed only the phase 7 ones, so a split
98
+ * tombstone never reached the resurrection pool). */
99
+ function deletedIds(old, dispositions) {
100
+ const deletedSet = new Set();
101
+ for (const d of dispositions)
102
+ if (d.kind === "deleted")
103
+ deletedSet.add(d.blockId);
104
+ return old.filter((o) => deletedSet.has(o.blockId)).map((o) => o.blockId);
96
105
  }
97
106
  function bulkRewrite(state) {
98
107
  // Mint everything; emit one doc-scoped bulk_rewrite + deleted for all old.
@@ -105,11 +114,9 @@ function bulkRewrite(state) {
105
114
  assignment.set(n.key, minted);
106
115
  dispositions.push({ blockId: minted, kind: "inserted", confidence: null, reason: null, matcherV: state.config.matcherV, detail: {} });
107
116
  }
108
- const deleted = [];
109
117
  for (const o of state.old) {
110
- deleted.push(o.blockId);
111
118
  dispositions.push({ blockId: o.blockId, kind: "deleted", confidence: null, reason: "tombstone", matcherV: state.config.matcherV, detail: {} });
112
119
  }
113
- return { assignment, dispositions, deleted, consumedPool: [] };
120
+ return { assignment, dispositions, deleted: deletedIds(state.old, dispositions), consumedPool: [] };
114
121
  }
115
122
  //# sourceMappingURL=reconcile.js.map
@@ -1,5 +1,5 @@
1
1
  export type DispositionKind = "same" | "edited" | "moved" | "edited_moved" | "inserted" | "deleted" | "split_from" | "merged_into" | "copied_from" | "resurrected" | "bulk_rewrite";
2
- export type Reason = "exact_hash" | "normalized_hash" | "anchor" | "context_unique" | "scored" | "tombstone" | "api";
2
+ export type Reason = "exact_hash" | "normalized_hash" | "anchor" | "context_unique" | "context_children" | "scored" | "tombstone" | "api";
3
3
  export interface MatchBlock {
4
4
  /** present on old blocks; undefined on new blocks until assigned */
5
5
  blockId?: string;
@@ -34,6 +34,9 @@ export interface ReconcileConfig {
34
34
  maxScoredBlocks: number;
35
35
  smallBlockTokens: number;
36
36
  contextSimFloor: number;
37
+ /** phase-4b: minimum fraction of an old container's children that must have
38
+ * carried into one new container for the children to vouch for it (0.5). */
39
+ childrenVouchFrac: number;
37
40
  }
38
41
  export declare const DEFAULT_CONFIG: ReconcileConfig;
39
42
  /** The result of matching one document's old tree against new blocks. */
@@ -3,9 +3,17 @@
3
3
  // without ids) and produces an assignment: each new block either CARRIES an
4
4
  // existing id or is MINTED, plus a disposition per carry/mint.
5
5
  export const DEFAULT_CONFIG = {
6
- matcherV: "m1.0",
6
+ // "m" + spec/reconcile/VERSION. m2.0: phase 4b (children vouch for their
7
+ // parent) and the phase-4 fixed point. m2.1: the four spec §10 fixes — phase 5
8
+ // skips (not stops at) a sub-threshold candidate, position_prior over the
9
+ // sibling count, every split and merge per run, split tombstones listed in
10
+ // `deleted`. m2.2: thetaSmall 0.80 → 0.62 (eval harness, 2026-09-26). m2.3:
11
+ // phase 4a's singleton rule — the lone unmatched child of a carried container
12
+ // carries into the lone unmatched child in the same slot without a text floor
13
+ // (2026-09-26). Rule refinements bump the minor; phase changes bump the major.
14
+ matcherV: "m2.3",
7
15
  thetaAccept: 0.62,
8
- thetaSmall: 0.8,
16
+ thetaSmall: 0.62,
9
17
  thetaXdoc: 0.8,
10
18
  splitCoverage: 0.8,
11
19
  splitDominantShare: 0.7,
@@ -15,5 +23,6 @@ export const DEFAULT_CONFIG = {
15
23
  maxScoredBlocks: 2000,
16
24
  smallBlockTokens: 8,
17
25
  contextSimFloor: 0.35,
26
+ childrenVouchFrac: 0.5,
18
27
  };
19
28
  //# sourceMappingURL=types.js.map
@@ -11,3 +11,13 @@ export interface ExternalProvider {
11
11
  * failure. Callers must call close() when done.
12
12
  */
13
13
  export declare function createExternalProvider(settings: EmbeddingSettings): Promise<ExternalProvider | null>;
14
+ /**
15
+ * The environment a stdio embedder is spawned with: our own env, with the
16
+ * repo's explicit `embedding.model` / `dim` / `maxInputTokens` settings laid over
17
+ * it as the `OMGBASE_EMBEDDER_*` variables `omgbase-embedder` reads. Explicit
18
+ * settings win over an inherited variable (config is the deliberate per-repo
19
+ * choice; the env is ambient); an unset setting leaves the inherited value (or
20
+ * the embedder's own default) in place. The handshake still has the last word on
21
+ * what the provider reports back (see connectStdio).
22
+ */
23
+ export declare function embedderEnv(settings: EmbeddingSettings, base?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
@@ -67,7 +67,7 @@ async function connectStdio(settings) {
67
67
  // even after we exit. A private pipe we own severs that coupling.
68
68
  let child;
69
69
  try {
70
- child = spawn(cmd, args, { stdio: ["pipe", "pipe", "pipe"] });
70
+ child = spawn(cmd, args, { stdio: ["pipe", "pipe", "pipe"], env: embedderEnv(settings) });
71
71
  }
72
72
  catch (err) {
73
73
  throw new Error(`embedding command '${settings.provider}' failed to spawn: ${err.message}`);
@@ -153,6 +153,23 @@ async function connectStdio(settings) {
153
153
  };
154
154
  return { provider, close };
155
155
  }
156
+ /**
157
+ * The environment a stdio embedder is spawned with: our own env, with the
158
+ * repo's explicit `embedding.model` / `dim` / `maxInputTokens` settings laid over
159
+ * it as the `OMGBASE_EMBEDDER_*` variables `omgbase-embedder` reads. Explicit
160
+ * settings win over an inherited variable (config is the deliberate per-repo
161
+ * choice; the env is ambient); an unset setting leaves the inherited value (or
162
+ * the embedder's own default) in place. The handshake still has the last word on
163
+ * what the provider reports back (see connectStdio).
164
+ */
165
+ export function embedderEnv(settings, base = process.env) {
166
+ return {
167
+ ...base,
168
+ ...(settings.model !== undefined ? { OMGBASE_EMBEDDER_MODEL: settings.model } : {}),
169
+ ...(settings.dim !== undefined ? { OMGBASE_EMBEDDER_DIM: String(settings.dim) } : {}),
170
+ ...(settings.maxInputTokens !== undefined ? { OMGBASE_EMBEDDER_MAX_TOKENS: String(settings.maxInputTokens) } : {}),
171
+ };
172
+ }
156
173
  // A pull-based async line reader over a readline Interface: next() resolves with
157
174
  // the next line, buffering lines that arrive before they're requested.
158
175
  function lineQueue(rl) {
@@ -1,9 +1,13 @@
1
1
  export interface EmbeddingSettings {
2
- /** Package name to import, e.g. "@omgbase/embedder". Absent ⇒ no provider. */
2
+ /** The embedder: a command to spawn (e.g. "omgbase-embedder") or an http(s)
3
+ * URL. Absent ⇒ no provider (`semantic_unavailable`). */
3
4
  provider?: string;
4
- /** Optional model id passed through to the provider factory. */
5
+ /** Optional model id. For a spawned command it is exported as
6
+ * OMGBASE_EMBEDDER_MODEL (explicit setting wins over an inherited variable);
7
+ * the provider's handshake/metadata reply overrides it for reporting. */
5
8
  model?: string;
6
- /** Optional dimension override passed through to the provider factory. */
9
+ /** Optional dimension. For a spawned command it is exported as
10
+ * OMGBASE_EMBEDDER_DIM; the handshake/metadata reply overrides it. */
7
11
  dim?: number;
8
12
  /**
9
13
  * Optional max input length (tokens) the provider's model accepts before it
@@ -1,10 +1,11 @@
1
- // Provider configuration (05 §6). The embedding provider is a plugin named in
2
- // repo settings (embedding.provider = a package that exports
3
- // `createProvider(opts?) => EmbeddingProvider`). The dynamic import itself lives
4
- // in the application (the CLI), not here, so it resolves against the app's
5
- // dependency tree and core carries no ML dependency. @omgbase/embedder is the
6
- // default local implementation; a remote HTTP provider is the same contract
7
- // behind a different package name — nothing privileges local vs. remote.
1
+ // Provider configuration (05 §6). The embedding provider is an external
2
+ // *process or endpoint* named in repo settings — `embedding.provider` is either
3
+ // a shell command (spawned and spoken to over the stdio JSON protocol) or an
4
+ // http(s) URL (GET metadata, POST embed) — see `search/external.ts`. Nothing is
5
+ // imported in-process, so core carries no ML dependency and the embedder can be
6
+ // any language. `@omgbase/embedder`'s `omgbase-embedder` binary is the default
7
+ // local implementation; an HTTP endpoint is the same contract over the wire —
8
+ // nothing privileges local vs. remote.
8
9
  /** Read the embedding.* block out of a repo's parsed settings JSON. */
9
10
  export function embeddingSettings(settings) {
10
11
  const e = settings.embedding;
@@ -50,7 +50,10 @@ export function hybridSearch(store, input) {
50
50
  const vecRanks = new Map();
51
51
  const cosineByBlock = new Map();
52
52
  if (input.vector) {
53
+ // First occurrence wins, exactly as for FTS (spec/search §4).
53
54
  vectorSearch(store, input.repoId, input.vector.model, input.vector.vec, { limit: 200 }).forEach((h, i) => {
55
+ if (vecRanks.has(h.blockId))
56
+ return;
54
57
  vecRanks.set(h.blockId, i + 1);
55
58
  cosineByBlock.set(h.blockId, h.cosine);
56
59
  });
@@ -19,10 +19,18 @@ export function vectorSearch(store, repoId, model, queryVec, opts = {}) {
19
19
  JOIN docs d ON d.doc_id = b.doc_id
20
20
  WHERE b.repo_id = ? AND e.model = ?`)
21
21
  .all(repoId, model);
22
- const scored = rows.map((r) => {
22
+ // One hit per live block (spec/search §3). Several cache rows can join to the
23
+ // same block — a stale ctx row survives a heading rename next to the fresh one
24
+ // — and the block keeps its best cosine, not one hit per row.
25
+ const best = new Map();
26
+ for (const r of rows) {
23
27
  const v = new Float32Array(r.vec.buffer, r.vec.byteOffset, r.vec.byteLength / 4);
24
- return { blockId: r.blockId, docId: r.docId, path: r.path, cosine: cosine(queryVec, v) };
25
- });
28
+ const c = cosine(queryVec, v);
29
+ const prev = best.get(r.blockId);
30
+ if (!prev || c > prev.cosine)
31
+ best.set(r.blockId, { blockId: r.blockId, docId: r.docId, path: r.path, cosine: c });
32
+ }
33
+ const scored = [...best.values()];
26
34
  scored.sort((a, b) => b.cosine - a.cosine || (a.blockId < b.blockId ? -1 : 1));
27
35
  return scored.slice(0, limit);
28
36
  }
@@ -39,10 +47,17 @@ export function docVectorSearch(store, repoId, model, queryVec, opts = {}) {
39
47
  JOIN docs d ON d.doc_id = e.doc_id AND d.deleted_commit IS NULL
40
48
  WHERE d.repo_id = ? AND e.model = ?`)
41
49
  .all(repoId, model);
42
- const scored = rows.map((r) => {
50
+ // (doc_id, model) is the primary key, so this is one row per doc already;
51
+ // keep the best-cosine rule for symmetry with vectorSearch.
52
+ const best = new Map();
53
+ for (const r of rows) {
43
54
  const v = new Float32Array(r.vec.buffer, r.vec.byteOffset, r.vec.byteLength / 4);
44
- return { docId: r.docId, path: r.path, cosine: cosine(queryVec, v) };
45
- });
55
+ const c = cosine(queryVec, v);
56
+ const prev = best.get(r.docId);
57
+ if (!prev || c > prev.cosine)
58
+ best.set(r.docId, { docId: r.docId, path: r.path, cosine: c });
59
+ }
60
+ const scored = [...best.values()];
46
61
  scored.sort((a, b) => b.cosine - a.cosine || (a.docId < b.docId ? -1 : 1));
47
62
  return scored.slice(0, limit);
48
63
  }
@@ -1,4 +1,5 @@
1
1
  import type { Store } from "../core/store/store.js";
2
+ import { type SyncFs } from "./fs-util.js";
2
3
  export interface IngestDirResult {
3
4
  repoId: string;
4
5
  fileCount: number;
@@ -8,6 +9,10 @@ export interface IngestDirResult {
8
9
  * Ingest a directory as a repo: create/reuse the repo (registering its fs
9
10
  * source) + ingest all Markdown, reconciling identity and extracting edges.
10
11
  * `files` (repo-relative paths) may be supplied by a caller that already walked
11
- * the tree, to avoid walking twice; omit it to walk here.
12
+ * the tree, to avoid walking twice; omit it to walk here. `opts.ts` pins the
13
+ * commit timestamp (default now); `opts.fs` the filesystem seam (default node).
12
14
  */
13
- export declare function ingestDirectory(store: Store, slug: string, rootPath: string, files?: string[]): IngestDirResult;
15
+ export declare function ingestDirectory(store: Store, slug: string, rootPath: string, files?: string[], opts?: {
16
+ ts?: string;
17
+ fs?: SyncFs;
18
+ }): IngestDirResult;
@@ -1,22 +1,24 @@
1
- import { readFileSync } from "node:fs";
2
- import { join } from "node:path";
3
1
  import { ensureRepo } from "../core/attach.js";
4
2
  import { ingestFile } from "../core/ingest.js";
5
3
  import { makeReconcilingResolver } from "./reconciling-ingest.js";
6
- import { walkMarkdown } from "./fs-util.js";
4
+ import { nodeFs } from "./fs-util.js";
7
5
  /**
8
6
  * Ingest a directory as a repo: create/reuse the repo (registering its fs
9
7
  * source) + ingest all Markdown, reconciling identity and extracting edges.
10
8
  * `files` (repo-relative paths) may be supplied by a caller that already walked
11
- * the tree, to avoid walking twice; omit it to walk here.
9
+ * the tree, to avoid walking twice; omit it to walk here. `opts.ts` pins the
10
+ * commit timestamp (default now); `opts.fs` the filesystem seam (default node).
12
11
  */
13
- export function ingestDirectory(store, slug, rootPath, files) {
12
+ export function ingestDirectory(store, slug, rootPath, files, opts = {}) {
14
13
  const repoId = ensureRepo(store, slug, rootPath);
15
- const walked = files ?? walkMarkdown(rootPath);
16
- const ts = new Date().toISOString();
14
+ const fs = opts.fs ?? nodeFs;
15
+ const walked = files ?? fs.walk(rootPath);
16
+ const ts = opts.ts ?? new Date().toISOString();
17
17
  let allConverged = true;
18
18
  for (const rel of walked) {
19
- const content = readFileSync(join(rootPath, rel), "utf8");
19
+ const content = fs.read(rootPath, rel);
20
+ if (content === null)
21
+ continue;
20
22
  const res = ingestFile(store, repoId, rel, content, {
21
23
  ts,
22
24
  resolveIds: makeReconcilingResolver(store, repoId, { ts, path: rel }),
@@ -1,4 +1,6 @@
1
1
  import type { Store } from "../core/store/store.js";
2
+ import { type BatchOutcome } from "./observe.js";
3
+ import { type SyncFs } from "./fs-util.js";
2
4
  export interface FileChange {
3
5
  /** repo-relative canonical path */
4
6
  path: string;
@@ -13,9 +15,24 @@ export interface CheckpointResult {
13
15
  /**
14
16
  * Process a batch of changed paths against a repo as one checkpoint. On-disk
15
17
  * bytes are read directly; a hash equal to the stored file_hash is an engine
16
- * echo (no commit). Otherwise ingest as an observed commit.
18
+ * echo (no commit). Otherwise ingest as an observed commit. A path gone from
19
+ * disk tombstones its live doc (drop FTS, tombstone blocks + doc, pool blocks
20
+ * for resurrection) so it stops being served as a ghost. Every member is
21
+ * reconciled before any commits, so a block cut from one file and pasted into
22
+ * another in the same checkpoint keeps its id (`observeBatch`). spec/sync §4.1
23
+ * `process_checkpoint(repo, root, paths, ts)`.
17
24
  */
18
25
  export declare function processCheckpoint(store: Store, repoId: string, rootPath: string, changes: FileChange[], opts?: {
19
26
  ts?: string;
20
27
  gitHead?: string | null;
28
+ fs?: SyncFs;
29
+ }): CheckpointResult;
30
+ /**
31
+ * Turn a batch's outcomes into the checkpoint row + result, then sweep the
32
+ * resurrection pool. Shared with the external-source driver (driver.ts), which
33
+ * obtains its bytes asynchronously but records its checkpoint the same way.
34
+ */
35
+ export declare function finishCheckpoint(store: Store, repoId: string, outcomes: BatchOutcome[], opts: {
36
+ ts: string;
37
+ gitHead: string | null;
21
38
  }): CheckpointResult;
@@ -1,52 +1,54 @@
1
- import { readFileSync, existsSync } from "node:fs";
2
- import { join } from "node:path";
3
1
  import { mintId } from "../core/ids.js";
4
- import { observeOne } from "./observe.js";
2
+ import { observeBatch } from "./observe.js";
5
3
  import { sweepResurrectionPool } from "../core/store/gc.js";
6
- import { tombstoneObservedDeletion } from "./tombstone.js";
4
+ import { nodeFs } from "./fs-util.js";
7
5
  /**
8
6
  * Process a batch of changed paths against a repo as one checkpoint. On-disk
9
7
  * bytes are read directly; a hash equal to the stored file_hash is an engine
10
- * echo (no commit). Otherwise ingest as an observed commit.
8
+ * echo (no commit). Otherwise ingest as an observed commit. A path gone from
9
+ * disk tombstones its live doc (drop FTS, tombstone blocks + doc, pool blocks
10
+ * for resurrection) so it stops being served as a ghost. Every member is
11
+ * reconciled before any commits, so a block cut from one file and pasted into
12
+ * another in the same checkpoint keeps its id (`observeBatch`). spec/sync §4.1
13
+ * `process_checkpoint(repo, root, paths, ts)`.
11
14
  */
12
15
  export function processCheckpoint(store, repoId, rootPath, changes, opts = {}) {
13
16
  const ts = opts.ts ?? new Date().toISOString();
17
+ const fs = opts.fs ?? nodeFs;
18
+ const items = changes.map((change) => ({ path: change.path, content: fs.read(rootPath, change.path) }));
19
+ return finishCheckpoint(store, repoId, observeBatch(store, repoId, items, ts), { ts, gitHead: opts.gitHead ?? null });
20
+ }
21
+ /**
22
+ * Turn a batch's outcomes into the checkpoint row + result, then sweep the
23
+ * resurrection pool. Shared with the external-source driver (driver.ts), which
24
+ * obtains its bytes asynchronously but records its checkpoint the same way.
25
+ */
26
+ export function finishCheckpoint(store, repoId, outcomes, opts) {
14
27
  const checkpointId = mintId("cp");
15
28
  const ingested = [];
16
29
  const suppressed = [];
17
30
  const deleted = [];
18
31
  const conflicted = [];
19
32
  const fileEntries = [];
20
- for (const change of changes) {
21
- const abs = join(rootPath, change.path);
22
- if (!existsSync(abs)) {
23
- // File gone from disk: tombstone the live doc (drop FTS, tombstone blocks +
24
- // doc, pool blocks for resurrection) so it stops being served as a ghost.
25
- const existing = store.db
26
- .prepare("SELECT doc_id, file_hash FROM docs WHERE repo_id = ? AND path = ? AND deleted_commit IS NULL")
27
- .get(repoId, change.path);
28
- if (existing) {
29
- tombstoneObservedDeletion(store, repoId, existing.doc_id, ts);
30
- deleted.push(change.path);
31
- }
32
- fileEntries.push([change.path, existing?.file_hash?.toString("hex") ?? null, null]);
33
+ for (const o of outcomes) {
34
+ if (o.kind === "deleted") {
35
+ if (o.docId)
36
+ deleted.push(o.path);
37
+ fileEntries.push([o.path, o.oldHashHex, null]);
33
38
  continue;
34
39
  }
35
- // Reconcile the disk bytes through the shared observe primitive (echo gate,
36
- // identity threading, conflict-marker flagging — the one implementation).
37
- const r = observeOne(store, repoId, change.path, readFileSync(abs, "utf8"), ts);
38
- if (r.echo)
39
- suppressed.push(change.path);
40
- else if (r.conflicted)
41
- conflicted.push(change.path);
40
+ if (o.echo)
41
+ suppressed.push(o.path);
42
+ else if (o.conflicted)
43
+ conflicted.push(o.path);
42
44
  else
43
- ingested.push(change.path);
44
- fileEntries.push([change.path, r.oldHashHex, r.newHashHex]);
45
+ ingested.push(o.path);
46
+ fileEntries.push([o.path, o.oldHashHex, o.newHashHex]);
45
47
  }
46
48
  store.db
47
49
  .prepare("INSERT INTO checkpoints (id, repo_id, ts, files, git_head) VALUES (?, ?, ?, ?, ?)")
48
- .run(checkpointId, repoId, ts, JSON.stringify(fileEntries), opts.gitHead ?? null);
49
- sweepResurrectionPool(store, ts);
50
+ .run(checkpointId, repoId, opts.ts, JSON.stringify(fileEntries), opts.gitHead);
51
+ sweepResurrectionPool(store, opts.ts);
50
52
  return { checkpointId, ingested, suppressed, deleted, conflicted };
51
53
  }
52
54
  //# sourceMappingURL=checkpoint.js.map