@matatbread/matbot-core 0.2.4 → 0.2.5

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/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "node",
5
5
  "browser"
6
6
  ],
7
- "version": "0.2.4",
7
+ "version": "0.2.5",
8
8
  "engines": {
9
9
  "node": ">=24.0.0"
10
10
  },
@@ -35,7 +35,7 @@
35
35
  "access": "public"
36
36
  },
37
37
  "dependencies": {
38
- "@matatbread/matbot-plugin-api": "^0.2.4"
38
+ "@matatbread/matbot-plugin-api": "^0.2.5"
39
39
  },
40
40
  "devDependencies": {
41
41
  "typescript": "^5.8.0"
@@ -1,9 +1,101 @@
1
1
  import type { KnowledgeIndex, KnowledgeEntry } from '@matatbread/matbot-plugin-api';
2
2
 
3
- // In-memory KnowledgeIndex for development and environments without a BGE reranker.
4
- // Entries are held in a Set; search scores each doc by raw term-occurrence count, sorts
5
- // descending, then returns the top docs whose cumulative score covers 50% of the total —
6
- // surfacing clear winners quickly without drowning results in long-tail noise.
3
+ // In-memory KnowledgeIndex for development and environments without a persistent/reranking
4
+ // backend. Entries are held in a Set; search scores each entry across its curated metadata
5
+ // (entities, tags, summary) as well as its content, then returns the clear winners.
6
+ //
7
+ // Scoring rationale — the metadata is the signal, the body is the tie-breaker. A curated entity
8
+ // match is the strongest evidence an entry is *about* a term; a heading is next; the summary is a
9
+ // tight, deliberately-written description; raw body text is the weakest (it is incidental and
10
+ // length-biased — a long document mentions everything). Body occurrences are therefore saturated
11
+ // (tf/(tf+k)), so a wordy entry can't win on sheer volume of passing mentions over a short entry
12
+ // whose curated entities name the term outright. This deliberately does NOT weight by
13
+ // procedural/informational class: that is not a relevance signal (a how-to about X is a fair match
14
+ // for X) — it lives on the entry as metadata for other consumers, not here.
15
+ const WEIGHT = {
16
+ entityExact: 10, // the term IS a curated entity — strongest evidence
17
+ entityFuzzy: 4, // the term is only a substring of an entity ("matt" in "plant matter") — weaker
18
+ tag: 4,
19
+ summary: 3, // per occurrence, capped (the summary is short, so this is naturally bounded)
20
+ headingH1: 8,
21
+ headingH2: 5,
22
+ headingH3: 3,
23
+ body: 2, // multiplied by a 0–1 saturation factor, so this is the per-term ceiling
24
+ } as const;
25
+
26
+ // Body term-frequency saturation: contribution = WEIGHT.body * tf/(tf + BODY_K). Diminishing
27
+ // returns past a couple of mentions keep long documents from dominating on raw count.
28
+ const BODY_K = 3;
29
+
30
+ // Returned window: the clear winners by cumulative-score coverage, but always offer at least MIN
31
+ // alternatives (when they exist) so a consumer can re-rank/filter, and never more than MAX.
32
+ const COVERAGE = 0.5;
33
+ const MIN_RESULTS = 3;
34
+ const MAX_RESULTS = 10;
35
+
36
+ function escapeRegExp(s: string): string {
37
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
38
+ }
39
+
40
+ // Whole-word occurrence count, for free text (content, summary). Word-boundary matching avoids the
41
+ // substring trap — "matt" must not match "matter", "owl" must not match "fowl" — that plain
42
+ // indexOf() falls into. A multi-word term ("inner voice") is matched as a phrase.
43
+ function countWordMatches(haystack: string, term: string): number {
44
+ const m = haystack.match(new RegExp(`\\b${escapeRegExp(term)}\\b`, 'g'));
45
+ return m ? m.length : 0;
46
+ }
47
+
48
+ // Looser, substring-in-either-direction match for CURATED fields (entities, tags), where fuzziness
49
+ // is wanted: "matt" should match the entity "Matthew Woolf", "volvo" the entity "my volvo". Guarded
50
+ // so the contained string is at least 3 chars, keeping "us" out of "user"/"bus".
51
+ function fuzzyMatch(a: string, b: string): boolean {
52
+ if (a === b) return true;
53
+ if (b.length >= 3 && a.includes(b)) return true;
54
+ if (a.length >= 3 && b.includes(a)) return true;
55
+ return false;
56
+ }
57
+
58
+ function headingWeight(level: number): number {
59
+ if (level === 1) return WEIGHT.headingH1;
60
+ if (level === 2) return WEIGHT.headingH2;
61
+ return WEIGHT.headingH3;
62
+ }
63
+
64
+ function scoreContent(content: string, term: string): number {
65
+ let headingScore = 0;
66
+ let bodyTf = 0;
67
+ for (const line of content.split('\n')) {
68
+ const m = /^(#{1,3})(?!#)\s+(.+)/.exec(line);
69
+ if (m) {
70
+ if (countWordMatches(m[2]!.toLowerCase(), term) > 0) headingScore += headingWeight(m[1]!.length);
71
+ } else {
72
+ bodyTf += countWordMatches(line.toLowerCase(), term);
73
+ }
74
+ }
75
+ const bodyScore = bodyTf > 0 ? WEIGHT.body * (bodyTf / (bodyTf + BODY_K)) : 0;
76
+ return headingScore + bodyScore;
77
+ }
78
+
79
+ function scoreEntry(entry: KnowledgeEntry, terms: Array<{ term: string }>): number {
80
+ const entities = entry.entities.map(e => e.toLowerCase());
81
+ const tags = entry.tags.map(t => t.toLowerCase());
82
+ const summary = entry.summary.toLowerCase();
83
+ let score = 0;
84
+ for (const { term } of terms) {
85
+ const t = term.toLowerCase().trim();
86
+ if (t.length < 2) continue;
87
+ // Best single entity match for this term: exact beats fuzzy-substring, and we don't stack
88
+ // multiple entity hits for one term (one entity naming the term is the signal, not how many).
89
+ if (entities.includes(t)) score += WEIGHT.entityExact;
90
+ else if (entities.some(e => fuzzyMatch(e, t))) score += WEIGHT.entityFuzzy;
91
+ if (tags.some(g => fuzzyMatch(g, t))) score += WEIGHT.tag;
92
+ const sf = countWordMatches(summary, t);
93
+ if (sf > 0) score += WEIGHT.summary * Math.min(sf, 2);
94
+ score += scoreContent(entry.content, t);
95
+ }
96
+ return score;
97
+ }
98
+
7
99
  export class LookupKnowledgeIndex implements KnowledgeIndex {
8
100
  readonly docs = new Set<KnowledgeEntry>();
9
101
 
@@ -23,35 +115,29 @@ export class LookupKnowledgeIndex implements KnowledgeIndex {
23
115
  }
24
116
 
25
117
  async search(
26
- terms: Array<{ term: string; context?: string }>,
118
+ terms: Array<{ term: string; context?: string }>,
27
119
  _signal: AbortSignal,
28
120
  ): Promise<KnowledgeEntry[]> {
29
- const results: Array<{ entry: KnowledgeEntry; score: number }> = [];
121
+ if (terms.length === 0) return [];
30
122
 
123
+ const scored: Array<{ entry: KnowledgeEntry; score: number }> = [];
31
124
  for (const entry of this.docs) {
32
- const text = entry.content.toLowerCase();
33
- let score = 0;
34
- for (const { term } of terms) {
35
- const t = term.toLowerCase();
36
- let pos = 0;
37
- while ((pos = text.indexOf(t, pos)) !== -1) {
38
- score++;
39
- pos += t.length;
40
- }
41
- }
42
- if (score > 0) results.push({ entry, score });
125
+ const score = scoreEntry(entry, terms);
126
+ if (score > 0) scored.push({ entry, score });
43
127
  }
128
+ scored.sort((a, b) => b.score - a.score);
44
129
 
45
- results.sort((a, b) => b.score - a.score);
130
+ const total = scored.reduce((sum, r) => sum + r.score, 0);
131
+ if (total <= 0) return [];
46
132
 
47
- const total = results.reduce((sum, r) => sum + r.score, 0);
48
- const threshold = total * 0.5;
49
- let cumulative = 0;
133
+ const target = total * COVERAGE;
50
134
  const top: KnowledgeEntry[] = [];
51
- for (const { entry, score } of results) {
135
+ let cumulative = 0;
136
+ for (const { entry, score } of scored) {
52
137
  top.push(entry);
53
138
  cumulative += score;
54
- if (cumulative >= threshold) break;
139
+ if (top.length >= MAX_RESULTS) break;
140
+ if (top.length >= MIN_RESULTS && cumulative >= target) break;
55
141
  }
56
142
  return top;
57
143
  }
package/src/runner.ts CHANGED
@@ -42,7 +42,7 @@ export interface RunSessionOpts {
42
42
  * re-run of the originating user turn. Empty/absent for an ordinary turn.
43
43
  */
44
44
  injectedEphemeral?: MessageContent[];
45
- loadPlugin: (specifier: string, prompt?: PromptFn) => Promise<MatbotPlugin>;
45
+ loadPlugin: (specifier: string, prompt?: PromptFn, refresh?: boolean) => Promise<MatbotPlugin>;
46
46
  unloadPlugin: (specifier: string) => Promise<boolean>;
47
47
  }
48
48
 
@@ -273,7 +273,7 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
273
273
  callId: tc.id, session, signal, vault,
274
274
  provider: config.provider,
275
275
  prompt: promptFn,
276
- loadPlugin: (specifier: string) => opts.loadPlugin(specifier, promptFn),
276
+ loadPlugin: (specifier: string, refresh?: boolean) => opts.loadPlugin(specifier, promptFn, refresh),
277
277
  unloadPlugin: opts.unloadPlugin,
278
278
  ...(opts.workdir !== undefined ? { workdir: opts.workdir } : {}),
279
279
  ...(opts.configPath !== undefined ? { configPath: opts.configPath } : {}),
@@ -288,7 +288,7 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
288
288
  case 'file': yield { type: 'file', handle: toolEv.handle, traceId }; break;
289
289
  case 'result': result = toolEv.value; break;
290
290
  case 'marker': toolMarkers.push({ type: 'marker', creator: toolEv.creator, data: toolEv.data }); break;
291
- case 'progress': break;
291
+ case 'progress': yield { type: 'tool:progress', callId: tc.id, pct: toolEv.pct, ...(toolEv.message !== undefined ? { message: toolEv.message } : {}), traceId }; break;
292
292
  case 'error': result = {
293
293
  error: toolEv.message,
294
294
  ...(toolEv.stdout !== undefined ? { stdout: toolEv.stdout } : {}),
@@ -19,7 +19,7 @@ export interface SessionRunnerDeps {
19
19
  files?: FileStore;
20
20
  workdir?: string;
21
21
  configPath?: string;
22
- loadPlugin: (specifier: string, prompt?: PromptFn) => Promise<MatbotPlugin>;
22
+ loadPlugin: (specifier: string, prompt?: PromptFn, refresh?: boolean) => Promise<MatbotPlugin>;
23
23
  unloadPlugin: (specifier: string) => Promise<boolean>;
24
24
  }
25
25