@yurtsever/capsa 0.1.0-alpha.5 → 0.1.0-alpha.7

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 +446 -102
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -7415,6 +7415,7 @@ function findProjectRoot(startDir, homeDir) {
7415
7415
  // ../core/dist/chunk.js
7416
7416
  var DEFAULT_MAX_CHARS = 2e3;
7417
7417
  function chunkMarkdown(markdown, maxChars = DEFAULT_MAX_CHARS) {
7418
+ const body = parseFrontmatter(markdown).body;
7418
7419
  const chunks = [];
7419
7420
  const trail = [];
7420
7421
  let buffer = [];
@@ -7424,11 +7425,12 @@ function chunkMarkdown(markdown, maxChars = DEFAULT_MAX_CHARS) {
7424
7425
  buffer = [];
7425
7426
  if (!text)
7426
7427
  return;
7428
+ const headingPath = trail.filter((s) => Boolean(s));
7427
7429
  for (const part of splitLong(text, maxChars)) {
7428
- chunks.push({ index: chunks.length, headingPath: [...trail], text: part });
7430
+ chunks.push({ index: chunks.length, headingPath, text: part });
7429
7431
  }
7430
7432
  };
7431
- for (const line of markdown.split(/\r?\n/)) {
7433
+ for (const line of body.split(/\r?\n/)) {
7432
7434
  if (/^```/.test(line.trim()))
7433
7435
  inFence = !inFence;
7434
7436
  const heading = !inFence && /^(#{1,6})\s+(.*\S)\s*$/.exec(line);
@@ -10072,7 +10074,32 @@ function loadSqlite() {
10072
10074
  }
10073
10075
  var DB_DIR = ".capsa";
10074
10076
  var DB_FILE = "index.db";
10075
- var SCHEMA_VERSION = 3;
10077
+ var SCHEMA_VERSION = 7;
10078
+ var META_INDEXED_AT = "indexed_at";
10079
+ var META_INDEX_FAILED_AT = "index_failed_at";
10080
+ var META_INDEX_ERROR = "index_error";
10081
+ var CONTEXT_LOG_DDL = `
10082
+ -- Every context delivery. This is the measurement for the MVP gate and
10083
+ -- the seed of the organisation tier's audit log. Alone among these
10084
+ -- tables its rows are never dropped, so a row has to stand on its own:
10085
+ -- it records what was delivered (path, heading trail, tokens per chunk)
10086
+ -- rather than chunk ids, which a rebuild hands to different text.
10087
+ CREATE TABLE IF NOT EXISTS context_log (
10088
+ id INTEGER PRIMARY KEY,
10089
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
10090
+ source TEXT NOT NULL,
10091
+ query TEXT NOT NULL,
10092
+ delivered TEXT NOT NULL,
10093
+ tokens_est INTEGER NOT NULL,
10094
+ -- The ceiling this call ran under. Without it a row says what was
10095
+ -- spent but not what it was allowed to spend, and two deliveries are
10096
+ -- only comparable when that number matches. NULL on rows written
10097
+ -- before v6: they ran under a budget nobody recorded, and filling in
10098
+ -- today's default would be a guess wearing the costume of data.
10099
+ tokens_budget INTEGER,
10100
+ duration_ms INTEGER NOT NULL
10101
+ );
10102
+ `;
10076
10103
  function dbPath(projectRoot) {
10077
10104
  return join6(projectRoot, DB_DIR, DB_FILE);
10078
10105
  }
@@ -10091,24 +10118,44 @@ var Store = class {
10091
10118
  }
10092
10119
  /** True when this index was written by an older schema and must be rebuilt. */
10093
10120
  needsRebuild() {
10094
- const row = this.db.prepare("SELECT value FROM meta WHERE key = 'schema'").get();
10095
- return row?.value !== String(SCHEMA_VERSION);
10121
+ return this.getMeta("schema") !== String(SCHEMA_VERSION);
10122
+ }
10123
+ getMeta(key) {
10124
+ const row = this.db.prepare("SELECT value FROM meta WHERE key = ?").get(key);
10125
+ return row?.value;
10126
+ }
10127
+ setMeta(key, value) {
10128
+ this.db.prepare("INSERT OR REPLACE INTO meta(key, value) VALUES (?, ?)").run(key, value);
10129
+ }
10130
+ deleteMeta(key) {
10131
+ this.db.prepare("DELETE FROM meta WHERE key = ?").run(key);
10096
10132
  }
10097
10133
  /**
10098
10134
  * Drop all indexed content (not the log, not decisions) and mark the schema
10099
- * current. The virtual tables are dropped and recreated, not emptied:
10100
- * `CREATE VIRTUAL TABLE IF NOT EXISTS` would keep an old tokenizer alive
10101
- * through a rebuild which once left FTS without stemming after v2.
10135
+ * current. Every one of these tables is dropped and recreated, not emptied,
10136
+ * because `CREATE TABLE IF NOT EXISTS` in migrate() is a no-op against a
10137
+ * table that already exists under its old shape. For the virtual tables that
10138
+ * once left FTS without stemming after v2; for `items` it meant a schema
10139
+ * bump could not add a column at all — v7 added `title` and the rebuild it
10140
+ * asked for failed with "table items has no column named title", because
10141
+ * DELETE had emptied the v6 table and left it standing.
10142
+ *
10143
+ * Nothing here is a source of truth: items and chunks are derived from files
10144
+ * on disk and from `decisions`, which is one of the two tables a reset
10145
+ * spares.
10102
10146
  */
10103
10147
  reset() {
10104
10148
  this.db.exec(`
10105
10149
  DROP TABLE IF EXISTS chunks_fts;
10106
10150
  DROP TABLE IF EXISTS chunks_vec;
10107
- DELETE FROM chunks;
10108
- DELETE FROM items;
10151
+ DROP TABLE IF EXISTS chunks;
10152
+ DROP TABLE IF EXISTS items;
10109
10153
  `);
10110
10154
  this.migrate();
10111
- this.db.prepare("INSERT OR REPLACE INTO meta(key, value) VALUES ('schema', ?)").run(String(SCHEMA_VERSION));
10155
+ this.setMeta("schema", String(SCHEMA_VERSION));
10156
+ this.deleteMeta(META_INDEXED_AT);
10157
+ this.deleteMeta(META_INDEX_FAILED_AT);
10158
+ this.deleteMeta(META_INDEX_ERROR);
10112
10159
  }
10113
10160
  migrate() {
10114
10161
  this.db.exec(`
@@ -10119,6 +10166,7 @@ var Store = class {
10119
10166
  format TEXT NOT NULL,
10120
10167
  kind TEXT NOT NULL,
10121
10168
  name TEXT NOT NULL,
10169
+ title TEXT NOT NULL,
10122
10170
  path TEXT NOT NULL UNIQUE,
10123
10171
  rel_path TEXT NOT NULL,
10124
10172
  content_hash TEXT NOT NULL,
@@ -10158,19 +10206,44 @@ var Store = class {
10158
10206
  related TEXT
10159
10207
  );
10160
10208
 
10161
- -- Every context delivery. This is the measurement for the MVP gate and
10162
- -- the seed of the organisation tier's audit log.
10163
- CREATE TABLE IF NOT EXISTS context_log (
10164
- id INTEGER PRIMARY KEY,
10165
- created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
10166
- source TEXT NOT NULL,
10167
- query TEXT NOT NULL,
10168
- chunk_ids TEXT NOT NULL,
10169
- tokens_est INTEGER NOT NULL,
10170
- duration_ms INTEGER NOT NULL
10171
- );
10209
+ ${CONTEXT_LOG_DDL}
10210
+ `);
10211
+ this.migrateContextLog();
10212
+ this.setMeta("dimensions", String(this.dimensions));
10213
+ }
10214
+ /**
10215
+ * `context_log` outlives every rebuild, so it is the one table that has to
10216
+ * be migrated in place instead of recreated — twice now.
10217
+ *
10218
+ * v5 replaced `chunk_ids` with `delivered`: a chunk id is a rowid the next
10219
+ * `capsa index` reassigns, so an older row ended up naming whatever text
10220
+ * inherited its ids — the measurement quietly decayed. Rows written before
10221
+ * v5 keep everything that still means something (when, who asked, what for,
10222
+ * at what cost) and carry an empty delivery, because their ids can no longer
10223
+ * be resolved honestly.
10224
+ *
10225
+ * v6 added `tokens_budget`. A delivery of 1727 tokens against one of 758
10226
+ * reads like a saving until you learn the first ran on a larger ceiling;
10227
+ * that comparison was attempted on this repo's own log and could not be
10228
+ * settled, because the number was never written down. Old rows get NULL
10229
+ * rather than a backfilled default — an unknown budget is a fact about the
10230
+ * measurement, and inventing one would hide exactly the gap this closes.
10231
+ *
10232
+ * Keyed on the newest column, so a v4 database reaches v6 in one pass.
10233
+ */
10234
+ migrateContextLog() {
10235
+ const columns = this.db.prepare("PRAGMA table_info(context_log)").all();
10236
+ const names = new Set(columns.map((c) => c.name));
10237
+ if (names.has("tokens_budget"))
10238
+ return;
10239
+ const delivered = names.has("delivered") ? "delivered" : "'[]'";
10240
+ this.db.exec(`
10241
+ ALTER TABLE context_log RENAME TO context_log_old;
10242
+ ${CONTEXT_LOG_DDL}
10243
+ INSERT INTO context_log (id, created_at, source, query, delivered, tokens_est, tokens_budget, duration_ms)
10244
+ SELECT id, created_at, source, query, ${delivered}, tokens_est, NULL, duration_ms FROM context_log_old;
10245
+ DROP TABLE context_log_old;
10172
10246
  `);
10173
- this.db.prepare("INSERT OR REPLACE INTO meta(key, value) VALUES ('dimensions', ?)").run(String(this.dimensions));
10174
10247
  }
10175
10248
  getItem(path) {
10176
10249
  return this.db.prepare("SELECT * FROM items WHERE path = ?").get(path);
@@ -10187,13 +10260,14 @@ var Store = class {
10187
10260
  this.db.prepare("DELETE FROM items WHERE id = ?").run(id);
10188
10261
  }
10189
10262
  upsertItem(row) {
10190
- this.db.prepare(`INSERT INTO items (id, format, kind, name, path, rel_path, content_hash, status, updated_at, tags)
10191
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
10263
+ this.db.prepare(`INSERT INTO items (id, format, kind, name, title, path, rel_path, content_hash, status, updated_at, tags)
10264
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
10192
10265
  ON CONFLICT(id) DO UPDATE SET
10193
10266
  format = excluded.format, kind = excluded.kind, name = excluded.name,
10267
+ title = excluded.title,
10194
10268
  rel_path = excluded.rel_path, content_hash = excluded.content_hash,
10195
10269
  status = excluded.status, updated_at = excluded.updated_at, tags = excluded.tags,
10196
- 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);
10270
+ indexed_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`).run(row.id, row.format, row.kind, row.name, row.title, row.path, row.rel_path, row.content_hash, row.status, row.updated_at, row.tags);
10197
10271
  }
10198
10272
  /**
10199
10273
  * `text` is what the user sees; `searchText` (chunk + provenance header)
@@ -10224,7 +10298,7 @@ var Store = class {
10224
10298
  return [];
10225
10299
  const placeholders = ids.map(() => "?").join(",");
10226
10300
  const rows = this.db.prepare(`SELECT c.id, c.item_id, c.idx, c.heading_path, c.text,
10227
- i.id AS i_id, i.format, i.kind, i.name, i.path, i.rel_path, i.content_hash, i.status, i.updated_at, i.tags
10301
+ i.id AS i_id, i.format, i.kind, i.name, i.title, i.path, i.rel_path, i.content_hash, i.status, i.updated_at, i.tags
10228
10302
  FROM chunks c JOIN items i ON i.id = c.item_id WHERE c.id IN (${placeholders})`).all(...ids);
10229
10303
  return rows.map((r) => ({
10230
10304
  id: r["id"],
@@ -10237,6 +10311,7 @@ var Store = class {
10237
10311
  format: r["format"],
10238
10312
  kind: r["kind"],
10239
10313
  name: r["name"],
10314
+ title: r["title"],
10240
10315
  path: r["path"],
10241
10316
  rel_path: r["rel_path"],
10242
10317
  content_hash: r["content_hash"],
@@ -10254,11 +10329,22 @@ var Store = class {
10254
10329
  return this.db.prepare("SELECT * FROM items ORDER BY kind, rel_path").all();
10255
10330
  }
10256
10331
  logContext(entry) {
10257
- this.db.prepare("INSERT INTO context_log (source, query, chunk_ids, tokens_est, duration_ms) VALUES (?, ?, ?, ?, ?)").run(entry.source, entry.query, JSON.stringify(entry.chunkIds), entry.tokensEst, entry.durationMs);
10332
+ this.db.prepare("INSERT INTO context_log (source, query, delivered, tokens_est, tokens_budget, duration_ms) VALUES (?, ?, ?, ?, ?, ?)").run(entry.source, entry.query, JSON.stringify(entry.delivered), entry.tokensEst, entry.tokensBudget, entry.durationMs);
10258
10333
  }
10334
+ /**
10335
+ * Returns the stored row, not just its id: `created_at` is SQL's to write,
10336
+ * and the indexed item dates itself from it.
10337
+ */
10259
10338
  recordDecision(title, body, related) {
10260
10339
  const res = this.db.prepare("INSERT INTO decisions (title, body, related) VALUES (?, ?, ?)").run(title, body, related ? JSON.stringify(related) : null);
10261
- return Number(res.lastInsertRowid);
10340
+ return this.decision(Number(res.lastInsertRowid));
10341
+ }
10342
+ decision(id) {
10343
+ return this.db.prepare("SELECT * FROM decisions WHERE id = ?").get(id);
10344
+ }
10345
+ /** Every decision, oldest first. The rows survive `reset()`; the items do not. */
10346
+ listDecisions() {
10347
+ return this.db.prepare("SELECT * FROM decisions ORDER BY id").all();
10262
10348
  }
10263
10349
  close() {
10264
10350
  this.db.close();
@@ -10604,6 +10690,66 @@ function firstHeading2(raw2) {
10604
10690
  return void 0;
10605
10691
  }
10606
10692
 
10693
+ // ../index/dist/decision.js
10694
+ import { join as join8 } from "path";
10695
+ var ITEM_DIR2 = join8(".capsa", "decisions");
10696
+ function decisionPath(projectRoot, id) {
10697
+ return join8(projectRoot, ITEM_DIR2, `decision-${id}.md`);
10698
+ }
10699
+ function decisionItem(projectRoot, row) {
10700
+ const path = decisionPath(projectRoot, row.id);
10701
+ const related = parseRelated(row.related);
10702
+ return {
10703
+ id: stableId(path),
10704
+ format: "decision",
10705
+ formatLabel: "Decision",
10706
+ name: row.title,
10707
+ // The description becomes the provenance line of every embedded chunk.
10708
+ description: row.title,
10709
+ path,
10710
+ scope: "project",
10711
+ projectRoot,
10712
+ content: renderDecision(row, related),
10713
+ metadata: { decisionId: row.id, author: row.author, createdAt: row.created_at, related },
10714
+ knowledge: {
10715
+ kind: "decision",
10716
+ // Written once, never edited: the day it was made is the day it last
10717
+ // changed, so recency ranks it from `created_at`.
10718
+ updatedAt: row.created_at,
10719
+ // No lifecycle to report. "unknown" is the neutral weight (1.0) and
10720
+ // says so out loud instead of leaving the column blank.
10721
+ status: "unknown",
10722
+ relatedPaths: related.length ? related : void 0
10723
+ }
10724
+ };
10725
+ }
10726
+ function renderDecision(row, related) {
10727
+ const lines = [
10728
+ `# ${row.title}`,
10729
+ "",
10730
+ `decision \`#${row.id}\` \xB7 ${row.author} \xB7 ${row.created_at.slice(0, 10)}`,
10731
+ "",
10732
+ row.body.trim()
10733
+ ];
10734
+ if (related.length) {
10735
+ lines.push("", "## Related", "");
10736
+ for (const p of related)
10737
+ lines.push(`- ${p}`);
10738
+ }
10739
+ return `${lines.join("\n")}
10740
+ `;
10741
+ }
10742
+ function parseRelated(json2) {
10743
+ if (!json2)
10744
+ return [];
10745
+ try {
10746
+ const parsed = JSON.parse(json2);
10747
+ return Array.isArray(parsed) ? parsed.filter((p) => typeof p === "string") : [];
10748
+ } catch {
10749
+ return [];
10750
+ }
10751
+ }
10752
+
10607
10753
  // ../index/dist/indexer.js
10608
10754
  function knowledgeAdapters() {
10609
10755
  return [
@@ -10619,70 +10765,152 @@ function openStore(projectRoot, embedder) {
10619
10765
  return new Store(dbPath(projectRoot), embedder.dimensions);
10620
10766
  }
10621
10767
  async function indexProject(projectRoot, embedder, onProgress) {
10622
- const started = Date.now();
10623
10768
  const store = openStore(projectRoot, embedder);
10624
10769
  try {
10625
- if (store.needsRebuild()) {
10626
- onProgress?.({ phase: "rebuild" });
10627
- store.reset();
10628
- }
10629
- onProgress?.({ phase: "scan" });
10630
- const result = await scan(knowledgeAdapters(), { roots: [projectRoot] });
10631
- const items = result.items.filter((i) => i.scope === "project" && isInside(projectRoot, i.path));
10632
- let indexed = 0;
10633
- let skipped = 0;
10634
- const seen = /* @__PURE__ */ new Set();
10635
- for (const item of items) {
10636
- seen.add(item.path);
10637
- const hash2 = sha1(item.content);
10638
- const existing = store.getItem(item.path);
10639
- if (existing && existing.content_hash === hash2) {
10640
- skipped++;
10641
- continue;
10642
- }
10643
- if (existing)
10644
- store.deleteItem(existing.id);
10645
- const chunks = chunkMarkdown(item.content);
10646
- const searchTexts = chunks.map((c) => withContext(item, c.headingPath, c.text));
10647
- const vectors = searchTexts.length ? await embedder.embed(searchTexts) : [];
10648
- const k = item.knowledge;
10649
- store.upsertItem({
10650
- id: item.id,
10651
- format: item.format,
10652
- kind: k?.kind ?? "instruction",
10653
- name: item.name,
10654
- path: item.path,
10655
- rel_path: relative5(projectRoot, item.path),
10656
- content_hash: hash2,
10657
- status: k?.status ?? null,
10658
- updated_at: k?.updatedAt ?? null,
10659
- tags: k?.tags ? JSON.stringify(k.tags) : null
10660
- });
10661
- chunks.forEach((c, i) => store.insertChunk(item.id, c.index, c.headingPath, c.text, vectors[i], searchTexts[i]));
10662
- indexed++;
10663
- onProgress?.({ phase: "embed", scanned: items.length, indexed, skipped });
10664
- }
10665
- let removed = 0;
10666
- for (const row of store.listItems()) {
10667
- if (!seen.has(row.path)) {
10668
- store.deleteItem(row.id);
10669
- removed++;
10670
- }
10671
- }
10672
- const out = {
10673
- scanned: items.length,
10674
- indexed,
10675
- skipped,
10676
- removed,
10677
- issues: result.issues.map((i) => ({ path: i.path, message: i.message })),
10678
- durationMs: Date.now() - started
10679
- };
10680
- onProgress?.({ phase: "done", ...out, issues: out.issues.length });
10681
- return out;
10770
+ return await indexInto(store, projectRoot, embedder, { onProgress });
10682
10771
  } finally {
10683
10772
  store.close();
10684
10773
  }
10685
10774
  }
10775
+ var TooManyChangesError = class extends Error {
10776
+ changed;
10777
+ limit;
10778
+ constructor(changed, limit) {
10779
+ super(`${changed} items changed since the last index \u2014 run \`capsa index\``);
10780
+ this.changed = changed;
10781
+ this.limit = limit;
10782
+ this.name = "TooManyChangesError";
10783
+ }
10784
+ };
10785
+ async function indexInto(store, projectRoot, embedder, options = {}) {
10786
+ const { onProgress, maxChanges } = options;
10787
+ const started = Date.now();
10788
+ if (store.needsRebuild()) {
10789
+ onProgress?.({ phase: "rebuild" });
10790
+ store.reset();
10791
+ }
10792
+ onProgress?.({ phase: "scan" });
10793
+ const result = await scan(knowledgeAdapters(), { roots: [projectRoot] });
10794
+ const items = result.items.filter((i) => i.scope === "project" && isInside(projectRoot, i.path));
10795
+ const all = [...items, ...decisionItems(store, projectRoot)];
10796
+ const { changed, skipped } = changedItems(store, all);
10797
+ if (maxChanges !== void 0 && changed.length > maxChanges) {
10798
+ throw new TooManyChangesError(changed.length, maxChanges);
10799
+ }
10800
+ let indexed = 0;
10801
+ for (const c of changed) {
10802
+ await indexItem(store, embedder, c.item, projectRoot, c.hash, c.previousId);
10803
+ indexed++;
10804
+ onProgress?.({ phase: "embed", scanned: all.length, indexed, skipped });
10805
+ }
10806
+ const seen = new Set(all.map((i) => i.path));
10807
+ let removed = 0;
10808
+ for (const row of store.listItems()) {
10809
+ if (!seen.has(row.path)) {
10810
+ store.deleteItem(row.id);
10811
+ removed++;
10812
+ }
10813
+ }
10814
+ const out = {
10815
+ scanned: all.length,
10816
+ indexed,
10817
+ skipped,
10818
+ removed,
10819
+ issues: result.issues.map((i) => ({ path: i.path, message: i.message })),
10820
+ durationMs: Date.now() - started
10821
+ };
10822
+ store.setMeta(META_INDEXED_AT, new Date(started).toISOString());
10823
+ store.deleteMeta(META_INDEX_FAILED_AT);
10824
+ store.deleteMeta(META_INDEX_ERROR);
10825
+ onProgress?.({ phase: "done", ...out, issues: out.issues.length });
10826
+ return out;
10827
+ }
10828
+ var DEFAULT_MAX_INDEX_AGE_MS = 6e4;
10829
+ var DEFAULT_MAX_REFRESH_CHANGES = 20;
10830
+ async function freshenIndex(store, projectRoot, embedder, options = {}) {
10831
+ const maxAge = options.maxAgeMs ?? DEFAULT_MAX_INDEX_AGE_MS;
10832
+ const maxChanges = options.maxChanges ?? DEFAULT_MAX_REFRESH_CHANGES;
10833
+ const now = Date.now();
10834
+ if (ageMs(store.getMeta(META_INDEXED_AT), now) <= maxAge)
10835
+ return { refreshed: false, stale: false };
10836
+ if (store.needsRebuild()) {
10837
+ return {
10838
+ refreshed: false,
10839
+ stale: true,
10840
+ reason: "this index was never built or its schema changed \u2014 run `capsa index`"
10841
+ };
10842
+ }
10843
+ if (ageMs(store.getMeta(META_INDEX_FAILED_AT), now) <= maxAge) {
10844
+ return { refreshed: false, stale: true, reason: store.getMeta(META_INDEX_ERROR) ?? "the last index run failed" };
10845
+ }
10846
+ try {
10847
+ const res = await indexInto(store, projectRoot, embedder, { maxChanges });
10848
+ return { refreshed: true, stale: false, indexed: res.indexed, removed: res.removed };
10849
+ } catch (err) {
10850
+ const reason = err instanceof Error ? err.message : String(err);
10851
+ store.setMeta(META_INDEX_FAILED_AT, (/* @__PURE__ */ new Date()).toISOString());
10852
+ store.setMeta(META_INDEX_ERROR, reason);
10853
+ return { refreshed: false, stale: true, reason };
10854
+ }
10855
+ }
10856
+ function ageMs(iso, now) {
10857
+ if (!iso)
10858
+ return Number.POSITIVE_INFINITY;
10859
+ const t = Date.parse(iso);
10860
+ return Number.isNaN(t) ? Number.POSITIVE_INFINITY : now - t;
10861
+ }
10862
+ function changedItems(store, items) {
10863
+ const changed = [];
10864
+ let skipped = 0;
10865
+ for (const item of items) {
10866
+ const hash2 = sha1(item.content);
10867
+ const existing = store.getItem(item.path);
10868
+ if (existing && existing.content_hash === hash2) {
10869
+ skipped++;
10870
+ continue;
10871
+ }
10872
+ changed.push({ item, hash: hash2, previousId: existing?.id });
10873
+ }
10874
+ return { changed, skipped };
10875
+ }
10876
+ function decisionItems(store, projectRoot) {
10877
+ return store.listDecisions().map((row) => decisionItem(projectRoot, row));
10878
+ }
10879
+ async function recordDecision(store, embedder, projectRoot, input2) {
10880
+ const row = store.recordDecision(input2.title, input2.body, input2.related);
10881
+ const item = decisionItem(projectRoot, row);
10882
+ try {
10883
+ await indexItem(store, embedder, item, projectRoot, sha1(item.content));
10884
+ return { id: row.id, indexed: true };
10885
+ } catch (err) {
10886
+ return { id: row.id, indexed: false, reason: err.message };
10887
+ }
10888
+ }
10889
+ async function indexItem(store, embedder, item, projectRoot, hash2, previousId) {
10890
+ const chunks = chunkMarkdown(item.content);
10891
+ const searchTexts = chunks.map((c) => withContext(item, c.headingPath, c.text));
10892
+ const vectors = searchTexts.length ? await embedder.embed(searchTexts) : [];
10893
+ if (previousId)
10894
+ store.deleteItem(previousId);
10895
+ const k = item.knowledge;
10896
+ store.upsertItem({
10897
+ id: item.id,
10898
+ format: item.format,
10899
+ kind: k?.kind ?? "instruction",
10900
+ name: item.name,
10901
+ // The same expression the provenance header uses (see withContext): the
10902
+ // title an item claims for itself, falling back to its name when the
10903
+ // format has none to give.
10904
+ title: item.description ?? item.name,
10905
+ path: item.path,
10906
+ rel_path: relative5(projectRoot, item.path),
10907
+ content_hash: hash2,
10908
+ status: k?.status ?? null,
10909
+ updated_at: k?.updatedAt ?? null,
10910
+ tags: k?.tags ? JSON.stringify(k.tags) : null
10911
+ });
10912
+ chunks.forEach((c, i) => store.insertChunk(item.id, c.index, c.headingPath, c.text, vectors[i], searchTexts[i]));
10913
+ }
10686
10914
  function withContext(item, headingPath, text) {
10687
10915
  const kind = item.knowledge?.kind ?? "instruction";
10688
10916
  const head = [kind, item.description ?? item.name, ...headingPath].filter(Boolean).join(" \u203A ");
@@ -10831,9 +11059,17 @@ async function retrieve(store, embedder, query, options = {}) {
10831
11059
  const pinnedIds = new Set(pinned.map((i) => i.id));
10832
11060
  const neighbourCutoff = pinned.length ? Math.max(minRelative, 0.75) : minRelative;
10833
11061
  const neighbourBudget = pinned.length ? Math.min(maxTokens, tokens + Math.floor(maxTokens / 3)) : maxTokens;
10834
- const [qv] = await embedder.embed([query]);
10835
- const vec = qv ? store.vectorSearch(qv, candidates).map((r) => r.chunkId) : [];
10836
- const fts = store.textSearch(query, candidates).map((r) => r.chunkId);
11062
+ const searchText = neighbourQuery(query, pinned);
11063
+ let vec = [];
11064
+ let degraded;
11065
+ try {
11066
+ const [qv] = await embedder.embed([searchText]);
11067
+ if (qv)
11068
+ vec = store.vectorSearch(qv, candidates).map((r) => r.chunkId);
11069
+ } catch (err) {
11070
+ degraded = err instanceof Error ? err.message : String(err);
11071
+ }
11072
+ const fts = store.textSearch(searchText, candidates).map((r) => r.chunkId);
10837
11073
  const fused = rrf([vec, fts], weights.rrfK);
10838
11074
  const rows = store.chunksWithItems([...fused.keys()]).filter((r) => !pinnedIds.has(r.item_id));
10839
11075
  const now = Date.now();
@@ -10842,10 +11078,33 @@ async function retrieve(store, embedder, query, options = {}) {
10842
11078
  const factor = signalFactor({ kind: r.item.kind, status: r.item.status, updatedAt: r.item.updated_at }, weights, now);
10843
11079
  return { row: r, fusedScore, factor, score: fusedScore * factor };
10844
11080
  }).sort((a, b) => b.score - a.score);
10845
- const best = scored[0]?.score ?? 0;
11081
+ const neighbourhood = [];
11082
+ for (const c of scored) {
11083
+ if (options.excludeRootInstructions && isRootInstruction(c.row.item.rel_path)) {
11084
+ if (options.debug) {
11085
+ debug.push({
11086
+ chunkId: c.row.id,
11087
+ relPath: c.row.item.rel_path,
11088
+ headingPath: JSON.parse(c.row.heading_path),
11089
+ kind: c.row.item.kind,
11090
+ status: c.row.item.status,
11091
+ vecRank: rankIn(vec, c.row.id),
11092
+ ftsRank: rankIn(fts, c.row.id),
11093
+ fused: c.fusedScore,
11094
+ factor: c.factor,
11095
+ score: c.score,
11096
+ kept: false,
11097
+ why: "in prompt"
11098
+ });
11099
+ }
11100
+ continue;
11101
+ }
11102
+ neighbourhood.push(c);
11103
+ }
11104
+ const best = neighbourhood[0]?.score ?? 0;
10846
11105
  let cut = tokens >= maxTokens;
10847
11106
  let kept = 0;
10848
- for (const { row, fusedScore, factor, score } of scored) {
11107
+ for (const { row, fusedScore, factor, score } of neighbourhood) {
10849
11108
  let why = "kept";
10850
11109
  if (cut || kept > 0 && score < best * neighbourCutoff) {
10851
11110
  cut = true;
@@ -10893,13 +11152,37 @@ async function retrieve(store, embedder, query, options = {}) {
10893
11152
  }
10894
11153
  const durationMs = Date.now() - started;
10895
11154
  store.logContext({
10896
- source: options.source ?? "unknown",
11155
+ // A keyword-only delivery is logged under its own source. The week-4
11156
+ // gate is read from this table, and a run with half the retriever
11157
+ // missing is not a sample of how the ranking performs — it should show
11158
+ // up as its own bucket in a `group by source`, not quietly average in.
11159
+ source: (options.source ?? "unknown") + (degraded ? ":degraded" : ""),
10897
11160
  query,
10898
- chunkIds: chunks.map((c) => c.chunkId),
11161
+ delivered: chunks.map((c) => ({ rel_path: c.relPath, heading_path: c.headingPath, tokens: c.tokens })),
10899
11162
  tokensEst: tokens,
11163
+ // The ceiling, not the spend: two rows are only comparable when this
11164
+ // matches, and retrieval stops at the relevance cliff well before it.
11165
+ tokensBudget: maxTokens,
10900
11166
  durationMs
10901
11167
  });
10902
- return { query, chunks, tokensEst: tokens, durationMs, ...options.debug ? { debug } : {} };
11168
+ return {
11169
+ query,
11170
+ searchText,
11171
+ chunks,
11172
+ tokensEst: tokens,
11173
+ durationMs,
11174
+ ...degraded ? { degraded } : {},
11175
+ ...options.debug ? { debug } : {}
11176
+ };
11177
+ }
11178
+ function neighbourQuery(query, pinned) {
11179
+ if (pinned.length === 0)
11180
+ return query;
11181
+ return pinned.map((i) => i.title).join("; ");
11182
+ }
11183
+ var ROOT_INSTRUCTION_FILES = /* @__PURE__ */ new Set(["CLAUDE.md", "CLAUDE.local.md", "AGENTS.md"]);
11184
+ function isRootInstruction(relPath) {
11185
+ return ROOT_INSTRUCTION_FILES.has(relPath);
10903
11186
  }
10904
11187
  function rankIn(list, id) {
10905
11188
  const i = list.indexOf(id);
@@ -10908,7 +11191,11 @@ function rankIn(list, id) {
10908
11191
  function formatDebug(result) {
10909
11192
  if (!result.debug)
10910
11193
  return "";
10911
- const lines = [" vec fts factor score keep where"];
11194
+ const lines = [
11195
+ `stage 2 searched: ${JSON.stringify(result.searchText)}${result.searchText === result.query ? "" : " (from the pinned titles)"}`,
11196
+ "",
11197
+ " vec fts factor score keep where"
11198
+ ];
10912
11199
  for (const d of result.debug) {
10913
11200
  const where = [d.relPath, ...d.headingPath].join(" \u203A ").slice(0, 70);
10914
11201
  lines.push([
@@ -40247,21 +40534,31 @@ var StdioServerTransport = class {
40247
40534
  function createCapsaServer(options) {
40248
40535
  const embedder = options.embedder ?? ollamaEmbedder();
40249
40536
  const server = new McpServer({ name: "capsa", version: "0.0.1" });
40537
+ const ensureFresh = freshener(options, embedder);
40250
40538
  server.registerTool("get_context", {
40251
40539
  title: "Get project context",
40252
40540
  description: "Return the smallest set of project knowledge (runbooks, tickets, plans, decisions, instruction files) relevant to a task. Call this before starting work on a task; pass the task as you understand it.",
40253
40541
  inputSchema: {
40254
40542
  task: external_exports.string().describe("The task or question, in one or two sentences"),
40255
- maxTokens: external_exports.number().int().min(200).max(6e3).optional().describe("Token budget, default 1500")
40543
+ // 3000 is a ceiling on a ceiling: retrieval stops at the relevance
40544
+ // cliff long before it fills a budget, so a larger number only ever
40545
+ // buys a longer tail of near-misses.
40546
+ maxTokens: external_exports.number().int().min(200).max(3e3).optional().describe("Token budget, default 1500")
40256
40547
  }
40257
40548
  }, async ({ task, maxTokens }) => {
40549
+ const freshness = await ensureFresh();
40258
40550
  const store = openStore(options.projectRoot, embedder);
40259
40551
  try {
40260
- const result = await retrieve(store, embedder, task, { maxTokens, source: "mcp:get_context" });
40552
+ const result = await retrieve(store, embedder, task, {
40553
+ maxTokens,
40554
+ source: "mcp:get_context",
40555
+ excludeRootInstructions: true
40556
+ });
40557
+ const caveat = caveats(freshness, result.degraded);
40261
40558
  const footer = `
40262
40559
 
40263
40560
  ---
40264
- _capsa: ${result.chunks.length} chunks, ~${result.tokensEst} tokens, ${result.durationMs} ms_`;
40561
+ _capsa: ${result.chunks.length} chunks, ~${result.tokensEst} tokens, ${result.durationMs} ms${caveat ? ` \xB7 ${caveat}` : ""}_`;
40265
40562
  return { content: [{ type: "text", text: formatContext(result) + footer }] };
40266
40563
  } finally {
40267
40564
  store.close();
@@ -40272,9 +40569,14 @@ _capsa: ${result.chunks.length} chunks, ~${result.tokensEst} tokens, ${result.du
40272
40569
  description: "Where the project stands: open tickets and plans, recent decisions, recently changed knowledge.",
40273
40570
  inputSchema: {}
40274
40571
  }, async () => {
40572
+ const freshness = await ensureFresh();
40275
40573
  const store = openStore(options.projectRoot, embedder);
40276
40574
  try {
40277
- return { content: [{ type: "text", text: formatState(projectState(store)) }] };
40575
+ const caveat = caveats(freshness);
40576
+ const text = formatState(projectState(store)) + (caveat ? `
40577
+
40578
+ _${caveat}_` : "");
40579
+ return { content: [{ type: "text", text }] };
40278
40580
  } finally {
40279
40581
  store.close();
40280
40582
  }
@@ -40290,14 +40592,54 @@ _capsa: ${result.chunks.length} chunks, ~${result.tokensEst} tokens, ${result.du
40290
40592
  }, async ({ title, body, related }) => {
40291
40593
  const store = openStore(options.projectRoot, embedder);
40292
40594
  try {
40293
- const id = store.recordDecision(title, body, related);
40294
- return { content: [{ type: "text", text: `Recorded decision #${id}: ${title}` }] };
40595
+ const { id, indexed, reason } = await recordDecision(store, embedder, options.projectRoot, {
40596
+ title,
40597
+ body,
40598
+ related
40599
+ });
40600
+ const note = indexed ? "" : ` \u2014 not searchable yet (${reason}); run \`capsa index\` once that is fixed`;
40601
+ return { content: [{ type: "text", text: `Recorded decision #${id}: ${title}${note}` }] };
40295
40602
  } finally {
40296
40603
  store.close();
40297
40604
  }
40298
40605
  });
40299
40606
  return server;
40300
40607
  }
40608
+ function freshener(options, embedder) {
40609
+ const maxAgeMs = options.maxIndexAgeMs ?? DEFAULT_MAX_INDEX_AGE_MS;
40610
+ let inFlight = null;
40611
+ return () => {
40612
+ if (!inFlight) {
40613
+ const run = (async () => {
40614
+ let store;
40615
+ try {
40616
+ store = openStore(options.projectRoot, embedder);
40617
+ return await freshenIndex(store, options.projectRoot, embedder, { maxAgeMs });
40618
+ } catch (err) {
40619
+ return { refreshed: false, stale: true, reason: err instanceof Error ? err.message : String(err) };
40620
+ } finally {
40621
+ store?.close();
40622
+ }
40623
+ })();
40624
+ inFlight = run;
40625
+ void run.finally(() => {
40626
+ if (inFlight === run)
40627
+ inFlight = null;
40628
+ });
40629
+ }
40630
+ return inFlight;
40631
+ };
40632
+ }
40633
+ function caveats(freshness, degraded) {
40634
+ const parts = [];
40635
+ if (freshness.stale) {
40636
+ parts.push(`index may be out of date \u2014 ${freshness.reason ?? "the last index run did not finish"}`);
40637
+ }
40638
+ if (degraded) {
40639
+ parts.push(freshness.stale ? "keyword-only search (no embeddings)" : `keyword-only search \u2014 ${degraded}`);
40640
+ }
40641
+ return parts.join(" \xB7 ");
40642
+ }
40301
40643
  async function serveStdio(options) {
40302
40644
  const server = createCapsaServer(options);
40303
40645
  await server.connect(new StdioServerTransport());
@@ -40374,6 +40716,8 @@ async function runSearchCommand(args) {
40374
40716
  } else {
40375
40717
  console.log(formatContext(res));
40376
40718
  }
40719
+ if (res.degraded) console.error(`
40720
+ capsa: keyword-only search \u2014 ${res.degraded}`);
40377
40721
  console.error(`
40378
40722
  ${res.chunks.length} chunks, ~${res.tokensEst} tokens, ${res.durationMs} ms`);
40379
40723
  } finally {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yurtsever/capsa",
3
- "version": "0.1.0-alpha.5",
3
+ "version": "0.1.0-alpha.7",
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",