@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.
- package/dist/index.js +446 -102
- 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
|
|
7430
|
+
chunks.push({ index: chunks.length, headingPath, text: part });
|
|
7429
7431
|
}
|
|
7430
7432
|
};
|
|
7431
|
-
for (const line of
|
|
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 =
|
|
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
|
-
|
|
10095
|
-
|
|
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.
|
|
10100
|
-
* `CREATE
|
|
10101
|
-
*
|
|
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
|
-
|
|
10108
|
-
|
|
10151
|
+
DROP TABLE IF EXISTS chunks;
|
|
10152
|
+
DROP TABLE IF EXISTS items;
|
|
10109
10153
|
`);
|
|
10110
10154
|
this.migrate();
|
|
10111
|
-
this.
|
|
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
|
-
|
|
10162
|
-
|
|
10163
|
-
|
|
10164
|
-
|
|
10165
|
-
|
|
10166
|
-
|
|
10167
|
-
|
|
10168
|
-
|
|
10169
|
-
|
|
10170
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
10835
|
-
|
|
10836
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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 = [
|
|
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
|
-
|
|
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, {
|
|
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}
|
|
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
|
-
|
|
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 =
|
|
40294
|
-
|
|
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.
|
|
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",
|