imprnt 0.1.3 → 0.1.4-edge.74

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 CHANGED
@@ -123,14 +123,10 @@ asking ("add the anti-slop plugin"), each a separate `imprnt-plugin-*` package:
123
123
 
124
124
  | Package | What it gives your assistant |
125
125
  |---------|------------------------------|
126
- | `imprnt-plugin-character` | A voice and standards to write in. "Scribe" is the default you copy and personalize. |
127
126
  | `imprnt-plugin-anti-slop` | Rules that keep its prose from reading like AI. |
128
- | `imprnt-plugin-whenful` | A local mirror of your [Whenful](https://whenful.com) tasks, shown inline at read. |
129
- | `imprnt-plugin-kleinanzeigen` | A watcher for your Kleinanzeigen inbox: regex triage of buyer messages, drafts, a phone digest. You press send. |
130
- | `imprnt-plugin-session-host` | A warm browser holding your logged-in sessions, providing the authed-session capability. You enroll each site once. Not yet on npm: install from a repo checkout with `imprnt plugin add session-host --from <dir>`. |
131
- | `imprnt-plugin-timemachine` | Snapshots your work before each change so you can recover what the agent breaks. |
127
+ | `imprnt-plugin-character` | A voice and standards to write in. "Scribe" is the default you copy and personalize. |
132
128
  | `imprnt-plugin-statusline` | A customizable status line: model, branch, context, cost, rate limits, clock. |
133
- | `imprnt-plugin-telegram` | Your vault from your phone: text a bot, the answer comes from your notes. |
129
+ | `imprnt-plugin-timemachine` | Snapshots your work before each change so you can recover what the agent breaks. |
134
130
 
135
131
  Adding one copies it into your project and wires it into `CLAUDE.local.md`, the per-machine file
136
132
  your assistant loads each session. A fresh setup loads zero plugins until you add them. The full
@@ -180,3 +176,7 @@ only, never needed by people who use it through their assistant). Clone, `bun in
180
176
  ## License
181
177
 
182
178
  MIT (c) 2026 Aleksandr Bogdanov
179
+
180
+ ---
181
+
182
+ <img src=".github/mark.svg" height="15" alt=""> built by [bogdanov.wtf](https://bogdanov.wtf)
package/dist/cli.js CHANGED
@@ -1879,6 +1879,28 @@ var init_tags = __esm(() => {
1879
1879
  var exports_recall = {};
1880
1880
  import { lstatSync as lstatSync2, readdirSync as readdirSync6, readFileSync as readFileSync11 } from "node:fs";
1881
1881
  import { basename as basename4, join as join12, relative as relative4 } from "node:path";
1882
+ function stemWord(w) {
1883
+ if (w.length <= 3)
1884
+ return w;
1885
+ if (/[a-z]$/.test(w) === false)
1886
+ return w;
1887
+ if (w.endsWith("sses"))
1888
+ return w.slice(0, -2);
1889
+ if (w.endsWith("ies") && w.length > 4)
1890
+ return w.slice(0, -3) + "y";
1891
+ if (w.endsWith("ss") || w.endsWith("us") || w.endsWith("is")) {} else if (w.endsWith("s") && !w.endsWith("ous"))
1892
+ w = w.slice(0, -1);
1893
+ if (w.length > 4 && w.endsWith("ing")) {
1894
+ const base = w.slice(0, -3);
1895
+ if (base.length >= 3)
1896
+ w = /([bdfglmnprt])\1$/.test(base) ? base.slice(0, -1) : base;
1897
+ } else if (w.length > 3 && w.endsWith("ed")) {
1898
+ const base = w.slice(0, -2);
1899
+ if (base.length >= 3)
1900
+ w = /([bdfglmnprt])\1$/.test(base) ? base.slice(0, -1) : base;
1901
+ }
1902
+ return w;
1903
+ }
1882
1904
  function walk2(dir2) {
1883
1905
  const out = [];
1884
1906
  for (const entry of readdirSync6(dir2)) {
@@ -1902,7 +1924,53 @@ function walk2(dir2) {
1902
1924
  }
1903
1925
  return out;
1904
1926
  }
1905
- var args2, vault2, limit = 15, positional2, query, vocab, MAX_SYNONYM_NGRAM, STOPWORDS, tokenize = (text2) => text2.normalize("NFC").toLowerCase().replace(/['’]/gu, "").split(/[^\p{L}\p{N}]+/u).filter(Boolean), phraseSynonymTokens = (q) => {
1927
+ function bestPassages(absPath, n) {
1928
+ let raw = "";
1929
+ try {
1930
+ raw = stripBom(readFileSync11(absPath, "utf8"));
1931
+ } catch {
1932
+ return [];
1933
+ }
1934
+ const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
1935
+ const body2 = fmMatch ? raw.slice(fmMatch.index + fmMatch[0].length) : raw;
1936
+ const paras = body2.split(/\n\s*\n/).map((p) => p.trim()).filter((p) => p.length > 0);
1937
+ if (paras.length <= n)
1938
+ return paras;
1939
+ const scoredParas = paras.map((p) => {
1940
+ const tf = new Map;
1941
+ for (const t of tokenize(p))
1942
+ tf.set(t, (tf.get(t) ?? 0) + 1);
1943
+ let plen = 0;
1944
+ for (const c of tf.values())
1945
+ plen += c;
1946
+ let sc = 0;
1947
+ const used = new Set;
1948
+ for (const group of scoringGroups) {
1949
+ let best = 0, bv = "";
1950
+ for (const v of group) {
1951
+ if (used.has(v))
1952
+ continue;
1953
+ const x = bm25Term(tf.get(v) ?? 0, plen, variantIdf.get(v) ?? 0);
1954
+ if (x > best) {
1955
+ best = x;
1956
+ bv = v;
1957
+ }
1958
+ }
1959
+ if (best > 0) {
1960
+ sc += best;
1961
+ used.add(bv);
1962
+ }
1963
+ }
1964
+ return { p, sc };
1965
+ });
1966
+ const keep = new Set(scoredParas.slice().sort((a, b) => b.sc - a.sc).slice(0, n).filter((x) => x.sc > 0).map((x) => x.p));
1967
+ const out = paras.filter((p) => keep.has(p));
1968
+ return out.length ? out : paras.slice(0, n);
1969
+ }
1970
+ var args2, vault2, limit = 15, gap = 0, passages = 0, proximity = false, coverage = false, stem = false, positional2, query, vocab, MAX_SYNONYM_NGRAM, STOPWORDS, tokenize = (text2) => {
1971
+ const raw = text2.normalize("NFC").toLowerCase().replace(/['’]/gu, "").split(/[^\p{L}\p{N}]+/u).filter(Boolean);
1972
+ return stem ? raw.map(stemWord) : raw;
1973
+ }, phraseSynonymTokens = (q) => {
1906
1974
  const out = [];
1907
1975
  const words = q.normalize("NFC").toLowerCase().split(/[\s-]+/).map((w) => w.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, "")).filter(Boolean);
1908
1976
  for (let n = Math.min(words.length, MAX_SYNONYM_NGRAM);n >= 2; n--) {
@@ -1916,14 +1984,14 @@ var args2, vault2, limit = 15, positional2, query, vocab, MAX_SYNONYM_NGRAM, STO
1916
1984
  }
1917
1985
  }
1918
1986
  return out;
1919
- }, rawTerms, contentTerms, baseTerms, queryTerms, CONTROL2, files, TITLE_BOOST = 3, TAG_BOOST = 2, SUMMARY_BOOST = 1, BODY_BOOST = 1, docs, df, N, avgdl, K1 = 1.5, B = 0.75, idf = (term) => {
1987
+ }, rawTerms, contentTerms, baseTerms, queryTerms, queryVariantSet, CONTROL2, files, TITLE_BOOST = 3, TAG_BOOST = 2, SUMMARY_BOOST = 1, BODY_BOOST = 1, docs, df, N, avgdl, K1 = 1.5, B = 0.75, PROX_WINDOW = 25, PROX_WEIGHT = 0.35, idf = (term) => {
1920
1988
  const n = df.get(term) ?? 0;
1921
1989
  return Math.max(0, Math.log(1 + (N - n + 0.5) / (n + 0.5)));
1922
1990
  }, bm25Term = (tf, dl, termIdf) => {
1923
1991
  if (tf <= 0)
1924
1992
  return 0;
1925
1993
  return termIdf * (tf * (K1 + 1)) / (tf + K1 * (1 - B + B * (dl / avgdl)));
1926
- }, variantIdf, hits, scoringGroups, shown, expanded;
1994
+ }, variantIdf, hits, scoringGroups, cut, shown, expanded;
1927
1995
  var init_recall = __esm(() => {
1928
1996
  init_tags();
1929
1997
  init_moc();
@@ -1950,12 +2018,42 @@ var init_recall = __esm(() => {
1950
2018
  process.exit(1);
1951
2019
  }
1952
2020
  limit = n;
2021
+ } else if (args2[i] === "--proximity") {
2022
+ proximity = true;
2023
+ } else if (args2[i] === "--coverage") {
2024
+ coverage = true;
2025
+ } else if (args2[i] === "--stem") {
2026
+ stem = true;
2027
+ } else if (args2[i] === "--passages") {
2028
+ const tok = args2[++i];
2029
+ if (tok === undefined || !/^[0-9]+$/.test(tok)) {
2030
+ console.error("--passages must be a positive integer");
2031
+ process.exit(1);
2032
+ }
2033
+ const n = parseInt(tok, 10);
2034
+ if (!Number.isFinite(n) || n <= 0) {
2035
+ console.error("--passages must be a positive integer");
2036
+ process.exit(1);
2037
+ }
2038
+ passages = n;
2039
+ } else if (args2[i] === "--gap") {
2040
+ const tok = args2[++i];
2041
+ if (tok === undefined || !/^0?\.[0-9]+$/.test(tok)) {
2042
+ console.error("--gap must be a ratio between 0 and 1 (e.g. 0.25)");
2043
+ process.exit(1);
2044
+ }
2045
+ const g = parseFloat(tok);
2046
+ if (!Number.isFinite(g) || g <= 0 || g >= 1) {
2047
+ console.error("--gap must be a ratio between 0 and 1 (e.g. 0.25)");
2048
+ process.exit(1);
2049
+ }
2050
+ gap = g;
1953
2051
  } else
1954
2052
  positional2.push(args2[i]);
1955
2053
  }
1956
2054
  query = positional2.join(" ").trim();
1957
2055
  if (!query) {
1958
- console.error('usage: imprnt recall "<query>" [--vault DIR] [--limit N]');
2056
+ console.error('usage: imprnt recall "<query>" [--vault DIR] [--limit N] [--gap R] [--passages N] [--proximity] [--coverage] [--stem]');
1959
2057
  process.exit(1);
1960
2058
  }
1961
2059
  vocab = loadTags(vault2);
@@ -2063,6 +2161,7 @@ var init_recall = __esm(() => {
2063
2161
  contentTerms = rawTerms.filter((w) => !STOPWORDS.has(w));
2064
2162
  baseTerms = contentTerms.length ? contentTerms : rawTerms;
2065
2163
  queryTerms = baseTerms.map((w) => [...new Set([w, ...tokenize(normalize(vocab, w))])]);
2164
+ queryVariantSet = new Set(queryTerms.flat());
2066
2165
  for (const t of phraseSynonymTokens(query)) {
2067
2166
  if (!STOPWORDS.has(t))
2068
2167
  queryTerms.push([t]);
@@ -2099,11 +2198,26 @@ var init_recall = __esm(() => {
2099
2198
  add(tokenize(aliases), TITLE_BOOST);
2100
2199
  add(tags.flatMap(tokenize), TAG_BOOST);
2101
2200
  add(tokenize(summary), SUMMARY_BOOST);
2102
- add(tokenize(body2), BODY_BOOST);
2201
+ const bodyTokens = tokenize(body2);
2202
+ add(bodyTokens, BODY_BOOST);
2203
+ let pos;
2204
+ if (proximity) {
2205
+ pos = new Map;
2206
+ for (let bi = 0;bi < bodyTokens.length; bi++) {
2207
+ const t = bodyTokens[bi];
2208
+ if (!queryVariantSet.has(t))
2209
+ continue;
2210
+ const arr = pos.get(t);
2211
+ if (arr)
2212
+ arr.push(bi);
2213
+ else
2214
+ pos.set(t, [bi]);
2215
+ }
2216
+ }
2103
2217
  let len = 0;
2104
2218
  for (const c of tf.values())
2105
2219
  len += c;
2106
- docs.push({ path, tf, len });
2220
+ docs.push({ path, tf, len, pos });
2107
2221
  for (const term of tf.keys())
2108
2222
  df.set(term, (df.get(term) ?? 0) + 1);
2109
2223
  }
@@ -2136,23 +2250,69 @@ var init_recall = __esm(() => {
2136
2250
  scored.add(bestVariant);
2137
2251
  }
2138
2252
  }
2139
- if (score > 0)
2253
+ if (score > 0) {
2254
+ if (coverage && scoringGroups.length > 1) {
2255
+ score *= 0.5 + 0.5 * (scored.size / scoringGroups.length);
2256
+ }
2257
+ if (proximity && d.pos && scored.size > 1) {
2258
+ const present = [...scored].filter((t) => d.pos.has(t));
2259
+ let bonus = 0;
2260
+ for (let x = 0;x < present.length; x++) {
2261
+ for (let y = x + 1;y < present.length; y++) {
2262
+ const a = d.pos.get(present[x]), b = d.pos.get(present[y]);
2263
+ let best = Infinity;
2264
+ for (let ia = 0, ib = 0;ia < a.length && ib < b.length; ) {
2265
+ const dist = Math.abs(a[ia] - b[ib]);
2266
+ if (dist < best)
2267
+ best = dist;
2268
+ if (a[ia] < b[ib])
2269
+ ia++;
2270
+ else
2271
+ ib++;
2272
+ }
2273
+ if (best < PROX_WINDOW) {
2274
+ const w = Math.min(variantIdf.get(present[x]) ?? 0, variantIdf.get(present[y]) ?? 0);
2275
+ bonus += PROX_WEIGHT * w * (1 - best / PROX_WINDOW);
2276
+ }
2277
+ }
2278
+ }
2279
+ score += bonus;
2280
+ }
2140
2281
  hits.push({ path: relative4(vault2, d.path), score: Math.round(score * 100) / 100 });
2282
+ }
2141
2283
  }
2142
2284
  hits.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
2143
2285
  if (!hits.length) {
2144
2286
  console.log(`no matches for "${query}" in ${vault2}`);
2145
2287
  process.exit(0);
2146
2288
  }
2147
- shown = hits.slice(0, limit);
2289
+ cut = Math.min(limit, hits.length);
2290
+ if (gap > 0) {
2291
+ for (let i = 1;i < cut; i++) {
2292
+ if (hits[i].score < gap * hits[i - 1].score) {
2293
+ cut = i;
2294
+ break;
2295
+ }
2296
+ }
2297
+ }
2298
+ shown = hits.slice(0, cut);
2148
2299
  expanded = queryTerms.map((g) => g.join("|")).join(" ");
2149
- console.log(`recall "${query}" [${expanded}] ${hits.length} match(es)${hits.length > shown.length ? `, showing top ${shown.length}` : ""}, BM25-ranked:
2300
+ console.log(`recall "${query}" [${expanded}] - ${hits.length} match(es)${hits.length > shown.length ? `, showing top ${shown.length}` : ""}, BM25-ranked:
2150
2301
  `);
2151
- for (const h of shown)
2302
+ for (const h of shown) {
2152
2303
  console.log(` [${h.score.toFixed(2)}] ${h.path}`);
2304
+ if (passages > 0) {
2305
+ for (const p of bestPassages(join12(vault2, h.path), passages)) {
2306
+ for (const line of p.split(`
2307
+ `))
2308
+ console.log(` ${line}`);
2309
+ console.log("");
2310
+ }
2311
+ }
2312
+ }
2153
2313
  if (hits.length > shown.length) {
2154
2314
  console.log(`
2155
- ${hits.length - shown.length} lower-ranked hit(s) hidden. Raise with --limit if needed; usually you don't.`);
2315
+ ... ${hits.length - shown.length} lower-ranked hit(s) hidden. Raise with --limit if needed. Usually you do not.`);
2156
2316
  }
2157
2317
  });
2158
2318
 
@@ -2261,11 +2421,11 @@ var init_snapshot = __esm(() => {
2261
2421
  continue;
2262
2422
  }
2263
2423
  const ext = extname2(rel);
2264
- const stem = rel.slice(0, rel.length - ext.length);
2265
- let relD = `${stem}-${hash2.slice(0, 8)}${ext}`;
2424
+ const stem2 = rel.slice(0, rel.length - ext.length);
2425
+ let relD = `${stem2}-${hash2.slice(0, 8)}${ext}`;
2266
2426
  let n = 2;
2267
2427
  while (existsSync11(join13(destRoot, relD)) && Buffer.compare(readFileSync12(join13(destRoot, relD)), srcBytes2) !== 0) {
2268
- relD = `${stem}-${hash2.slice(0, 8)}-${n}${ext}`;
2428
+ relD = `${stem2}-${hash2.slice(0, 8)}-${n}${ext}`;
2269
2429
  n++;
2270
2430
  }
2271
2431
  rawPath2 = join13(destRoot, relD);
package/dist/imp.js CHANGED
@@ -1879,6 +1879,28 @@ var init_tags = __esm(() => {
1879
1879
  var exports_recall = {};
1880
1880
  import { lstatSync as lstatSync2, readdirSync as readdirSync6, readFileSync as readFileSync11 } from "node:fs";
1881
1881
  import { basename as basename4, join as join12, relative as relative4 } from "node:path";
1882
+ function stemWord(w) {
1883
+ if (w.length <= 3)
1884
+ return w;
1885
+ if (/[a-z]$/.test(w) === false)
1886
+ return w;
1887
+ if (w.endsWith("sses"))
1888
+ return w.slice(0, -2);
1889
+ if (w.endsWith("ies") && w.length > 4)
1890
+ return w.slice(0, -3) + "y";
1891
+ if (w.endsWith("ss") || w.endsWith("us") || w.endsWith("is")) {} else if (w.endsWith("s") && !w.endsWith("ous"))
1892
+ w = w.slice(0, -1);
1893
+ if (w.length > 4 && w.endsWith("ing")) {
1894
+ const base = w.slice(0, -3);
1895
+ if (base.length >= 3)
1896
+ w = /([bdfglmnprt])\1$/.test(base) ? base.slice(0, -1) : base;
1897
+ } else if (w.length > 3 && w.endsWith("ed")) {
1898
+ const base = w.slice(0, -2);
1899
+ if (base.length >= 3)
1900
+ w = /([bdfglmnprt])\1$/.test(base) ? base.slice(0, -1) : base;
1901
+ }
1902
+ return w;
1903
+ }
1882
1904
  function walk2(dir2) {
1883
1905
  const out = [];
1884
1906
  for (const entry of readdirSync6(dir2)) {
@@ -1902,7 +1924,53 @@ function walk2(dir2) {
1902
1924
  }
1903
1925
  return out;
1904
1926
  }
1905
- var args2, vault2, limit = 15, positional2, query, vocab, MAX_SYNONYM_NGRAM, STOPWORDS, tokenize = (text2) => text2.normalize("NFC").toLowerCase().replace(/['’]/gu, "").split(/[^\p{L}\p{N}]+/u).filter(Boolean), phraseSynonymTokens = (q) => {
1927
+ function bestPassages(absPath, n) {
1928
+ let raw = "";
1929
+ try {
1930
+ raw = stripBom(readFileSync11(absPath, "utf8"));
1931
+ } catch {
1932
+ return [];
1933
+ }
1934
+ const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
1935
+ const body2 = fmMatch ? raw.slice(fmMatch.index + fmMatch[0].length) : raw;
1936
+ const paras = body2.split(/\n\s*\n/).map((p) => p.trim()).filter((p) => p.length > 0);
1937
+ if (paras.length <= n)
1938
+ return paras;
1939
+ const scoredParas = paras.map((p) => {
1940
+ const tf = new Map;
1941
+ for (const t of tokenize(p))
1942
+ tf.set(t, (tf.get(t) ?? 0) + 1);
1943
+ let plen = 0;
1944
+ for (const c of tf.values())
1945
+ plen += c;
1946
+ let sc = 0;
1947
+ const used = new Set;
1948
+ for (const group of scoringGroups) {
1949
+ let best = 0, bv = "";
1950
+ for (const v of group) {
1951
+ if (used.has(v))
1952
+ continue;
1953
+ const x = bm25Term(tf.get(v) ?? 0, plen, variantIdf.get(v) ?? 0);
1954
+ if (x > best) {
1955
+ best = x;
1956
+ bv = v;
1957
+ }
1958
+ }
1959
+ if (best > 0) {
1960
+ sc += best;
1961
+ used.add(bv);
1962
+ }
1963
+ }
1964
+ return { p, sc };
1965
+ });
1966
+ const keep = new Set(scoredParas.slice().sort((a, b) => b.sc - a.sc).slice(0, n).filter((x) => x.sc > 0).map((x) => x.p));
1967
+ const out = paras.filter((p) => keep.has(p));
1968
+ return out.length ? out : paras.slice(0, n);
1969
+ }
1970
+ var args2, vault2, limit = 15, gap = 0, passages = 0, proximity = false, coverage = false, stem = false, positional2, query, vocab, MAX_SYNONYM_NGRAM, STOPWORDS, tokenize = (text2) => {
1971
+ const raw = text2.normalize("NFC").toLowerCase().replace(/['’]/gu, "").split(/[^\p{L}\p{N}]+/u).filter(Boolean);
1972
+ return stem ? raw.map(stemWord) : raw;
1973
+ }, phraseSynonymTokens = (q) => {
1906
1974
  const out = [];
1907
1975
  const words = q.normalize("NFC").toLowerCase().split(/[\s-]+/).map((w) => w.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, "")).filter(Boolean);
1908
1976
  for (let n = Math.min(words.length, MAX_SYNONYM_NGRAM);n >= 2; n--) {
@@ -1916,14 +1984,14 @@ var args2, vault2, limit = 15, positional2, query, vocab, MAX_SYNONYM_NGRAM, STO
1916
1984
  }
1917
1985
  }
1918
1986
  return out;
1919
- }, rawTerms, contentTerms, baseTerms, queryTerms, CONTROL2, files, TITLE_BOOST = 3, TAG_BOOST = 2, SUMMARY_BOOST = 1, BODY_BOOST = 1, docs, df, N, avgdl, K1 = 1.5, B = 0.75, idf = (term) => {
1987
+ }, rawTerms, contentTerms, baseTerms, queryTerms, queryVariantSet, CONTROL2, files, TITLE_BOOST = 3, TAG_BOOST = 2, SUMMARY_BOOST = 1, BODY_BOOST = 1, docs, df, N, avgdl, K1 = 1.5, B = 0.75, PROX_WINDOW = 25, PROX_WEIGHT = 0.35, idf = (term) => {
1920
1988
  const n = df.get(term) ?? 0;
1921
1989
  return Math.max(0, Math.log(1 + (N - n + 0.5) / (n + 0.5)));
1922
1990
  }, bm25Term = (tf, dl, termIdf) => {
1923
1991
  if (tf <= 0)
1924
1992
  return 0;
1925
1993
  return termIdf * (tf * (K1 + 1)) / (tf + K1 * (1 - B + B * (dl / avgdl)));
1926
- }, variantIdf, hits, scoringGroups, shown, expanded;
1994
+ }, variantIdf, hits, scoringGroups, cut, shown, expanded;
1927
1995
  var init_recall = __esm(() => {
1928
1996
  init_tags();
1929
1997
  init_moc();
@@ -1950,12 +2018,42 @@ var init_recall = __esm(() => {
1950
2018
  process.exit(1);
1951
2019
  }
1952
2020
  limit = n;
2021
+ } else if (args2[i] === "--proximity") {
2022
+ proximity = true;
2023
+ } else if (args2[i] === "--coverage") {
2024
+ coverage = true;
2025
+ } else if (args2[i] === "--stem") {
2026
+ stem = true;
2027
+ } else if (args2[i] === "--passages") {
2028
+ const tok = args2[++i];
2029
+ if (tok === undefined || !/^[0-9]+$/.test(tok)) {
2030
+ console.error("--passages must be a positive integer");
2031
+ process.exit(1);
2032
+ }
2033
+ const n = parseInt(tok, 10);
2034
+ if (!Number.isFinite(n) || n <= 0) {
2035
+ console.error("--passages must be a positive integer");
2036
+ process.exit(1);
2037
+ }
2038
+ passages = n;
2039
+ } else if (args2[i] === "--gap") {
2040
+ const tok = args2[++i];
2041
+ if (tok === undefined || !/^0?\.[0-9]+$/.test(tok)) {
2042
+ console.error("--gap must be a ratio between 0 and 1 (e.g. 0.25)");
2043
+ process.exit(1);
2044
+ }
2045
+ const g = parseFloat(tok);
2046
+ if (!Number.isFinite(g) || g <= 0 || g >= 1) {
2047
+ console.error("--gap must be a ratio between 0 and 1 (e.g. 0.25)");
2048
+ process.exit(1);
2049
+ }
2050
+ gap = g;
1953
2051
  } else
1954
2052
  positional2.push(args2[i]);
1955
2053
  }
1956
2054
  query = positional2.join(" ").trim();
1957
2055
  if (!query) {
1958
- console.error('usage: imprnt recall "<query>" [--vault DIR] [--limit N]');
2056
+ console.error('usage: imprnt recall "<query>" [--vault DIR] [--limit N] [--gap R] [--passages N] [--proximity] [--coverage] [--stem]');
1959
2057
  process.exit(1);
1960
2058
  }
1961
2059
  vocab = loadTags(vault2);
@@ -2063,6 +2161,7 @@ var init_recall = __esm(() => {
2063
2161
  contentTerms = rawTerms.filter((w) => !STOPWORDS.has(w));
2064
2162
  baseTerms = contentTerms.length ? contentTerms : rawTerms;
2065
2163
  queryTerms = baseTerms.map((w) => [...new Set([w, ...tokenize(normalize(vocab, w))])]);
2164
+ queryVariantSet = new Set(queryTerms.flat());
2066
2165
  for (const t of phraseSynonymTokens(query)) {
2067
2166
  if (!STOPWORDS.has(t))
2068
2167
  queryTerms.push([t]);
@@ -2099,11 +2198,26 @@ var init_recall = __esm(() => {
2099
2198
  add(tokenize(aliases), TITLE_BOOST);
2100
2199
  add(tags.flatMap(tokenize), TAG_BOOST);
2101
2200
  add(tokenize(summary), SUMMARY_BOOST);
2102
- add(tokenize(body2), BODY_BOOST);
2201
+ const bodyTokens = tokenize(body2);
2202
+ add(bodyTokens, BODY_BOOST);
2203
+ let pos;
2204
+ if (proximity) {
2205
+ pos = new Map;
2206
+ for (let bi = 0;bi < bodyTokens.length; bi++) {
2207
+ const t = bodyTokens[bi];
2208
+ if (!queryVariantSet.has(t))
2209
+ continue;
2210
+ const arr = pos.get(t);
2211
+ if (arr)
2212
+ arr.push(bi);
2213
+ else
2214
+ pos.set(t, [bi]);
2215
+ }
2216
+ }
2103
2217
  let len = 0;
2104
2218
  for (const c of tf.values())
2105
2219
  len += c;
2106
- docs.push({ path, tf, len });
2220
+ docs.push({ path, tf, len, pos });
2107
2221
  for (const term of tf.keys())
2108
2222
  df.set(term, (df.get(term) ?? 0) + 1);
2109
2223
  }
@@ -2136,23 +2250,69 @@ var init_recall = __esm(() => {
2136
2250
  scored.add(bestVariant);
2137
2251
  }
2138
2252
  }
2139
- if (score > 0)
2253
+ if (score > 0) {
2254
+ if (coverage && scoringGroups.length > 1) {
2255
+ score *= 0.5 + 0.5 * (scored.size / scoringGroups.length);
2256
+ }
2257
+ if (proximity && d.pos && scored.size > 1) {
2258
+ const present = [...scored].filter((t) => d.pos.has(t));
2259
+ let bonus = 0;
2260
+ for (let x = 0;x < present.length; x++) {
2261
+ for (let y = x + 1;y < present.length; y++) {
2262
+ const a = d.pos.get(present[x]), b = d.pos.get(present[y]);
2263
+ let best = Infinity;
2264
+ for (let ia = 0, ib = 0;ia < a.length && ib < b.length; ) {
2265
+ const dist = Math.abs(a[ia] - b[ib]);
2266
+ if (dist < best)
2267
+ best = dist;
2268
+ if (a[ia] < b[ib])
2269
+ ia++;
2270
+ else
2271
+ ib++;
2272
+ }
2273
+ if (best < PROX_WINDOW) {
2274
+ const w = Math.min(variantIdf.get(present[x]) ?? 0, variantIdf.get(present[y]) ?? 0);
2275
+ bonus += PROX_WEIGHT * w * (1 - best / PROX_WINDOW);
2276
+ }
2277
+ }
2278
+ }
2279
+ score += bonus;
2280
+ }
2140
2281
  hits.push({ path: relative4(vault2, d.path), score: Math.round(score * 100) / 100 });
2282
+ }
2141
2283
  }
2142
2284
  hits.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
2143
2285
  if (!hits.length) {
2144
2286
  console.log(`no matches for "${query}" in ${vault2}`);
2145
2287
  process.exit(0);
2146
2288
  }
2147
- shown = hits.slice(0, limit);
2289
+ cut = Math.min(limit, hits.length);
2290
+ if (gap > 0) {
2291
+ for (let i = 1;i < cut; i++) {
2292
+ if (hits[i].score < gap * hits[i - 1].score) {
2293
+ cut = i;
2294
+ break;
2295
+ }
2296
+ }
2297
+ }
2298
+ shown = hits.slice(0, cut);
2148
2299
  expanded = queryTerms.map((g) => g.join("|")).join(" ");
2149
- console.log(`recall "${query}" [${expanded}] ${hits.length} match(es)${hits.length > shown.length ? `, showing top ${shown.length}` : ""}, BM25-ranked:
2300
+ console.log(`recall "${query}" [${expanded}] - ${hits.length} match(es)${hits.length > shown.length ? `, showing top ${shown.length}` : ""}, BM25-ranked:
2150
2301
  `);
2151
- for (const h of shown)
2302
+ for (const h of shown) {
2152
2303
  console.log(` [${h.score.toFixed(2)}] ${h.path}`);
2304
+ if (passages > 0) {
2305
+ for (const p of bestPassages(join12(vault2, h.path), passages)) {
2306
+ for (const line of p.split(`
2307
+ `))
2308
+ console.log(` ${line}`);
2309
+ console.log("");
2310
+ }
2311
+ }
2312
+ }
2153
2313
  if (hits.length > shown.length) {
2154
2314
  console.log(`
2155
- ${hits.length - shown.length} lower-ranked hit(s) hidden. Raise with --limit if needed; usually you don't.`);
2315
+ ... ${hits.length - shown.length} lower-ranked hit(s) hidden. Raise with --limit if needed. Usually you do not.`);
2156
2316
  }
2157
2317
  });
2158
2318
 
@@ -2261,11 +2421,11 @@ var init_snapshot = __esm(() => {
2261
2421
  continue;
2262
2422
  }
2263
2423
  const ext = extname2(rel);
2264
- const stem = rel.slice(0, rel.length - ext.length);
2265
- let relD = `${stem}-${hash2.slice(0, 8)}${ext}`;
2424
+ const stem2 = rel.slice(0, rel.length - ext.length);
2425
+ let relD = `${stem2}-${hash2.slice(0, 8)}${ext}`;
2266
2426
  let n = 2;
2267
2427
  while (existsSync11(join13(destRoot, relD)) && Buffer.compare(readFileSync12(join13(destRoot, relD)), srcBytes2) !== 0) {
2268
- relD = `${stem}-${hash2.slice(0, 8)}-${n}${ext}`;
2428
+ relD = `${stem2}-${hash2.slice(0, 8)}-${n}${ext}`;
2269
2429
  n++;
2270
2430
  }
2271
2431
  rawPath2 = join13(destRoot, relD);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imprnt",
3
- "version": "0.1.3",
3
+ "version": "0.1.4-edge.74",
4
4
  "stable": true,
5
5
  "description": "Deterministic-first, plain-markdown knowledge vault. Code does the bulk transform. The LLM only touches the irreducibly-semantic 20%. Native grep plus BM25 retrieval, zero MCP on the vault.",
6
6
  "type": "module",