@yurtsever/capsa 0.1.0-alpha.2 → 0.1.0-alpha.4

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 (2) hide show
  1. package/dist/index.js +206 -39
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -10053,7 +10053,7 @@ function loadSqlite() {
10053
10053
  }
10054
10054
  var DB_DIR = ".capsa";
10055
10055
  var DB_FILE = "index.db";
10056
- var SCHEMA_VERSION = 2;
10056
+ var SCHEMA_VERSION = 3;
10057
10057
  function dbPath(projectRoot) {
10058
10058
  return join6(projectRoot, DB_DIR, DB_FILE);
10059
10059
  }
@@ -10075,9 +10075,20 @@ var Store = class {
10075
10075
  const row = this.db.prepare("SELECT value FROM meta WHERE key = 'schema'").get();
10076
10076
  return row?.value !== String(SCHEMA_VERSION);
10077
10077
  }
10078
- /** Drop all indexed content (not the log, not decisions) and mark the schema current. */
10078
+ /**
10079
+ * Drop all indexed content (not the log, not decisions) and mark the schema
10080
+ * current. The virtual tables are dropped and recreated, not emptied:
10081
+ * `CREATE VIRTUAL TABLE IF NOT EXISTS` would keep an old tokenizer alive
10082
+ * through a rebuild — which once left FTS without stemming after v2.
10083
+ */
10079
10084
  reset() {
10080
- this.db.exec("DELETE FROM chunks_vec; DELETE FROM chunks_fts; DELETE FROM chunks; DELETE FROM items;");
10085
+ this.db.exec(`
10086
+ DROP TABLE IF EXISTS chunks_fts;
10087
+ DROP TABLE IF EXISTS chunks_vec;
10088
+ DELETE FROM chunks;
10089
+ DELETE FROM items;
10090
+ `);
10091
+ this.migrate();
10081
10092
  this.db.prepare("INSERT OR REPLACE INTO meta(key, value) VALUES ('schema', ?)").run(String(SCHEMA_VERSION));
10082
10093
  }
10083
10094
  migrate() {
@@ -10216,6 +10227,10 @@ var Store = class {
10216
10227
  }
10217
10228
  }));
10218
10229
  }
10230
+ /** All chunks of one item, in document order. */
10231
+ chunksForItem(itemId) {
10232
+ return this.db.prepare("SELECT id, item_id, idx, heading_path, text FROM chunks WHERE item_id = ? ORDER BY idx").all(itemId);
10233
+ }
10219
10234
  listItems() {
10220
10235
  return this.db.prepare("SELECT * FROM items ORDER BY kind, rel_path").all();
10221
10236
  }
@@ -10555,6 +10570,40 @@ function rrf(lists, k = DEFAULT_WEIGHTS.rrfK) {
10555
10570
  return scores;
10556
10571
  }
10557
10572
 
10573
+ // ../index/dist/pin.js
10574
+ var LEADING_ID = /^([A-Za-z]{1,8}-?\d{1,5}(?:-\d{1,5})?|\d{2,6})(?=[-_.\s]|$)/;
10575
+ function leadingId(relPath) {
10576
+ const base = relPath.split(/[\\/]/).pop() ?? "";
10577
+ return LEADING_ID.exec(base)?.[1];
10578
+ }
10579
+ function pinnedItems(query, items) {
10580
+ const q = query.toLowerCase();
10581
+ const idCount = /* @__PURE__ */ new Map();
10582
+ for (const item of items) {
10583
+ const id = leadingId(item.rel_path)?.toLowerCase();
10584
+ if (id)
10585
+ idCount.set(id, (idCount.get(id) ?? 0) + 1);
10586
+ }
10587
+ const out = [];
10588
+ for (const item of items) {
10589
+ const rel = item.rel_path.toLowerCase();
10590
+ if (q.includes(rel)) {
10591
+ out.push(item);
10592
+ continue;
10593
+ }
10594
+ const id = leadingId(item.rel_path);
10595
+ if (!id || (idCount.get(id.toLowerCase()) ?? 0) > 1)
10596
+ continue;
10597
+ const re = new RegExp(`(^|[^\\w-])${escape2(id)}(?=$|[^\\w-])`, "i");
10598
+ if (re.test(query))
10599
+ out.push(item);
10600
+ }
10601
+ return out;
10602
+ }
10603
+ function escape2(s) {
10604
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
10605
+ }
10606
+
10558
10607
  // ../index/dist/retrieve.js
10559
10608
  async function retrieve(store, embedder, query, options = {}) {
10560
10609
  const started = Date.now();
@@ -10562,31 +10611,97 @@ async function retrieve(store, embedder, query, options = {}) {
10562
10611
  const candidates = options.candidates ?? 30;
10563
10612
  const weights = options.weights ?? DEFAULT_WEIGHTS;
10564
10613
  const minRelative = options.minRelativeScore ?? 0.5;
10614
+ const perItem = /* @__PURE__ */ new Map();
10615
+ const chunks = [];
10616
+ const debug = [];
10617
+ let tokens = 0;
10618
+ const pinned = pinnedItems(query, store.listItems());
10619
+ for (const item of pinned) {
10620
+ for (const c of store.chunksForItem(item.id)) {
10621
+ const t = estimateTokens(c.text);
10622
+ const fits = tokens + t <= maxTokens || chunks.length === 0;
10623
+ if (options.debug) {
10624
+ debug.push({
10625
+ chunkId: c.id,
10626
+ relPath: item.rel_path,
10627
+ headingPath: JSON.parse(c.heading_path),
10628
+ kind: item.kind,
10629
+ status: item.status,
10630
+ vecRank: null,
10631
+ ftsRank: null,
10632
+ fused: 0,
10633
+ factor: 1,
10634
+ score: Number.POSITIVE_INFINITY,
10635
+ kept: fits,
10636
+ why: fits ? "pinned" : "budget"
10637
+ });
10638
+ }
10639
+ if (!fits)
10640
+ continue;
10641
+ perItem.set(item.id, (perItem.get(item.id) ?? 0) + 1);
10642
+ tokens += t;
10643
+ chunks.push({
10644
+ chunkId: c.id,
10645
+ score: Number.POSITIVE_INFINITY,
10646
+ kind: item.kind,
10647
+ status: item.status,
10648
+ updatedAt: item.updated_at,
10649
+ relPath: item.rel_path,
10650
+ headingPath: JSON.parse(c.heading_path),
10651
+ text: c.text,
10652
+ tokens: t
10653
+ });
10654
+ }
10655
+ }
10656
+ const pinnedIds = new Set(pinned.map((i) => i.id));
10657
+ const neighbourCutoff = pinned.length ? Math.max(minRelative, 0.75) : minRelative;
10658
+ const neighbourBudget = pinned.length ? Math.min(maxTokens, tokens + Math.floor(maxTokens / 3)) : maxTokens;
10565
10659
  const [qv] = await embedder.embed([query]);
10566
10660
  const vec = qv ? store.vectorSearch(qv, candidates).map((r) => r.chunkId) : [];
10567
10661
  const fts = store.textSearch(query, candidates).map((r) => r.chunkId);
10568
10662
  const fused = rrf([vec, fts], weights.rrfK);
10569
- const rows = store.chunksWithItems([...fused.keys()]);
10663
+ const rows = store.chunksWithItems([...fused.keys()]).filter((r) => !pinnedIds.has(r.item_id));
10570
10664
  const now = Date.now();
10571
- const scored = rows.map((r) => ({
10572
- row: r,
10573
- score: (fused.get(r.id) ?? 0) * signalFactor({ kind: r.item.kind, status: r.item.status, updatedAt: r.item.updated_at }, weights, now)
10574
- })).sort((a, b) => b.score - a.score);
10575
- const perItem = /* @__PURE__ */ new Map();
10576
- const chunks = [];
10577
- let tokens = 0;
10665
+ const scored = rows.map((r) => {
10666
+ const fusedScore = fused.get(r.id) ?? 0;
10667
+ const factor = signalFactor({ kind: r.item.kind, status: r.item.status, updatedAt: r.item.updated_at }, weights, now);
10668
+ return { row: r, fusedScore, factor, score: fusedScore * factor };
10669
+ }).sort((a, b) => b.score - a.score);
10578
10670
  const best = scored[0]?.score ?? 0;
10579
- for (const { row, score } of scored) {
10580
- if (chunks.length > 0 && score < best * minRelative)
10581
- break;
10582
- const used = perItem.get(row.item_id) ?? 0;
10583
- if (used >= 2)
10671
+ let cut = tokens >= maxTokens;
10672
+ let kept = 0;
10673
+ for (const { row, fusedScore, factor, score } of scored) {
10674
+ let why = "kept";
10675
+ if (cut || kept > 0 && score < best * neighbourCutoff) {
10676
+ cut = true;
10677
+ why = "cutoff";
10678
+ } else if ((perItem.get(row.item_id) ?? 0) >= 2) {
10679
+ why = "per-item cap";
10680
+ } else if (tokens + estimateTokens(row.text) > neighbourBudget && chunks.length > 0) {
10681
+ why = "budget";
10682
+ }
10683
+ if (options.debug) {
10684
+ debug.push({
10685
+ chunkId: row.id,
10686
+ relPath: row.item.rel_path,
10687
+ headingPath: JSON.parse(row.heading_path),
10688
+ kind: row.item.kind,
10689
+ status: row.item.status,
10690
+ vecRank: rankIn(vec, row.id),
10691
+ ftsRank: rankIn(fts, row.id),
10692
+ fused: fusedScore,
10693
+ factor,
10694
+ score,
10695
+ kept: why === "kept",
10696
+ why
10697
+ });
10698
+ }
10699
+ if (why !== "kept")
10584
10700
  continue;
10585
10701
  const t = estimateTokens(row.text);
10586
- if (tokens + t > maxTokens && chunks.length > 0)
10587
- continue;
10588
- perItem.set(row.item_id, used + 1);
10702
+ perItem.set(row.item_id, (perItem.get(row.item_id) ?? 0) + 1);
10589
10703
  tokens += t;
10704
+ kept++;
10590
10705
  chunks.push({
10591
10706
  chunkId: row.id,
10592
10707
  score,
@@ -10598,8 +10713,8 @@ async function retrieve(store, embedder, query, options = {}) {
10598
10713
  text: row.text,
10599
10714
  tokens: t
10600
10715
  });
10601
- if (tokens >= maxTokens)
10602
- break;
10716
+ if (tokens >= neighbourBudget)
10717
+ cut = true;
10603
10718
  }
10604
10719
  const durationMs = Date.now() - started;
10605
10720
  store.logContext({
@@ -10609,7 +10724,28 @@ async function retrieve(store, embedder, query, options = {}) {
10609
10724
  tokensEst: tokens,
10610
10725
  durationMs
10611
10726
  });
10612
- return { query, chunks, tokensEst: tokens, durationMs };
10727
+ return { query, chunks, tokensEst: tokens, durationMs, ...options.debug ? { debug } : {} };
10728
+ }
10729
+ function rankIn(list, id) {
10730
+ const i = list.indexOf(id);
10731
+ return i === -1 ? null : i + 1;
10732
+ }
10733
+ function formatDebug(result) {
10734
+ if (!result.debug)
10735
+ return "";
10736
+ const lines = [" vec fts factor score keep where"];
10737
+ for (const d of result.debug) {
10738
+ const where = [d.relPath, ...d.headingPath].join(" \u203A ").slice(0, 70);
10739
+ lines.push([
10740
+ String(d.vecRank ?? "-").padStart(5),
10741
+ String(d.ftsRank ?? "-").padStart(4),
10742
+ d.factor.toFixed(2).padStart(7),
10743
+ (Number.isFinite(d.score) ? d.score.toFixed(4) : "pin").padStart(8),
10744
+ (d.kept ? d.why === "pinned" ? "pinned" : "yes" : d.why).padEnd(12),
10745
+ where
10746
+ ].join(" "));
10747
+ }
10748
+ return lines.join("\n");
10613
10749
  }
10614
10750
  function formatContext(result) {
10615
10751
  if (result.chunks.length === 0)
@@ -10631,7 +10767,8 @@ function projectState(store) {
10631
10767
  for (const i of items)
10632
10768
  counts[i.kind] = (counts[i.kind] ?? 0) + 1;
10633
10769
  const live = (i) => (i.kind === "ticket" || i.kind === "plan") && (i.status === "open" || i.status === "in-progress");
10634
- const open2 = items.filter(live).sort((a, b) => (b.updated_at ?? "").localeCompare(a.updated_at ?? "")).map((i) => ({ relPath: i.rel_path, name: i.name, status: i.status ?? "unknown", updatedAt: i.updated_at }));
10770
+ const rank = (i) => i.status === "in-progress" ? 0 : 1;
10771
+ const open2 = items.filter(live).sort((a, b) => rank(a) - rank(b) || a.rel_path.localeCompare(b.rel_path)).map((i) => ({ relPath: i.rel_path, name: i.name, status: i.status ?? "unknown", updatedAt: i.updated_at }));
10635
10772
  const recentlyChanged = [...items].sort((a, b) => (b.updated_at ?? "").localeCompare(a.updated_at ?? "")).slice(0, 10).map((i) => ({ relPath: i.rel_path, kind: i.kind, updatedAt: i.updated_at }));
10636
10773
  const decisions = store.db.prepare("SELECT id, created_at, title FROM decisions ORDER BY id DESC LIMIT 10").all().map((d) => ({ id: d.id, createdAt: d.created_at, title: d.title }));
10637
10774
  return { counts, open: open2, recentlyChanged, decisions };
@@ -40000,17 +40137,37 @@ async function guarded(run) {
40000
40137
  process.exit(1);
40001
40138
  }
40002
40139
  }
40003
- function rootFrom(args) {
40004
- const positional = args.filter((a) => !a.startsWith("--"));
40005
- return resolve3(positional[0] ?? process.cwd());
40140
+ var VALUE_FLAGS = /* @__PURE__ */ new Set(["--model", "--tokens"]);
40141
+ function parseArgs(args) {
40142
+ const positional = [];
40143
+ const flags = /* @__PURE__ */ new Map();
40144
+ for (let i = 0; i < args.length; i++) {
40145
+ const arg = args[i];
40146
+ if (VALUE_FLAGS.has(arg)) {
40147
+ const value = args[++i];
40148
+ if (value === void 0 || value.startsWith("--")) {
40149
+ throw new Error(`${arg} expects a value`);
40150
+ }
40151
+ flags.set(arg, value);
40152
+ } else if (arg.startsWith("--")) {
40153
+ flags.set(arg, "true");
40154
+ } else {
40155
+ positional.push(arg);
40156
+ }
40157
+ }
40158
+ return { positional, flags };
40006
40159
  }
40007
- function flag(args, name) {
40008
- const i = args.indexOf(name);
40009
- return i >= 0 ? args[i + 1] : void 0;
40160
+ function tokensFlag(flags) {
40161
+ const raw2 = flags.get("--tokens");
40162
+ if (raw2 === void 0) return void 0;
40163
+ const n = Number(raw2);
40164
+ if (!Number.isInteger(n) || n < 200) throw new Error(`--tokens expects an integer \u2265 200, got "${raw2}"`);
40165
+ return n;
40010
40166
  }
40011
40167
  async function runIndexCommand(args) {
40012
- const root = rootFrom(args);
40013
- const embedder = ollamaEmbedder({ model: flag(args, "--model") });
40168
+ const { positional, flags } = parseArgs(args);
40169
+ const root = resolve3(positional[0] ?? process.cwd());
40170
+ const embedder = ollamaEmbedder({ model: flags.get("--model") });
40014
40171
  console.error(`capsa: indexing ${root} with ${embedder.model} \u2026`);
40015
40172
  const res = await indexProject(root, embedder, (p) => {
40016
40173
  if (p.phase === "rebuild") console.error("capsa: index schema changed, rebuilding from scratch \u2026");
@@ -40023,19 +40180,25 @@ async function runIndexCommand(args) {
40023
40180
  for (const i of res.issues.slice(0, 10)) console.error(` ! ${i.path}: ${i.message}`);
40024
40181
  }
40025
40182
  async function runSearchCommand(args) {
40026
- const positional = args.filter((a) => !a.startsWith("--"));
40183
+ const { positional, flags } = parseArgs(args);
40027
40184
  const query = positional[0];
40028
40185
  if (!query) {
40029
- console.error("capsa search <query> [path]");
40186
+ console.error("capsa search <query> [path] [--tokens n] [--model name] [--debug]");
40030
40187
  process.exit(1);
40031
40188
  }
40032
40189
  const root = resolve3(positional[1] ?? process.cwd());
40033
- const embedder = ollamaEmbedder({ model: flag(args, "--model") });
40034
- const maxTokens = flag(args, "--tokens") ? Number(flag(args, "--tokens")) : void 0;
40190
+ const embedder = ollamaEmbedder({ model: flags.get("--model") });
40191
+ const maxTokens = tokensFlag(flags);
40035
40192
  const store = openStore(root, embedder);
40036
40193
  try {
40037
- const res = await retrieve(store, embedder, query, { maxTokens, source: "cli:search" });
40038
- console.log(formatContext(res));
40194
+ const debug = flags.get("--debug") === "true";
40195
+ const res = await retrieve(store, embedder, query, { maxTokens, source: "cli:search", debug });
40196
+ if (debug) {
40197
+ console.log(formatDebug(res));
40198
+ console.log("");
40199
+ } else {
40200
+ console.log(formatContext(res));
40201
+ }
40039
40202
  console.error(`
40040
40203
  ${res.chunks.length} chunks, ~${res.tokensEst} tokens, ${res.durationMs} ms`);
40041
40204
  } finally {
@@ -40043,7 +40206,7 @@ ${res.chunks.length} chunks, ~${res.tokensEst} tokens, ${res.durationMs} ms`);
40043
40206
  }
40044
40207
  }
40045
40208
  async function runStateCommand(args) {
40046
- const root = rootFrom(args);
40209
+ const root = resolve3(parseArgs(args).positional[0] ?? process.cwd());
40047
40210
  const store = openStore(root, ollamaEmbedder());
40048
40211
  try {
40049
40212
  console.log(formatState(projectState(store)));
@@ -40052,7 +40215,11 @@ async function runStateCommand(args) {
40052
40215
  }
40053
40216
  }
40054
40217
  async function runMcpCommand(args) {
40055
- await serveStdio({ projectRoot: rootFrom(args) });
40218
+ const { positional, flags } = parseArgs(args);
40219
+ await serveStdio({
40220
+ projectRoot: resolve3(positional[0] ?? process.cwd()),
40221
+ embedder: ollamaEmbedder({ model: flags.get("--model") })
40222
+ });
40056
40223
  }
40057
40224
 
40058
40225
  // src/index.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yurtsever/capsa",
3
- "version": "0.1.0-alpha.2",
3
+ "version": "0.1.0-alpha.4",
4
4
  "description": "Your project's knowledge in one capsule. Local-first project memory and cockpit for AI coding agents: indexes runbooks, tickets, plans and instruction files, serves the smallest relevant context over MCP.",
5
5
  "type": "module",
6
6
  "license": "MIT",