wispy-cli 2.7.10 → 2.7.12
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 +302 -0
- package/core/config.mjs +63 -11
- package/core/engine.mjs +71 -11
- package/core/features.mjs +225 -0
- package/core/harness.mjs +135 -1
- package/core/index.mjs +2 -1
- package/core/loop-detector.mjs +183 -0
- package/core/memory.mjs +243 -0
- package/core/providers.mjs +10 -0
- package/core/secrets.mjs +4 -4
- package/core/session.mjs +107 -1
- package/lib/commands/review.mjs +272 -0
- package/lib/jsonl-emitter.mjs +101 -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,13 +85,19 @@ 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]
|
|
53
97
|
Browser control via local-browser-bridge
|
|
98
|
+
wispy secrets [list|set|delete|get]
|
|
99
|
+
Manage encrypted secrets & API keys
|
|
100
|
+
wispy tts "<text>" Text-to-speech (OpenAI or macOS say)
|
|
54
101
|
wispy trust [level|log] Security level & audit
|
|
55
102
|
wispy ws [start-client|run-debug]
|
|
56
103
|
WebSocket operations
|
|
@@ -66,9 +113,12 @@ Usage:
|
|
|
66
113
|
|
|
67
114
|
Options:
|
|
68
115
|
-w, --workstream <name> Set active workstream
|
|
116
|
+
-p, --profile <name> Use a named config profile
|
|
69
117
|
--session <id> Resume a session
|
|
70
118
|
--model <name> Override AI model
|
|
71
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)
|
|
72
122
|
--help, -h Show this help
|
|
73
123
|
--version, -v Show version
|
|
74
124
|
`);
|
|
@@ -338,6 +388,133 @@ if (command === "browser") {
|
|
|
338
388
|
process.exit(0);
|
|
339
389
|
}
|
|
340
390
|
|
|
391
|
+
// ── Secrets ───────────────────────────────────────────────────────────────────
|
|
392
|
+
|
|
393
|
+
if (command === "secrets") {
|
|
394
|
+
try {
|
|
395
|
+
const { SecretsManager } = await import(join(rootDir, "core/secrets.mjs"));
|
|
396
|
+
const sm = new SecretsManager();
|
|
397
|
+
const sub = args[1];
|
|
398
|
+
|
|
399
|
+
if (!sub || sub === "list") {
|
|
400
|
+
const keys = await sm.list();
|
|
401
|
+
if (keys.length === 0) {
|
|
402
|
+
console.log("No secrets stored. Use: wispy secrets set <key> <value>");
|
|
403
|
+
} else {
|
|
404
|
+
console.log(`\n Stored secrets (${keys.length}):\n`);
|
|
405
|
+
for (const k of keys) {
|
|
406
|
+
console.log(` ${k}`);
|
|
407
|
+
}
|
|
408
|
+
console.log("");
|
|
409
|
+
}
|
|
410
|
+
} else if (sub === "set") {
|
|
411
|
+
const key = args[2];
|
|
412
|
+
const value = args[3];
|
|
413
|
+
if (!key || value === undefined) {
|
|
414
|
+
console.error("Usage: wispy secrets set <key> <value>");
|
|
415
|
+
process.exit(1);
|
|
416
|
+
}
|
|
417
|
+
await sm.set(key, value);
|
|
418
|
+
console.log(`Secret '${key}' stored (encrypted).`);
|
|
419
|
+
} else if (sub === "delete") {
|
|
420
|
+
const key = args[2];
|
|
421
|
+
if (!key) {
|
|
422
|
+
console.error("Usage: wispy secrets delete <key>");
|
|
423
|
+
process.exit(1);
|
|
424
|
+
}
|
|
425
|
+
const result = await sm.delete(key);
|
|
426
|
+
if (result.success) {
|
|
427
|
+
console.log(`Secret '${key}' deleted.`);
|
|
428
|
+
} else {
|
|
429
|
+
console.error(`Secret '${key}' not found.`);
|
|
430
|
+
process.exit(1);
|
|
431
|
+
}
|
|
432
|
+
} else if (sub === "get") {
|
|
433
|
+
const key = args[2];
|
|
434
|
+
if (!key) {
|
|
435
|
+
console.error("Usage: wispy secrets get <key>");
|
|
436
|
+
process.exit(1);
|
|
437
|
+
}
|
|
438
|
+
const value = await sm.resolve(key);
|
|
439
|
+
if (value) {
|
|
440
|
+
console.log(`${key} = ***`);
|
|
441
|
+
console.log("(Use --reveal flag to show value — not recommended)");
|
|
442
|
+
if (args.includes("--reveal")) {
|
|
443
|
+
console.log(`Value: ${value}`);
|
|
444
|
+
}
|
|
445
|
+
} else {
|
|
446
|
+
console.log(`Secret '${key}' not found.`);
|
|
447
|
+
}
|
|
448
|
+
} else {
|
|
449
|
+
console.error(`Unknown secrets subcommand: ${sub}`);
|
|
450
|
+
console.log("Available: list, set <key> <value>, delete <key>, get <key>");
|
|
451
|
+
process.exit(1);
|
|
452
|
+
}
|
|
453
|
+
} catch (err) {
|
|
454
|
+
console.error("Secrets error:", err.message);
|
|
455
|
+
process.exit(1);
|
|
456
|
+
}
|
|
457
|
+
process.exit(0);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ── TTS ───────────────────────────────────────────────────────────────────────
|
|
461
|
+
|
|
462
|
+
if (command === "tts") {
|
|
463
|
+
try {
|
|
464
|
+
const { SecretsManager } = await import(join(rootDir, "core/secrets.mjs"));
|
|
465
|
+
const { TTSManager } = await import(join(rootDir, "core/tts.mjs"));
|
|
466
|
+
|
|
467
|
+
const sm = new SecretsManager();
|
|
468
|
+
const tts = new TTSManager(sm);
|
|
469
|
+
|
|
470
|
+
// Parse args: wispy tts "text" [--voice name] [--provider openai|macos] [--play]
|
|
471
|
+
let textArg = args[1];
|
|
472
|
+
if (!textArg) {
|
|
473
|
+
console.error('Usage: wispy tts "text to speak" [--voice <name>] [--provider openai|macos] [--play]');
|
|
474
|
+
process.exit(1);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const voiceIdx = args.indexOf("--voice");
|
|
478
|
+
const providerIdx = args.indexOf("--provider");
|
|
479
|
+
const shouldPlay = args.includes("--play");
|
|
480
|
+
|
|
481
|
+
const voice = voiceIdx !== -1 ? args[voiceIdx + 1] : undefined;
|
|
482
|
+
const provider = providerIdx !== -1 ? args[providerIdx + 1] : "auto";
|
|
483
|
+
|
|
484
|
+
console.log(` Generating speech (provider: ${provider})...`);
|
|
485
|
+
const result = await tts.speak(textArg, { voice, provider });
|
|
486
|
+
|
|
487
|
+
if (result.error) {
|
|
488
|
+
console.error(` TTS Error: ${result.error}`);
|
|
489
|
+
process.exit(1);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
console.log(` Provider: ${result.provider}`);
|
|
493
|
+
console.log(` Voice: ${result.voice}`);
|
|
494
|
+
console.log(` Format: ${result.format}`);
|
|
495
|
+
console.log(` File: ${result.path}`);
|
|
496
|
+
|
|
497
|
+
if (shouldPlay) {
|
|
498
|
+
const { execFile } = await import("node:child_process");
|
|
499
|
+
const { promisify } = await import("node:util");
|
|
500
|
+
const exec = promisify(execFile);
|
|
501
|
+
try {
|
|
502
|
+
if (process.platform === "darwin") {
|
|
503
|
+
await exec("afplay", [result.path]);
|
|
504
|
+
} else {
|
|
505
|
+
console.log(' Use --play flag only on macOS. Play manually with your audio player.');
|
|
506
|
+
}
|
|
507
|
+
} catch (playErr) {
|
|
508
|
+
console.error(` Playback failed: ${playErr.message}`);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
} catch (err) {
|
|
512
|
+
console.error("TTS error:", err.message);
|
|
513
|
+
process.exit(1);
|
|
514
|
+
}
|
|
515
|
+
process.exit(0);
|
|
516
|
+
}
|
|
517
|
+
|
|
341
518
|
// ── Trust ─────────────────────────────────────────────────────────────────────
|
|
342
519
|
|
|
343
520
|
if (command === "trust") {
|
|
@@ -399,6 +576,131 @@ if (command === "improve") {
|
|
|
399
576
|
process.exit(0);
|
|
400
577
|
}
|
|
401
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
|
+
|
|
402
704
|
// ── Continuity ────────────────────────────────────────────────────────────────
|
|
403
705
|
|
|
404
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
|
*/
|
|
@@ -193,17 +227,35 @@ export async function detectProvider() {
|
|
|
193
227
|
}
|
|
194
228
|
}
|
|
195
229
|
|
|
196
|
-
// 4. macOS Keychain
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
[
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
230
|
+
// 4. macOS Keychain (via SecretsManager for unified resolution)
|
|
231
|
+
try {
|
|
232
|
+
const { getSecretsManager } = await import("./secrets.mjs");
|
|
233
|
+
const sm = getSecretsManager({ wispyDir: WISPY_DIR });
|
|
234
|
+
const keychainProviderMap = [
|
|
235
|
+
["GOOGLE_AI_KEY", "google"],
|
|
236
|
+
["ANTHROPIC_API_KEY", "anthropic"],
|
|
237
|
+
["OPENAI_API_KEY", "openai"],
|
|
238
|
+
];
|
|
239
|
+
for (const [envKey, provider] of keychainProviderMap) {
|
|
240
|
+
const key = await sm.resolve(envKey);
|
|
241
|
+
if (key) {
|
|
242
|
+
process.env[PROVIDERS[provider].envKeys[0]] = key;
|
|
243
|
+
return { provider, key, model: process.env.WISPY_MODEL ?? PROVIDERS[provider].defaultModel };
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
} catch {
|
|
247
|
+
// SecretsManager unavailable — fallback to legacy Keychain
|
|
248
|
+
const keychainMap = [
|
|
249
|
+
["google-ai-key", "google"],
|
|
250
|
+
["anthropic-api-key", "anthropic"],
|
|
251
|
+
["openai-api-key", "openai"],
|
|
252
|
+
];
|
|
253
|
+
for (const [service, provider] of keychainMap) {
|
|
254
|
+
const key = await tryKeychainKey(service);
|
|
255
|
+
if (key) {
|
|
256
|
+
process.env[PROVIDERS[provider].envKeys[0]] = key;
|
|
257
|
+
return { provider, key, model: process.env.WISPY_MODEL ?? PROVIDERS[provider].defaultModel };
|
|
258
|
+
}
|
|
207
259
|
}
|
|
208
260
|
}
|
|
209
261
|
|
package/core/engine.mjs
CHANGED
|
@@ -15,6 +15,19 @@ import path from "node:path";
|
|
|
15
15
|
import { readFile, writeFile, mkdir, appendFile } 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";
|
|
@@ -58,6 +71,8 @@ export class WispyEngine {
|
|
|
58
71
|
?? process.env.WISPY_WORKSTREAM
|
|
59
72
|
?? process.argv.find((a, i) => (process.argv[i-1] === "-w" || process.argv[i-1] === "--workstream"))
|
|
60
73
|
?? "default";
|
|
74
|
+
// Personality: from config, or null (use default Wispy personality)
|
|
75
|
+
this._personality = config.personality ?? null;
|
|
61
76
|
}
|
|
62
77
|
|
|
63
78
|
get activeWorkstream() { return this._activeWorkstream; }
|
|
@@ -130,7 +145,7 @@ export class WispyEngine {
|
|
|
130
145
|
*
|
|
131
146
|
* @param {string|null} sessionId - Session ID (null = create new)
|
|
132
147
|
* @param {string} userMessage - The user's message
|
|
133
|
-
* @param {object} opts - Options: { onChunk, systemPrompt, workstream, noSave }
|
|
148
|
+
* @param {object} opts - Options: { onChunk, systemPrompt, workstream, noSave, emitter, personality }
|
|
134
149
|
* @returns {object} { role: "assistant", content: string, usage? }
|
|
135
150
|
*/
|
|
136
151
|
async processMessage(sessionId, userMessage, opts = {}) {
|
|
@@ -161,10 +176,25 @@ export class WispyEngine {
|
|
|
161
176
|
session = this.sessions.create({ workstream: opts.workstream ?? this._activeWorkstream });
|
|
162
177
|
}
|
|
163
178
|
|
|
179
|
+
// JSONL emitter (no-op by default)
|
|
180
|
+
const emitter = opts.emitter ?? new NullEmitter();
|
|
181
|
+
|
|
182
|
+
// Emit start event
|
|
183
|
+
emitter.start({
|
|
184
|
+
model: this.providers.model ?? "unknown",
|
|
185
|
+
session_id: session.id,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Emit user message event
|
|
189
|
+
emitter.message("user", userMessage);
|
|
190
|
+
|
|
191
|
+
// Resolve personality for this call
|
|
192
|
+
const personality = opts.personality ?? this._personality ?? null;
|
|
193
|
+
|
|
164
194
|
// Build messages array for the provider
|
|
165
195
|
let systemPrompt;
|
|
166
196
|
try {
|
|
167
|
-
systemPrompt = opts.systemPrompt ?? await this._buildSystemPrompt(userMessage);
|
|
197
|
+
systemPrompt = opts.systemPrompt ?? await this._buildSystemPrompt(userMessage, { personality });
|
|
168
198
|
} catch {
|
|
169
199
|
systemPrompt = "You are Wispy 🌿 — a helpful AI assistant in the terminal.";
|
|
170
200
|
}
|
|
@@ -195,11 +225,13 @@ export class WispyEngine {
|
|
|
195
225
|
}).catch(() => {});
|
|
196
226
|
|
|
197
227
|
// Run agentic loop with error handling
|
|
228
|
+
const _startMs = Date.now();
|
|
198
229
|
let responseText;
|
|
199
230
|
try {
|
|
200
|
-
responseText = await this._agentLoop(messages, session, opts);
|
|
231
|
+
responseText = await this._agentLoop(messages, session, { ...opts, emitter });
|
|
201
232
|
} catch (err) {
|
|
202
233
|
responseText = this._friendlyError(err);
|
|
234
|
+
emitter.error(err);
|
|
203
235
|
this.audit.log({
|
|
204
236
|
type: EVENT_TYPES.ERROR,
|
|
205
237
|
sessionId: session.id,
|
|
@@ -207,6 +239,13 @@ export class WispyEngine {
|
|
|
207
239
|
}).catch(() => {});
|
|
208
240
|
}
|
|
209
241
|
|
|
242
|
+
// Emit assistant message + done events
|
|
243
|
+
emitter.message("assistant", responseText);
|
|
244
|
+
emitter.done({
|
|
245
|
+
tokens: { ...(this.providers.sessionTokens ?? {}) },
|
|
246
|
+
duration_ms: Date.now() - _startMs,
|
|
247
|
+
});
|
|
248
|
+
|
|
210
249
|
// Audit: log outgoing response
|
|
211
250
|
this.audit.log({
|
|
212
251
|
type: EVENT_TYPES.MESSAGE_SENT,
|
|
@@ -308,9 +347,13 @@ export class WispyEngine {
|
|
|
308
347
|
const toolCallMsg = { role: "assistant", toolCalls: result.calls, content: "" };
|
|
309
348
|
messages.push(toolCallMsg);
|
|
310
349
|
|
|
350
|
+
const loopEmitter = opts.emitter ?? new NullEmitter();
|
|
351
|
+
|
|
311
352
|
for (const call of result.calls) {
|
|
312
353
|
if (opts.onToolCall) opts.onToolCall(call.name, call.args);
|
|
354
|
+
loopEmitter.toolCall(call.name, call.args);
|
|
313
355
|
|
|
356
|
+
const _toolStartMs = Date.now();
|
|
314
357
|
let toolResult;
|
|
315
358
|
try {
|
|
316
359
|
// Enforce 60s timeout on individual tool calls
|
|
@@ -328,7 +371,9 @@ export class WispyEngine {
|
|
|
328
371
|
}
|
|
329
372
|
}
|
|
330
373
|
|
|
374
|
+
const _toolDuration = Date.now() - _toolStartMs;
|
|
331
375
|
if (opts.onToolResult) opts.onToolResult(call.name, toolResult);
|
|
376
|
+
loopEmitter.toolResult(call.name, toolResult, _toolDuration);
|
|
332
377
|
|
|
333
378
|
messages.push({
|
|
334
379
|
role: "tool_result",
|
|
@@ -783,17 +828,32 @@ export class WispyEngine {
|
|
|
783
828
|
|
|
784
829
|
// ── System prompt ────────────────────────────────────────────────────────────
|
|
785
830
|
|
|
786
|
-
async _buildSystemPrompt(lastUserMessage = "") {
|
|
831
|
+
async _buildSystemPrompt(lastUserMessage = "", opts = {}) {
|
|
832
|
+
const personality = opts.personality ?? this._personality ?? null;
|
|
833
|
+
|
|
787
834
|
const parts = [
|
|
788
835
|
"You are Wispy 🌿 — a small ghost that lives in terminals.",
|
|
789
836
|
"You float between code, files, and servers. You're playful, honest, and curious.",
|
|
790
837
|
"",
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
838
|
+
];
|
|
839
|
+
|
|
840
|
+
// Inject personality override if set
|
|
841
|
+
if (personality && PERSONALITIES[personality]) {
|
|
842
|
+
parts.push(`## Personality Override (${personality})`);
|
|
843
|
+
parts.push(PERSONALITIES[personality]);
|
|
844
|
+
parts.push("");
|
|
845
|
+
} else {
|
|
846
|
+
parts.push(
|
|
847
|
+
"## Personality",
|
|
848
|
+
"- Playful with a bit of humor, but serious when working",
|
|
849
|
+
"- Always use casual speech (반말). Never formal/polite speech.",
|
|
850
|
+
"- Honest — if you don't know, say so.",
|
|
851
|
+
"- Concise — don't over-explain.",
|
|
852
|
+
"",
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
parts.push(
|
|
797
857
|
"## Speech rules",
|
|
798
858
|
"- ALWAYS end your response with exactly one 🌿 emoji (signature)",
|
|
799
859
|
"- Use 🌿 ONLY at the very end, not in the middle",
|
|
@@ -805,7 +865,7 @@ export class WispyEngine {
|
|
|
805
865
|
`You have ${this.tools.getDefinitions().length} tools available: ${this.tools.getDefinitions().map(t => t.name).join(", ")}.`,
|
|
806
866
|
"Use them proactively. Briefly mention what you're doing.",
|
|
807
867
|
"",
|
|
808
|
-
|
|
868
|
+
);
|
|
809
869
|
|
|
810
870
|
// Load user model personalization
|
|
811
871
|
try {
|