@yurtsever/capsa 0.1.0-alpha.0 → 0.1.0-alpha.3

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 +154 -38
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -10053,6 +10053,7 @@ function loadSqlite() {
10053
10053
  }
10054
10054
  var DB_DIR = ".capsa";
10055
10055
  var DB_FILE = "index.db";
10056
+ var SCHEMA_VERSION = 3;
10056
10057
  function dbPath(projectRoot) {
10057
10058
  return join6(projectRoot, DB_DIR, DB_FILE);
10058
10059
  }
@@ -10069,6 +10070,27 @@ var Store = class {
10069
10070
  this.db.exec("PRAGMA journal_mode = WAL");
10070
10071
  this.migrate();
10071
10072
  }
10073
+ /** True when this index was written by an older schema and must be rebuilt. */
10074
+ needsRebuild() {
10075
+ const row = this.db.prepare("SELECT value FROM meta WHERE key = 'schema'").get();
10076
+ return row?.value !== String(SCHEMA_VERSION);
10077
+ }
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
+ */
10084
+ reset() {
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();
10092
+ this.db.prepare("INSERT OR REPLACE INTO meta(key, value) VALUES ('schema', ?)").run(String(SCHEMA_VERSION));
10093
+ }
10072
10094
  migrate() {
10073
10095
  this.db.exec(`
10074
10096
  CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
@@ -10097,7 +10119,10 @@ var Store = class {
10097
10119
  );
10098
10120
  CREATE INDEX IF NOT EXISTS chunks_item ON chunks(item_id);
10099
10121
 
10100
- CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(text, tokenize = 'unicode61');
10122
+ -- porter: "enforcement" must find "Enforce". The indexed text is the
10123
+ -- chunk plus its provenance header (title, heading trail) so a section
10124
+ -- called "Acceptance criteria" still knows which ticket it belongs to.
10125
+ CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(text, tokenize = 'porter unicode61');
10101
10126
 
10102
10127
  CREATE VIRTUAL TABLE IF NOT EXISTS chunks_vec USING vec0(
10103
10128
  chunk_id INTEGER PRIMARY KEY,
@@ -10151,10 +10176,14 @@ var Store = class {
10151
10176
  status = excluded.status, updated_at = excluded.updated_at, tags = excluded.tags,
10152
10177
  indexed_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`).run(row.id, row.format, row.kind, row.name, row.path, row.rel_path, row.content_hash, row.status, row.updated_at, row.tags);
10153
10178
  }
10154
- insertChunk(itemId, idx, headingPath, text, embedding) {
10179
+ /**
10180
+ * `text` is what the user sees; `searchText` (chunk + provenance header)
10181
+ * is what FTS and the embedder see. Falls back to `text` when omitted.
10182
+ */
10183
+ insertChunk(itemId, idx, headingPath, text, embedding, searchText = text) {
10155
10184
  const res = this.db.prepare("INSERT INTO chunks (item_id, idx, heading_path, text) VALUES (?, ?, ?, ?)").run(itemId, idx, JSON.stringify(headingPath), text);
10156
10185
  const id = Number(res.lastInsertRowid);
10157
- this.db.prepare("INSERT INTO chunks_fts (rowid, text) VALUES (?, ?)").run(id, text);
10186
+ this.db.prepare("INSERT INTO chunks_fts (rowid, text) VALUES (?, ?)").run(id, searchText);
10158
10187
  this.db.prepare("INSERT INTO chunks_vec (chunk_id, embedding) VALUES (?, ?)").run(BigInt(id), new Uint8Array(embedding.buffer, embedding.byteOffset, embedding.byteLength));
10159
10188
  return id;
10160
10189
  }
@@ -10221,7 +10250,7 @@ import { relative as relative5 } from "path";
10221
10250
  import { readFile as readFile5, stat as stat3 } from "fs/promises";
10222
10251
  import { basename as basename4, dirname as dirname7, relative as relative3, sep as sep4 } from "path";
10223
10252
  var DOC_DIRS = /* @__PURE__ */ new Set(["docs", "doc", "runbooks", "runbook", "wiki", "adr", "decisions"]);
10224
- var EXCLUDED_DIRS = /* @__PURE__ */ new Set(["tickets", "issues", "plans", "node_modules"]);
10253
+ var EXCLUDED_DIRS = /* @__PURE__ */ new Set(["tickets", "issues", "todo", "backlog", "plans", "plan", "roadmap", "milestones", "milestone", "node_modules"]);
10225
10254
  function segments(relPath) {
10226
10255
  return relPath.split(/[\\/]/).filter(Boolean);
10227
10256
  }
@@ -10294,7 +10323,7 @@ function firstHeading(raw2) {
10294
10323
  import { readFile as readFile6, stat as stat4 } from "fs/promises";
10295
10324
  import { basename as basename5, dirname as dirname8, relative as relative4, sep as sep5 } from "path";
10296
10325
  var TICKET_DIRS = /* @__PURE__ */ new Set(["tickets", "issues", "todo", "backlog"]);
10297
- var PLAN_DIRS = /* @__PURE__ */ new Set(["plans", "plan", "roadmap"]);
10326
+ var PLAN_DIRS = /* @__PURE__ */ new Set(["plans", "plan", "roadmap", "milestones", "milestone"]);
10298
10327
  var CHECKBOX = /^\s*[-*+]\s+\[( |x|X)\]\s+/gm;
10299
10328
  function segments2(relPath) {
10300
10329
  return relPath.split(/[\\/]/).filter(Boolean);
@@ -10414,6 +10443,10 @@ async function indexProject(projectRoot, embedder, onProgress) {
10414
10443
  const started = Date.now();
10415
10444
  const store = openStore(projectRoot, embedder);
10416
10445
  try {
10446
+ if (store.needsRebuild()) {
10447
+ onProgress?.({ phase: "rebuild" });
10448
+ store.reset();
10449
+ }
10417
10450
  onProgress?.({ phase: "scan" });
10418
10451
  const result = await scan(knowledgeAdapters(), { roots: [projectRoot] });
10419
10452
  const items = result.items.filter((i) => i.scope === "project" && isInside(projectRoot, i.path));
@@ -10431,8 +10464,8 @@ async function indexProject(projectRoot, embedder, onProgress) {
10431
10464
  if (existing)
10432
10465
  store.deleteItem(existing.id);
10433
10466
  const chunks = chunkMarkdown(item.content);
10434
- const texts = chunks.map((c) => withContext(item, c.headingPath, c.text));
10435
- const vectors = texts.length ? await embedder.embed(texts) : [];
10467
+ const searchTexts = chunks.map((c) => withContext(item, c.headingPath, c.text));
10468
+ const vectors = searchTexts.length ? await embedder.embed(searchTexts) : [];
10436
10469
  const k = item.knowledge;
10437
10470
  store.upsertItem({
10438
10471
  id: item.id,
@@ -10446,7 +10479,7 @@ async function indexProject(projectRoot, embedder, onProgress) {
10446
10479
  updated_at: k?.updatedAt ?? null,
10447
10480
  tags: k?.tags ? JSON.stringify(k.tags) : null
10448
10481
  });
10449
- chunks.forEach((c, i) => store.insertChunk(item.id, c.index, c.headingPath, c.text, vectors[i]));
10482
+ chunks.forEach((c, i) => store.insertChunk(item.id, c.index, c.headingPath, c.text, vectors[i], searchTexts[i]));
10450
10483
  indexed++;
10451
10484
  onProgress?.({ phase: "embed", scanned: items.length, indexed, skipped });
10452
10485
  }
@@ -10503,6 +10536,7 @@ var DEFAULT_WEIGHTS = {
10503
10536
  plan: 1.05,
10504
10537
  ticket: 1,
10505
10538
  runbook: 1,
10539
+ doc: 0.95,
10506
10540
  wiki: 0.95,
10507
10541
  commit: 0.85
10508
10542
  }
@@ -10518,7 +10552,9 @@ function recencyFactor(updatedAt, w, now = Date.now()) {
10518
10552
  return w.recencyFloor + (1 - w.recencyFloor) * decay;
10519
10553
  }
10520
10554
  function signalFactor(s, w = DEFAULT_WEIGHTS, now = Date.now()) {
10521
- return recencyFactor(s.updatedAt, w, now) * w.status[s.status ?? "unknown"] * w.kind[s.kind];
10555
+ const status = w.status[s.status ?? "unknown"] ?? 1;
10556
+ const kind = w.kind[s.kind] ?? 1;
10557
+ return recencyFactor(s.updatedAt, w, now) * status * kind;
10522
10558
  }
10523
10559
  function rrf(lists, k = DEFAULT_WEIGHTS.rrfK) {
10524
10560
  const scores = /* @__PURE__ */ new Map();
@@ -10536,27 +10572,54 @@ async function retrieve(store, embedder, query, options = {}) {
10536
10572
  const maxTokens = options.maxTokens ?? 1500;
10537
10573
  const candidates = options.candidates ?? 30;
10538
10574
  const weights = options.weights ?? DEFAULT_WEIGHTS;
10575
+ const minRelative = options.minRelativeScore ?? 0.5;
10539
10576
  const [qv] = await embedder.embed([query]);
10540
10577
  const vec = qv ? store.vectorSearch(qv, candidates).map((r) => r.chunkId) : [];
10541
10578
  const fts = store.textSearch(query, candidates).map((r) => r.chunkId);
10542
10579
  const fused = rrf([vec, fts], weights.rrfK);
10543
10580
  const rows = store.chunksWithItems([...fused.keys()]);
10544
10581
  const now = Date.now();
10545
- const scored = rows.map((r) => ({
10546
- row: r,
10547
- score: (fused.get(r.id) ?? 0) * signalFactor({ kind: r.item.kind, status: r.item.status, updatedAt: r.item.updated_at }, weights, now)
10548
- })).sort((a, b) => b.score - a.score);
10582
+ const scored = rows.map((r) => {
10583
+ const fusedScore = fused.get(r.id) ?? 0;
10584
+ const factor = signalFactor({ kind: r.item.kind, status: r.item.status, updatedAt: r.item.updated_at }, weights, now);
10585
+ return { row: r, fusedScore, factor, score: fusedScore * factor };
10586
+ }).sort((a, b) => b.score - a.score);
10549
10587
  const perItem = /* @__PURE__ */ new Map();
10550
10588
  const chunks = [];
10589
+ const debug = [];
10551
10590
  let tokens = 0;
10552
- for (const { row, score } of scored) {
10553
- const used = perItem.get(row.item_id) ?? 0;
10554
- if (used >= 2)
10591
+ const best = scored[0]?.score ?? 0;
10592
+ let cut = false;
10593
+ for (const { row, fusedScore, factor, score } of scored) {
10594
+ let why = "kept";
10595
+ if (cut || chunks.length > 0 && score < best * minRelative) {
10596
+ cut = true;
10597
+ why = "cutoff";
10598
+ } else if ((perItem.get(row.item_id) ?? 0) >= 2) {
10599
+ why = "per-item cap";
10600
+ } else if (tokens + estimateTokens(row.text) > maxTokens && chunks.length > 0) {
10601
+ why = "budget";
10602
+ }
10603
+ if (options.debug) {
10604
+ debug.push({
10605
+ chunkId: row.id,
10606
+ relPath: row.item.rel_path,
10607
+ headingPath: JSON.parse(row.heading_path),
10608
+ kind: row.item.kind,
10609
+ status: row.item.status,
10610
+ vecRank: rankIn(vec, row.id),
10611
+ ftsRank: rankIn(fts, row.id),
10612
+ fused: fusedScore,
10613
+ factor,
10614
+ score,
10615
+ kept: why === "kept",
10616
+ why
10617
+ });
10618
+ }
10619
+ if (why !== "kept")
10555
10620
  continue;
10556
10621
  const t = estimateTokens(row.text);
10557
- if (tokens + t > maxTokens && chunks.length > 0)
10558
- continue;
10559
- perItem.set(row.item_id, used + 1);
10622
+ perItem.set(row.item_id, (perItem.get(row.item_id) ?? 0) + 1);
10560
10623
  tokens += t;
10561
10624
  chunks.push({
10562
10625
  chunkId: row.id,
@@ -10570,7 +10633,7 @@ async function retrieve(store, embedder, query, options = {}) {
10570
10633
  tokens: t
10571
10634
  });
10572
10635
  if (tokens >= maxTokens)
10573
- break;
10636
+ cut = true;
10574
10637
  }
10575
10638
  const durationMs = Date.now() - started;
10576
10639
  store.logContext({
@@ -10580,7 +10643,28 @@ async function retrieve(store, embedder, query, options = {}) {
10580
10643
  tokensEst: tokens,
10581
10644
  durationMs
10582
10645
  });
10583
- return { query, chunks, tokensEst: tokens, durationMs };
10646
+ return { query, chunks, tokensEst: tokens, durationMs, ...options.debug ? { debug } : {} };
10647
+ }
10648
+ function rankIn(list, id) {
10649
+ const i = list.indexOf(id);
10650
+ return i === -1 ? null : i + 1;
10651
+ }
10652
+ function formatDebug(result) {
10653
+ if (!result.debug)
10654
+ return "";
10655
+ const lines = [" vec fts factor score keep where"];
10656
+ for (const d of result.debug) {
10657
+ const where = [d.relPath, ...d.headingPath].join(" \u203A ").slice(0, 70);
10658
+ lines.push([
10659
+ String(d.vecRank ?? "-").padStart(5),
10660
+ String(d.ftsRank ?? "-").padStart(4),
10661
+ d.factor.toFixed(2).padStart(7),
10662
+ d.score.toFixed(4).padStart(8),
10663
+ (d.kept ? "yes" : d.why).padEnd(12),
10664
+ where
10665
+ ].join(" "));
10666
+ }
10667
+ return lines.join("\n");
10584
10668
  }
10585
10669
  function formatContext(result) {
10586
10670
  if (result.chunks.length === 0)
@@ -10602,7 +10686,8 @@ function projectState(store) {
10602
10686
  for (const i of items)
10603
10687
  counts[i.kind] = (counts[i.kind] ?? 0) + 1;
10604
10688
  const live = (i) => (i.kind === "ticket" || i.kind === "plan") && (i.status === "open" || i.status === "in-progress");
10605
- 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 }));
10689
+ const rank = (i) => i.status === "in-progress" ? 0 : 1;
10690
+ 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 }));
10606
10691
  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 }));
10607
10692
  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 }));
10608
10693
  return { counts, open: open2, recentlyChanged, decisions };
@@ -39971,19 +40056,40 @@ async function guarded(run) {
39971
40056
  process.exit(1);
39972
40057
  }
39973
40058
  }
39974
- function rootFrom(args) {
39975
- const positional = args.filter((a) => !a.startsWith("--"));
39976
- return resolve3(positional[0] ?? process.cwd());
40059
+ var VALUE_FLAGS = /* @__PURE__ */ new Set(["--model", "--tokens"]);
40060
+ function parseArgs(args) {
40061
+ const positional = [];
40062
+ const flags = /* @__PURE__ */ new Map();
40063
+ for (let i = 0; i < args.length; i++) {
40064
+ const arg = args[i];
40065
+ if (VALUE_FLAGS.has(arg)) {
40066
+ const value = args[++i];
40067
+ if (value === void 0 || value.startsWith("--")) {
40068
+ throw new Error(`${arg} expects a value`);
40069
+ }
40070
+ flags.set(arg, value);
40071
+ } else if (arg.startsWith("--")) {
40072
+ flags.set(arg, "true");
40073
+ } else {
40074
+ positional.push(arg);
40075
+ }
40076
+ }
40077
+ return { positional, flags };
39977
40078
  }
39978
- function flag(args, name) {
39979
- const i = args.indexOf(name);
39980
- return i >= 0 ? args[i + 1] : void 0;
40079
+ function tokensFlag(flags) {
40080
+ const raw2 = flags.get("--tokens");
40081
+ if (raw2 === void 0) return void 0;
40082
+ const n = Number(raw2);
40083
+ if (!Number.isInteger(n) || n < 200) throw new Error(`--tokens expects an integer \u2265 200, got "${raw2}"`);
40084
+ return n;
39981
40085
  }
39982
40086
  async function runIndexCommand(args) {
39983
- const root = rootFrom(args);
39984
- const embedder = ollamaEmbedder({ model: flag(args, "--model") });
40087
+ const { positional, flags } = parseArgs(args);
40088
+ const root = resolve3(positional[0] ?? process.cwd());
40089
+ const embedder = ollamaEmbedder({ model: flags.get("--model") });
39985
40090
  console.error(`capsa: indexing ${root} with ${embedder.model} \u2026`);
39986
40091
  const res = await indexProject(root, embedder, (p) => {
40092
+ if (p.phase === "rebuild") console.error("capsa: index schema changed, rebuilding from scratch \u2026");
39987
40093
  if (p.phase === "embed") process.stderr.write(`\r embedded ${p.indexed} / ${p.scanned} (skipped ${p.skipped}) `);
39988
40094
  });
39989
40095
  process.stderr.write("\n");
@@ -39993,19 +40099,25 @@ async function runIndexCommand(args) {
39993
40099
  for (const i of res.issues.slice(0, 10)) console.error(` ! ${i.path}: ${i.message}`);
39994
40100
  }
39995
40101
  async function runSearchCommand(args) {
39996
- const positional = args.filter((a) => !a.startsWith("--"));
40102
+ const { positional, flags } = parseArgs(args);
39997
40103
  const query = positional[0];
39998
40104
  if (!query) {
39999
- console.error("capsa search <query> [path]");
40105
+ console.error("capsa search <query> [path] [--tokens n] [--model name] [--debug]");
40000
40106
  process.exit(1);
40001
40107
  }
40002
40108
  const root = resolve3(positional[1] ?? process.cwd());
40003
- const embedder = ollamaEmbedder({ model: flag(args, "--model") });
40004
- const maxTokens = flag(args, "--tokens") ? Number(flag(args, "--tokens")) : void 0;
40109
+ const embedder = ollamaEmbedder({ model: flags.get("--model") });
40110
+ const maxTokens = tokensFlag(flags);
40005
40111
  const store = openStore(root, embedder);
40006
40112
  try {
40007
- const res = await retrieve(store, embedder, query, { maxTokens, source: "cli:search" });
40008
- console.log(formatContext(res));
40113
+ const debug = flags.get("--debug") === "true";
40114
+ const res = await retrieve(store, embedder, query, { maxTokens, source: "cli:search", debug });
40115
+ if (debug) {
40116
+ console.log(formatDebug(res));
40117
+ console.log("");
40118
+ } else {
40119
+ console.log(formatContext(res));
40120
+ }
40009
40121
  console.error(`
40010
40122
  ${res.chunks.length} chunks, ~${res.tokensEst} tokens, ${res.durationMs} ms`);
40011
40123
  } finally {
@@ -40013,7 +40125,7 @@ ${res.chunks.length} chunks, ~${res.tokensEst} tokens, ${res.durationMs} ms`);
40013
40125
  }
40014
40126
  }
40015
40127
  async function runStateCommand(args) {
40016
- const root = rootFrom(args);
40128
+ const root = resolve3(parseArgs(args).positional[0] ?? process.cwd());
40017
40129
  const store = openStore(root, ollamaEmbedder());
40018
40130
  try {
40019
40131
  console.log(formatState(projectState(store)));
@@ -40022,7 +40134,11 @@ async function runStateCommand(args) {
40022
40134
  }
40023
40135
  }
40024
40136
  async function runMcpCommand(args) {
40025
- await serveStdio({ projectRoot: rootFrom(args) });
40137
+ const { positional, flags } = parseArgs(args);
40138
+ await serveStdio({
40139
+ projectRoot: resolve3(positional[0] ?? process.cwd()),
40140
+ embedder: ollamaEmbedder({ model: flags.get("--model") })
40141
+ });
40026
40142
  }
40027
40143
 
40028
40144
  // src/index.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yurtsever/capsa",
3
- "version": "0.1.0-alpha.0",
3
+ "version": "0.1.0-alpha.3",
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",