@matatbread/matbot-core 0.2.4 → 0.2.6
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 +2 -2
- package/src/knowledge/lookup-knowledge-index.ts +118 -23
- package/src/loader.ts +7 -2
- package/src/runner.ts +3 -3
- package/src/session-runner.ts +1 -1
- package/src/single-turn.ts +10 -4
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"node",
|
|
5
5
|
"browser"
|
|
6
6
|
],
|
|
7
|
-
"version": "0.2.
|
|
7
|
+
"version": "0.2.6",
|
|
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.
|
|
38
|
+
"@matatbread/matbot-plugin-api": "^0.2.6"
|
|
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
|
|
4
|
-
// Entries are held in a Set; search scores each
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
|
|
@@ -22,36 +114,39 @@ export class LookupKnowledgeIndex implements KnowledgeIndex {
|
|
|
22
114
|
this.docs.add(entry);
|
|
23
115
|
}
|
|
24
116
|
|
|
117
|
+
async remove(id: string): Promise<void> {
|
|
118
|
+
for (const existing of this.docs) {
|
|
119
|
+
if (existing.id === id) {
|
|
120
|
+
this.docs.delete(existing);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
25
126
|
async search(
|
|
26
|
-
terms:
|
|
127
|
+
terms: Array<{ term: string; context?: string }>,
|
|
27
128
|
_signal: AbortSignal,
|
|
28
129
|
): Promise<KnowledgeEntry[]> {
|
|
29
|
-
|
|
130
|
+
if (terms.length === 0) return [];
|
|
30
131
|
|
|
132
|
+
const scored: Array<{ entry: KnowledgeEntry; score: number }> = [];
|
|
31
133
|
for (const entry of this.docs) {
|
|
32
|
-
const
|
|
33
|
-
|
|
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 });
|
|
134
|
+
const score = scoreEntry(entry, terms);
|
|
135
|
+
if (score > 0) scored.push({ entry, score });
|
|
43
136
|
}
|
|
137
|
+
scored.sort((a, b) => b.score - a.score);
|
|
44
138
|
|
|
45
|
-
|
|
139
|
+
const total = scored.reduce((sum, r) => sum + r.score, 0);
|
|
140
|
+
if (total <= 0) return [];
|
|
46
141
|
|
|
47
|
-
const
|
|
48
|
-
const threshold = total * 0.5;
|
|
49
|
-
let cumulative = 0;
|
|
142
|
+
const target = total * COVERAGE;
|
|
50
143
|
const top: KnowledgeEntry[] = [];
|
|
51
|
-
|
|
144
|
+
let cumulative = 0;
|
|
145
|
+
for (const { entry, score } of scored) {
|
|
52
146
|
top.push(entry);
|
|
53
147
|
cumulative += score;
|
|
54
|
-
if (
|
|
148
|
+
if (top.length >= MAX_RESULTS) break;
|
|
149
|
+
if (top.length >= MIN_RESULTS && cumulative >= target) break;
|
|
55
150
|
}
|
|
56
151
|
return top;
|
|
57
152
|
}
|
package/src/loader.ts
CHANGED
|
@@ -118,7 +118,7 @@ export async function loadPlugins(
|
|
|
118
118
|
),
|
|
119
119
|
);
|
|
120
120
|
|
|
121
|
-
const toLoad: { spec: string; importSpec: string; name?: string; runtimes?: readonly Runtime[] }[] = [];
|
|
121
|
+
const toLoad: { spec: string; importSpec: string; resolvedUrl: string; name?: string; runtimes?: readonly Runtime[] }[] = [];
|
|
122
122
|
for (let i = 0; i < reqs.length; i++) {
|
|
123
123
|
const { spec, importSpec, name } = reqs[i]!;
|
|
124
124
|
const runtimes = declared[i];
|
|
@@ -132,6 +132,10 @@ export async function loadPlugins(
|
|
|
132
132
|
toLoad.push({
|
|
133
133
|
spec,
|
|
134
134
|
importSpec: freshImportSpec(spec, importSpec, bustCache),
|
|
135
|
+
// The resolved location WITHOUT the reload cache-bust stamp — a stable `file:`/bare URL a
|
|
136
|
+
// consumer can map back to source (e.g. skills_compiler building a types program over the
|
|
137
|
+
// live plugin set). Falls back to the bare spec when the host pre-resolved nothing.
|
|
138
|
+
resolvedUrl: importSpec ?? spec,
|
|
135
139
|
...(name !== undefined ? { name } : {}),
|
|
136
140
|
...(runtimes !== undefined ? { runtimes } : {}),
|
|
137
141
|
});
|
|
@@ -211,7 +215,8 @@ export async function loadPlugins(
|
|
|
211
215
|
const plugin: MatbotPlugin = {
|
|
212
216
|
...spec_obj,
|
|
213
217
|
name,
|
|
214
|
-
specifier:
|
|
218
|
+
specifier: spec,
|
|
219
|
+
resolvedUrl: toLoad[i]!.resolvedUrl,
|
|
215
220
|
...(source !== undefined ? { source } : {}),
|
|
216
221
|
...(runtimes !== undefined ? { matbotRuntime: runtimes } : {}),
|
|
217
222
|
};
|
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 } : {}),
|
package/src/session-runner.ts
CHANGED
|
@@ -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
|
|
package/src/single-turn.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import type { Tool, ToolExecutor,
|
|
1
|
+
import type { Tool, ToolExecutor, ToolResultOf, ToolContext, MatbotMachine } from '@matatbread/matbot-plugin-api';
|
|
2
|
+
|
|
3
|
+
declare module '@matatbread/matbot-plugin-api' {
|
|
4
|
+
interface ToolResults {
|
|
5
|
+
single_turn: { text: string }; // the consulted provider's reply text
|
|
6
|
+
}
|
|
7
|
+
}
|
|
2
8
|
|
|
3
9
|
/**
|
|
4
10
|
* Exposes {@link MatbotMachine.singleTurn} to the model: a one-shot completion against a configured
|
|
@@ -12,9 +18,9 @@ import type { Tool, ToolExecutor, ToolContext, ToolEvent, MatbotMachine } from '
|
|
|
12
18
|
* a core service (`singleTurn`); core is also the one cross-runtime home both the node app and the
|
|
13
19
|
* browser bundle register it from — the tool-plugin barrel (where `plugin`/`provider` live) is node-only.
|
|
14
20
|
*/
|
|
15
|
-
export function createSingleTurnTool(services: MatbotMachine): Tool {
|
|
16
|
-
const executor: ToolExecutor = {
|
|
17
|
-
async *execute(input: unknown, ctx: ToolContext)
|
|
21
|
+
export function createSingleTurnTool(services: MatbotMachine): Tool<ToolResultOf<'single_turn'>> {
|
|
22
|
+
const executor: ToolExecutor<ToolResultOf<'single_turn'>> = {
|
|
23
|
+
async *execute(input: unknown, ctx: ToolContext) {
|
|
18
24
|
const args = input as { provider?: string; prompt?: string; system?: string };
|
|
19
25
|
if (typeof args.prompt !== 'string') { yield { type: 'error', message: 'single_turn requires a string "prompt".' }; return; }
|
|
20
26
|
const provider = args.provider ?? ctx.provider;
|