@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.
- package/README.md +172 -0
- package/dist/src/cli/eval-matcher.d.ts +6 -1
- package/dist/src/cli/eval-matcher.js +14 -2
- package/dist/src/core/hash.d.ts +25 -1
- package/dist/src/core/hash.js +110 -10
- package/dist/src/core/ids.d.ts +12 -0
- package/dist/src/core/ids.js +43 -1
- package/dist/src/core/ingest.d.ts +22 -2
- package/dist/src/core/ingest.js +92 -18
- package/dist/src/core/parse/parse.d.ts +1 -1
- package/dist/src/core/parse/parse.js +31 -9
- package/dist/src/core/store/nodes.d.ts +13 -0
- package/dist/src/core/store/nodes.js +24 -0
- package/dist/src/core/utf8.d.ts +29 -0
- package/dist/src/core/utf8.js +96 -0
- package/dist/src/format/adapter.d.ts +7 -1
- package/dist/src/format/markdown.js +9 -18
- package/dist/src/graph/history.d.ts +6 -0
- package/dist/src/graph/history.js +13 -2
- package/dist/src/index.d.ts +8 -5
- package/dist/src/index.js +8 -5
- package/dist/src/mcp/graph.js +8 -4
- package/dist/src/mcp/reference.d.ts +1 -1
- package/dist/src/mcp/reference.js +17 -6
- package/dist/src/mcp/server.js +12 -7
- package/dist/src/mutate/apply.d.ts +7 -0
- package/dist/src/mutate/apply.js +17 -7
- package/dist/src/mutate/docs.d.ts +2 -0
- package/dist/src/mutate/docs.js +10 -6
- package/dist/src/mutate/known-ids.js +9 -1
- package/dist/src/mutate/macros.js +9 -2
- package/dist/src/mutate/ops.d.ts +2 -1
- package/dist/src/mutate/ops.js +24 -2
- package/dist/src/mutate/plan-update.d.ts +7 -1
- package/dist/src/mutate/plan-update.js +3 -1
- package/dist/src/oqx-js/context.js +43 -6
- package/dist/src/reconcile/eval/generator.d.ts +24 -1
- package/dist/src/reconcile/eval/generator.js +248 -0
- package/dist/src/reconcile/eval/metrics.d.ts +33 -9
- package/dist/src/reconcile/eval/metrics.js +55 -32
- package/dist/src/reconcile/flatten.js +7 -5
- package/dist/src/reconcile/phase5.d.ts +2 -1
- package/dist/src/reconcile/phase5.js +27 -10
- package/dist/src/reconcile/phase6a.js +17 -5
- package/dist/src/reconcile/phases.d.ts +44 -6
- package/dist/src/reconcile/phases.js +0 -0
- package/dist/src/reconcile/reconcile.js +16 -9
- package/dist/src/reconcile/types.d.ts +4 -1
- package/dist/src/reconcile/types.js +11 -2
- package/dist/src/search/external.d.ts +10 -0
- package/dist/src/search/external.js +18 -1
- package/dist/src/search/provider.d.ts +7 -3
- package/dist/src/search/provider.js +8 -7
- package/dist/src/search/rrf.js +3 -0
- package/dist/src/search/vector.js +21 -6
- package/dist/src/sync/attach.d.ts +7 -2
- package/dist/src/sync/attach.js +10 -8
- package/dist/src/sync/checkpoint.d.ts +18 -1
- package/dist/src/sync/checkpoint.js +31 -29
- package/dist/src/sync/driver.d.ts +15 -4
- package/dist/src/sync/driver.js +18 -43
- package/dist/src/sync/external-source.js +7 -1
- package/dist/src/sync/freshness.d.ts +65 -14
- package/dist/src/sync/freshness.js +107 -96
- package/dist/src/sync/fs-util.d.ts +19 -0
- package/dist/src/sync/fs-util.js +24 -1
- package/dist/src/sync/observe.d.ts +53 -5
- package/dist/src/sync/observe.js +162 -21
- package/dist/src/sync/reconciling-ingest.d.ts +42 -2
- package/dist/src/sync/reconciling-ingest.js +82 -10
- package/dist/src/sync/recovery.d.ts +5 -1
- package/dist/src/sync/recovery.js +8 -8
- package/dist/src/sync/workspace.d.ts +11 -3
- package/dist/src/sync/workspace.js +39 -30
- package/dist/src/sync/writer-lock.js +16 -26
- 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,
|
|
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 - |
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const positionPrior = 1 - Math.abs(
|
|
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
|
|
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
|
-
|
|
112
|
-
const
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
22
|
-
* stranger between them: within a gap bounded by matched blocks, if
|
|
23
|
-
* unmatched OLD child remains, pair it with its best-matching
|
|
24
|
-
* child in that same gap (text_sim ≥ floor, unique best).
|
|
25
|
-
* do not block the carry — they simply lose to the
|
|
26
|
-
* which is why an inserted block never steals an
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
2
|
-
// repo settings
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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;
|
package/dist/src/search/rrf.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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[]
|
|
15
|
+
export declare function ingestDirectory(store: Store, slug: string, rootPath: string, files?: string[], opts?: {
|
|
16
|
+
ts?: string;
|
|
17
|
+
fs?: SyncFs;
|
|
18
|
+
}): IngestDirResult;
|
package/dist/src/sync/attach.js
CHANGED
|
@@ -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 {
|
|
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
|
|
16
|
-
const
|
|
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 =
|
|
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 {
|
|
2
|
+
import { observeBatch } from "./observe.js";
|
|
5
3
|
import { sweepResurrectionPool } from "../core/store/gc.js";
|
|
6
|
-
import {
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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(
|
|
44
|
-
fileEntries.push([
|
|
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
|
|
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
|