wispy-cli 2.7.10 → 2.7.11
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/bin/wispy.mjs +130 -0
- package/core/config.mjs +29 -11
- package/core/features.mjs +225 -0
- package/core/loop-detector.mjs +183 -0
- package/core/memory.mjs +243 -0
- package/core/secrets.mjs +4 -4
- package/core/session.mjs +4 -1
- package/lib/jsonl-emitter.mjs +101 -0
- package/package.json +1 -1
package/bin/wispy.mjs
CHANGED
|
@@ -51,6 +51,9 @@ Usage:
|
|
|
51
51
|
wispy doctor Check system health
|
|
52
52
|
wispy browser [tabs|attach|navigate|screenshot|doctor]
|
|
53
53
|
Browser control via local-browser-bridge
|
|
54
|
+
wispy secrets [list|set|delete|get]
|
|
55
|
+
Manage encrypted secrets & API keys
|
|
56
|
+
wispy tts "<text>" Text-to-speech (OpenAI or macOS say)
|
|
54
57
|
wispy trust [level|log] Security level & audit
|
|
55
58
|
wispy ws [start-client|run-debug]
|
|
56
59
|
WebSocket operations
|
|
@@ -338,6 +341,133 @@ if (command === "browser") {
|
|
|
338
341
|
process.exit(0);
|
|
339
342
|
}
|
|
340
343
|
|
|
344
|
+
// ── Secrets ───────────────────────────────────────────────────────────────────
|
|
345
|
+
|
|
346
|
+
if (command === "secrets") {
|
|
347
|
+
try {
|
|
348
|
+
const { SecretsManager } = await import(join(rootDir, "core/secrets.mjs"));
|
|
349
|
+
const sm = new SecretsManager();
|
|
350
|
+
const sub = args[1];
|
|
351
|
+
|
|
352
|
+
if (!sub || sub === "list") {
|
|
353
|
+
const keys = await sm.list();
|
|
354
|
+
if (keys.length === 0) {
|
|
355
|
+
console.log("No secrets stored. Use: wispy secrets set <key> <value>");
|
|
356
|
+
} else {
|
|
357
|
+
console.log(`\n Stored secrets (${keys.length}):\n`);
|
|
358
|
+
for (const k of keys) {
|
|
359
|
+
console.log(` ${k}`);
|
|
360
|
+
}
|
|
361
|
+
console.log("");
|
|
362
|
+
}
|
|
363
|
+
} else if (sub === "set") {
|
|
364
|
+
const key = args[2];
|
|
365
|
+
const value = args[3];
|
|
366
|
+
if (!key || value === undefined) {
|
|
367
|
+
console.error("Usage: wispy secrets set <key> <value>");
|
|
368
|
+
process.exit(1);
|
|
369
|
+
}
|
|
370
|
+
await sm.set(key, value);
|
|
371
|
+
console.log(`Secret '${key}' stored (encrypted).`);
|
|
372
|
+
} else if (sub === "delete") {
|
|
373
|
+
const key = args[2];
|
|
374
|
+
if (!key) {
|
|
375
|
+
console.error("Usage: wispy secrets delete <key>");
|
|
376
|
+
process.exit(1);
|
|
377
|
+
}
|
|
378
|
+
const result = await sm.delete(key);
|
|
379
|
+
if (result.success) {
|
|
380
|
+
console.log(`Secret '${key}' deleted.`);
|
|
381
|
+
} else {
|
|
382
|
+
console.error(`Secret '${key}' not found.`);
|
|
383
|
+
process.exit(1);
|
|
384
|
+
}
|
|
385
|
+
} else if (sub === "get") {
|
|
386
|
+
const key = args[2];
|
|
387
|
+
if (!key) {
|
|
388
|
+
console.error("Usage: wispy secrets get <key>");
|
|
389
|
+
process.exit(1);
|
|
390
|
+
}
|
|
391
|
+
const value = await sm.resolve(key);
|
|
392
|
+
if (value) {
|
|
393
|
+
console.log(`${key} = ***`);
|
|
394
|
+
console.log("(Use --reveal flag to show value — not recommended)");
|
|
395
|
+
if (args.includes("--reveal")) {
|
|
396
|
+
console.log(`Value: ${value}`);
|
|
397
|
+
}
|
|
398
|
+
} else {
|
|
399
|
+
console.log(`Secret '${key}' not found.`);
|
|
400
|
+
}
|
|
401
|
+
} else {
|
|
402
|
+
console.error(`Unknown secrets subcommand: ${sub}`);
|
|
403
|
+
console.log("Available: list, set <key> <value>, delete <key>, get <key>");
|
|
404
|
+
process.exit(1);
|
|
405
|
+
}
|
|
406
|
+
} catch (err) {
|
|
407
|
+
console.error("Secrets error:", err.message);
|
|
408
|
+
process.exit(1);
|
|
409
|
+
}
|
|
410
|
+
process.exit(0);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ── TTS ───────────────────────────────────────────────────────────────────────
|
|
414
|
+
|
|
415
|
+
if (command === "tts") {
|
|
416
|
+
try {
|
|
417
|
+
const { SecretsManager } = await import(join(rootDir, "core/secrets.mjs"));
|
|
418
|
+
const { TTSManager } = await import(join(rootDir, "core/tts.mjs"));
|
|
419
|
+
|
|
420
|
+
const sm = new SecretsManager();
|
|
421
|
+
const tts = new TTSManager(sm);
|
|
422
|
+
|
|
423
|
+
// Parse args: wispy tts "text" [--voice name] [--provider openai|macos] [--play]
|
|
424
|
+
let textArg = args[1];
|
|
425
|
+
if (!textArg) {
|
|
426
|
+
console.error('Usage: wispy tts "text to speak" [--voice <name>] [--provider openai|macos] [--play]');
|
|
427
|
+
process.exit(1);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const voiceIdx = args.indexOf("--voice");
|
|
431
|
+
const providerIdx = args.indexOf("--provider");
|
|
432
|
+
const shouldPlay = args.includes("--play");
|
|
433
|
+
|
|
434
|
+
const voice = voiceIdx !== -1 ? args[voiceIdx + 1] : undefined;
|
|
435
|
+
const provider = providerIdx !== -1 ? args[providerIdx + 1] : "auto";
|
|
436
|
+
|
|
437
|
+
console.log(` Generating speech (provider: ${provider})...`);
|
|
438
|
+
const result = await tts.speak(textArg, { voice, provider });
|
|
439
|
+
|
|
440
|
+
if (result.error) {
|
|
441
|
+
console.error(` TTS Error: ${result.error}`);
|
|
442
|
+
process.exit(1);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
console.log(` Provider: ${result.provider}`);
|
|
446
|
+
console.log(` Voice: ${result.voice}`);
|
|
447
|
+
console.log(` Format: ${result.format}`);
|
|
448
|
+
console.log(` File: ${result.path}`);
|
|
449
|
+
|
|
450
|
+
if (shouldPlay) {
|
|
451
|
+
const { execFile } = await import("node:child_process");
|
|
452
|
+
const { promisify } = await import("node:util");
|
|
453
|
+
const exec = promisify(execFile);
|
|
454
|
+
try {
|
|
455
|
+
if (process.platform === "darwin") {
|
|
456
|
+
await exec("afplay", [result.path]);
|
|
457
|
+
} else {
|
|
458
|
+
console.log(' Use --play flag only on macOS. Play manually with your audio player.');
|
|
459
|
+
}
|
|
460
|
+
} catch (playErr) {
|
|
461
|
+
console.error(` Playback failed: ${playErr.message}`);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
} catch (err) {
|
|
465
|
+
console.error("TTS error:", err.message);
|
|
466
|
+
process.exit(1);
|
|
467
|
+
}
|
|
468
|
+
process.exit(0);
|
|
469
|
+
}
|
|
470
|
+
|
|
341
471
|
// ── Trust ─────────────────────────────────────────────────────────────────────
|
|
342
472
|
|
|
343
473
|
if (command === "trust") {
|
package/core/config.mjs
CHANGED
|
@@ -193,17 +193,35 @@ export async function detectProvider() {
|
|
|
193
193
|
}
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
-
// 4. macOS Keychain
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
[
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
196
|
+
// 4. macOS Keychain (via SecretsManager for unified resolution)
|
|
197
|
+
try {
|
|
198
|
+
const { getSecretsManager } = await import("./secrets.mjs");
|
|
199
|
+
const sm = getSecretsManager({ wispyDir: WISPY_DIR });
|
|
200
|
+
const keychainProviderMap = [
|
|
201
|
+
["GOOGLE_AI_KEY", "google"],
|
|
202
|
+
["ANTHROPIC_API_KEY", "anthropic"],
|
|
203
|
+
["OPENAI_API_KEY", "openai"],
|
|
204
|
+
];
|
|
205
|
+
for (const [envKey, provider] of keychainProviderMap) {
|
|
206
|
+
const key = await sm.resolve(envKey);
|
|
207
|
+
if (key) {
|
|
208
|
+
process.env[PROVIDERS[provider].envKeys[0]] = key;
|
|
209
|
+
return { provider, key, model: process.env.WISPY_MODEL ?? PROVIDERS[provider].defaultModel };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
} catch {
|
|
213
|
+
// SecretsManager unavailable — fallback to legacy Keychain
|
|
214
|
+
const keychainMap = [
|
|
215
|
+
["google-ai-key", "google"],
|
|
216
|
+
["anthropic-api-key", "anthropic"],
|
|
217
|
+
["openai-api-key", "openai"],
|
|
218
|
+
];
|
|
219
|
+
for (const [service, provider] of keychainMap) {
|
|
220
|
+
const key = await tryKeychainKey(service);
|
|
221
|
+
if (key) {
|
|
222
|
+
process.env[PROVIDERS[provider].envKeys[0]] = key;
|
|
223
|
+
return { provider, key, model: process.env.WISPY_MODEL ?? PROVIDERS[provider].defaultModel };
|
|
224
|
+
}
|
|
207
225
|
}
|
|
208
226
|
}
|
|
209
227
|
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* core/features.mjs — Feature flag system for Wispy
|
|
3
|
+
*
|
|
4
|
+
* Provides a registry of feature flags with stages (stable/experimental/development)
|
|
5
|
+
* and methods to enable/disable features, persisting state to config.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
10
|
+
import { WISPY_DIR, CONFIG_PATH, loadConfig, saveConfig } from "./config.mjs";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Registry of all known features.
|
|
14
|
+
* @type {Record<string, { stage: "stable"|"experimental"|"development", default: boolean, description: string }>}
|
|
15
|
+
*/
|
|
16
|
+
const FEATURE_REGISTRY = {
|
|
17
|
+
// ── Stable features ──────────────────────────────────────────────────────
|
|
18
|
+
smart_routing: {
|
|
19
|
+
stage: "stable",
|
|
20
|
+
default: true,
|
|
21
|
+
description: "Route tasks to optimal models",
|
|
22
|
+
},
|
|
23
|
+
task_decomposition: {
|
|
24
|
+
stage: "stable",
|
|
25
|
+
default: true,
|
|
26
|
+
description: "Split complex tasks into parallel subtasks",
|
|
27
|
+
},
|
|
28
|
+
loop_detection: {
|
|
29
|
+
stage: "stable",
|
|
30
|
+
default: true,
|
|
31
|
+
description: "Detect and break tool-call loops",
|
|
32
|
+
},
|
|
33
|
+
context_compaction: {
|
|
34
|
+
stage: "stable",
|
|
35
|
+
default: true,
|
|
36
|
+
description: "Auto-compact context when approaching token limit",
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
// ── Experimental features ────────────────────────────────────────────────
|
|
40
|
+
browser_integration: {
|
|
41
|
+
stage: "experimental",
|
|
42
|
+
default: false,
|
|
43
|
+
description: "Native browser control via local-browser-bridge",
|
|
44
|
+
},
|
|
45
|
+
auto_memory: {
|
|
46
|
+
stage: "experimental",
|
|
47
|
+
default: false,
|
|
48
|
+
description: "Auto-extract facts from conversations to memory",
|
|
49
|
+
},
|
|
50
|
+
tts: {
|
|
51
|
+
stage: "experimental",
|
|
52
|
+
default: false,
|
|
53
|
+
description: "Text-to-speech output",
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
// ── Under development ────────────────────────────────────────────────────
|
|
57
|
+
multi_agent: {
|
|
58
|
+
stage: "development",
|
|
59
|
+
default: false,
|
|
60
|
+
description: "Multi-agent orchestration patterns",
|
|
61
|
+
},
|
|
62
|
+
cloud_sync: {
|
|
63
|
+
stage: "development",
|
|
64
|
+
default: false,
|
|
65
|
+
description: "Sync sessions to cloud",
|
|
66
|
+
},
|
|
67
|
+
image_generation: {
|
|
68
|
+
stage: "development",
|
|
69
|
+
default: false,
|
|
70
|
+
description: "Generate images from prompts",
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export class FeatureManager {
|
|
75
|
+
/**
|
|
76
|
+
* @param {string} [configPath] - Path to config.json (defaults to ~/.wispy/config.json)
|
|
77
|
+
*/
|
|
78
|
+
constructor(configPath) {
|
|
79
|
+
this._configPath = configPath ?? CONFIG_PATH;
|
|
80
|
+
/** @type {Record<string, boolean>} — cached overrides from config */
|
|
81
|
+
this._overrides = null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Load feature overrides from config (lazy, cached per call).
|
|
86
|
+
* @returns {Promise<Record<string, boolean>>}
|
|
87
|
+
*/
|
|
88
|
+
async _loadOverrides() {
|
|
89
|
+
if (this._overrides !== null) return this._overrides;
|
|
90
|
+
try {
|
|
91
|
+
const raw = await readFile(this._configPath, "utf8");
|
|
92
|
+
const cfg = JSON.parse(raw);
|
|
93
|
+
this._overrides = cfg.features ?? {};
|
|
94
|
+
} catch {
|
|
95
|
+
this._overrides = {};
|
|
96
|
+
}
|
|
97
|
+
return this._overrides;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Invalidate the cache so next read picks up fresh config. */
|
|
101
|
+
_invalidate() {
|
|
102
|
+
this._overrides = null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Check if a feature is enabled.
|
|
107
|
+
* Config override takes precedence over registry default.
|
|
108
|
+
* Profile-level features (passed in opts) take precedence over global config.
|
|
109
|
+
* @param {string} name
|
|
110
|
+
* @param {Record<string, boolean>} [profileFeatures] - Profile-level overrides
|
|
111
|
+
* @returns {Promise<boolean>}
|
|
112
|
+
*/
|
|
113
|
+
async isEnabled(name, profileFeatures = {}) {
|
|
114
|
+
const reg = FEATURE_REGISTRY[name];
|
|
115
|
+
if (!reg) return false; // Unknown features are disabled
|
|
116
|
+
|
|
117
|
+
// Profile override first
|
|
118
|
+
if (name in profileFeatures) return Boolean(profileFeatures[name]);
|
|
119
|
+
|
|
120
|
+
// Config override second
|
|
121
|
+
const overrides = await this._loadOverrides();
|
|
122
|
+
if (name in overrides) return Boolean(overrides[name]);
|
|
123
|
+
|
|
124
|
+
// Registry default
|
|
125
|
+
return reg.default;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Synchronous isEnabled using pre-loaded overrides.
|
|
130
|
+
* Call _loadOverrides() first if needed.
|
|
131
|
+
* @param {string} name
|
|
132
|
+
* @param {Record<string, boolean>} [overrides]
|
|
133
|
+
* @param {Record<string, boolean>} [profileFeatures]
|
|
134
|
+
* @returns {boolean}
|
|
135
|
+
*/
|
|
136
|
+
isEnabledSync(name, overrides = {}, profileFeatures = {}) {
|
|
137
|
+
const reg = FEATURE_REGISTRY[name];
|
|
138
|
+
if (!reg) return false;
|
|
139
|
+
if (name in profileFeatures) return Boolean(profileFeatures[name]);
|
|
140
|
+
if (name in overrides) return Boolean(overrides[name]);
|
|
141
|
+
return reg.default;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Enable a feature and persist to config.
|
|
146
|
+
* @param {string} name
|
|
147
|
+
*/
|
|
148
|
+
async enable(name) {
|
|
149
|
+
if (!FEATURE_REGISTRY[name]) {
|
|
150
|
+
throw new Error(`Unknown feature: "${name}". Use "wispy features" to see available features.`);
|
|
151
|
+
}
|
|
152
|
+
const raw = await this._readConfig();
|
|
153
|
+
if (!raw.features) raw.features = {};
|
|
154
|
+
raw.features[name] = true;
|
|
155
|
+
await this._writeConfig(raw);
|
|
156
|
+
this._invalidate();
|
|
157
|
+
return { success: true, name, enabled: true };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Disable a feature and persist to config.
|
|
162
|
+
* @param {string} name
|
|
163
|
+
*/
|
|
164
|
+
async disable(name) {
|
|
165
|
+
if (!FEATURE_REGISTRY[name]) {
|
|
166
|
+
throw new Error(`Unknown feature: "${name}". Use "wispy features" to see available features.`);
|
|
167
|
+
}
|
|
168
|
+
const raw = await this._readConfig();
|
|
169
|
+
if (!raw.features) raw.features = {};
|
|
170
|
+
raw.features[name] = false;
|
|
171
|
+
await this._writeConfig(raw);
|
|
172
|
+
this._invalidate();
|
|
173
|
+
return { success: true, name, enabled: false };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* List all features with their current status.
|
|
178
|
+
* @returns {Promise<Array<{ name: string, stage: string, enabled: boolean, default: boolean, description: string }>>}
|
|
179
|
+
*/
|
|
180
|
+
async list() {
|
|
181
|
+
const overrides = await this._loadOverrides();
|
|
182
|
+
return Object.entries(FEATURE_REGISTRY).map(([name, meta]) => ({
|
|
183
|
+
name,
|
|
184
|
+
stage: meta.stage,
|
|
185
|
+
enabled: name in overrides ? Boolean(overrides[name]) : meta.default,
|
|
186
|
+
default: meta.default,
|
|
187
|
+
description: meta.description,
|
|
188
|
+
}));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Get the stage of a feature.
|
|
193
|
+
* @param {string} name
|
|
194
|
+
* @returns {"stable"|"experimental"|"development"|null}
|
|
195
|
+
*/
|
|
196
|
+
getStage(name) {
|
|
197
|
+
return FEATURE_REGISTRY[name]?.stage ?? null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async _readConfig() {
|
|
201
|
+
await mkdir(WISPY_DIR, { recursive: true });
|
|
202
|
+
try {
|
|
203
|
+
return JSON.parse(await readFile(this._configPath, "utf8"));
|
|
204
|
+
} catch {
|
|
205
|
+
return {};
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async _writeConfig(cfg) {
|
|
210
|
+
await mkdir(WISPY_DIR, { recursive: true });
|
|
211
|
+
await writeFile(this._configPath, JSON.stringify(cfg, null, 2) + "\n", "utf8");
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Singleton instance */
|
|
216
|
+
let _instance = null;
|
|
217
|
+
|
|
218
|
+
/** Get or create the global FeatureManager instance. */
|
|
219
|
+
export function getFeatureManager(configPath) {
|
|
220
|
+
if (!_instance) _instance = new FeatureManager(configPath);
|
|
221
|
+
return _instance;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Export the registry for introspection. */
|
|
225
|
+
export { FEATURE_REGISTRY };
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* core/loop-detector.mjs — Tool-call loop detection for Wispy
|
|
3
|
+
*
|
|
4
|
+
* Detects four patterns:
|
|
5
|
+
* 1. Exact repeat: same tool + same args hash 3+ times in window
|
|
6
|
+
* 2. Oscillation: A→B→A→B pattern (alternating between two calls)
|
|
7
|
+
* 3. No progress: 5+ tool calls where result hash doesn't change
|
|
8
|
+
* 4. Error loop: same tool fails 3+ times in a row
|
|
9
|
+
*
|
|
10
|
+
* v1.0.0
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { EventEmitter } from "node:events";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Compute a short hash of any value.
|
|
18
|
+
*/
|
|
19
|
+
function hashValue(value) {
|
|
20
|
+
if (value === null || value === undefined) return "null";
|
|
21
|
+
let str;
|
|
22
|
+
try {
|
|
23
|
+
str = JSON.stringify(value);
|
|
24
|
+
} catch {
|
|
25
|
+
str = String(value);
|
|
26
|
+
}
|
|
27
|
+
return createHash("sha1").update(str).digest("hex").slice(0, 16);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class LoopDetector extends EventEmitter {
|
|
31
|
+
/**
|
|
32
|
+
* @param {object} options
|
|
33
|
+
* @param {number} options.windowSize - Look at last N calls (default 10)
|
|
34
|
+
* @param {number} options.maxRepeats - Same call N times = loop (default 3)
|
|
35
|
+
* @param {number} options.maxNoProgress - N calls with no new output = stuck (default 5)
|
|
36
|
+
*/
|
|
37
|
+
constructor(options = {}) {
|
|
38
|
+
super();
|
|
39
|
+
this.windowSize = options.windowSize ?? 10;
|
|
40
|
+
this.maxRepeats = options.maxRepeats ?? 3;
|
|
41
|
+
this.maxNoProgress = options.maxNoProgress ?? 5;
|
|
42
|
+
|
|
43
|
+
// Array of { tool, argsHash, resultHash, isError, timestamp }
|
|
44
|
+
this._history = [];
|
|
45
|
+
|
|
46
|
+
// How many warnings have been emitted (to decide force-break)
|
|
47
|
+
this._warningCount = 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Record a tool call result.
|
|
52
|
+
* @param {string} toolName
|
|
53
|
+
* @param {object} args
|
|
54
|
+
* @param {any} result - The result (or Error/string on failure)
|
|
55
|
+
*/
|
|
56
|
+
record(toolName, args, result) {
|
|
57
|
+
const argsHash = hashValue(args);
|
|
58
|
+
const isError = (result && typeof result === "object" && result.success === false)
|
|
59
|
+
|| (result instanceof Error);
|
|
60
|
+
|
|
61
|
+
// Use error message as result hash if error, so errors are deduplicated
|
|
62
|
+
const resultForHash = isError
|
|
63
|
+
? (result?.error ?? result?.message ?? "error")
|
|
64
|
+
: result;
|
|
65
|
+
|
|
66
|
+
const resultHash = hashValue(resultForHash);
|
|
67
|
+
|
|
68
|
+
this._history.push({
|
|
69
|
+
tool: toolName,
|
|
70
|
+
argsHash,
|
|
71
|
+
resultHash,
|
|
72
|
+
isError,
|
|
73
|
+
timestamp: Date.now(),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Keep only the window
|
|
77
|
+
if (this._history.length > this.windowSize) {
|
|
78
|
+
this._history = this._history.slice(-this.windowSize);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Check if the agent is in a loop.
|
|
84
|
+
* @returns {{ looping: boolean, reason?: string, suggestion?: string, warningCount: number }}
|
|
85
|
+
*/
|
|
86
|
+
check() {
|
|
87
|
+
const history = this._history;
|
|
88
|
+
if (history.length < 2) return { looping: false, warningCount: this._warningCount };
|
|
89
|
+
|
|
90
|
+
// ── 1. Exact repeat: same tool + same args hash N+ times in window ────────
|
|
91
|
+
const callKey = (e) => `${e.tool}:${e.argsHash}`;
|
|
92
|
+
const counts = new Map();
|
|
93
|
+
for (const entry of history) {
|
|
94
|
+
const key = callKey(entry);
|
|
95
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
96
|
+
}
|
|
97
|
+
for (const [key, count] of counts) {
|
|
98
|
+
if (count >= this.maxRepeats) {
|
|
99
|
+
const [tool] = key.split(":");
|
|
100
|
+
this._warningCount++;
|
|
101
|
+
return {
|
|
102
|
+
looping: true,
|
|
103
|
+
reason: `exact_repeat`,
|
|
104
|
+
tool,
|
|
105
|
+
count,
|
|
106
|
+
warningCount: this._warningCount,
|
|
107
|
+
suggestion: `Loop detected: you've called ${tool} with the same arguments ${count} times. Try a different approach or verify the outcome before retrying.`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── 2. Oscillation: A→B→A→B pattern ─────────────────────────────────────
|
|
113
|
+
if (history.length >= 4) {
|
|
114
|
+
const last4 = history.slice(-4).map(callKey);
|
|
115
|
+
// A→B→A→B
|
|
116
|
+
if (last4[0] === last4[2] && last4[1] === last4[3] && last4[0] !== last4[1]) {
|
|
117
|
+
const toolA = history[history.length - 4].tool;
|
|
118
|
+
const toolB = history[history.length - 3].tool;
|
|
119
|
+
this._warningCount++;
|
|
120
|
+
return {
|
|
121
|
+
looping: true,
|
|
122
|
+
reason: `oscillation`,
|
|
123
|
+
tool: `${toolA}↔${toolB}`,
|
|
124
|
+
warningCount: this._warningCount,
|
|
125
|
+
suggestion: `Loop detected: oscillating between ${toolA} and ${toolB}. These calls aren't making progress. Try a different strategy.`,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── 3. No progress: N consecutive calls with identical result hash ────────
|
|
131
|
+
if (history.length >= this.maxNoProgress) {
|
|
132
|
+
const recent = history.slice(-this.maxNoProgress);
|
|
133
|
+
const firstHash = recent[0].resultHash;
|
|
134
|
+
// All results are the same AND none are "null" (null = tool produced nothing)
|
|
135
|
+
if (firstHash !== "null" && recent.every(e => e.resultHash === firstHash)) {
|
|
136
|
+
const tool = recent[recent.length - 1].tool;
|
|
137
|
+
this._warningCount++;
|
|
138
|
+
return {
|
|
139
|
+
looping: true,
|
|
140
|
+
reason: `no_progress`,
|
|
141
|
+
tool,
|
|
142
|
+
count: this.maxNoProgress,
|
|
143
|
+
warningCount: this._warningCount,
|
|
144
|
+
suggestion: `Loop detected: ${this.maxNoProgress} consecutive tool calls produced identical results. The agent appears to be stuck — try a different approach.`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── 4. Error loop: same tool fails 3+ times in a row ─────────────────────
|
|
150
|
+
if (history.length >= 3) {
|
|
151
|
+
const recent = history.slice(-3);
|
|
152
|
+
if (recent.every(e => e.isError && e.tool === recent[0].tool)) {
|
|
153
|
+
const tool = recent[0].tool;
|
|
154
|
+
this._warningCount++;
|
|
155
|
+
return {
|
|
156
|
+
looping: true,
|
|
157
|
+
reason: `error_loop`,
|
|
158
|
+
tool,
|
|
159
|
+
count: 3,
|
|
160
|
+
warningCount: this._warningCount,
|
|
161
|
+
suggestion: `Loop detected: ${tool} has failed 3 times in a row. Stop retrying and try a different approach or report the error.`,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return { looping: false, warningCount: this._warningCount };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Reset the detector state (e.g., after user intervention or successful progress).
|
|
171
|
+
*/
|
|
172
|
+
reset() {
|
|
173
|
+
this._history = [];
|
|
174
|
+
this._warningCount = 0;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Returns the number of recorded entries.
|
|
179
|
+
*/
|
|
180
|
+
get size() {
|
|
181
|
+
return this._history.length;
|
|
182
|
+
}
|
|
183
|
+
}
|
package/core/memory.mjs
CHANGED
|
@@ -293,4 +293,247 @@ ${recentUserMsgs.slice(0, 2000)}`;
|
|
|
293
293
|
return null;
|
|
294
294
|
}
|
|
295
295
|
}
|
|
296
|
+
|
|
297
|
+
// ── v2.8 Enhanced Memory Methods ────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Get top N most relevant memories for a given user message.
|
|
301
|
+
* Uses keyword matching + recency scoring + frequency weighting.
|
|
302
|
+
*
|
|
303
|
+
* @param {string} userMessage
|
|
304
|
+
* @param {number} limit - max memories to return (default 3)
|
|
305
|
+
* @returns {Promise<string|null>} formatted system message injection or null
|
|
306
|
+
*/
|
|
307
|
+
async getRelevantMemories(userMessage, limit = 3) {
|
|
308
|
+
if (!userMessage?.trim()) return null;
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
await this._ensureDir();
|
|
312
|
+
const terms = this._extractKeywords(userMessage);
|
|
313
|
+
if (terms.length === 0) return null;
|
|
314
|
+
|
|
315
|
+
// Collect all memory files with metadata
|
|
316
|
+
const allKeys = await this.list();
|
|
317
|
+
if (allKeys.length === 0) return null;
|
|
318
|
+
|
|
319
|
+
const scored = [];
|
|
320
|
+
const now = Date.now();
|
|
321
|
+
|
|
322
|
+
for (const item of allKeys) {
|
|
323
|
+
const content = await readFile(item.path, "utf8").catch(() => "");
|
|
324
|
+
if (!content.trim()) continue;
|
|
325
|
+
|
|
326
|
+
// 1. Keyword match score
|
|
327
|
+
const lower = content.toLowerCase();
|
|
328
|
+
let matchScore = 0;
|
|
329
|
+
for (const term of terms) {
|
|
330
|
+
// Count occurrences for stronger weighting
|
|
331
|
+
const regex = new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g");
|
|
332
|
+
const matches = (lower.match(regex) ?? []).length;
|
|
333
|
+
matchScore += matches;
|
|
334
|
+
}
|
|
335
|
+
if (matchScore === 0) continue;
|
|
336
|
+
|
|
337
|
+
// 2. Recency score (0–1, decays over 30 days)
|
|
338
|
+
const ageDays = item.updatedAt
|
|
339
|
+
? (now - new Date(item.updatedAt).getTime()) / (1000 * 60 * 60 * 24)
|
|
340
|
+
: 30;
|
|
341
|
+
const recencyScore = Math.max(0, 1 - ageDays / 30);
|
|
342
|
+
|
|
343
|
+
// 3. Frequency score (often-accessed memories rank higher)
|
|
344
|
+
const freq = this._accessFrequency.get(item.key) ?? 0;
|
|
345
|
+
const freqScore = Math.min(freq / 10, 1); // cap at 10 accesses = 1.0
|
|
346
|
+
|
|
347
|
+
// Combined score
|
|
348
|
+
const totalScore = matchScore * 2 + recencyScore * 1.5 + freqScore;
|
|
349
|
+
|
|
350
|
+
scored.push({ key: item.key, content, score: totalScore });
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (scored.length === 0) return null;
|
|
354
|
+
|
|
355
|
+
// Sort by score descending, take top N
|
|
356
|
+
scored.sort((a, b) => b.score - a.score);
|
|
357
|
+
const top = scored.slice(0, limit);
|
|
358
|
+
|
|
359
|
+
// Format as system message injection
|
|
360
|
+
const parts = top.map(m => {
|
|
361
|
+
const snippet = m.content.trim().slice(0, 500);
|
|
362
|
+
return `### memory:${m.key}\n${snippet}`;
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
return parts.join("\n\n");
|
|
366
|
+
} catch {
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Auto-extract important facts from conversation and save to memory.
|
|
373
|
+
* Triggered every AUTO_EXTRACT_INTERVAL messages.
|
|
374
|
+
*
|
|
375
|
+
* @param {Array<{role,content}>} messages - conversation history
|
|
376
|
+
* @param {Function} aiCall - async (prompt) => string
|
|
377
|
+
* @returns {Promise<string|null>} extracted facts or null
|
|
378
|
+
*/
|
|
379
|
+
async autoExtractFacts(messages, aiCall) {
|
|
380
|
+
if (!messages?.length || !aiCall) return null;
|
|
381
|
+
|
|
382
|
+
// Only run every N messages to avoid excessive AI calls
|
|
383
|
+
const userMsgs = messages.filter(m => m.role === "user");
|
|
384
|
+
if (userMsgs.length % AUTO_EXTRACT_INTERVAL !== 0) return null;
|
|
385
|
+
|
|
386
|
+
const recentMsgs = messages.slice(-AUTO_EXTRACT_INTERVAL * 2);
|
|
387
|
+
const conversationText = recentMsgs
|
|
388
|
+
.map(m => `${m.role === "user" ? "User" : "Assistant"}: ${
|
|
389
|
+
typeof m.content === "string" ? m.content : JSON.stringify(m.content)
|
|
390
|
+
}`)
|
|
391
|
+
.join("\n\n");
|
|
392
|
+
|
|
393
|
+
if (!conversationText.trim()) return null;
|
|
394
|
+
|
|
395
|
+
try {
|
|
396
|
+
const prompt = `Analyze this conversation and extract key facts, preferences, or important information worth remembering for future sessions. Be specific and concise. Format as bullet points. If nothing important, reply "nothing".
|
|
397
|
+
|
|
398
|
+
Conversation:
|
|
399
|
+
${conversationText.slice(0, 3000)}
|
|
400
|
+
|
|
401
|
+
Examples of things to extract:
|
|
402
|
+
- User preferences (tools, languages, styles)
|
|
403
|
+
- Project names and goals
|
|
404
|
+
- Personal context (name, location, role)
|
|
405
|
+
- Decisions made
|
|
406
|
+
- Important deadlines or dates`;
|
|
407
|
+
|
|
408
|
+
const result = await aiCall(prompt);
|
|
409
|
+
if (!result || result.toLowerCase().trim() === "nothing") return null;
|
|
410
|
+
|
|
411
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
412
|
+
|
|
413
|
+
// Deduplicate: check if very similar content already exists
|
|
414
|
+
const existingDaily = await this.get(`daily/${today}`);
|
|
415
|
+
if (existingDaily?.content?.includes(result.slice(0, 50))) return null;
|
|
416
|
+
|
|
417
|
+
await this.append(`daily/${today}`, result.slice(0, 800));
|
|
418
|
+
return result;
|
|
419
|
+
} catch {
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Fuzzy search across all memory files.
|
|
426
|
+
* Extends base search() with recency and frequency weighting.
|
|
427
|
+
*
|
|
428
|
+
* @param {string} query
|
|
429
|
+
* @param {object} opts
|
|
430
|
+
* @param {number} [opts.limit]
|
|
431
|
+
* @param {boolean} [opts.fuzzy] - enable fuzzy matching (default true)
|
|
432
|
+
* @returns {Promise<Array>}
|
|
433
|
+
*/
|
|
434
|
+
async searchEnhanced(query, opts = {}) {
|
|
435
|
+
if (!query?.trim()) return [];
|
|
436
|
+
await this._ensureDir();
|
|
437
|
+
|
|
438
|
+
const terms = this._extractKeywords(query);
|
|
439
|
+
if (terms.length === 0) return [];
|
|
440
|
+
|
|
441
|
+
const results = [];
|
|
442
|
+
await this._searchDirEnhanced(this.memoryDir, this.memoryDir, terms, results, opts.fuzzy !== false);
|
|
443
|
+
|
|
444
|
+
const now = Date.now();
|
|
445
|
+
|
|
446
|
+
// Apply recency and frequency weighting to results
|
|
447
|
+
for (const r of results) {
|
|
448
|
+
try {
|
|
449
|
+
const fileStat = await stat(r.path).catch(() => null);
|
|
450
|
+
const ageDays = fileStat?.mtime
|
|
451
|
+
? (now - fileStat.mtime.getTime()) / (1000 * 60 * 60 * 24)
|
|
452
|
+
: 30;
|
|
453
|
+
const recencyBonus = Math.max(0, 1 - ageDays / 30) * 2;
|
|
454
|
+
const freqBonus = Math.min((this._accessFrequency.get(r.key) ?? 0) / 5, 1);
|
|
455
|
+
r.matchCount = r.matchCount + recencyBonus + freqBonus;
|
|
456
|
+
} catch { /* ignore stat errors */ }
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
results.sort((a, b) => b.matchCount - a.matchCount);
|
|
460
|
+
return results.slice(0, opts.limit ?? MAX_SEARCH_RESULTS);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async _searchDirEnhanced(baseDir, dir, terms, results, fuzzy = true) {
|
|
464
|
+
let entries;
|
|
465
|
+
try {
|
|
466
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
467
|
+
} catch {
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
for (const entry of entries) {
|
|
471
|
+
const fullPath = path.join(dir, entry.name);
|
|
472
|
+
if (entry.isDirectory()) {
|
|
473
|
+
await this._searchDirEnhanced(baseDir, fullPath, terms, results, fuzzy);
|
|
474
|
+
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
475
|
+
const content = await readFile(fullPath, "utf8").catch(() => "");
|
|
476
|
+
const lines = content.split("\n");
|
|
477
|
+
const key = path.relative(baseDir, fullPath).replace(/\.md$/, "");
|
|
478
|
+
|
|
479
|
+
let matchCount = 0;
|
|
480
|
+
const matchingLines = [];
|
|
481
|
+
|
|
482
|
+
for (let i = 0; i < lines.length; i++) {
|
|
483
|
+
const line = lines[i];
|
|
484
|
+
const lower = line.toLowerCase();
|
|
485
|
+
|
|
486
|
+
let lineMatches = terms.filter(t => lower.includes(t)).length;
|
|
487
|
+
|
|
488
|
+
// Fuzzy: also check for partial matches (e.g. "proj" matches "project")
|
|
489
|
+
if (fuzzy && lineMatches === 0) {
|
|
490
|
+
lineMatches = terms.filter(t =>
|
|
491
|
+
t.length >= 4 && lower.split(/\s+/).some(word => word.startsWith(t.slice(0, -1)))
|
|
492
|
+
).length * 0.5; // partial credit
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (lineMatches > 0) {
|
|
496
|
+
matchCount += lineMatches;
|
|
497
|
+
matchingLines.push({
|
|
498
|
+
lineNumber: i + 1,
|
|
499
|
+
text: line.slice(0, MAX_SNIPPET_CHARS),
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (matchCount > 0) {
|
|
505
|
+
results.push({
|
|
506
|
+
key,
|
|
507
|
+
path: fullPath,
|
|
508
|
+
matchCount,
|
|
509
|
+
snippets: matchingLines.slice(0, 5),
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Extract meaningful keywords from a string for search/scoring.
|
|
518
|
+
* Filters out common stop words.
|
|
519
|
+
*/
|
|
520
|
+
_extractKeywords(text) {
|
|
521
|
+
const STOP_WORDS = new Set([
|
|
522
|
+
"the", "a", "an", "is", "are", "was", "were", "be", "been",
|
|
523
|
+
"have", "has", "had", "do", "does", "did", "will", "would",
|
|
524
|
+
"could", "should", "may", "might", "can", "shall",
|
|
525
|
+
"i", "you", "he", "she", "it", "we", "they", "me", "him", "her",
|
|
526
|
+
"us", "them", "my", "your", "his", "its", "our", "their",
|
|
527
|
+
"this", "that", "these", "those", "what", "which", "who",
|
|
528
|
+
"how", "when", "where", "why", "and", "or", "but", "not",
|
|
529
|
+
"in", "on", "at", "to", "for", "of", "with", "by", "from",
|
|
530
|
+
"up", "out", "as", "if", "then", "than", "so", "just",
|
|
531
|
+
]);
|
|
532
|
+
|
|
533
|
+
return text
|
|
534
|
+
.toLowerCase()
|
|
535
|
+
.replace(/[^a-z0-9\s]/g, " ")
|
|
536
|
+
.split(/\s+/)
|
|
537
|
+
.filter(w => w.length >= 3 && !STOP_WORDS.has(w));
|
|
538
|
+
}
|
|
296
539
|
}
|
package/core/secrets.mjs
CHANGED
|
@@ -44,15 +44,15 @@ export class SecretsManager {
|
|
|
44
44
|
async resolve(key) {
|
|
45
45
|
if (!key) return null;
|
|
46
46
|
|
|
47
|
-
// 1.
|
|
48
|
-
if (this._cache.has(key)) return this._cache.get(key);
|
|
49
|
-
|
|
50
|
-
// 2. Environment variable
|
|
47
|
+
// 1. Environment variable (always takes priority — can be overridden at runtime)
|
|
51
48
|
if (process.env[key]) {
|
|
52
49
|
this._cache.set(key, process.env[key]);
|
|
53
50
|
return process.env[key];
|
|
54
51
|
}
|
|
55
52
|
|
|
53
|
+
// 2. In-memory cache (from previous keychain/secrets.json lookup)
|
|
54
|
+
if (this._cache.has(key)) return this._cache.get(key);
|
|
55
|
+
|
|
56
56
|
// 3. macOS Keychain — try common service name patterns
|
|
57
57
|
const keychainValue = await this._fromKeychain(key, null);
|
|
58
58
|
if (keychainValue) {
|
package/core/session.mjs
CHANGED
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
* - create({ workstream?, channel?, chatId? }) → Session
|
|
6
6
|
* - get(id) → Session
|
|
7
7
|
* - list(filter?) → Session[]
|
|
8
|
+
* - listSessions(options?) → metadata array (from disk)
|
|
9
|
+
* - loadSession(id) → full session with messages (alias for load)
|
|
10
|
+
* - forkSession(id) → new session copied from existing
|
|
8
11
|
* - addMessage(id, message) → void
|
|
9
12
|
* - clear(id) → void
|
|
10
13
|
* - save(id) → void
|
|
@@ -16,7 +19,7 @@
|
|
|
16
19
|
|
|
17
20
|
import os from "node:os";
|
|
18
21
|
import path from "node:path";
|
|
19
|
-
import { readFile, writeFile, mkdir, readdir } from "node:fs/promises";
|
|
22
|
+
import { readFile, writeFile, mkdir, readdir, stat } from "node:fs/promises";
|
|
20
23
|
import { SESSIONS_DIR } from "./config.mjs";
|
|
21
24
|
|
|
22
25
|
export class Session {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/jsonl-emitter.mjs — Structured JSONL event emitter for CI/pipeline integration
|
|
3
|
+
*
|
|
4
|
+
* When --json flag is used, all events are emitted as newline-delimited JSON
|
|
5
|
+
* to stdout. Event types: start, user_message, assistant_message,
|
|
6
|
+
* tool_call, tool_result, error, done.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export class JsonlEmitter {
|
|
10
|
+
/**
|
|
11
|
+
* Emit a raw event object as a JSON line to stdout.
|
|
12
|
+
* @param {object} event
|
|
13
|
+
*/
|
|
14
|
+
emit(event) {
|
|
15
|
+
process.stdout.write(JSON.stringify(event) + "\n");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Emit a "start" event with session metadata.
|
|
20
|
+
* @param {{ model?: string, session_id?: string, [key: string]: any }} meta
|
|
21
|
+
*/
|
|
22
|
+
start(meta) {
|
|
23
|
+
this.emit({ type: "start", ...meta, timestamp: new Date().toISOString() });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Emit a "tool_call" event.
|
|
28
|
+
* @param {string} name - Tool name
|
|
29
|
+
* @param {object} args - Tool arguments
|
|
30
|
+
*/
|
|
31
|
+
toolCall(name, args) {
|
|
32
|
+
this.emit({ type: "tool_call", name, args, timestamp: new Date().toISOString() });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Emit a "tool_result" event.
|
|
37
|
+
* @param {string} name - Tool name
|
|
38
|
+
* @param {string|object} result - Tool result
|
|
39
|
+
* @param {number} durationMs - Duration in milliseconds
|
|
40
|
+
*/
|
|
41
|
+
toolResult(name, result, durationMs) {
|
|
42
|
+
this.emit({
|
|
43
|
+
type: "tool_result",
|
|
44
|
+
name,
|
|
45
|
+
result: typeof result === "string" ? result.slice(0, 1000) : result,
|
|
46
|
+
duration_ms: durationMs,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Emit a user_message or assistant_message event.
|
|
52
|
+
* @param {"user"|"assistant"} role
|
|
53
|
+
* @param {string} content
|
|
54
|
+
*/
|
|
55
|
+
message(role, content) {
|
|
56
|
+
this.emit({ type: `${role}_message`, content, timestamp: new Date().toISOString() });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Emit an "error" event.
|
|
61
|
+
* @param {Error|string} err
|
|
62
|
+
*/
|
|
63
|
+
error(err) {
|
|
64
|
+
this.emit({
|
|
65
|
+
type: "error",
|
|
66
|
+
message: err?.message ?? String(err),
|
|
67
|
+
timestamp: new Date().toISOString(),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Emit a "done" event with final stats.
|
|
73
|
+
* @param {{ tokens?: { input: number, output: number }, duration_ms?: number, [key: string]: any }} stats
|
|
74
|
+
*/
|
|
75
|
+
done(stats) {
|
|
76
|
+
this.emit({ type: "done", ...stats, timestamp: new Date().toISOString() });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A no-op emitter used when --json flag is not set.
|
|
82
|
+
* All methods are silent.
|
|
83
|
+
*/
|
|
84
|
+
export class NullEmitter {
|
|
85
|
+
emit() {}
|
|
86
|
+
start() {}
|
|
87
|
+
toolCall() {}
|
|
88
|
+
toolResult() {}
|
|
89
|
+
message() {}
|
|
90
|
+
error() {}
|
|
91
|
+
done() {}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Create the appropriate emitter based on whether JSON mode is active.
|
|
96
|
+
* @param {boolean} jsonMode
|
|
97
|
+
* @returns {JsonlEmitter|NullEmitter}
|
|
98
|
+
*/
|
|
99
|
+
export function createEmitter(jsonMode) {
|
|
100
|
+
return jsonMode ? new JsonlEmitter() : new NullEmitter();
|
|
101
|
+
}
|
package/package.json
CHANGED