wispy-cli 2.7.11 → 2.7.13
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 +172 -0
- package/core/config.mjs +34 -0
- package/core/engine.mjs +107 -12
- package/core/harness.mjs +222 -1
- package/core/index.mjs +2 -1
- package/core/providers.mjs +30 -0
- package/core/session.mjs +103 -0
- package/core/tools.mjs +204 -1
- package/lib/commands/review.mjs +272 -0
- package/lib/commands/trust.mjs +57 -0
- package/package.json +1 -1
package/bin/wispy.mjs
CHANGED
|
@@ -23,6 +23,47 @@ const rootDir = join(__dirname, "..");
|
|
|
23
23
|
const args = process.argv.slice(2);
|
|
24
24
|
const command = args[0];
|
|
25
25
|
|
|
26
|
+
// ── Global flags parsed early ─────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Extract a flag value: --flag value or --flag=value
|
|
30
|
+
* Returns the value string or null if not found.
|
|
31
|
+
*/
|
|
32
|
+
function extractFlag(flagNames, removeFromArgs = false) {
|
|
33
|
+
for (const flag of Array.isArray(flagNames) ? flagNames : [flagNames]) {
|
|
34
|
+
const idx = args.indexOf(flag);
|
|
35
|
+
if (idx !== -1 && idx + 1 < args.length) {
|
|
36
|
+
const value = args[idx + 1];
|
|
37
|
+
if (removeFromArgs) { args.splice(idx, 2); }
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
// --flag=value form
|
|
41
|
+
const prefix = flag + "=";
|
|
42
|
+
const eqIdx = args.findIndex(a => a.startsWith(prefix));
|
|
43
|
+
if (eqIdx !== -1) {
|
|
44
|
+
const value = args[eqIdx].slice(prefix.length);
|
|
45
|
+
if (removeFromArgs) { args.splice(eqIdx, 1); }
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Returns true if a boolean flag is present. */
|
|
53
|
+
function hasFlag(flag) {
|
|
54
|
+
return args.includes(flag);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Parse global flags (order doesn't matter, remove from args so REPL doesn't see them)
|
|
58
|
+
const globalProfile = extractFlag(["--profile", "-p"], true);
|
|
59
|
+
const globalPersonality = extractFlag(["--personality"], true);
|
|
60
|
+
const globalJsonMode = hasFlag("--json");
|
|
61
|
+
if (globalJsonMode) { args.splice(args.indexOf("--json"), 1); }
|
|
62
|
+
|
|
63
|
+
// Expose for submodules via env
|
|
64
|
+
if (globalProfile) process.env.WISPY_PROFILE = globalProfile;
|
|
65
|
+
if (globalPersonality) process.env.WISPY_PERSONALITY = globalPersonality;
|
|
66
|
+
|
|
26
67
|
// ── Flags ─────────────────────────────────────────────────────────────────────
|
|
27
68
|
|
|
28
69
|
if (args.includes("--version") || command === "--version" || command === "-v") {
|
|
@@ -44,9 +85,12 @@ AI workspace assistant — chat, automate, and orchestrate from the terminal.
|
|
|
44
85
|
Usage:
|
|
45
86
|
wispy Start interactive REPL
|
|
46
87
|
wispy <message> One-shot chat
|
|
88
|
+
wispy exec <message> Non-interactive one-shot (CI/pipeline friendly)
|
|
47
89
|
wispy setup Run first-time setup wizard
|
|
48
90
|
wispy config [show|get|set|delete|reset|path|edit]
|
|
49
91
|
Manage configuration
|
|
92
|
+
wispy features [list|enable|disable]
|
|
93
|
+
Manage feature flags
|
|
50
94
|
wispy model Show or change AI model
|
|
51
95
|
wispy doctor Check system health
|
|
52
96
|
wispy browser [tabs|attach|navigate|screenshot|doctor]
|
|
@@ -69,9 +113,12 @@ Usage:
|
|
|
69
113
|
|
|
70
114
|
Options:
|
|
71
115
|
-w, --workstream <name> Set active workstream
|
|
116
|
+
-p, --profile <name> Use a named config profile
|
|
72
117
|
--session <id> Resume a session
|
|
73
118
|
--model <name> Override AI model
|
|
74
119
|
--provider <name> Override AI provider
|
|
120
|
+
--personality <name> Set personality (pragmatic|concise|explanatory|friendly|strict)
|
|
121
|
+
--json Output JSONL events (for exec command, CI/pipeline use)
|
|
75
122
|
--help, -h Show this help
|
|
76
123
|
--version, -v Show version
|
|
77
124
|
`);
|
|
@@ -529,6 +576,131 @@ if (command === "improve") {
|
|
|
529
576
|
process.exit(0);
|
|
530
577
|
}
|
|
531
578
|
|
|
579
|
+
// ── Features ──────────────────────────────────────────────────────────────────
|
|
580
|
+
|
|
581
|
+
if (command === "features") {
|
|
582
|
+
try {
|
|
583
|
+
const { FeatureManager } = await import(join(rootDir, "core/features.mjs"));
|
|
584
|
+
const fm = new FeatureManager();
|
|
585
|
+
const sub = args[1];
|
|
586
|
+
|
|
587
|
+
if (!sub || sub === "list") {
|
|
588
|
+
const features = await fm.list();
|
|
589
|
+
const stageOrder = { stable: 0, experimental: 1, development: 2 };
|
|
590
|
+
features.sort((a, b) => (stageOrder[a.stage] ?? 9) - (stageOrder[b.stage] ?? 9) || a.name.localeCompare(b.name));
|
|
591
|
+
|
|
592
|
+
const stageLabel = { stable: "stable", experimental: "experimental", development: "development" };
|
|
593
|
+
const stageColors = { stable: "\x1b[32m", experimental: "\x1b[33m", development: "\x1b[31m" };
|
|
594
|
+
const reset = "\x1b[0m";
|
|
595
|
+
const dim = "\x1b[2m";
|
|
596
|
+
|
|
597
|
+
console.log(`\n Feature Flags (${features.length})\n`);
|
|
598
|
+
let lastStage = null;
|
|
599
|
+
for (const f of features) {
|
|
600
|
+
if (f.stage !== lastStage) {
|
|
601
|
+
console.log(` ${stageColors[f.stage] ?? ""}── ${f.stage} ──${reset}`);
|
|
602
|
+
lastStage = f.stage;
|
|
603
|
+
}
|
|
604
|
+
const status = f.enabled ? "\x1b[32m✓ on \x1b[0m" : "\x1b[2m✗ off\x1b[0m";
|
|
605
|
+
const changed = f.enabled !== f.default ? " \x1b[33m(overridden)\x1b[0m" : "";
|
|
606
|
+
console.log(` ${status} ${f.name.padEnd(25)} ${dim}${f.description}${reset}${changed}`);
|
|
607
|
+
}
|
|
608
|
+
console.log("");
|
|
609
|
+
console.log(` ${dim}Use: wispy features enable <name> / wispy features disable <name>${reset}\n`);
|
|
610
|
+
} else if (sub === "enable") {
|
|
611
|
+
const name = args[2];
|
|
612
|
+
if (!name) { console.error("Usage: wispy features enable <name>"); process.exit(1); }
|
|
613
|
+
const result = await fm.enable(name);
|
|
614
|
+
console.log(` ✓ Feature "${name}" enabled.`);
|
|
615
|
+
} else if (sub === "disable") {
|
|
616
|
+
const name = args[2];
|
|
617
|
+
if (!name) { console.error("Usage: wispy features disable <name>"); process.exit(1); }
|
|
618
|
+
const result = await fm.disable(name);
|
|
619
|
+
console.log(` ✓ Feature "${name}" disabled.`);
|
|
620
|
+
} else {
|
|
621
|
+
console.error(`Unknown subcommand: ${sub}`);
|
|
622
|
+
console.log("Available: list, enable <name>, disable <name>");
|
|
623
|
+
process.exit(1);
|
|
624
|
+
}
|
|
625
|
+
} catch (err) {
|
|
626
|
+
console.error("Features error:", err.message);
|
|
627
|
+
process.exit(1);
|
|
628
|
+
}
|
|
629
|
+
process.exit(0);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// ── Exec (non-interactive one-shot with optional JSONL output) ────────────────
|
|
633
|
+
|
|
634
|
+
if (command === "exec") {
|
|
635
|
+
try {
|
|
636
|
+
// Message is everything after "exec" and any flags
|
|
637
|
+
const messageArgs = args.slice(1).filter(a => !a.startsWith("--") && !a.startsWith("-p"));
|
|
638
|
+
const message = messageArgs.join(" ").trim();
|
|
639
|
+
|
|
640
|
+
if (!message) {
|
|
641
|
+
console.error("Usage: wispy exec <message> [--json] [--profile <name>] [--personality <name>]");
|
|
642
|
+
process.exit(1);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Load config (with optional profile)
|
|
646
|
+
const { loadConfigWithProfile } = await import(join(rootDir, "core/config.mjs"));
|
|
647
|
+
const { WispyEngine } = await import(join(rootDir, "core/engine.mjs"));
|
|
648
|
+
const { createEmitter } = await import(join(rootDir, "lib/jsonl-emitter.mjs"));
|
|
649
|
+
|
|
650
|
+
const profileName = globalProfile;
|
|
651
|
+
const profileConfig = await loadConfigWithProfile(profileName);
|
|
652
|
+
|
|
653
|
+
// Apply config overrides from profile
|
|
654
|
+
if (profileConfig.provider) process.env.WISPY_PROVIDER = process.env.WISPY_PROVIDER || profileConfig.provider;
|
|
655
|
+
if (profileConfig.model) process.env.WISPY_MODEL = process.env.WISPY_MODEL || profileConfig.model;
|
|
656
|
+
|
|
657
|
+
const personality = globalPersonality || profileConfig.personality || null;
|
|
658
|
+
|
|
659
|
+
const engine = new WispyEngine({
|
|
660
|
+
personality,
|
|
661
|
+
workstream: process.env.WISPY_WORKSTREAM ?? "default",
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
const initResult = await engine.init({ skipMcp: false });
|
|
665
|
+
if (!initResult) {
|
|
666
|
+
if (globalJsonMode) {
|
|
667
|
+
const emitter = createEmitter(true);
|
|
668
|
+
emitter.error(new Error("No AI provider configured"));
|
|
669
|
+
emitter.done({ tokens: {}, duration_ms: 0 });
|
|
670
|
+
} else {
|
|
671
|
+
console.error("⚠️ No AI provider configured. Run `wispy setup` or set an API key.");
|
|
672
|
+
}
|
|
673
|
+
process.exit(1);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const emitter = createEmitter(globalJsonMode);
|
|
677
|
+
|
|
678
|
+
const result = await engine.processMessage(null, message, {
|
|
679
|
+
emitter,
|
|
680
|
+
personality,
|
|
681
|
+
skipSkillCapture: true,
|
|
682
|
+
skipUserModel: true,
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
if (!globalJsonMode) {
|
|
686
|
+
// Normal output: just print the assistant response
|
|
687
|
+
console.log(result.content);
|
|
688
|
+
}
|
|
689
|
+
// In JSON mode, everything was already emitted via the emitter
|
|
690
|
+
} catch (err) {
|
|
691
|
+
if (globalJsonMode) {
|
|
692
|
+
const { createEmitter } = await import(join(rootDir, "lib/jsonl-emitter.mjs"));
|
|
693
|
+
const emitter = createEmitter(true);
|
|
694
|
+
emitter.error(err);
|
|
695
|
+
emitter.done({ tokens: {}, duration_ms: 0 });
|
|
696
|
+
} else {
|
|
697
|
+
console.error("Exec error:", err.message);
|
|
698
|
+
}
|
|
699
|
+
process.exit(1);
|
|
700
|
+
}
|
|
701
|
+
process.exit(0);
|
|
702
|
+
}
|
|
703
|
+
|
|
532
704
|
// ── Continuity ────────────────────────────────────────────────────────────────
|
|
533
705
|
|
|
534
706
|
if (command === "where") {
|
package/core/config.mjs
CHANGED
|
@@ -133,6 +133,40 @@ export async function saveConfig(config) {
|
|
|
133
133
|
await writeFile(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Load config with a named profile merged on top.
|
|
138
|
+
* Profile keys override base config keys, but unset keys remain from base.
|
|
139
|
+
* The "profiles" map itself is stripped from the merged result.
|
|
140
|
+
*
|
|
141
|
+
* @param {string|null} profileName - Profile name to apply, or null for base config
|
|
142
|
+
* @returns {Promise<object>} Merged configuration object
|
|
143
|
+
*/
|
|
144
|
+
export async function loadConfigWithProfile(profileName) {
|
|
145
|
+
const config = await loadConfig();
|
|
146
|
+
if (!profileName) return config;
|
|
147
|
+
|
|
148
|
+
const profiles = config.profiles ?? {};
|
|
149
|
+
const profile = profiles[profileName];
|
|
150
|
+
|
|
151
|
+
if (!profile) {
|
|
152
|
+
throw new Error(`Profile "${profileName}" not found. Available profiles: ${Object.keys(profiles).join(", ") || "(none)"}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Merge: profile overrides base config, but unset keys are kept from base
|
|
156
|
+
// Strip the "profiles" map from the result so it doesn't leak into engine
|
|
157
|
+
const { profiles: _profiles, ...base } = config;
|
|
158
|
+
return { ...base, ...profile };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* List available profile names from config.
|
|
163
|
+
* @returns {Promise<string[]>}
|
|
164
|
+
*/
|
|
165
|
+
export async function listProfiles() {
|
|
166
|
+
const config = await loadConfig();
|
|
167
|
+
return Object.keys(config.profiles ?? {});
|
|
168
|
+
}
|
|
169
|
+
|
|
136
170
|
/**
|
|
137
171
|
* Returns true if no config exists or onboarded flag is not set.
|
|
138
172
|
*/
|
package/core/engine.mjs
CHANGED
|
@@ -12,9 +12,22 @@
|
|
|
12
12
|
|
|
13
13
|
import os from "node:os";
|
|
14
14
|
import path from "node:path";
|
|
15
|
-
import { readFile, writeFile, mkdir, appendFile } from "node:fs/promises";
|
|
15
|
+
import { readFile, writeFile, mkdir, appendFile, stat as fsStat } from "node:fs/promises";
|
|
16
16
|
|
|
17
17
|
import { WISPY_DIR, CONVERSATIONS_DIR, MEMORY_DIR, MCP_CONFIG_PATH, detectProvider, PROVIDERS } from "./config.mjs";
|
|
18
|
+
import { NullEmitter } from "../lib/jsonl-emitter.mjs";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Personality presets that modify the system prompt.
|
|
22
|
+
* Set via config, profile, or --personality CLI flag.
|
|
23
|
+
*/
|
|
24
|
+
export const PERSONALITIES = {
|
|
25
|
+
pragmatic: "Be direct and action-oriented. Minimize explanation, maximize execution. Show code, not words.",
|
|
26
|
+
concise: "Keep responses extremely brief. One-liners when possible. No filler.",
|
|
27
|
+
explanatory: "Explain your reasoning step by step. Help the user understand, not just get an answer.",
|
|
28
|
+
friendly: "Be warm and encouraging. Use casual language. Celebrate small wins.",
|
|
29
|
+
strict: "Follow instructions precisely. No creative interpretation. Verify before acting.",
|
|
30
|
+
};
|
|
18
31
|
import { ProviderRegistry } from "./providers.mjs";
|
|
19
32
|
import { ToolRegistry } from "./tools.mjs";
|
|
20
33
|
import { SessionManager } from "./session.mjs";
|
|
@@ -30,6 +43,7 @@ import { UserModel } from "./user-model.mjs";
|
|
|
30
43
|
import { routeTask, classifyTask, filterAvailableModels } from "./task-router.mjs";
|
|
31
44
|
import { decomposeTask, executeDecomposedPlan } from "./task-decomposer.mjs";
|
|
32
45
|
import { BrowserBridge } from "./browser.mjs";
|
|
46
|
+
import { LoopDetector } from "./loop-detector.mjs";
|
|
33
47
|
|
|
34
48
|
const MAX_TOOL_ROUNDS = 10;
|
|
35
49
|
const MAX_CONTEXT_CHARS = 40_000;
|
|
@@ -58,6 +72,8 @@ export class WispyEngine {
|
|
|
58
72
|
?? process.env.WISPY_WORKSTREAM
|
|
59
73
|
?? process.argv.find((a, i) => (process.argv[i-1] === "-w" || process.argv[i-1] === "--workstream"))
|
|
60
74
|
?? "default";
|
|
75
|
+
// Personality: from config, or null (use default Wispy personality)
|
|
76
|
+
this._personality = config.personality ?? null;
|
|
61
77
|
}
|
|
62
78
|
|
|
63
79
|
get activeWorkstream() { return this._activeWorkstream; }
|
|
@@ -130,7 +146,7 @@ export class WispyEngine {
|
|
|
130
146
|
*
|
|
131
147
|
* @param {string|null} sessionId - Session ID (null = create new)
|
|
132
148
|
* @param {string} userMessage - The user's message
|
|
133
|
-
* @param {object} opts - Options: { onChunk, systemPrompt, workstream, noSave }
|
|
149
|
+
* @param {object} opts - Options: { onChunk, systemPrompt, workstream, noSave, emitter, personality }
|
|
134
150
|
* @returns {object} { role: "assistant", content: string, usage? }
|
|
135
151
|
*/
|
|
136
152
|
async processMessage(sessionId, userMessage, opts = {}) {
|
|
@@ -161,10 +177,25 @@ export class WispyEngine {
|
|
|
161
177
|
session = this.sessions.create({ workstream: opts.workstream ?? this._activeWorkstream });
|
|
162
178
|
}
|
|
163
179
|
|
|
180
|
+
// JSONL emitter (no-op by default)
|
|
181
|
+
const emitter = opts.emitter ?? new NullEmitter();
|
|
182
|
+
|
|
183
|
+
// Emit start event
|
|
184
|
+
emitter.start({
|
|
185
|
+
model: this.providers.model ?? "unknown",
|
|
186
|
+
session_id: session.id,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Emit user message event
|
|
190
|
+
emitter.message("user", userMessage);
|
|
191
|
+
|
|
192
|
+
// Resolve personality for this call
|
|
193
|
+
const personality = opts.personality ?? this._personality ?? null;
|
|
194
|
+
|
|
164
195
|
// Build messages array for the provider
|
|
165
196
|
let systemPrompt;
|
|
166
197
|
try {
|
|
167
|
-
systemPrompt = opts.systemPrompt ?? await this._buildSystemPrompt(userMessage);
|
|
198
|
+
systemPrompt = opts.systemPrompt ?? await this._buildSystemPrompt(userMessage, { personality });
|
|
168
199
|
} catch {
|
|
169
200
|
systemPrompt = "You are Wispy 🌿 — a helpful AI assistant in the terminal.";
|
|
170
201
|
}
|
|
@@ -195,11 +226,13 @@ export class WispyEngine {
|
|
|
195
226
|
}).catch(() => {});
|
|
196
227
|
|
|
197
228
|
// Run agentic loop with error handling
|
|
229
|
+
const _startMs = Date.now();
|
|
198
230
|
let responseText;
|
|
199
231
|
try {
|
|
200
|
-
responseText = await this._agentLoop(messages, session, opts);
|
|
232
|
+
responseText = await this._agentLoop(messages, session, { ...opts, emitter });
|
|
201
233
|
} catch (err) {
|
|
202
234
|
responseText = this._friendlyError(err);
|
|
235
|
+
emitter.error(err);
|
|
203
236
|
this.audit.log({
|
|
204
237
|
type: EVENT_TYPES.ERROR,
|
|
205
238
|
sessionId: session.id,
|
|
@@ -207,6 +240,13 @@ export class WispyEngine {
|
|
|
207
240
|
}).catch(() => {});
|
|
208
241
|
}
|
|
209
242
|
|
|
243
|
+
// Emit assistant message + done events
|
|
244
|
+
emitter.message("assistant", responseText);
|
|
245
|
+
emitter.done({
|
|
246
|
+
tokens: { ...(this.providers.sessionTokens ?? {}) },
|
|
247
|
+
duration_ms: Date.now() - _startMs,
|
|
248
|
+
});
|
|
249
|
+
|
|
210
250
|
// Audit: log outgoing response
|
|
211
251
|
this.audit.log({
|
|
212
252
|
type: EVENT_TYPES.MESSAGE_SENT,
|
|
@@ -294,7 +334,38 @@ export class WispyEngine {
|
|
|
294
334
|
// Optimize context
|
|
295
335
|
messages = this._optimizeContext(messages);
|
|
296
336
|
|
|
337
|
+
// Create a fresh loop detector per agent turn
|
|
338
|
+
const loopDetector = new LoopDetector();
|
|
339
|
+
let loopWarned = false;
|
|
340
|
+
|
|
297
341
|
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
|
|
342
|
+
// ── Loop detection check before LLM call ─────────────────────────────
|
|
343
|
+
if (loopDetector.size >= 2) {
|
|
344
|
+
const loopCheck = loopDetector.check();
|
|
345
|
+
if (loopCheck.looping) {
|
|
346
|
+
if (opts.onLoopDetected) opts.onLoopDetected(loopCheck);
|
|
347
|
+
|
|
348
|
+
if (!loopWarned) {
|
|
349
|
+
// First warning: inject a system message and continue
|
|
350
|
+
loopWarned = true;
|
|
351
|
+
const warningMsg = loopCheck.suggestion ?? `Loop detected: agent called ${loopCheck.tool} multiple times without progress. Try a different approach.`;
|
|
352
|
+
messages.push({
|
|
353
|
+
role: "user",
|
|
354
|
+
content: `[SYSTEM WARNING] ${warningMsg}`,
|
|
355
|
+
});
|
|
356
|
+
if (process.env.WISPY_DEBUG) {
|
|
357
|
+
console.error(`[wispy] Loop detected: ${loopCheck.reason} — warning injected`);
|
|
358
|
+
}
|
|
359
|
+
} else {
|
|
360
|
+
// Second time loop detected after warning: force-break the agent turn
|
|
361
|
+
if (process.env.WISPY_DEBUG) {
|
|
362
|
+
console.error(`[wispy] Loop force-break: ${loopCheck.reason}`);
|
|
363
|
+
}
|
|
364
|
+
return `⚠️ Agent loop detected and stopped: ${loopCheck.suggestion ?? loopCheck.reason}. Please try rephrasing your request.`;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
298
369
|
const result = await this.providers.chat(messages, this.tools.getDefinitions(), {
|
|
299
370
|
onChunk: opts.onChunk,
|
|
300
371
|
model: opts.model,
|
|
@@ -308,9 +379,13 @@ export class WispyEngine {
|
|
|
308
379
|
const toolCallMsg = { role: "assistant", toolCalls: result.calls, content: "" };
|
|
309
380
|
messages.push(toolCallMsg);
|
|
310
381
|
|
|
382
|
+
const loopEmitter = opts.emitter ?? new NullEmitter();
|
|
383
|
+
|
|
311
384
|
for (const call of result.calls) {
|
|
312
385
|
if (opts.onToolCall) opts.onToolCall(call.name, call.args);
|
|
386
|
+
loopEmitter.toolCall(call.name, call.args);
|
|
313
387
|
|
|
388
|
+
const _toolStartMs = Date.now();
|
|
314
389
|
let toolResult;
|
|
315
390
|
try {
|
|
316
391
|
// Enforce 60s timeout on individual tool calls
|
|
@@ -328,7 +403,12 @@ export class WispyEngine {
|
|
|
328
403
|
}
|
|
329
404
|
}
|
|
330
405
|
|
|
406
|
+
// Record into loop detector
|
|
407
|
+
loopDetector.record(call.name, call.args, toolResult);
|
|
408
|
+
|
|
409
|
+
const _toolDuration = Date.now() - _toolStartMs;
|
|
331
410
|
if (opts.onToolResult) opts.onToolResult(call.name, toolResult);
|
|
411
|
+
loopEmitter.toolResult(call.name, toolResult, _toolDuration);
|
|
332
412
|
|
|
333
413
|
messages.push({
|
|
334
414
|
role: "tool_result",
|
|
@@ -783,17 +863,32 @@ export class WispyEngine {
|
|
|
783
863
|
|
|
784
864
|
// ── System prompt ────────────────────────────────────────────────────────────
|
|
785
865
|
|
|
786
|
-
async _buildSystemPrompt(lastUserMessage = "") {
|
|
866
|
+
async _buildSystemPrompt(lastUserMessage = "", opts = {}) {
|
|
867
|
+
const personality = opts.personality ?? this._personality ?? null;
|
|
868
|
+
|
|
787
869
|
const parts = [
|
|
788
870
|
"You are Wispy 🌿 — a small ghost that lives in terminals.",
|
|
789
871
|
"You float between code, files, and servers. You're playful, honest, and curious.",
|
|
790
872
|
"",
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
873
|
+
];
|
|
874
|
+
|
|
875
|
+
// Inject personality override if set
|
|
876
|
+
if (personality && PERSONALITIES[personality]) {
|
|
877
|
+
parts.push(`## Personality Override (${personality})`);
|
|
878
|
+
parts.push(PERSONALITIES[personality]);
|
|
879
|
+
parts.push("");
|
|
880
|
+
} else {
|
|
881
|
+
parts.push(
|
|
882
|
+
"## Personality",
|
|
883
|
+
"- Playful with a bit of humor, but serious when working",
|
|
884
|
+
"- Always use casual speech (반말). Never formal/polite speech.",
|
|
885
|
+
"- Honest — if you don't know, say so.",
|
|
886
|
+
"- Concise — don't over-explain.",
|
|
887
|
+
"",
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
parts.push(
|
|
797
892
|
"## Speech rules",
|
|
798
893
|
"- ALWAYS end your response with exactly one 🌿 emoji (signature)",
|
|
799
894
|
"- Use 🌿 ONLY at the very end, not in the middle",
|
|
@@ -805,7 +900,7 @@ export class WispyEngine {
|
|
|
805
900
|
`You have ${this.tools.getDefinitions().length} tools available: ${this.tools.getDefinitions().map(t => t.name).join(", ")}.`,
|
|
806
901
|
"Use them proactively. Briefly mention what you're doing.",
|
|
807
902
|
"",
|
|
808
|
-
|
|
903
|
+
);
|
|
809
904
|
|
|
810
905
|
// Load user model personalization
|
|
811
906
|
try {
|