@sns-myagent/cli 0.2.0 → 0.3.1

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.
Files changed (67) hide show
  1. package/CHANGELOG.md +12 -1
  2. package/README.md +7 -8
  3. package/bin/.gitkeep +0 -0
  4. package/bin/snscoder.js +7 -96
  5. package/dist/cli.js +22026 -0
  6. package/package.json +4 -3
  7. package/scripts/apply-pi-natives-patch.js +82 -56
  8. package/scripts/fetch-binary.mjs +106 -217
  9. package/scripts/smoke-test.sh +92 -0
  10. package/src/adapters/telegram/bot.ts +41 -1
  11. package/src/adapters/telegram/bridge.ts +186 -0
  12. package/src/adapters/telegram/handler.ts +138 -13
  13. package/src/adapters/telegram/index.ts +12 -2
  14. package/src/agents/__tests__/config.test.ts +79 -0
  15. package/src/agents/__tests__/resilience.test.ts +119 -0
  16. package/src/agents/__tests__/strategies.test.ts +83 -0
  17. package/src/agents/config.ts +367 -0
  18. package/src/agents/ensemble.ts +230 -0
  19. package/src/agents/resilience.ts +332 -0
  20. package/src/agents/strategies/best-of-n.ts +108 -0
  21. package/src/agents/strategies/consensus.ts +104 -0
  22. package/src/agents/strategies/critic.ts +131 -0
  23. package/src/agents/strategies/index.ts +12 -0
  24. package/src/agents/strategies/types.ts +68 -0
  25. package/src/async/__tests__/task-runner.test.ts +162 -0
  26. package/src/async/__tests__/task-store.test.ts +146 -0
  27. package/src/async/index.ts +28 -1
  28. package/src/async/notifier.ts +133 -0
  29. package/src/async/task-runner.ts +175 -0
  30. package/src/async/task-store.ts +170 -0
  31. package/src/async/types.ts +70 -0
  32. package/src/cli/entry.ts +3 -1
  33. package/src/cli/index.ts +74 -55
  34. package/src/config/index.ts +1 -1
  35. package/src/debug/index.ts +1 -1
  36. package/src/internal-urls/docs-index.generated.txt +0 -2
  37. package/src/modes/components/welcome.ts +13 -6
  38. package/src/modes/controllers/event-controller.ts +1 -1
  39. package/src/modes/setup-wizard/scenes/splash.ts +1 -1
  40. package/src/session/agent-session.ts +1 -1
  41. package/src/slash-commands/builtin-registry.ts +63 -0
  42. package/src/slash-commands/helpers/task.ts +181 -0
  43. package/src/tbm/__tests__/tbm.test.ts +660 -0
  44. package/src/tbm/comm-modes.ts +165 -0
  45. package/src/tbm/config.ts +136 -0
  46. package/src/tbm/context-delta.ts +146 -0
  47. package/src/tbm/context-pyramid.ts +202 -0
  48. package/src/tbm/dashboard.ts +131 -0
  49. package/src/tbm/index.ts +247 -0
  50. package/src/tbm/lazy-skills.ts +182 -0
  51. package/src/tbm/response-cache.ts +220 -0
  52. package/src/tbm/tombstone.ts +189 -0
  53. package/src/tbm/tool-compress.ts +230 -0
  54. package/src/tools/ask.ts +1 -1
  55. package/src/tui/chat-blocks.ts +205 -0
  56. package/src/tui/chat-ui.ts +270 -0
  57. package/src/tui/code-cell.ts +90 -1
  58. package/src/tui/command-palette.ts +189 -0
  59. package/src/tui/index.ts +4 -0
  60. package/src/tui/splash.ts +130 -0
  61. package/src/ui/banner.ts +69 -29
  62. package/src/ui/colors.ts +24 -0
  63. package/src/ui/error-display.ts +130 -0
  64. package/src/ui/gradient.ts +104 -0
  65. package/src/ui/index.ts +15 -0
  66. package/src/ui/memory-toast.ts +102 -0
  67. package/src/ui/status-bar.ts +36 -30
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Token Dashboard — `/tokens` command implementation.
3
+ *
4
+ * Shows session stats: duration, tokens, cost, cache hit rate,
5
+ * pyramid level, communication mode, compression stats.
6
+ */
7
+
8
+ import type { TbmManager } from "./index";
9
+
10
+ export interface DashboardData {
11
+ sessionDuration: string;
12
+ totalInputTokens: number;
13
+ totalOutputTokens: number;
14
+ cachedTokens: number;
15
+ estimatedCost: string;
16
+ cacheHitRate: string;
17
+ pyramidLevel: number;
18
+ pyramidLabel: string;
19
+ commMode: string;
20
+ compressionRatio: string;
21
+ tokensSaved: string;
22
+ tombstonesActive: number;
23
+ responseCacheSize: number;
24
+ skillsLoaded: string;
25
+ }
26
+
27
+ /**
28
+ * Format a number with K/M suffix.
29
+ */
30
+ function formatNumber(n: number): string {
31
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
32
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
33
+ return String(n);
34
+ }
35
+
36
+ /**
37
+ * Format duration from milliseconds.
38
+ */
39
+ function formatDuration(ms: number): string {
40
+ const seconds = Math.floor(ms / 1000);
41
+ if (seconds < 60) return `${seconds}s`;
42
+ const minutes = Math.floor(seconds / 60);
43
+ const secs = seconds % 60;
44
+ if (minutes < 60) return `${minutes}m ${secs}s`;
45
+ const hours = Math.floor(minutes / 60);
46
+ const mins = minutes % 60;
47
+ return `${hours}h ${mins}m`;
48
+ }
49
+
50
+ /**
51
+ * Estimate cost from token counts.
52
+ * Rough estimate: $3/1M input, $15/1M output (Claude Sonnet class).
53
+ */
54
+ function estimateCost(inputTokens: number, outputTokens: number): string {
55
+ const inputCost = (inputTokens / 1_000_000) * 3;
56
+ const outputCost = (outputTokens / 1_000_000) * 15;
57
+ const total = inputCost + outputCost;
58
+ if (total < 0.01) return "<$0.01";
59
+ return `$${total.toFixed(3)}`;
60
+ }
61
+
62
+ /**
63
+ * Build dashboard data from TBM manager state.
64
+ */
65
+ export function buildDashboard(mgr: TbmManager, sessionStartTime: number): DashboardData {
66
+ const deltaStats = mgr.contextDelta.stats;
67
+ const pyramidStats = mgr.pyramid.stats;
68
+ const compressStats = mgr.compressor.stats;
69
+ const cacheStats = mgr.responseCache.stats;
70
+ const tombstoneStats = mgr.tombstoner.stats;
71
+ const skillStats = mgr.lazySkills.stats;
72
+
73
+ const totalSaved = deltaStats.tokens_saved
74
+ + compressStats.tokensSaved
75
+ + tombstoneStats.tokensSaved
76
+ + cacheStats.tokensSaved
77
+ + skillStats.tokensSaved;
78
+
79
+ return {
80
+ sessionDuration: formatDuration(Date.now() - sessionStartTime),
81
+ totalInputTokens: deltaStats.total_input_tokens,
82
+ totalOutputTokens: 0, // tracked by provider
83
+ cachedTokens: deltaStats.tokens_saved,
84
+ estimatedCost: estimateCost(deltaStats.total_input_tokens, 0),
85
+ cacheHitRate: `${(deltaStats.cache_hits / Math.max(1, deltaStats.turns) * 100).toFixed(0)}%`,
86
+ pyramidLevel: pyramidStats.currentLevel,
87
+ pyramidLabel: pyramidStats.currentLevel.toString(),
88
+ commMode: mgr.commMode.effective,
89
+ compressionRatio: `${(compressStats.totalOutputs > 0 ? (compressStats.compressed / compressStats.totalOutputs * 100) : 0).toFixed(0)}%`,
90
+ tokensSaved: formatNumber(totalSaved),
91
+ tombstonesActive: tombstoneStats.messagesTombstoned,
92
+ responseCacheSize: cacheStats.cacheSize,
93
+ skillsLoaded: `${skillStats.loadedOnDemand}/${skillStats.totalAvailable}`,
94
+ };
95
+ }
96
+
97
+ /**
98
+ * Render dashboard as formatted text for display.
99
+ */
100
+ export function renderDashboard(data: DashboardData): string {
101
+ return `╔══════════════════════════════════════╗
102
+ ║ 🧠 Token Budget Manager ║
103
+ ╠══════════════════════════════════════╣
104
+ ║ Session ${data.sessionDuration.padEnd(25)}║
105
+ ║ Input ${String(data.totalInputTokens).padEnd(25)}║
106
+ ║ Cached ${String(data.cachedTokens).padEnd(25)}║
107
+ ║ Est. Cost ${data.estimatedCost.padEnd(25)}║
108
+ ╠══════════════════════════════════════╣
109
+ ║ 📊 Context Delta ║
110
+ ║ Cache Hit ${data.cacheHitRate.padEnd(25)}║
111
+ ╠══════════════════════════════════════╣
112
+ ║ 🔺 Pyramid ║
113
+ ║ Level ${String(data.pyramidLevel).padEnd(25)}║
114
+ ╠══════════════════════════════════════╣
115
+ ║ 💬 Mode ${data.commMode.padEnd(23)}║
116
+ ╠══════════════════════════════════════╣
117
+ ║ 🗜️ Compression ${data.compressionRatio.padEnd(22)}║
118
+ ║ 💾 Saved ${data.tokensSaved.padEnd(23)}║
119
+ ╠══════════════════════════════════════╣
120
+ ║ 🗂 Tombstones ${String(data.tombstonesActive).padEnd(23)}║
121
+ ║ 📦 Cache Size ${String(data.responseCacheSize).padEnd(23)}║
122
+ ║ 🎯 Skills ${data.skillsLoaded.padEnd(23)}║
123
+ ╚══════════════════════════════════════╝`;
124
+ }
125
+
126
+ /**
127
+ * Render dashboard as compact one-liner for status bar.
128
+ */
129
+ export function renderCompactDashboard(data: DashboardData): string {
130
+ return `🧠 TBM | L${data.pyramidLevel} ${data.commMode} | Δ${data.cacheHitRate} | 🗜${data.compressionRatio} | 💾${data.tokensSaved}`;
131
+ }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * TBM Manager — coordinates all Token Budget Manager subsystems.
3
+ *
4
+ * Single entry point for the session/agent loop to interact with TBM.
5
+ * Each subsystem is independently toggleable via config.
6
+ *
7
+ * Usage:
8
+ * const mgr = new TbmManager(config);
9
+ * const ctx = mgr.processTurn(message, fullContext);
10
+ * // use ctx.content, ctx.directive, etc.
11
+ */
12
+
13
+ import { type TbmConfig, DEFAULT_TBM_CONFIG, resolveTbmConfig } from "./config";
14
+ import { ContextDeltaCache, estimateTokens } from "./context-delta";
15
+ import { ContextPyramid, type PyramidLevel } from "./context-pyramid";
16
+ import { LazySkillLoader, type SkillEntry } from "./lazy-skills";
17
+ import { ToolOutputCompressor } from "./tool-compress";
18
+ import { CommunicationModeManager, type CommMode } from "./comm-modes";
19
+ import { ConversationTombstoner } from "./tombstone";
20
+ import { ResponseCache } from "./response-cache";
21
+ import { buildDashboard, renderDashboard, renderCompactDashboard, type DashboardData } from "./dashboard";
22
+
23
+ export type { TbmConfig } from "./config";
24
+ export type { ContextDeltaStats } from "./context-delta";
25
+ export type { PyramidLevel, PyramidStats } from "./context-pyramid";
26
+ export type { SkillEntry, LazySkillStats } from "./lazy-skills";
27
+ export type { CompressStats } from "./tool-compress";
28
+ export type { CommMode } from "./comm-modes";
29
+ export type { TombstoneStats } from "./tombstone";
30
+ export type { ResponseCacheStats } from "./response-cache";
31
+ export type { DashboardData } from "./dashboard";
32
+
33
+ export interface TurnResult {
34
+ /** Content to send (possibly delta-compressed). */
35
+ content: string;
36
+ /** Communication mode directive to inject into system prompt. */
37
+ directive: string;
38
+ /** Current pyramid level. */
39
+ pyramidLevel: PyramidLevel;
40
+ /** Tokens saved this turn across all subsystems. */
41
+ tokensSavedThisTurn: number;
42
+ /** Whether context delta cache hit. */
43
+ deltaCacheHit: boolean;
44
+ }
45
+
46
+ export class TbmManager {
47
+ readonly #config: TbmConfig;
48
+ readonly #contextDelta: ContextDeltaCache;
49
+ readonly #pyramid: ContextPyramid;
50
+ readonly #lazySkills: LazySkillLoader;
51
+ readonly #compressor: ToolOutputCompressor;
52
+ readonly #commMode: CommunicationModeManager;
53
+ readonly #tombstoner: ConversationTombstoner;
54
+ readonly #responseCache: ResponseCache;
55
+ readonly #sessionStartTime: number;
56
+
57
+ constructor(config?: Partial<TbmConfig>) {
58
+ this.#config = resolveTbmConfig(config);
59
+ this.#contextDelta = new ContextDeltaCache();
60
+ this.#pyramid = new ContextPyramid(
61
+ this.#config.pyramid.start_level as PyramidLevel,
62
+ this.#config.pyramid.max_level as PyramidLevel,
63
+ );
64
+ this.#lazySkills = new LazySkillLoader();
65
+ this.#compressor = new ToolOutputCompressor(this.#config.compress.budgets);
66
+ this.#commMode = new CommunicationModeManager(this.#config.comm_mode);
67
+ this.#tombstoner = new ConversationTombstoner(
68
+ this.#config.tombstone.after_turns,
69
+ this.#config.tombstone.keep_recent,
70
+ );
71
+ this.#responseCache = new ResponseCache(
72
+ this.#config.response_cache.ttl_seconds,
73
+ this.#config.response_cache.max_entries,
74
+ this.#config.response_cache.similarity_threshold,
75
+ );
76
+ this.#sessionStartTime = Date.now();
77
+ }
78
+
79
+ /** Whether TBM is enabled. */
80
+ get enabled(): boolean {
81
+ return this.#config.enabled;
82
+ }
83
+
84
+ /** Access subsystems directly. */
85
+ get contextDelta(): ContextDeltaCache { return this.#contextDelta; }
86
+ get pyramid(): ContextPyramid { return this.#pyramid; }
87
+ get lazySkills(): LazySkillLoader { return this.#lazySkills; }
88
+ get compressor(): ToolOutputCompressor { return this.#compressor; }
89
+ get commMode(): CommunicationModeManager { return this.#commMode; }
90
+ get tombstoner(): ConversationTombstoner { return this.#tombstoner; }
91
+ get responseCache(): ResponseCache { return this.#responseCache; }
92
+ get config(): Readonly<TbmConfig> { return this.#config; }
93
+
94
+ /**
95
+ * Process a new user turn — main entry point for the session loop.
96
+ *
97
+ * 1. Check response cache
98
+ * 2. Resolve communication mode
99
+ * 3. Determine pyramid level
100
+ * 4. Process context delta
101
+ * 5. Load relevant skills (lazy)
102
+ */
103
+ processTurn(message: string, fullContext: string): TurnResult {
104
+ if (!this.#config.enabled) {
105
+ return {
106
+ content: fullContext,
107
+ directive: "",
108
+ pyramidLevel: 4 as PyramidLevel,
109
+ tokensSavedThisTurn: 0,
110
+ deltaCacheHit: false,
111
+ };
112
+ }
113
+
114
+ let totalSaved = 0;
115
+
116
+ // 1. Response cache check
117
+ const cached = this.#responseCache.get(message);
118
+ if (cached.hit && cached.response) {
119
+ return {
120
+ content: cached.response,
121
+ directive: `[Cache ${cached.matchType} hit]`,
122
+ pyramidLevel: this.#pyramid.level,
123
+ tokensSavedThisTurn: estimateTokens(cached.response),
124
+ deltaCacheHit: true,
125
+ };
126
+ }
127
+
128
+ // 2. Communication mode
129
+ const { directive } = this.#commMode.resolveForMessage(message);
130
+
131
+ // 3. Pyramid level
132
+ if (this.#config.pyramid.enabled) {
133
+ const level = this.#pyramid.resolveLevel(message);
134
+ this.#pyramid.setLevel(level);
135
+ }
136
+
137
+ // 4. Context delta
138
+ let content = fullContext;
139
+ let deltaCacheHit = false;
140
+ if (this.#config.context_delta.enabled) {
141
+ const delta = this.#contextDelta.processTurn(fullContext);
142
+ content = delta.content;
143
+ totalSaved += delta.tokensSaved;
144
+ deltaCacheHit = !delta.staticChanged;
145
+ }
146
+
147
+ // 5. Lazy skills
148
+ if (this.#config.lazy_skills.enabled) {
149
+ const { skillsToLoad, indexSection } = this.#lazySkills.processMessage(
150
+ message,
151
+ this.#config.lazy_skills.max_per_turn,
152
+ );
153
+ if (skillsToLoad.length > 0) {
154
+ content += "\n\n## Loaded Skills\n\n" + skillsToLoad.map((s) => s.fullContent).join("\n\n---\n\n");
155
+ }
156
+ }
157
+
158
+ return {
159
+ content,
160
+ directive,
161
+ pyramidLevel: this.#pyramid.level,
162
+ tokensSavedThisTurn: totalSaved,
163
+ deltaCacheHit,
164
+ };
165
+ }
166
+
167
+ /**
168
+ * Compress tool output before adding to context.
169
+ */
170
+ compressToolOutput(toolName: string, output: string): { output: string; compressed: boolean; tokensSaved: number } {
171
+ if (!this.#config.compress.enabled) {
172
+ return { output, compressed: false, tokensSaved: 0 };
173
+ }
174
+ return this.#compressor.compress(toolName, output);
175
+ }
176
+
177
+ /**
178
+ * Cache a response for future exact/semantic match.
179
+ */
180
+ cacheResponse(query: string, response: string): void {
181
+ if (this.#config.response_cache.enabled) {
182
+ this.#responseCache.set(query, response);
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Tombstone old conversation messages.
188
+ */
189
+ tombstoneMessages(messages: Array<{ role: "user" | "assistant"; content: string }>): {
190
+ compressed: Array<{ role: "user" | "assistant"; content: string }>;
191
+ tombstoned: number;
192
+ tokensSaved: number;
193
+ } {
194
+ if (!this.#config.tombstone.enabled) {
195
+ return { compressed: messages, tombstoned: 0, tokensSaved: 0 };
196
+ }
197
+ return this.#tombstoner.tombstone(messages);
198
+ }
199
+
200
+ /**
201
+ * Register skills for lazy loading.
202
+ */
203
+ registerSkills(skills: SkillEntry[]): void {
204
+ this.#lazySkills.registerSkills(skills);
205
+ }
206
+
207
+ /**
208
+ * Set communication mode explicitly.
209
+ */
210
+ setMode(mode: CommMode | "auto"): void {
211
+ this.#commMode.setMode(mode);
212
+ }
213
+
214
+ /**
215
+ * Build and render the token dashboard.
216
+ */
217
+ getDashboard(): DashboardData {
218
+ return buildDashboard(this, this.#sessionStartTime);
219
+ }
220
+
221
+ /**
222
+ * Render the dashboard as formatted text.
223
+ */
224
+ renderDashboard(): string {
225
+ return renderDashboard(this.getDashboard());
226
+ }
227
+
228
+ /**
229
+ * Render the dashboard as a compact one-liner.
230
+ */
231
+ renderCompactDashboard(): string {
232
+ return renderCompactDashboard(this.getDashboard());
233
+ }
234
+
235
+ /**
236
+ * Reset all subsystems (e.g. on session reset).
237
+ */
238
+ reset(): void {
239
+ this.#contextDelta.reset();
240
+ this.#pyramid.reset();
241
+ this.#lazySkills.reset();
242
+ this.#compressor.reset();
243
+ this.#commMode.reset();
244
+ this.#tombstoner.reset();
245
+ this.#responseCache.reset();
246
+ }
247
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Lazy Skill Loading — inject skill names only, load full content on-demand.
3
+ *
4
+ * Instead of loading all skills into context (~50,000+ tokens),
5
+ * inject only skill names (~200 tokens) and load full content
6
+ * when the model explicitly references a skill.
7
+ *
8
+ * Target: ~700 tokens vs 50,000+ if all loaded.
9
+ */
10
+
11
+ export interface SkillEntry {
12
+ /** Skill name (kebab-case). */
13
+ name: string;
14
+ /** Short description for name-only injection. */
15
+ description: string;
16
+ /** Full content — only loaded on-demand. */
17
+ fullContent: string;
18
+ /** Approximate token count of full content. */
19
+ tokens: number;
20
+ /** Category for grouping. */
21
+ category?: string;
22
+ }
23
+
24
+ export interface LazySkillStats {
25
+ /** Total skills available. */
26
+ totalAvailable: number;
27
+ /** Skills injected as names only. */
28
+ namesInjected: number;
29
+ /** Skills fully loaded this session. */
30
+ loadedOnDemand: number;
31
+ /** Tokens saved by not loading all skills. */
32
+ tokensSaved: number;
33
+ /** Names injection token cost. */
34
+ nameTokens: number;
35
+ }
36
+
37
+ /**
38
+ * Build a compact skill-name-only prompt section.
39
+ * Format: "- skill-name: short description"
40
+ * Stays within the name_budget token limit.
41
+ */
42
+ export function buildSkillNameIndex(skills: SkillEntry[], nameBudget: number): string {
43
+ let tokens = 0;
44
+ const lines: string[] = ["## Available Skills (load on-demand with skill name)"];
45
+
46
+ for (const skill of skills) {
47
+ const line = `- **${skill.name}**: ${skill.description}`;
48
+ const lineTokens = Math.ceil(line.length / 4);
49
+
50
+ if (tokens + lineTokens > nameBudget) {
51
+ lines.push(`- ...and ${skills.length - lines.length + 1} more`);
52
+ break;
53
+ }
54
+
55
+ lines.push(line);
56
+ tokens += lineTokens;
57
+ }
58
+
59
+ return lines.join("\n");
60
+ }
61
+
62
+ /**
63
+ * Detect which skill names are referenced in a message.
64
+ * Returns names of skills that should be fully loaded.
65
+ */
66
+ export function detectSkillReferences(message: string, availableSkills: SkillEntry[]): string[] {
67
+ const lower = message.toLowerCase();
68
+ const referenced: string[] = [];
69
+
70
+ for (const skill of availableSkills) {
71
+ // Direct name mention
72
+ if (lower.includes(skill.name)) {
73
+ referenced.push(skill.name);
74
+ continue;
75
+ }
76
+
77
+ // Description keyword match (at least 2 significant words)
78
+ const descWords = skill.description
79
+ .toLowerCase()
80
+ .split(/\s+/)
81
+ .filter((w) => w.length > 4);
82
+ const matchCount = descWords.filter((w) => lower.includes(w)).length;
83
+ if (matchCount >= 2) {
84
+ referenced.push(skill.name);
85
+ }
86
+ }
87
+
88
+ return referenced;
89
+ }
90
+
91
+ export class LazySkillLoader {
92
+ #skills: SkillEntry[] = [];
93
+ #loadedThisSession: Set<string> = new Set();
94
+ #stats: LazySkillStats = {
95
+ totalAvailable: 0,
96
+ namesInjected: 0,
97
+ loadedOnDemand: 0,
98
+ tokensSaved: 0,
99
+ nameTokens: 0,
100
+ };
101
+
102
+ /**
103
+ * Register available skills (called once at session start).
104
+ */
105
+ registerSkills(skills: SkillEntry[]): void {
106
+ this.#skills = skills;
107
+ this.#stats.totalAvailable = skills.length;
108
+ }
109
+
110
+ /**
111
+ * Get the compact skill name index for prompt injection.
112
+ * Returns the name-only section (~200 tokens).
113
+ */
114
+ getNameIndex(nameBudget: number = 200): string {
115
+ const index = buildSkillNameIndex(this.#skills, nameBudget);
116
+ this.#stats.namesInjected = Math.min(this.#skills.length, this.#countLines(index));
117
+ this.#stats.nameTokens = Math.ceil(index.length / 4);
118
+ return index;
119
+ }
120
+
121
+ /**
122
+ * Process a user message — detect skill references and return
123
+ * full content for skills that should be loaded.
124
+ */
125
+ processMessage(message: string, maxPerTurn: number = 3): {
126
+ skillsToLoad: SkillEntry[];
127
+ indexSection: string;
128
+ } {
129
+ const refs = detectSkillReferences(message, this.#skills);
130
+ const newRefs = refs.filter((r) => !this.#loadedThisSession.has(r));
131
+
132
+ // Limit per-turn loading
133
+ const toLoad = newRefs.slice(0, maxPerTurn);
134
+ const skillsToLoad: SkillEntry[] = [];
135
+
136
+ for (const name of toLoad) {
137
+ const skill = this.#skills.find((s) => s.name === name);
138
+ if (skill) {
139
+ skillsToLoad.push(skill);
140
+ this.#loadedThisSession.add(name);
141
+ this.#stats.loadedOnDemand++;
142
+ this.#stats.tokensSaved += skill.tokens - Math.ceil(skill.name.length / 4);
143
+ }
144
+ }
145
+
146
+ return {
147
+ skillsToLoad,
148
+ indexSection: this.getNameIndex(),
149
+ };
150
+ }
151
+
152
+ /** Get stats snapshot. */
153
+ get stats(): Readonly<LazySkillStats> {
154
+ return { ...this.#stats };
155
+ }
156
+
157
+ /** Get number of skills currently loaded in full. */
158
+ get loadedCount(): number {
159
+ return this.#loadedThisSession.size;
160
+ }
161
+
162
+ /** Check if a skill is already loaded. */
163
+ isLoaded(name: string): boolean {
164
+ return this.#loadedThisSession.has(name);
165
+ }
166
+
167
+ /** Reset loader state. */
168
+ reset(): void {
169
+ this.#loadedThisSession.clear();
170
+ this.#stats = {
171
+ totalAvailable: this.#skills.length,
172
+ namesInjected: 0,
173
+ loadedOnDemand: 0,
174
+ tokensSaved: 0,
175
+ nameTokens: 0,
176
+ };
177
+ }
178
+
179
+ #countLines(text: string): number {
180
+ return text.split("\n").filter((l) => l.startsWith("- **")).length;
181
+ }
182
+ }