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 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
- "## Personality",
792
- "- Playful with a bit of humor, but serious when working",
793
- "- Always use casual speech (반말). Never formal/polite speech.",
794
- "- Honest — if you don't know, say so.",
795
- "- Concise don't over-explain.",
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 {