@diegoaltoworks/chatter 2.0.0 → 2.1.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.
@@ -92,6 +92,126 @@ var init_buildLock = __esm({
92
92
  }
93
93
  });
94
94
 
95
+ // src/core/chunking.ts
96
+ function headingOf(line) {
97
+ const match = HEADING.exec(line);
98
+ if (!match) return void 0;
99
+ const title = (match[2] ?? "").replace(/[ \t]+#+$/, "").replace(/^#+$/, "").trim();
100
+ return { level: match[1].length, title };
101
+ }
102
+ function startsTable(lines, i) {
103
+ return lines[i].includes("|") && i + 1 < lines.length && TABLE_DELIMITER.test(lines[i + 1]);
104
+ }
105
+ function fenceEnd(lines, start) {
106
+ const opener = FENCE.exec(lines[start]);
107
+ if (!opener) return start + 1;
108
+ const marker = opener[1];
109
+ const closer = new RegExp(`^ {0,3}${marker[0] === "`" ? "`" : "~"}{${marker.length},}[ \\t]*$`);
110
+ for (let i = start + 1; i < lines.length; i++) if (closer.test(lines[i])) return i + 1;
111
+ return lines.length;
112
+ }
113
+ function listEnd(lines, start) {
114
+ let end = start + 1;
115
+ for (let i = start + 1; i < lines.length; i++) {
116
+ const line = lines[i];
117
+ if (isBlank(line)) {
118
+ let next = i + 1;
119
+ while (next < lines.length && isBlank(lines[next])) next++;
120
+ if (next < lines.length && (LIST_ITEM.test(lines[next]) || isIndented(lines[next]))) {
121
+ i = next - 1;
122
+ continue;
123
+ }
124
+ break;
125
+ }
126
+ if (headingOf(line) && !isIndented(line)) break;
127
+ if (FENCE.test(line) && !isIndented(line)) break;
128
+ end = i + 1;
129
+ }
130
+ return end;
131
+ }
132
+ function tableEnd(lines, start) {
133
+ let end = start + 2;
134
+ while (end < lines.length && !isBlank(lines[end]) && lines[end].includes("|")) end++;
135
+ return end;
136
+ }
137
+ function paragraphEnd(lines, start) {
138
+ let end = start + 1;
139
+ while (end < lines.length && !isBlank(lines[end]) && !headingOf(lines[end]) && !FENCE.test(lines[end]) && !LIST_ITEM.test(lines[end])) {
140
+ end++;
141
+ }
142
+ return end;
143
+ }
144
+ function scan(text) {
145
+ const lines = text.replace(/\r\n?/g, "\n").split("\n");
146
+ const sections = [{ trail: [], blocks: [] }];
147
+ const stack = [];
148
+ let i = 0;
149
+ while (i < lines.length) {
150
+ const line = lines[i];
151
+ if (isBlank(line)) {
152
+ i++;
153
+ continue;
154
+ }
155
+ const heading = FENCE.test(line) ? void 0 : headingOf(line);
156
+ if (heading) {
157
+ while (stack.length && stack[stack.length - 1].level >= heading.level) stack.pop();
158
+ stack.push(heading);
159
+ sections.push({ trail: stack.map((h) => h.title), heading: line, blocks: [] });
160
+ i++;
161
+ continue;
162
+ }
163
+ let end;
164
+ if (FENCE.test(line)) end = fenceEnd(lines, i);
165
+ else if (LIST_ITEM.test(line)) end = listEnd(lines, i);
166
+ else if (startsTable(lines, i)) end = tableEnd(lines, i);
167
+ else end = paragraphEnd(lines, i);
168
+ sections[sections.length - 1].blocks.push({ lines: lines.slice(i, end) });
169
+ i = end;
170
+ }
171
+ return sections;
172
+ }
173
+ function chunkSections(text, max = 900) {
174
+ const cap = Math.max(1, max);
175
+ const out = [];
176
+ const emit = (parts, section) => {
177
+ const body = parts.join("\n\n");
178
+ if (body.trim() === "") return;
179
+ out.push({ text: body, section: [...section], position: out.length });
180
+ };
181
+ for (const section of scan(text)) {
182
+ let parts = section.heading ? [section.heading.trim()] : [];
183
+ let size = parts.join("\n\n").length;
184
+ let hasBlock = false;
185
+ for (const block of section.blocks) {
186
+ const piece = block.lines.join("\n");
187
+ const added = piece.length + (parts.length ? 2 : 0);
188
+ if (hasBlock && size + added > cap) {
189
+ emit(parts, section.trail);
190
+ parts = [];
191
+ size = 0;
192
+ hasBlock = false;
193
+ }
194
+ size += piece.length + (parts.length ? 2 : 0);
195
+ parts.push(piece);
196
+ hasBlock = true;
197
+ }
198
+ emit(parts, section.trail);
199
+ }
200
+ return out;
201
+ }
202
+ var FENCE, HEADING, LIST_ITEM, TABLE_DELIMITER, isBlank, isIndented;
203
+ var init_chunking = __esm({
204
+ "src/core/chunking.ts"() {
205
+ "use strict";
206
+ FENCE = /^ {0,3}(`{3,}|~{3,})/;
207
+ HEADING = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?[ \t]*$/;
208
+ LIST_ITEM = /^[ \t]*(?:[-*+]|\d{1,9}[.)])[ \t]+\S/;
209
+ TABLE_DELIMITER = /^[ \t]*\|?[ \t]*:?-+:?[ \t]*(?:\|[ \t]*:?-+:?[ \t]*)*\|?[ \t]*$/;
210
+ isBlank = (line) => line.trim() === "";
211
+ isIndented = (line) => /^(?: {2,}|\t)/.test(line);
212
+ }
213
+ });
214
+
95
215
  // src/core/loaders.ts
96
216
  import { readdirSync, readFileSync as readFileSync2, statSync } from "node:fs";
97
217
  import { join as join2 } from "node:path";
@@ -134,6 +254,7 @@ __export(retrieval_exports, {
134
254
  openLibsqlClient: () => openLibsqlClient,
135
255
  wrapMissingLibsqlError: () => wrapMissingLibsqlError
136
256
  });
257
+ import { relative, sep } from "node:path";
137
258
  function createOpenAIEmbedder(client) {
138
259
  return async (input) => {
139
260
  const res = await client.embeddings.create({ model: EMB_MODEL, input });
@@ -176,6 +297,7 @@ var init_retrieval = __esm({
176
297
  "src/core/retrieval.ts"() {
177
298
  "use strict";
178
299
  init_buildLock();
300
+ init_chunking();
179
301
  init_loaders();
180
302
  init_logger();
181
303
  EMB_MODEL = "text-embedding-3-large";
@@ -186,6 +308,7 @@ var init_retrieval = __esm({
186
308
  this.embed = embed;
187
309
  this.db = options.databaseClient;
188
310
  this.knowledgeDir = options.knowledgeDir || DEFAULT_KNOWLEDGE_DIR;
311
+ this.chunking = options.chunking ?? "lines";
189
312
  this.logger = options.logger ?? createConsoleLogger();
190
313
  this.buildLock = options.buildLock ?? createTursoBuildLock(this.db);
191
314
  this.instanceId = options.instanceId ?? crypto.randomUUID();
@@ -199,6 +322,7 @@ var init_retrieval = __esm({
199
322
  */
200
323
  db;
201
324
  knowledgeDir;
325
+ chunking;
202
326
  logger;
203
327
  buildLock;
204
328
  instanceId;
@@ -259,11 +383,41 @@ var init_retrieval = __esm({
259
383
  this.logger.info("No knowledge documents and no existing chunks - nothing to build");
260
384
  return;
261
385
  }
386
+ this.logger.info(`Chunking mode: ${this.chunking}`);
387
+ if (this.chunking === "sections") await this.ensureSectionColumns();
262
388
  const rows = [];
263
389
  for (const d of docs) {
264
- for (const part of chunk(d.text)) {
265
- const id = await sha256(`${d.bucket}|${d.source}|${part}`);
266
- rows.push({ id, bucket: d.bucket, source: d.source, text: part });
390
+ if (this.chunking === "sections") {
391
+ const source = relative(this.knowledgeDir, d.source).split(sep).join("/");
392
+ for (const c of chunkSections(d.text)) {
393
+ const section = c.section.join(" > ");
394
+ const id = await sha256(`${d.bucket}|${source}|${section}|${c.text}`);
395
+ const context = section ? `${source} > ${section}` : source;
396
+ rows.push({
397
+ id,
398
+ bucket: d.bucket,
399
+ source,
400
+ text: c.text,
401
+ embedText: `${context}
402
+
403
+ ${c.text}`,
404
+ section,
405
+ position: c.position
406
+ });
407
+ }
408
+ } else {
409
+ for (const part of chunk(d.text)) {
410
+ const id = await sha256(`${d.bucket}|${d.source}|${part}`);
411
+ rows.push({
412
+ id,
413
+ bucket: d.bucket,
414
+ source: d.source,
415
+ text: part,
416
+ embedText: part,
417
+ section: null,
418
+ position: null
419
+ });
420
+ }
267
421
  }
268
422
  }
269
423
  this.logger.info(`Created ${rows.length} chunks from knowledge documents`);
@@ -272,11 +426,17 @@ var init_retrieval = __esm({
272
426
  for (let i = 0; i < rows.length; i += UPSERT_BATCH) {
273
427
  const batch = rows.slice(i, i + UPSERT_BATCH);
274
428
  await this.db.batch(
275
- batch.map((r) => ({
276
- sql: `INSERT INTO chunks(id,bucket,source,text) VALUES(?,?,?,?)
277
- ON CONFLICT(id) DO NOTHING`,
278
- args: [r.id, r.bucket, r.source, r.text]
279
- })),
429
+ batch.map(
430
+ (r) => this.chunking === "sections" ? {
431
+ sql: `INSERT INTO chunks(id,bucket,source,text,section,position) VALUES(?,?,?,?,?,?)
432
+ ON CONFLICT(id) DO NOTHING`,
433
+ args: [r.id, r.bucket, r.source, r.text, r.section, r.position]
434
+ } : {
435
+ sql: `INSERT INTO chunks(id,bucket,source,text) VALUES(?,?,?,?)
436
+ ON CONFLICT(id) DO NOTHING`,
437
+ args: [r.id, r.bucket, r.source, r.text]
438
+ }
439
+ ),
280
440
  "write"
281
441
  );
282
442
  }
@@ -297,7 +457,7 @@ var init_retrieval = __esm({
297
457
  return;
298
458
  }
299
459
  this.logger.info(`Embedding ${missing.length} new/updated chunks with ${EMB_MODEL}...`);
300
- const textById = new Map(rows.map((r) => [r.id, r.text]));
460
+ const textById = new Map(rows.map((r) => [r.id, r.embedText]));
301
461
  const BATCH = 96;
302
462
  for (let i = 0; i < missing.length; i += BATCH) {
303
463
  const batchIds = missing.slice(i, i + BATCH);
@@ -318,6 +478,18 @@ var init_retrieval = __esm({
318
478
  }
319
479
  this.logger.info(`Successfully embedded ${missing.length} new chunks`);
320
480
  }
481
+ /**
482
+ * Adds the nullable `section`/`position` columns to a `chunks` table that
483
+ * every released version created without them. Checked via table_info so it
484
+ * is a no-op once present; only `'sections'` mode ever calls it.
485
+ */
486
+ async ensureSectionColumns() {
487
+ const info = await this.db.execute("PRAGMA table_info(chunks)");
488
+ const have = new Set(info.rows.map((r) => String(r.name)));
489
+ if (!have.has("section")) await this.db.execute("ALTER TABLE chunks ADD COLUMN section TEXT");
490
+ if (!have.has("position"))
491
+ await this.db.execute("ALTER TABLE chunks ADD COLUMN position INTEGER");
492
+ }
321
493
  // Remove chunks from database that no longer exist in markdown files
322
494
  async cleanupStaleChunks(currentIds) {
323
495
  const result = await this.db.execute("SELECT id FROM chunks");
@@ -364,25 +536,34 @@ var init_retrieval = __esm({
364
536
  * interpolated. An empty list retrieves nothing, and short-circuits before
365
537
  * the embedding call.
366
538
  */
367
- async query(q, k = 6, allowed = ["base"]) {
539
+ async queryChunks(q, k = 6, allowed = ["base"]) {
368
540
  if (allowed.length === 0) return [];
369
541
  const [qv] = await this.embed([q]);
370
542
  const placeholders = allowed.map(() => "?").join(",");
371
543
  const res = await this.db.execute({
372
- sql: `SELECT c.id, c.text, e.embedding
544
+ sql: `SELECT c.*, e.embedding
373
545
  FROM chunks c
374
546
  JOIN embeddings e ON e.id = c.id
375
547
  WHERE c.bucket IN (${placeholders})`,
376
548
  args: allowed
377
549
  });
378
- const scored = [];
379
- for (const row of res.rows) {
550
+ const scored = res.rows.map((row) => {
380
551
  const emb = JSON.parse(String(row.embedding));
381
- const s = _VectorStore.cosine(qv, emb);
382
- scored.push({ s, text: String(row.text) });
383
- }
384
- scored.sort((a, b) => b.s - a.s);
385
- return scored.slice(0, k).map((r) => r.text);
552
+ const trail = row.section == null ? "" : String(row.section);
553
+ return {
554
+ text: String(row.text),
555
+ bucket: String(row.bucket ?? ""),
556
+ source: String(row.source ?? ""),
557
+ section: trail ? trail.split(" > ") : [],
558
+ score: _VectorStore.cosine(qv, emb)
559
+ };
560
+ });
561
+ scored.sort((a, b) => b.score - a.score);
562
+ return scored.slice(0, k);
563
+ }
564
+ /** Same results as {@link queryChunks}, as bare text. */
565
+ async query(q, k = 6, allowed = ["base"]) {
566
+ return (await this.queryChunks(q, k, allowed)).map((c) => c.text);
386
567
  }
387
568
  };
388
569
  }
@@ -1004,6 +1185,7 @@ async function createMCPServer(config) {
1004
1185
  store = new VectorStore2(createOpenAIEmbedder2(client), {
1005
1186
  databaseClient: db,
1006
1187
  knowledgeDir: config.knowledgeDir || DEFAULT_KNOWLEDGE_DIR2,
1188
+ chunking: config.chunking,
1007
1189
  logger: log
1008
1190
  });
1009
1191
  }
@@ -211,7 +211,7 @@ function timeContext(zones, now = Date.now()) {
211
211
  const at = new Date(now);
212
212
  const parts = zones.flatMap((zone) => {
213
213
  try {
214
- const formatted = new Intl.DateTimeFormat("en-GB", {
214
+ const fields = new Intl.DateTimeFormat("en-GB", {
215
215
  timeZone: zone,
216
216
  weekday: "long",
217
217
  day: "numeric",
@@ -220,7 +220,9 @@ function timeContext(zones, now = Date.now()) {
220
220
  hour: "2-digit",
221
221
  minute: "2-digit",
222
222
  hour12: false
223
- }).format(at);
223
+ }).formatToParts(at);
224
+ const part = (type) => fields.find((field) => field.type === type)?.value ?? "";
225
+ const formatted = `${part("weekday")} ${part("day")} ${part("month")} ${part("year")} at ${part("hour")}:${part("minute")}`;
224
226
  return [`${formatted} (${zone})`];
225
227
  } catch {
226
228
  return [];
@@ -183,7 +183,7 @@ function timeContext(zones, now = Date.now()) {
183
183
  const at = new Date(now);
184
184
  const parts = zones.flatMap((zone) => {
185
185
  try {
186
- const formatted = new Intl.DateTimeFormat("en-GB", {
186
+ const fields = new Intl.DateTimeFormat("en-GB", {
187
187
  timeZone: zone,
188
188
  weekday: "long",
189
189
  day: "numeric",
@@ -192,7 +192,9 @@ function timeContext(zones, now = Date.now()) {
192
192
  hour: "2-digit",
193
193
  minute: "2-digit",
194
194
  hour12: false
195
- }).format(at);
195
+ }).formatToParts(at);
196
+ const part = (type) => fields.find((field) => field.type === type)?.value ?? "";
197
+ const formatted = `${part("weekday")} ${part("day")} ${part("month")} ${part("year")} at ${part("hour")}:${part("minute")}`;
196
198
  return [`${formatted} (${zone})`];
197
199
  } catch {
198
200
  return [];
@@ -1 +1 @@
1
- {"version":3,"file":"timeContext.d.ts","sourceRoot":"","sources":["../../src/personas/timeContext.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,GAAG,GAAE,MAAmB,GAAG,MAAM,CAuB7E"}
1
+ {"version":3,"file":"timeContext.d.ts","sourceRoot":"","sources":["../../src/personas/timeContext.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,GAAG,GAAE,MAAmB,GAAG,MAAM,CA6B7E"}
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAIH,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAiB5B,OAAO,KAAK,EAAE,aAAa,EAAsB,MAAM,SAAS,CAAC;AAEjE;;;;;;;;;;;;;;;GAeG;AACH,MAAM,MAAM,UAAU,GAAG,IAAI,GAAG;IAAE,YAAY,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAAE,CAAC;AAEtE;;;;;GAKG;AACH,wBAAsB,YAAY,CAAC,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC,UAAU,CAAC,CAqQ7E"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAIH,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAiB5B,OAAO,KAAK,EAAE,aAAa,EAAsB,MAAM,SAAS,CAAC;AAEjE;;;;;;;;;;;;;;;GAeG;AACH,MAAM,MAAM,UAAU,GAAG,IAAI,GAAG;IAAE,YAAY,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAAE,CAAC;AAEtE;;;;;GAKG;AACH,wBAAsB,YAAY,CAAC,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC,UAAU,CAAC,CAsQ7E"}