kiro-memory 1.7.0 → 1.8.0

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.
@@ -23,19 +23,28 @@ __export(Observations_exports, {
23
23
  deleteObservation: () => deleteObservation,
24
24
  getObservationsByProject: () => getObservationsByProject,
25
25
  getObservationsBySession: () => getObservationsBySession,
26
+ isDuplicateObservation: () => isDuplicateObservation,
26
27
  searchObservations: () => searchObservations,
27
28
  updateLastAccessed: () => updateLastAccessed
28
29
  });
29
30
  function escapeLikePattern(input) {
30
31
  return input.replace(/[%_\\]/g, "\\$&");
31
32
  }
32
- function createObservation(db, memorySessionId, project, type, title, subtitle, text, narrative, facts, concepts, filesRead, filesModified, promptNumber) {
33
+ function isDuplicateObservation(db, contentHash, windowMs = 3e4) {
34
+ if (!contentHash) return false;
35
+ const threshold = Date.now() - windowMs;
36
+ const result = db.query(
37
+ "SELECT id FROM observations WHERE content_hash = ? AND created_at_epoch > ? LIMIT 1"
38
+ ).get(contentHash, threshold);
39
+ return !!result;
40
+ }
41
+ function createObservation(db, memorySessionId, project, type, title, subtitle, text, narrative, facts, concepts, filesRead, filesModified, promptNumber, contentHash = null, discoveryTokens = 0) {
33
42
  const now = /* @__PURE__ */ new Date();
34
43
  const result = db.run(
35
- `INSERT INTO observations
36
- (memory_session_id, project, type, title, subtitle, text, narrative, facts, concepts, files_read, files_modified, prompt_number, created_at, created_at_epoch)
37
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
38
- [memorySessionId, project, type, title, subtitle, text, narrative, facts, concepts, filesRead, filesModified, promptNumber, now.toISOString(), now.getTime()]
44
+ `INSERT INTO observations
45
+ (memory_session_id, project, type, title, subtitle, text, narrative, facts, concepts, files_read, files_modified, prompt_number, created_at, created_at_epoch, content_hash, discovery_tokens)
46
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
47
+ [memorySessionId, project, type, title, subtitle, text, narrative, facts, concepts, filesRead, filesModified, promptNumber, now.toISOString(), now.getTime(), contentHash, discoveryTokens]
39
48
  );
40
49
  return Number(result.lastInsertRowid);
41
50
  }
@@ -91,39 +100,42 @@ function consolidateObservations(db, project, options = {}) {
91
100
  if (groups.length === 0) return { merged: 0, removed: 0 };
92
101
  let totalMerged = 0;
93
102
  let totalRemoved = 0;
94
- for (const group of groups) {
95
- const obsIds = group.ids.split(",").map(Number);
96
- const placeholders = obsIds.map(() => "?").join(",");
97
- const observations = db.query(
98
- `SELECT * FROM observations WHERE id IN (${placeholders}) ORDER BY created_at_epoch DESC`
99
- ).all(...obsIds);
100
- if (observations.length < minGroupSize) continue;
101
- if (options.dryRun) {
102
- totalMerged += 1;
103
- totalRemoved += observations.length - 1;
104
- continue;
105
- }
106
- const keeper = observations[0];
107
- const others = observations.slice(1);
108
- const uniqueTexts = /* @__PURE__ */ new Set();
109
- if (keeper.text) uniqueTexts.add(keeper.text);
110
- for (const obs of others) {
111
- if (obs.text && !uniqueTexts.has(obs.text)) {
112
- uniqueTexts.add(obs.text);
103
+ const runConsolidation = db.transaction(() => {
104
+ for (const group of groups) {
105
+ const obsIds = group.ids.split(",").map(Number);
106
+ const placeholders = obsIds.map(() => "?").join(",");
107
+ const observations = db.query(
108
+ `SELECT * FROM observations WHERE id IN (${placeholders}) ORDER BY created_at_epoch DESC`
109
+ ).all(...obsIds);
110
+ if (observations.length < minGroupSize) continue;
111
+ if (options.dryRun) {
112
+ totalMerged += 1;
113
+ totalRemoved += observations.length - 1;
114
+ continue;
113
115
  }
116
+ const keeper = observations[0];
117
+ const others = observations.slice(1);
118
+ const uniqueTexts = /* @__PURE__ */ new Set();
119
+ if (keeper.text) uniqueTexts.add(keeper.text);
120
+ for (const obs of others) {
121
+ if (obs.text && !uniqueTexts.has(obs.text)) {
122
+ uniqueTexts.add(obs.text);
123
+ }
124
+ }
125
+ const consolidatedText = Array.from(uniqueTexts).join("\n---\n").substring(0, 1e5);
126
+ db.run(
127
+ "UPDATE observations SET text = ?, title = ? WHERE id = ?",
128
+ [consolidatedText, `[consolidato x${observations.length}] ${keeper.title}`, keeper.id]
129
+ );
130
+ const removeIds = others.map((o) => o.id);
131
+ const removePlaceholders = removeIds.map(() => "?").join(",");
132
+ db.run(`DELETE FROM observations WHERE id IN (${removePlaceholders})`, removeIds);
133
+ db.run(`DELETE FROM observation_embeddings WHERE observation_id IN (${removePlaceholders})`, removeIds);
134
+ totalMerged += 1;
135
+ totalRemoved += removeIds.length;
114
136
  }
115
- const consolidatedText = Array.from(uniqueTexts).join("\n---\n").substring(0, 1e5);
116
- db.run(
117
- "UPDATE observations SET text = ?, title = ? WHERE id = ?",
118
- [consolidatedText, `[consolidato x${observations.length}] ${keeper.title}`, keeper.id]
119
- );
120
- const removeIds = others.map((o) => o.id);
121
- const removePlaceholders = removeIds.map(() => "?").join(",");
122
- db.run(`DELETE FROM observations WHERE id IN (${removePlaceholders})`, removeIds);
123
- db.run(`DELETE FROM observation_embeddings WHERE observation_id IN (${removePlaceholders})`, removeIds);
124
- totalMerged += 1;
125
- totalRemoved += removeIds.length;
126
- }
137
+ });
138
+ runConsolidation();
127
139
  return { merged: totalMerged, removed: totalRemoved };
128
140
  }
129
141
  var init_Observations = __esm({
@@ -181,7 +193,7 @@ function searchObservationsFTS(db, query, filters = {}) {
181
193
  sql += " AND o.created_at_epoch <= ?";
182
194
  params.push(filters.dateEnd);
183
195
  }
184
- sql += " ORDER BY rank LIMIT ?";
196
+ sql += ` ORDER BY bm25(observations_fts, ${BM25_WEIGHTS}) LIMIT ?`;
185
197
  params.push(limit);
186
198
  const stmt = db.query(sql);
187
199
  return stmt.all(...params);
@@ -195,7 +207,7 @@ function searchObservationsFTSWithRank(db, query, filters = {}) {
195
207
  const safeQuery = sanitizeFTS5Query(query);
196
208
  if (!safeQuery) return [];
197
209
  let sql = `
198
- SELECT o.*, rank as fts5_rank FROM observations o
210
+ SELECT o.*, bm25(observations_fts, ${BM25_WEIGHTS}) as fts5_rank FROM observations o
199
211
  JOIN observations_fts fts ON o.id = fts.rowid
200
212
  WHERE observations_fts MATCH ?
201
213
  `;
@@ -216,7 +228,7 @@ function searchObservationsFTSWithRank(db, query, filters = {}) {
216
228
  sql += " AND o.created_at_epoch <= ?";
217
229
  params.push(filters.dateEnd);
218
230
  }
219
- sql += " ORDER BY rank LIMIT ?";
231
+ sql += ` ORDER BY bm25(observations_fts, ${BM25_WEIGHTS}) LIMIT ?`;
220
232
  params.push(limit);
221
233
  const stmt = db.query(sql);
222
234
  return stmt.all(...params);
@@ -320,11 +332,23 @@ function getProjectStats(db, project) {
320
332
  const sumStmt = db.query("SELECT COUNT(*) as count FROM summaries WHERE project = ?");
321
333
  const sesStmt = db.query("SELECT COUNT(*) as count FROM sessions WHERE project = ?");
322
334
  const prmStmt = db.query("SELECT COUNT(*) as count FROM prompts WHERE project = ?");
335
+ const discoveryStmt = db.query(
336
+ "SELECT COALESCE(SUM(discovery_tokens), 0) as total FROM observations WHERE project = ?"
337
+ );
338
+ const discoveryTokens = discoveryStmt.get(project)?.total || 0;
339
+ const readStmt = db.query(
340
+ `SELECT COALESCE(SUM(
341
+ CAST((LENGTH(COALESCE(title, '')) + LENGTH(COALESCE(narrative, ''))) / 4 AS INTEGER)
342
+ ), 0) as total FROM observations WHERE project = ?`
343
+ );
344
+ const readTokens = readStmt.get(project)?.total || 0;
345
+ const savings = Math.max(0, discoveryTokens - readTokens);
323
346
  return {
324
347
  observations: obsStmt.get(project)?.count || 0,
325
348
  summaries: sumStmt.get(project)?.count || 0,
326
349
  sessions: sesStmt.get(project)?.count || 0,
327
- prompts: prmStmt.get(project)?.count || 0
350
+ prompts: prmStmt.get(project)?.count || 0,
351
+ tokenEconomics: { discoveryTokens, readTokens, savings }
328
352
  };
329
353
  }
330
354
  function getStaleObservations(db, project) {
@@ -366,9 +390,11 @@ function markObservationsStale(db, ids, stale) {
366
390
  [stale ? 1 : 0, ...validIds]
367
391
  );
368
392
  }
393
+ var BM25_WEIGHTS;
369
394
  var init_Search = __esm({
370
395
  "src/services/sqlite/Search.ts"() {
371
396
  "use strict";
397
+ BM25_WEIGHTS = "10.0, 1.0, 5.0, 3.0";
372
398
  }
373
399
  });
374
400
 
@@ -948,6 +974,29 @@ var MigrationRunner = class {
948
974
  db.run("CREATE INDEX IF NOT EXISTS idx_checkpoints_project ON checkpoints(project)");
949
975
  db.run("CREATE INDEX IF NOT EXISTS idx_checkpoints_epoch ON checkpoints(created_at_epoch)");
950
976
  }
977
+ },
978
+ {
979
+ version: 7,
980
+ up: (db) => {
981
+ db.run("ALTER TABLE observations ADD COLUMN content_hash TEXT");
982
+ db.run("CREATE INDEX IF NOT EXISTS idx_observations_hash ON observations(content_hash)");
983
+ }
984
+ },
985
+ {
986
+ version: 8,
987
+ up: (db) => {
988
+ db.run("ALTER TABLE observations ADD COLUMN discovery_tokens INTEGER DEFAULT 0");
989
+ db.run("ALTER TABLE summaries ADD COLUMN discovery_tokens INTEGER DEFAULT 0");
990
+ }
991
+ },
992
+ {
993
+ version: 9,
994
+ up: (db) => {
995
+ db.run("CREATE INDEX IF NOT EXISTS idx_observations_project_epoch ON observations(project, created_at_epoch DESC)");
996
+ db.run("CREATE INDEX IF NOT EXISTS idx_observations_project_type ON observations(project, type)");
997
+ db.run("CREATE INDEX IF NOT EXISTS idx_summaries_project_epoch ON summaries(project, created_at_epoch DESC)");
998
+ db.run("CREATE INDEX IF NOT EXISTS idx_prompts_project_epoch ON prompts(project, created_at_epoch DESC)");
999
+ }
951
1000
  }
952
1001
  ];
953
1002
  }
@@ -1192,6 +1241,7 @@ init_Search();
1192
1241
 
1193
1242
  // src/sdk/index.ts
1194
1243
  init_Observations();
1244
+ import { createHash } from "crypto";
1195
1245
  init_Search();
1196
1246
 
1197
1247
  // src/services/search/EmbeddingService.ts
@@ -1750,31 +1800,71 @@ var KiroMemorySDK = class {
1750
1800
  logger.debug("SDK", `Embedding generation fallita per obs ${observationId}: ${error}`);
1751
1801
  }
1752
1802
  }
1803
+ /**
1804
+ * Genera content hash SHA256 per deduplicazione basata su contenuto.
1805
+ * Usa (project + type + title + narrative) come tupla di identità semantica.
1806
+ * NON include sessionId perché è unico ad ogni invocazione.
1807
+ */
1808
+ generateContentHash(type, title, narrative) {
1809
+ const payload = `${this.project}|${type}|${title}|${narrative || ""}`;
1810
+ return createHash("sha256").update(payload).digest("hex");
1811
+ }
1812
+ /**
1813
+ * Finestre di deduplicazione per tipo (ms).
1814
+ * Tipi con molte ripetizioni hanno finestre più ampie.
1815
+ */
1816
+ getDeduplicationWindow(type) {
1817
+ switch (type) {
1818
+ case "file-read":
1819
+ return 6e4;
1820
+ // 60s — letture frequenti sugli stessi file
1821
+ case "file-write":
1822
+ return 1e4;
1823
+ // 10s — scritture rapide consecutive
1824
+ case "command":
1825
+ return 3e4;
1826
+ // 30s — standard
1827
+ case "research":
1828
+ return 12e4;
1829
+ // 120s — web search e fetch ripetuti
1830
+ case "delegation":
1831
+ return 6e4;
1832
+ // 60s — delegazioni rapide
1833
+ default:
1834
+ return 3e4;
1835
+ }
1836
+ }
1753
1837
  /**
1754
1838
  * Store a new observation
1755
1839
  */
1756
1840
  async storeObservation(data) {
1757
1841
  this.validateObservationInput(data);
1842
+ const sessionId = "sdk-" + Date.now();
1843
+ const contentHash = this.generateContentHash(data.type, data.title, data.narrative);
1844
+ const dedupWindow = this.getDeduplicationWindow(data.type);
1845
+ if (isDuplicateObservation(this.db.db, contentHash, dedupWindow)) {
1846
+ logger.debug("SDK", `Osservazione duplicata scartata (${data.type}, ${dedupWindow}ms): ${data.title}`);
1847
+ return -1;
1848
+ }
1849
+ const filesRead = data.filesRead || (data.type === "file-read" ? data.files : void 0);
1850
+ const filesModified = data.filesModified || (data.type === "file-write" ? data.files : void 0);
1851
+ const discoveryTokens = Math.ceil(data.content.length / 4);
1758
1852
  const observationId = createObservation(
1759
1853
  this.db.db,
1760
- "sdk-" + Date.now(),
1854
+ sessionId,
1761
1855
  this.project,
1762
1856
  data.type,
1763
1857
  data.title,
1764
- null,
1765
- // subtitle
1858
+ data.subtitle || null,
1766
1859
  data.content,
1767
- null,
1768
- // narrative
1769
- null,
1770
- // facts
1860
+ data.narrative || null,
1861
+ data.facts || null,
1771
1862
  data.concepts?.join(", ") || null,
1772
- data.files?.join(", ") || null,
1773
- // files_read
1774
- data.files?.join(", ") || null,
1775
- // files_modified
1776
- 0
1777
- // prompt_number
1863
+ filesRead?.join(", ") || null,
1864
+ filesModified?.join(", ") || null,
1865
+ 0,
1866
+ contentHash,
1867
+ discoveryTokens
1778
1868
  );
1779
1869
  this.generateEmbeddingAsync(observationId, data.title, data.content, data.concepts).catch(() => {
1780
1870
  });
@@ -1817,9 +1907,16 @@ var KiroMemorySDK = class {
1817
1907
  };
1818
1908
  }
1819
1909
  })();
1910
+ const sessionId = "sdk-" + Date.now();
1911
+ const contentHash = this.generateContentHash(data.type, data.title);
1912
+ if (isDuplicateObservation(this.db.db, contentHash)) {
1913
+ logger.debug("SDK", `Knowledge duplicata scartata: ${data.title}`);
1914
+ return -1;
1915
+ }
1916
+ const discoveryTokens = Math.ceil(data.content.length / 4);
1820
1917
  const observationId = createObservation(
1821
1918
  this.db.db,
1822
- "sdk-" + Date.now(),
1919
+ sessionId,
1823
1920
  data.project || this.project,
1824
1921
  data.knowledgeType,
1825
1922
  // type = knowledgeType
@@ -1833,9 +1930,12 @@ var KiroMemorySDK = class {
1833
1930
  // facts = metadati JSON
1834
1931
  data.concepts?.join(", ") || null,
1835
1932
  data.files?.join(", ") || null,
1836
- data.files?.join(", ") || null,
1837
- 0
1933
+ null,
1934
+ // filesModified: knowledge non modifica file
1935
+ 0,
1838
1936
  // prompt_number
1937
+ contentHash,
1938
+ discoveryTokens
1839
1939
  );
1840
1940
  this.generateEmbeddingAsync(observationId, data.title, data.content, data.concepts).catch(() => {
1841
1941
  });
@@ -1851,11 +1951,11 @@ var KiroMemorySDK = class {
1851
1951
  "sdk-" + Date.now(),
1852
1952
  this.project,
1853
1953
  data.request || null,
1854
- null,
1954
+ data.investigated || null,
1855
1955
  data.learned || null,
1856
1956
  data.completed || null,
1857
1957
  data.nextSteps || null,
1858
- null
1958
+ data.notes || null
1859
1959
  );
1860
1960
  }
1861
1961
  /**
@@ -2039,7 +2139,14 @@ var KiroMemorySDK = class {
2039
2139
  }));
2040
2140
  } else {
2041
2141
  const observations = getObservationsByProject(this.db.db, this.project, 30);
2042
- items = observations.map((obs) => {
2142
+ const knowledgeTypes = new Set(KNOWLEDGE_TYPES);
2143
+ const knowledgeObs = [];
2144
+ const normalObs = [];
2145
+ for (const obs of observations) {
2146
+ if (knowledgeTypes.has(obs.type)) knowledgeObs.push(obs);
2147
+ else normalObs.push(obs);
2148
+ }
2149
+ const scoreObs = (obs) => {
2043
2150
  const signals = {
2044
2151
  semantic: 0,
2045
2152
  fts5: 0,
@@ -2047,7 +2154,6 @@ var KiroMemorySDK = class {
2047
2154
  projectMatch: projectMatchScore(obs.project, this.project)
2048
2155
  };
2049
2156
  const baseScore = computeCompositeScore(signals, CONTEXT_WEIGHTS);
2050
- const boostedScore = Math.min(1, baseScore * knowledgeTypeBoost(obs.type));
2051
2157
  return {
2052
2158
  id: obs.id,
2053
2159
  title: obs.title,
@@ -2056,17 +2162,23 @@ var KiroMemorySDK = class {
2056
2162
  project: obs.project,
2057
2163
  created_at: obs.created_at,
2058
2164
  created_at_epoch: obs.created_at_epoch,
2059
- score: boostedScore,
2165
+ score: Math.min(1, baseScore * knowledgeTypeBoost(obs.type)),
2060
2166
  signals
2061
2167
  };
2062
- });
2063
- items.sort((a, b) => b.score - a.score);
2168
+ };
2169
+ const scoredKnowledge = knowledgeObs.map(scoreObs).sort((a, b) => b.score - a.score);
2170
+ const scoredNormal = normalObs.map(scoreObs).sort((a, b) => b.score - a.score);
2171
+ items = [...scoredKnowledge, ...scoredNormal];
2064
2172
  }
2065
2173
  let tokensUsed = 0;
2174
+ const budgetItems = [];
2066
2175
  for (const item of items) {
2067
- tokensUsed += Math.ceil((item.title.length + item.content.length) / 4);
2068
- if (tokensUsed > tokenBudget) break;
2176
+ const itemTokens = Math.ceil((item.title.length + item.content.length) / 4);
2177
+ if (tokensUsed + itemTokens > tokenBudget) break;
2178
+ tokensUsed += itemTokens;
2179
+ budgetItems.push(item);
2069
2180
  }
2181
+ items = budgetItems;
2070
2182
  return {
2071
2183
  project: this.project,
2072
2184
  items,
@@ -145,6 +145,21 @@ var TOOLS = [
145
145
  required: []
146
146
  }
147
147
  },
148
+ {
149
+ name: "save_memory",
150
+ description: "Save a memory/observation manually. Use to persist important information, learnings, decisions, or context that should be remembered across sessions.",
151
+ inputSchema: {
152
+ type: "object",
153
+ properties: {
154
+ project: { type: "string", description: "Project name (required)" },
155
+ title: { type: "string", description: "Short descriptive title for the memory" },
156
+ content: { type: "string", description: "Full content of the memory to save" },
157
+ type: { type: "string", description: "Observation type: research, file-write, command, etc. (default: research)" },
158
+ concepts: { type: "array", items: { type: "string" }, description: "Related concepts/tags (optional)" }
159
+ },
160
+ required: ["project", "title", "content"]
161
+ }
162
+ },
148
163
  {
149
164
  name: "generate_report",
150
165
  description: "Generate an activity report for a project. Returns a markdown summary with observations, sessions, learnings, completed tasks, and file hotspots for the specified time period.",
@@ -371,6 +386,19 @@ _Tip: Run \`kiro-memory embeddings backfill\` to generate missing embeddings._
371
386
  _Checkpoint created: ${checkpoint.created_at}_`);
372
387
  return parts.join("\n");
373
388
  },
389
+ async save_memory(args) {
390
+ const result = await callWorkerPOST("/api/memory/save", {
391
+ project: args.project,
392
+ title: args.title,
393
+ content: args.content,
394
+ type: args.type || "research",
395
+ concepts: args.concepts
396
+ });
397
+ return `Memory saved successfully.
398
+ - **ID**: ${result.id}
399
+ - **Project**: ${args.project}
400
+ - **Title**: ${args.title}`;
401
+ },
374
402
  async generate_report(args) {
375
403
  const project = args.project || process.env.KIRO_MEMORY_PROJECT || "";
376
404
  const period = args.period === "monthly" ? "monthly" : "weekly";
@@ -58,7 +58,7 @@ function searchObservationsFTS(db, query, filters = {}) {
58
58
  sql += " AND o.created_at_epoch <= ?";
59
59
  params.push(filters.dateEnd);
60
60
  }
61
- sql += " ORDER BY rank LIMIT ?";
61
+ sql += ` ORDER BY bm25(observations_fts, ${BM25_WEIGHTS}) LIMIT ?`;
62
62
  params.push(limit);
63
63
  const stmt = db.query(sql);
64
64
  return stmt.all(...params);
@@ -72,7 +72,7 @@ function searchObservationsFTSWithRank(db, query, filters = {}) {
72
72
  const safeQuery = sanitizeFTS5Query(query);
73
73
  if (!safeQuery) return [];
74
74
  let sql = `
75
- SELECT o.*, rank as fts5_rank FROM observations o
75
+ SELECT o.*, bm25(observations_fts, ${BM25_WEIGHTS}) as fts5_rank FROM observations o
76
76
  JOIN observations_fts fts ON o.id = fts.rowid
77
77
  WHERE observations_fts MATCH ?
78
78
  `;
@@ -93,7 +93,7 @@ function searchObservationsFTSWithRank(db, query, filters = {}) {
93
93
  sql += " AND o.created_at_epoch <= ?";
94
94
  params.push(filters.dateEnd);
95
95
  }
96
- sql += " ORDER BY rank LIMIT ?";
96
+ sql += ` ORDER BY bm25(observations_fts, ${BM25_WEIGHTS}) LIMIT ?`;
97
97
  params.push(limit);
98
98
  const stmt = db.query(sql);
99
99
  return stmt.all(...params);
@@ -197,11 +197,23 @@ function getProjectStats(db, project) {
197
197
  const sumStmt = db.query("SELECT COUNT(*) as count FROM summaries WHERE project = ?");
198
198
  const sesStmt = db.query("SELECT COUNT(*) as count FROM sessions WHERE project = ?");
199
199
  const prmStmt = db.query("SELECT COUNT(*) as count FROM prompts WHERE project = ?");
200
+ const discoveryStmt = db.query(
201
+ "SELECT COALESCE(SUM(discovery_tokens), 0) as total FROM observations WHERE project = ?"
202
+ );
203
+ const discoveryTokens = discoveryStmt.get(project)?.total || 0;
204
+ const readStmt = db.query(
205
+ `SELECT COALESCE(SUM(
206
+ CAST((LENGTH(COALESCE(title, '')) + LENGTH(COALESCE(narrative, ''))) / 4 AS INTEGER)
207
+ ), 0) as total FROM observations WHERE project = ?`
208
+ );
209
+ const readTokens = readStmt.get(project)?.total || 0;
210
+ const savings = Math.max(0, discoveryTokens - readTokens);
200
211
  return {
201
212
  observations: obsStmt.get(project)?.count || 0,
202
213
  summaries: sumStmt.get(project)?.count || 0,
203
214
  sessions: sesStmt.get(project)?.count || 0,
204
- prompts: prmStmt.get(project)?.count || 0
215
+ prompts: prmStmt.get(project)?.count || 0,
216
+ tokenEconomics: { discoveryTokens, readTokens, savings }
205
217
  };
206
218
  }
207
219
  function getStaleObservations(db, project) {
@@ -243,9 +255,11 @@ function markObservationsStale(db, ids, stale) {
243
255
  [stale ? 1 : 0, ...validIds]
244
256
  );
245
257
  }
258
+ var BM25_WEIGHTS;
246
259
  var init_Search = __esm({
247
260
  "src/services/sqlite/Search.ts"() {
248
261
  "use strict";
262
+ BM25_WEIGHTS = "10.0, 1.0, 5.0, 3.0";
249
263
  }
250
264
  });
251
265
 
@@ -257,19 +271,28 @@ __export(Observations_exports, {
257
271
  deleteObservation: () => deleteObservation,
258
272
  getObservationsByProject: () => getObservationsByProject,
259
273
  getObservationsBySession: () => getObservationsBySession,
274
+ isDuplicateObservation: () => isDuplicateObservation,
260
275
  searchObservations: () => searchObservations,
261
276
  updateLastAccessed: () => updateLastAccessed
262
277
  });
263
278
  function escapeLikePattern2(input) {
264
279
  return input.replace(/[%_\\]/g, "\\$&");
265
280
  }
266
- function createObservation(db, memorySessionId, project, type, title, subtitle, text, narrative, facts, concepts, filesRead, filesModified, promptNumber) {
281
+ function isDuplicateObservation(db, contentHash, windowMs = 3e4) {
282
+ if (!contentHash) return false;
283
+ const threshold = Date.now() - windowMs;
284
+ const result = db.query(
285
+ "SELECT id FROM observations WHERE content_hash = ? AND created_at_epoch > ? LIMIT 1"
286
+ ).get(contentHash, threshold);
287
+ return !!result;
288
+ }
289
+ function createObservation(db, memorySessionId, project, type, title, subtitle, text, narrative, facts, concepts, filesRead, filesModified, promptNumber, contentHash = null, discoveryTokens = 0) {
267
290
  const now = /* @__PURE__ */ new Date();
268
291
  const result = db.run(
269
- `INSERT INTO observations
270
- (memory_session_id, project, type, title, subtitle, text, narrative, facts, concepts, files_read, files_modified, prompt_number, created_at, created_at_epoch)
271
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
272
- [memorySessionId, project, type, title, subtitle, text, narrative, facts, concepts, filesRead, filesModified, promptNumber, now.toISOString(), now.getTime()]
292
+ `INSERT INTO observations
293
+ (memory_session_id, project, type, title, subtitle, text, narrative, facts, concepts, files_read, files_modified, prompt_number, created_at, created_at_epoch, content_hash, discovery_tokens)
294
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
295
+ [memorySessionId, project, type, title, subtitle, text, narrative, facts, concepts, filesRead, filesModified, promptNumber, now.toISOString(), now.getTime(), contentHash, discoveryTokens]
273
296
  );
274
297
  return Number(result.lastInsertRowid);
275
298
  }
@@ -325,39 +348,42 @@ function consolidateObservations(db, project, options = {}) {
325
348
  if (groups.length === 0) return { merged: 0, removed: 0 };
326
349
  let totalMerged = 0;
327
350
  let totalRemoved = 0;
328
- for (const group of groups) {
329
- const obsIds = group.ids.split(",").map(Number);
330
- const placeholders = obsIds.map(() => "?").join(",");
331
- const observations = db.query(
332
- `SELECT * FROM observations WHERE id IN (${placeholders}) ORDER BY created_at_epoch DESC`
333
- ).all(...obsIds);
334
- if (observations.length < minGroupSize) continue;
335
- if (options.dryRun) {
336
- totalMerged += 1;
337
- totalRemoved += observations.length - 1;
338
- continue;
339
- }
340
- const keeper = observations[0];
341
- const others = observations.slice(1);
342
- const uniqueTexts = /* @__PURE__ */ new Set();
343
- if (keeper.text) uniqueTexts.add(keeper.text);
344
- for (const obs of others) {
345
- if (obs.text && !uniqueTexts.has(obs.text)) {
346
- uniqueTexts.add(obs.text);
351
+ const runConsolidation = db.transaction(() => {
352
+ for (const group of groups) {
353
+ const obsIds = group.ids.split(",").map(Number);
354
+ const placeholders = obsIds.map(() => "?").join(",");
355
+ const observations = db.query(
356
+ `SELECT * FROM observations WHERE id IN (${placeholders}) ORDER BY created_at_epoch DESC`
357
+ ).all(...obsIds);
358
+ if (observations.length < minGroupSize) continue;
359
+ if (options.dryRun) {
360
+ totalMerged += 1;
361
+ totalRemoved += observations.length - 1;
362
+ continue;
347
363
  }
364
+ const keeper = observations[0];
365
+ const others = observations.slice(1);
366
+ const uniqueTexts = /* @__PURE__ */ new Set();
367
+ if (keeper.text) uniqueTexts.add(keeper.text);
368
+ for (const obs of others) {
369
+ if (obs.text && !uniqueTexts.has(obs.text)) {
370
+ uniqueTexts.add(obs.text);
371
+ }
372
+ }
373
+ const consolidatedText = Array.from(uniqueTexts).join("\n---\n").substring(0, 1e5);
374
+ db.run(
375
+ "UPDATE observations SET text = ?, title = ? WHERE id = ?",
376
+ [consolidatedText, `[consolidato x${observations.length}] ${keeper.title}`, keeper.id]
377
+ );
378
+ const removeIds = others.map((o) => o.id);
379
+ const removePlaceholders = removeIds.map(() => "?").join(",");
380
+ db.run(`DELETE FROM observations WHERE id IN (${removePlaceholders})`, removeIds);
381
+ db.run(`DELETE FROM observation_embeddings WHERE observation_id IN (${removePlaceholders})`, removeIds);
382
+ totalMerged += 1;
383
+ totalRemoved += removeIds.length;
348
384
  }
349
- const consolidatedText = Array.from(uniqueTexts).join("\n---\n").substring(0, 1e5);
350
- db.run(
351
- "UPDATE observations SET text = ?, title = ? WHERE id = ?",
352
- [consolidatedText, `[consolidato x${observations.length}] ${keeper.title}`, keeper.id]
353
- );
354
- const removeIds = others.map((o) => o.id);
355
- const removePlaceholders = removeIds.map(() => "?").join(",");
356
- db.run(`DELETE FROM observations WHERE id IN (${removePlaceholders})`, removeIds);
357
- db.run(`DELETE FROM observation_embeddings WHERE observation_id IN (${removePlaceholders})`, removeIds);
358
- totalMerged += 1;
359
- totalRemoved += removeIds.length;
360
- }
385
+ });
386
+ runConsolidation();
361
387
  return { merged: totalMerged, removed: totalRemoved };
362
388
  }
363
389
  var init_Observations = __esm({