wispy-cli 2.7.14 → 2.7.16

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
@@ -60,6 +60,16 @@ const globalPersonality = extractFlag(["--personality"], true);
60
60
  const globalJsonMode = hasFlag("--json");
61
61
  if (globalJsonMode) { args.splice(args.indexOf("--json"), 1); }
62
62
 
63
+ // New flags: system prompt, session name, effort, agent, budget, tool allow/deny
64
+ const globalSystemPrompt = extractFlag(["--system-prompt"], true);
65
+ const globalAppendSystemPrompt = extractFlag(["--append-system-prompt"], true);
66
+ const globalSessionName = extractFlag(["--name"], true);
67
+ const globalAgent = extractFlag(["--agent"], true);
68
+ const globalEffort = extractFlag(["--effort"], true);
69
+ const globalMaxBudget = extractFlag(["--max-budget-usd"], true);
70
+ const globalAllowedTools = extractFlag(["--allowedTools", "--allowed-tools"], true);
71
+ const globalDisallowedTools = extractFlag(["--disallowedTools", "--disallowed-tools"], true);
72
+
63
73
  // Parse image flags: -i <path> or --image <path> (multiple allowed)
64
74
  const imagePaths = [];
65
75
  {
@@ -78,6 +88,14 @@ const imagePaths = [];
78
88
  if (globalProfile) process.env.WISPY_PROFILE = globalProfile;
79
89
  if (globalPersonality) process.env.WISPY_PERSONALITY = globalPersonality;
80
90
  if (imagePaths.length > 0) process.env.WISPY_IMAGES = JSON.stringify(imagePaths);
91
+ if (globalSystemPrompt) process.env.WISPY_SYSTEM_PROMPT = globalSystemPrompt;
92
+ if (globalAppendSystemPrompt) process.env.WISPY_APPEND_SYSTEM_PROMPT = globalAppendSystemPrompt;
93
+ if (globalSessionName) process.env.WISPY_SESSION_NAME = globalSessionName;
94
+ if (globalAgent) process.env.WISPY_AGENT = globalAgent;
95
+ if (globalEffort) process.env.WISPY_EFFORT = globalEffort;
96
+ if (globalMaxBudget) process.env.WISPY_MAX_BUDGET_USD = globalMaxBudget;
97
+ if (globalAllowedTools) process.env.WISPY_ALLOWED_TOOLS = globalAllowedTools;
98
+ if (globalDisallowedTools) process.env.WISPY_DISALLOWED_TOOLS = globalDisallowedTools;
81
99
 
82
100
  // ── Flags ─────────────────────────────────────────────────────────────────────
83
101
 
@@ -134,22 +152,75 @@ Usage:
134
152
  wispy review --base <branch> Review changes against branch
135
153
  wispy review --commit <sha> Review a specific commit
136
154
  wispy review --json Output review as JSON
155
+ wispy agents [list] List available agents (built-in + custom)
156
+ wispy cost Show API spending report
137
157
 
138
158
  Options:
139
159
  -w, --workstream <name> Set active workstream
140
160
  -p, --profile <name> Use a named config profile
141
161
  -i, --image <path> Attach image (can use multiple times)
142
162
  --session <id> Resume a session
163
+ --name <name> Give this session a display name (e.g. --name "refactor-auth")
143
164
  --model <name> Override AI model
144
165
  --provider <name> Override AI provider
145
166
  --personality <name> Set personality (pragmatic|concise|explanatory|friendly|strict)
167
+ --agent <name> Use a named agent (reviewer|planner|explorer|custom)
168
+ --effort <level> Effort level: low|medium|high|max (default: medium)
169
+ --system-prompt <prompt> Replace the default system prompt entirely
170
+ --append-system-prompt <p> Append text to the default system prompt
171
+ --max-budget-usd <amount> Session budget cap in USD (e.g. 5.00)
172
+ --allowedTools <patterns> Only allow specified tools (e.g. "read_file Bash(git:*)")
173
+ --disallowedTools <patterns> Block specified tools (e.g. "write_file delete_file")
146
174
  --json Output JSONL events (for exec command, CI/pipeline use)
147
175
  --help, -h Show this help
148
176
  --version, -v Show version
177
+
178
+ Project settings:
179
+ .wispy/settings.json Per-project config (model, personality, tools, agents, etc.)
180
+ Wispy walks up from cwd to find this file.
149
181
  `);
150
182
  process.exit(0);
151
183
  }
152
184
 
185
+ // ── Agents ────────────────────────────────────────────────────────────────────
186
+
187
+ if (command === "agents" || command === "agent") {
188
+ try {
189
+ const { loadConfig } = await import(join(rootDir, "core/config.mjs"));
190
+ const { AgentManager } = await import(join(rootDir, "core/agents.mjs"));
191
+
192
+ const sub = args[1];
193
+ const config = await loadConfig();
194
+ const mgr = new AgentManager(config);
195
+
196
+ if (!sub || sub === "list") {
197
+ console.log(mgr.formatList());
198
+ } else {
199
+ console.error(`Unknown subcommand: ${sub}`);
200
+ console.log("Available: list");
201
+ process.exit(1);
202
+ }
203
+ } catch (err) {
204
+ console.error("Agents error:", err.message);
205
+ process.exit(1);
206
+ }
207
+ process.exit(0);
208
+ }
209
+
210
+ // ── Cost / Budget ──────────────────────────────────────────────────────────────
211
+
212
+ if (command === "cost" || command === "budget") {
213
+ try {
214
+ const { BudgetManager } = await import(join(rootDir, "core/budget.mjs"));
215
+ const budget = new BudgetManager();
216
+ console.log(await budget.formatReport());
217
+ } catch (err) {
218
+ console.error("Cost error:", err.message);
219
+ process.exit(1);
220
+ }
221
+ process.exit(0);
222
+ }
223
+
153
224
  // ── Setup (first-run wizard) ──────────────────────────────────────────────────
154
225
 
155
226
  if (command === "setup") {
@@ -680,9 +751,37 @@ if (command === "exec") {
680
751
  if (profileConfig.model) process.env.WISPY_MODEL = process.env.WISPY_MODEL || profileConfig.model;
681
752
 
682
753
  const personality = globalPersonality || profileConfig.personality || null;
754
+ const effort = globalEffort || profileConfig.effort || null;
755
+ const maxBudgetUsd = globalMaxBudget ? parseFloat(globalMaxBudget) : null;
756
+ const allowedTools = globalAllowedTools || null;
757
+ const disallowedTools = globalDisallowedTools || null;
758
+ const agent = globalAgent || null;
759
+
760
+ // Load project settings and merge
761
+ const { findProjectSettings, mergeSettings } = await import(join(rootDir, "core/project-settings.mjs"));
762
+ const projectSettings = await findProjectSettings();
763
+ const mergedConfig = mergeSettings(
764
+ profileConfig,
765
+ projectSettings,
766
+ null, // profile already merged into profileConfig via loadConfigWithProfile
767
+ {
768
+ ...(personality ? { personality } : {}),
769
+ ...(effort ? { effort } : {}),
770
+ ...(maxBudgetUsd ? { maxBudgetUsd } : {}),
771
+ ...(globalSystemPrompt ? { systemPrompt: globalSystemPrompt } : {}),
772
+ ...(globalAppendSystemPrompt ? { appendSystemPrompt: globalAppendSystemPrompt } : {}),
773
+ }
774
+ );
683
775
 
684
776
  const engine = new WispyEngine({
685
- personality,
777
+ ...mergedConfig,
778
+ personality: mergedConfig.personality ?? personality,
779
+ effort: mergedConfig.effort ?? effort,
780
+ maxBudgetUsd: mergedConfig.maxBudgetUsd ?? maxBudgetUsd,
781
+ allowedTools: mergedConfig.allowedTools ?? allowedTools,
782
+ disallowedTools: mergedConfig.disallowedTools ?? disallowedTools,
783
+ systemPrompt: mergedConfig.systemPrompt ?? null,
784
+ appendSystemPrompt: mergedConfig.appendSystemPrompt ?? null,
686
785
  workstream: process.env.WISPY_WORKSTREAM ?? "default",
687
786
  });
688
787
 
@@ -698,11 +797,18 @@ if (command === "exec") {
698
797
  process.exit(1);
699
798
  }
700
799
 
800
+ // Apply tool allow/deny to harness
801
+ if (allowedTools) engine.harness.setAllowedTools(allowedTools);
802
+ if (disallowedTools) engine.harness.setDisallowedTools(disallowedTools);
803
+
701
804
  const emitter = createEmitter(globalJsonMode);
702
805
 
703
806
  const result = await engine.processMessage(null, message, {
704
807
  emitter,
705
808
  personality,
809
+ effort,
810
+ agent,
811
+ sessionName: globalSessionName ?? null,
706
812
  skipSkillCapture: true,
707
813
  skipUserModel: true,
708
814
  });
@@ -890,7 +996,8 @@ if (command === "sessions") {
890
996
  for (const s of sessions) {
891
997
  const ts = new Date(s.updatedAt).toLocaleString();
892
998
  const preview = s.firstMessage ? ` "${s.firstMessage.slice(0, 60)}${s.firstMessage.length > 60 ? "…" : ""}"` : "";
893
- console.log(` ${s.id}`);
999
+ const nameLabel = s.name ? ` · 📎 ${s.name}` : "";
1000
+ console.log(` ${s.id}${nameLabel}`);
894
1001
  console.log(` ${ts} · ${s.workstream} · ${s.messageCount} msgs${s.model ? ` · ${s.model}` : ""}`);
895
1002
  if (preview) console.log(` ${preview}`);
896
1003
  console.log("");
@@ -901,7 +1008,7 @@ if (command === "sessions") {
901
1008
  const choices = [
902
1009
  { name: "(exit)", value: null },
903
1010
  ...sessions.map(s => ({
904
- name: `${s.id} ${new Date(s.updatedAt).toLocaleString()} "${(s.firstMessage ?? "").slice(0, 50)}"`,
1011
+ name: `${s.id}${s.name ? ` [${s.name}]` : ""} ${new Date(s.updatedAt).toLocaleString()} "${(s.firstMessage ?? "").slice(0, 50)}"`,
905
1012
  value: s.id,
906
1013
  })),
907
1014
  ];
@@ -970,7 +1077,7 @@ if (command === "resume") {
970
1077
  }
971
1078
 
972
1079
  const choices = sessions.map(s => ({
973
- name: `${new Date(s.updatedAt).toLocaleString()} [${s.workstream}] ${s.messageCount}msgs "${(s.firstMessage ?? "").slice(0, 50)}"`,
1080
+ name: `${new Date(s.updatedAt).toLocaleString()} [${s.workstream}]${s.name ? ` 📎 ${s.name}` : ""} ${s.messageCount}msgs "${(s.firstMessage ?? "").slice(0, 50)}"`,
974
1081
  value: s.id,
975
1082
  }));
976
1083
 
@@ -1019,7 +1126,7 @@ if (command === "fork") {
1019
1126
  }
1020
1127
 
1021
1128
  const choices = sessions.map(s => ({
1022
- name: `${new Date(s.updatedAt).toLocaleString()} [${s.workstream}] ${s.messageCount}msgs "${(s.firstMessage ?? "").slice(0, 50)}"`,
1129
+ name: `${new Date(s.updatedAt).toLocaleString()} [${s.workstream}]${s.name ? ` 📎 ${s.name}` : ""} ${s.messageCount}msgs "${(s.firstMessage ?? "").slice(0, 50)}"`,
1023
1130
  value: s.id,
1024
1131
  }));
1025
1132
 
@@ -0,0 +1,277 @@
1
+ /**
2
+ * core/budget.mjs — API Spending Tracker for Wispy
3
+ *
4
+ * Tracks API costs per session and across sessions.
5
+ * Persisted to ~/.wispy/budget.json
6
+ *
7
+ * Usage:
8
+ * const budget = new BudgetManager({ maxBudgetUsd: 5.00 });
9
+ * budget.record(1000, 500, "gpt-4o");
10
+ * const check = budget.checkBudget();
11
+ * // { ok: true, remaining: 4.98, spent: 0.02 }
12
+ */
13
+
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
17
+
18
+ // ── Model pricing (approximate, USD per 1M tokens) ────────────────────────────
19
+
20
+ export const MODEL_PRICING = {
21
+ // OpenAI
22
+ "gpt-4o": { input: 2.50, output: 10.00 },
23
+ "gpt-4o-mini": { input: 0.15, output: 0.60 },
24
+ "gpt-4-turbo": { input: 10.00, output: 30.00 },
25
+ "gpt-4": { input: 30.00, output: 60.00 },
26
+ "o1": { input: 15.00, output: 60.00 },
27
+ "o1-mini": { input: 3.00, output: 12.00 },
28
+ "o3": { input: 10.00, output: 40.00 },
29
+ "o3-mini": { input: 1.10, output: 4.40 },
30
+ "o4-mini": { input: 1.10, output: 4.40 },
31
+
32
+ // Anthropic
33
+ "claude-opus-4-20250514": { input: 15.00, output: 75.00 },
34
+ "claude-sonnet-4-20250514": { input: 3.00, output: 15.00 },
35
+ "claude-3-5-sonnet-20241022": { input: 3.00, output: 15.00 },
36
+ "claude-3-5-haiku-20241022": { input: 0.80, output: 4.00 },
37
+ "claude-3-opus-20240229": { input: 15.00, output: 75.00 },
38
+
39
+ // Google
40
+ "gemini-2.5-pro": { input: 1.25, output: 10.00 },
41
+ "gemini-2.5-flash": { input: 0.075, output: 0.30 },
42
+ "gemini-2.0-flash": { input: 0.075, output: 0.30 },
43
+ "gemini-1.5-pro": { input: 1.25, output: 5.00 },
44
+ "gemini-1.5-flash": { input: 0.075, output: 0.30 },
45
+
46
+ // DeepSeek
47
+ "deepseek-chat": { input: 0.14, output: 0.28 },
48
+ "deepseek-coder": { input: 0.14, output: 0.28 },
49
+ "deepseek-reasoner": { input: 0.55, output: 2.19 },
50
+
51
+ // Groq (free for now, but tracking usage)
52
+ "llama-3.3-70b-versatile": { input: 0, output: 0 },
53
+ "llama-3.1-8b-instant": { input: 0, output: 0 },
54
+ "mixtral-8x7b-32768": { input: 0, output: 0 },
55
+ "gemma2-9b-it": { input: 0, output: 0 },
56
+
57
+ // Mistral
58
+ "mistral-large-latest": { input: 2.00, output: 6.00 },
59
+ "mistral-small-latest": { input: 0.20, output: 0.60 },
60
+ "codestral-latest": { input: 1.00, output: 3.00 },
61
+
62
+ // xAI
63
+ "grok-3": { input: 3.00, output: 15.00 },
64
+ "grok-3-mini": { input: 0.30, output: 0.50 },
65
+ };
66
+
67
+ // ── BudgetManager ─────────────────────────────────────────────────────────────
68
+
69
+ export class BudgetManager {
70
+ /**
71
+ * @param {object} options
72
+ * @param {number|null} options.maxBudgetUsd - Max budget for this session (null = unlimited)
73
+ * @param {string} options.budgetPath - Path to persist budget.json
74
+ */
75
+ constructor(options = {}) {
76
+ this.maxBudgetUsd = options.maxBudgetUsd ?? null;
77
+ this.sessionSpend = 0; // USD spent this session
78
+ this.sessionInputTokens = 0;
79
+ this.sessionOutputTokens = 0;
80
+ this.sessionCalls = 0;
81
+ this.totalSpend = 0; // loaded from disk
82
+ this.totalInputTokens = 0;
83
+ this.totalOutputTokens = 0;
84
+ this.totalCalls = 0;
85
+ this._budgetPath = options.budgetPath ?? path.join(os.homedir(), ".wispy", "budget.json");
86
+ this._loaded = false;
87
+ }
88
+
89
+ /**
90
+ * Load persisted budget data from disk (lazy).
91
+ */
92
+ async _ensureLoaded() {
93
+ if (this._loaded) return;
94
+ this._loaded = true;
95
+ try {
96
+ const raw = await readFile(this._budgetPath, "utf8");
97
+ const data = JSON.parse(raw);
98
+ this.totalSpend = data.totalSpend ?? 0;
99
+ this.totalInputTokens = data.totalInputTokens ?? 0;
100
+ this.totalOutputTokens = data.totalOutputTokens ?? 0;
101
+ this.totalCalls = data.totalCalls ?? 0;
102
+ } catch {
103
+ // File doesn't exist yet — start fresh
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Persist total spend to disk.
109
+ */
110
+ async _persist() {
111
+ try {
112
+ await mkdir(path.dirname(this._budgetPath), { recursive: true });
113
+ await writeFile(
114
+ this._budgetPath,
115
+ JSON.stringify({
116
+ totalSpend: this.totalSpend,
117
+ totalInputTokens: this.totalInputTokens,
118
+ totalOutputTokens: this.totalOutputTokens,
119
+ totalCalls: this.totalCalls,
120
+ updatedAt: new Date().toISOString(),
121
+ }, null, 2) + "\n",
122
+ "utf8",
123
+ );
124
+ } catch { /* ignore write errors */ }
125
+ }
126
+
127
+ /**
128
+ * Get pricing for a model. Returns { input, output } in USD/1M tokens.
129
+ * Returns { input: 0, output: 0 } for unknown models (assume free/included).
130
+ * @param {string} model
131
+ */
132
+ getPricing(model) {
133
+ if (!model) return { input: 0, output: 0 };
134
+ // Exact match
135
+ if (MODEL_PRICING[model]) return MODEL_PRICING[model];
136
+ // Prefix match (e.g., "claude-sonnet-4" matches "claude-sonnet-4-20250514")
137
+ for (const [key, price] of Object.entries(MODEL_PRICING)) {
138
+ if (model.startsWith(key) || key.startsWith(model)) return price;
139
+ }
140
+ return { input: 0, output: 0 };
141
+ }
142
+
143
+ /**
144
+ * Estimate cost of an API call without recording it.
145
+ * @param {number} inputTokens
146
+ * @param {number} outputTokens
147
+ * @param {string} model
148
+ * @returns {number} Estimated cost in USD
149
+ */
150
+ estimateCost(inputTokens, outputTokens, model) {
151
+ const pricing = this.getPricing(model);
152
+ const inputCost = (inputTokens / 1_000_000) * pricing.input;
153
+ const outputCost = (outputTokens / 1_000_000) * pricing.output;
154
+ return inputCost + outputCost;
155
+ }
156
+
157
+ /**
158
+ * Record actual API usage after a call.
159
+ * @param {number} inputTokens
160
+ * @param {number} outputTokens
161
+ * @param {string} model
162
+ * @returns {number} Cost of this call in USD
163
+ */
164
+ async record(inputTokens, outputTokens, model) {
165
+ await this._ensureLoaded();
166
+
167
+ const cost = this.estimateCost(inputTokens || 0, outputTokens || 0, model);
168
+
169
+ this.sessionSpend += cost;
170
+ this.sessionInputTokens += inputTokens || 0;
171
+ this.sessionOutputTokens += outputTokens || 0;
172
+ this.sessionCalls++;
173
+
174
+ this.totalSpend += cost;
175
+ this.totalInputTokens += inputTokens || 0;
176
+ this.totalOutputTokens += outputTokens || 0;
177
+ this.totalCalls++;
178
+
179
+ // Persist async (don't await — non-blocking)
180
+ this._persist().catch(() => {});
181
+
182
+ return cost;
183
+ }
184
+
185
+ /**
186
+ * Check whether the current session is within budget.
187
+ * @returns {{ ok: boolean, remaining: number|null, spent: number }}
188
+ */
189
+ checkBudget() {
190
+ if (this.maxBudgetUsd === null) {
191
+ return { ok: true, remaining: null, spent: this.sessionSpend };
192
+ }
193
+ const remaining = this.maxBudgetUsd - this.sessionSpend;
194
+ return {
195
+ ok: remaining >= 0,
196
+ remaining,
197
+ spent: this.sessionSpend,
198
+ limit: this.maxBudgetUsd,
199
+ };
200
+ }
201
+
202
+ /**
203
+ * Get a spending report.
204
+ * @returns {object}
205
+ */
206
+ async getReport() {
207
+ await this._ensureLoaded();
208
+ return {
209
+ session: {
210
+ spend: this.sessionSpend,
211
+ inputTokens: this.sessionInputTokens,
212
+ outputTokens: this.sessionOutputTokens,
213
+ calls: this.sessionCalls,
214
+ limit: this.maxBudgetUsd,
215
+ remaining: this.maxBudgetUsd !== null ? this.maxBudgetUsd - this.sessionSpend : null,
216
+ },
217
+ total: {
218
+ spend: this.totalSpend,
219
+ inputTokens: this.totalInputTokens,
220
+ outputTokens: this.totalOutputTokens,
221
+ calls: this.totalCalls,
222
+ },
223
+ };
224
+ }
225
+
226
+ /**
227
+ * Format a spending report for CLI display.
228
+ */
229
+ async formatReport() {
230
+ const report = await this.getReport();
231
+ const fmt = (n) => `$${n.toFixed(4)}`;
232
+ const fmtTokens = (n) => n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
233
+
234
+ const lines = ["\n 💰 Wispy Spending Report\n"];
235
+
236
+ lines.push(" This session:");
237
+ lines.push(` Spent: ${fmt(report.session.spend)}`);
238
+ if (report.session.limit !== null) {
239
+ const pct = ((report.session.spend / report.session.limit) * 100).toFixed(1);
240
+ const bar = this._progressBar(report.session.spend, report.session.limit, 20);
241
+ lines.push(` Budget: ${fmt(report.session.spend)} / ${fmt(report.session.limit)} [${bar}] ${pct}%`);
242
+ lines.push(` Remaining: ${fmt(Math.max(0, report.session.remaining))}`);
243
+ }
244
+ lines.push(` Tokens: ${fmtTokens(report.session.inputTokens)} in + ${fmtTokens(report.session.outputTokens)} out`);
245
+ lines.push(` API calls: ${report.session.calls}`);
246
+
247
+ lines.push("\n All time:");
248
+ lines.push(` Spent: ${fmt(report.total.spend)}`);
249
+ lines.push(` Tokens: ${fmtTokens(report.total.inputTokens)} in + ${fmtTokens(report.total.outputTokens)} out`);
250
+ lines.push(` API calls: ${report.total.calls}`);
251
+ lines.push("");
252
+
253
+ return lines.join("\n");
254
+ }
255
+
256
+ _progressBar(value, max, width = 20) {
257
+ if (max <= 0) return " ".repeat(width);
258
+ const filled = Math.min(width, Math.round((value / max) * width));
259
+ const color = filled > width * 0.8 ? "\x1b[31m" : filled > width * 0.5 ? "\x1b[33m" : "\x1b[32m";
260
+ return `${color}${"█".repeat(filled)}\x1b[0m${"░".repeat(width - filled)}`;
261
+ }
262
+
263
+ /**
264
+ * Format a budget exceeded warning.
265
+ */
266
+ formatExceededWarning() {
267
+ const check = this.checkBudget();
268
+ return [
269
+ "",
270
+ " \x1b[31m⛔ Budget limit reached!\x1b[0m",
271
+ ` Spent: $${this.sessionSpend.toFixed(4)} / $${this.maxBudgetUsd.toFixed(4)} limit`,
272
+ ` Session ended to prevent overspending.`,
273
+ ` Use --max-budget-usd to set a higher limit, or run without it for unlimited.`,
274
+ "",
275
+ ].join("\n");
276
+ }
277
+ }
package/core/engine.mjs CHANGED
@@ -15,6 +15,7 @@ import path from "node:path";
15
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 { findProjectSettings, mergeSettings } from "./project-settings.mjs";
18
19
  import { NullEmitter } from "../lib/jsonl-emitter.mjs";
19
20
 
20
21
  /**
@@ -44,6 +45,33 @@ import { routeTask, classifyTask, filterAvailableModels } from "./task-router.mj
44
45
  import { decomposeTask, executeDecomposedPlan } from "./task-decomposer.mjs";
45
46
  import { BrowserBridge } from "./browser.mjs";
46
47
  import { LoopDetector } from "./loop-detector.mjs";
48
+ import { AgentManager } from "./agents.mjs";
49
+ import { BudgetManager } from "./budget.mjs";
50
+
51
+ // ── Effort levels ──────────────────────────────────────────────────────────────
52
+
53
+ export const EFFORT_LEVELS = {
54
+ low: {
55
+ maxRounds: 5,
56
+ maxTokens: 2000,
57
+ systemSuffix: "Be extremely brief. One-shot answers preferred. Minimize tool use.",
58
+ },
59
+ medium: {
60
+ maxRounds: 15,
61
+ maxTokens: 4000,
62
+ systemSuffix: "", // default behavior
63
+ },
64
+ high: {
65
+ maxRounds: 30,
66
+ maxTokens: 8000,
67
+ systemSuffix: "Be thorough. Explore multiple approaches. Verify your work.",
68
+ },
69
+ max: {
70
+ maxRounds: 50,
71
+ maxTokens: 16000,
72
+ systemSuffix: "Be exhaustive. Leave no stone unturned. Multiple verification passes. Consider edge cases.",
73
+ },
74
+ };
47
75
 
48
76
  const MAX_TOOL_ROUNDS = 10;
49
77
  const MAX_CONTEXT_CHARS = 40_000;
@@ -74,6 +102,21 @@ export class WispyEngine {
74
102
  ?? "default";
75
103
  // Personality: from config, or null (use default Wispy personality)
76
104
  this._personality = config.personality ?? null;
105
+ // System prompt overrides from config
106
+ this._systemPrompt = config.systemPrompt ?? null; // full replacement
107
+ this._appendSystemPrompt = config.appendSystemPrompt ?? null; // append to default
108
+ // Project settings cache (loaded lazily)
109
+ this._projectSettings = null;
110
+ this._projectSettingsLoaded = false;
111
+ // Agent manager
112
+ this.agentManager = new AgentManager(config);
113
+ // Effort level: low | medium | high | max
114
+ this._effort = config.effort ?? process.env.WISPY_EFFORT ?? "medium";
115
+ // Budget manager
116
+ this.budget = new BudgetManager({
117
+ maxBudgetUsd: config.maxBudgetUsd ?? null,
118
+ budgetPath: config.budgetPath,
119
+ });
77
120
  }
78
121
 
79
122
  get activeWorkstream() { return this._activeWorkstream; }
@@ -168,13 +211,25 @@ export class WispyEngine {
168
211
  if (sessionId) {
169
212
  session = this.sessions.get(sessionId) ?? await this.sessions.load(sessionId);
170
213
  if (!session) {
171
- session = this.sessions.create({ workstream: opts.workstream ?? this._activeWorkstream });
214
+ session = this.sessions.create({
215
+ workstream: opts.workstream ?? this._activeWorkstream,
216
+ name: opts.sessionName ?? null,
217
+ });
218
+ } else if (opts.sessionName && !session.name) {
219
+ // Set name on existing session if not already named
220
+ session.name = opts.sessionName;
172
221
  }
173
222
  } else {
174
- session = this.sessions.create({ workstream: opts.workstream ?? this._activeWorkstream });
223
+ session = this.sessions.create({
224
+ workstream: opts.workstream ?? this._activeWorkstream,
225
+ name: opts.sessionName ?? null,
226
+ });
175
227
  }
176
228
  } catch (err) {
177
- session = this.sessions.create({ workstream: opts.workstream ?? this._activeWorkstream });
229
+ session = this.sessions.create({
230
+ workstream: opts.workstream ?? this._activeWorkstream,
231
+ name: opts.sessionName ?? null,
232
+ });
178
233
  }
179
234
 
180
235
  // JSONL emitter (no-op by default)
@@ -192,10 +247,44 @@ export class WispyEngine {
192
247
  // Resolve personality for this call
193
248
  const personality = opts.personality ?? this._personality ?? null;
194
249
 
250
+ // Resolve effort level for this call
251
+ const effort = opts.effort ?? this._effort ?? "medium";
252
+ const effortConfig = EFFORT_LEVELS[effort] ?? EFFORT_LEVELS.medium;
253
+
254
+ // Resolve agent for this call
255
+ const agentName = opts.agent ?? null;
256
+ const agentDef = agentName ? this.agentManager.get(agentName) : null;
257
+ if (agentName && !agentDef) {
258
+ return {
259
+ role: "assistant",
260
+ content: `⚠️ Unknown agent: "${agentName}". Run \`wispy agents\` to list available agents.`,
261
+ sessionId: session.id,
262
+ error: "UNKNOWN_AGENT",
263
+ };
264
+ }
265
+
266
+ // Check budget before processing
267
+ const budgetCheck = this.budget.checkBudget();
268
+ if (!budgetCheck.ok) {
269
+ return {
270
+ role: "assistant",
271
+ content: this.budget.formatExceededWarning(),
272
+ sessionId: session.id,
273
+ error: "BUDGET_EXCEEDED",
274
+ };
275
+ }
276
+
195
277
  // Build messages array for the provider
278
+ // opts.systemPrompt = full override, opts.appendSystemPrompt = append to default
196
279
  let systemPrompt;
197
280
  try {
198
- systemPrompt = opts.systemPrompt ?? await this._buildSystemPrompt(userMessage, { personality });
281
+ systemPrompt = await this._buildSystemPrompt(userMessage, {
282
+ personality,
283
+ agentDef,
284
+ effortConfig,
285
+ systemPrompt: opts.systemPrompt ?? null,
286
+ appendSystemPrompt: opts.appendSystemPrompt ?? null,
287
+ });
199
288
  } catch {
200
289
  systemPrompt = "You are Wispy 🌿 — a helpful AI assistant in the terminal.";
201
290
  }
@@ -236,7 +325,7 @@ export class WispyEngine {
236
325
  const _startMs = Date.now();
237
326
  let responseText;
238
327
  try {
239
- responseText = await this._agentLoop(messages, session, { ...opts, emitter });
328
+ responseText = await this._agentLoop(messages, session, { ...opts, emitter, effortConfig, agentDef });
240
329
  } catch (err) {
241
330
  responseText = this._friendlyError(err);
242
331
  emitter.error(err);
@@ -345,7 +434,11 @@ export class WispyEngine {
345
434
  const loopDetector = new LoopDetector();
346
435
  let loopWarned = false;
347
436
 
348
- for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
437
+ // Effort level config
438
+ const effortConfig = opts.effortConfig ?? EFFORT_LEVELS.medium;
439
+ const maxRounds = effortConfig.maxRounds ?? MAX_TOOL_ROUNDS;
440
+
441
+ for (let round = 0; round < maxRounds; round++) {
349
442
  // ── Loop detection check before LLM call ─────────────────────────────
350
443
  if (loopDetector.size >= 2) {
351
444
  const loopCheck = loopDetector.check();
@@ -373,11 +466,25 @@ export class WispyEngine {
373
466
  }
374
467
  }
375
468
 
469
+ // Use agent model if specified
470
+ const agentDef = opts.agentDef ?? null;
471
+ const resolvedModel = agentDef?.model ?? opts.model;
472
+
376
473
  const result = await this.providers.chat(messages, this.tools.getDefinitions(), {
377
474
  onChunk: opts.onChunk,
378
- model: opts.model,
475
+ model: resolvedModel,
476
+ maxTokens: effortConfig.maxTokens,
379
477
  });
380
478
 
479
+ // Record spend if usage info available
480
+ if (result?.usage) {
481
+ this.budget.record(
482
+ result.usage.inputTokens ?? result.usage.prompt_tokens ?? 0,
483
+ result.usage.outputTokens ?? result.usage.completion_tokens ?? 0,
484
+ resolvedModel ?? this.model,
485
+ ).catch(() => {});
486
+ }
487
+
381
488
  if (result.type === "text") {
382
489
  return result.text;
383
490
  }
@@ -426,7 +533,8 @@ export class WispyEngine {
426
533
  }
427
534
  }
428
535
 
429
- return "(tool call limit reached)";
536
+ const effortName = Object.entries(EFFORT_LEVELS).find(([, v]) => v === effortConfig)?.[0] ?? "medium";
537
+ return `(tool call limit reached — effort: ${effortName}, max rounds: ${maxRounds})`;
430
538
  }
431
539
 
432
540
  /**
@@ -872,6 +980,52 @@ export class WispyEngine {
872
980
 
873
981
  async _buildSystemPrompt(lastUserMessage = "", opts = {}) {
874
982
  const personality = opts.personality ?? this._personality ?? null;
983
+ const agentDef = opts.agentDef ?? null;
984
+ const effortConfig = opts.effortConfig ?? null;
985
+
986
+ // Resolve system prompt override with precedence:
987
+ // opts.systemPrompt (call-level) > this._systemPrompt (engine config) > default
988
+ // Resolve append with precedence:
989
+ // opts.appendSystemPrompt (call-level) > this._appendSystemPrompt (engine config) > project settings
990
+ const systemPromptOverride = opts.systemPrompt ?? this._systemPrompt ?? null;
991
+ const appendSystemPrompt = opts.appendSystemPrompt ?? this._appendSystemPrompt ?? null;
992
+
993
+ // If a full system prompt override is provided (not from agent), use it as-is
994
+ // then apply appends
995
+ if (systemPromptOverride && !agentDef?.prompt) {
996
+ const parts = [systemPromptOverride];
997
+ if (appendSystemPrompt) {
998
+ parts.push("\n" + appendSystemPrompt);
999
+ }
1000
+ if (personality && PERSONALITIES[personality]) {
1001
+ parts.push(`\n## Personality Override (${personality})\n${PERSONALITIES[personality]}`);
1002
+ }
1003
+ if (effortConfig?.systemSuffix) {
1004
+ parts.push("\n## Effort Level\n" + effortConfig.systemSuffix);
1005
+ }
1006
+ return parts.join("");
1007
+ }
1008
+
1009
+ // If an agent with a custom prompt is active, use that as the system prompt base
1010
+ if (agentDef?.prompt) {
1011
+ const parts = [agentDef.prompt];
1012
+ // Append effort suffix if effort is not medium/default
1013
+ if (effortConfig?.systemSuffix) {
1014
+ parts.push("\n" + effortConfig.systemSuffix);
1015
+ }
1016
+ // Append custom system prompt addition if provided
1017
+ if (appendSystemPrompt) {
1018
+ parts.push("\n" + appendSystemPrompt);
1019
+ }
1020
+ // Still inject memories for context
1021
+ try {
1022
+ const memories = await this.memory.getContextForPrompt(lastUserMessage);
1023
+ if (memories) {
1024
+ parts.push("\n## Persistent Memory\n" + memories);
1025
+ }
1026
+ } catch { /* ignore */ }
1027
+ return parts.join("\n");
1028
+ }
875
1029
 
876
1030
  const parts = [
877
1031
  "You are Wispy 🌿 — a small ghost that lives in terminals.",
@@ -879,6 +1033,13 @@ export class WispyEngine {
879
1033
  "",
880
1034
  ];
881
1035
 
1036
+ // Inject effort level modifier if not default (medium)
1037
+ if (effortConfig?.systemSuffix) {
1038
+ parts.push("## Effort Level");
1039
+ parts.push(effortConfig.systemSuffix);
1040
+ parts.push("");
1041
+ }
1042
+
882
1043
  // Inject personality override if set
883
1044
  if (personality && PERSONALITIES[personality]) {
884
1045
  parts.push(`## Personality Override (${personality})`);
@@ -943,6 +1104,11 @@ export class WispyEngine {
943
1104
  }
944
1105
  }
945
1106
 
1107
+ // Append custom system prompt addition (from opts, engine config, or project settings)
1108
+ if (appendSystemPrompt) {
1109
+ parts.push("## Additional Instructions", appendSystemPrompt, "");
1110
+ }
1111
+
946
1112
  return parts.join("\n");
947
1113
  }
948
1114
 
package/core/harness.mjs CHANGED
@@ -19,6 +19,99 @@ import os from "node:os";
19
19
 
20
20
  import { EVENT_TYPES } from "./audit.mjs";
21
21
 
22
+ // ── Tool allow/deny patterns ───────────────────────────────────────────────────
23
+
24
+ /**
25
+ * Claude Code compatible tool name aliases.
26
+ * Maps user-facing alias to internal tool name.
27
+ */
28
+ export const TOOL_ALIASES = {
29
+ "Bash": "run_command",
30
+ "Edit": "file_edit",
31
+ "Write": "write_file",
32
+ "Read": "read_file",
33
+ "Grep": "file_search",
34
+ "LS": "list_directory",
35
+ "WebSearch": "web_search",
36
+ };
37
+
38
+ /**
39
+ * Parse a tool pattern string like:
40
+ * "Bash(git:*)" → { tool: "run_command", argPattern: "git *" }
41
+ * "Edit" → { tool: "file_edit", argPattern: "*" }
42
+ * "Read(*.ts)" → { tool: "read_file", argPattern: "*.ts" }
43
+ * "run_command" → { tool: "run_command", argPattern: "*" }
44
+ *
45
+ * @param {string} pattern
46
+ * @returns {{ tool: string, argPattern: string }}
47
+ */
48
+ export function parseToolPattern(pattern) {
49
+ const m = pattern.match(/^([^(]+)(?:\(([^)]*)\))?$/);
50
+ if (!m) return null;
51
+
52
+ const rawName = m[1].trim();
53
+ const argSpec = m[2] ?? "*";
54
+
55
+ // Resolve alias
56
+ const toolName = TOOL_ALIASES[rawName] ?? rawName;
57
+
58
+ // Normalize "git:*" style to "git *" for command matching
59
+ const argPattern = argSpec.replace(/:/g, " ").trim() || "*";
60
+
61
+ return { tool: toolName, argPattern };
62
+ }
63
+
64
+ /**
65
+ * Parse a space-separated list of tool patterns.
66
+ * @param {string} patternsStr - e.g. "Bash(git:*) read_file Edit"
67
+ * @returns {Array<{ tool: string, argPattern: string }>}
68
+ */
69
+ export function parseToolPatternList(patternsStr) {
70
+ if (!patternsStr) return [];
71
+ // Split on whitespace but respect parentheses
72
+ const patterns = [];
73
+ let current = "";
74
+ let depth = 0;
75
+ for (const ch of patternsStr) {
76
+ if (ch === "(") { depth++; current += ch; }
77
+ else if (ch === ")") { depth--; current += ch; }
78
+ else if ((ch === " " || ch === ",") && depth === 0) {
79
+ if (current.trim()) patterns.push(current.trim());
80
+ current = "";
81
+ } else {
82
+ current += ch;
83
+ }
84
+ }
85
+ if (current.trim()) patterns.push(current.trim());
86
+ return patterns.map(parseToolPattern).filter(Boolean);
87
+ }
88
+
89
+ /**
90
+ * Check whether a tool call matches a pattern.
91
+ * @param {string} toolName
92
+ * @param {object} args
93
+ * @param {{ tool: string, argPattern: string }} pattern
94
+ */
95
+ export function matchesPattern(toolName, args, pattern) {
96
+ // Tool name must match
97
+ if (pattern.tool !== toolName && pattern.tool !== "*") return false;
98
+ // If no arg pattern, it's a match
99
+ if (pattern.argPattern === "*") return true;
100
+ // Get the relevant arg string
101
+ const argStr = _getArgString(toolName, args);
102
+ return _globMatch(argStr, pattern.argPattern);
103
+ }
104
+
105
+ /**
106
+ * Simple glob matching (supports * wildcard).
107
+ */
108
+ function _globMatch(str, pattern) {
109
+ if (pattern === "*") return true;
110
+ // Convert glob to regex
111
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
112
+ return new RegExp(`^${escaped}`, "i").test(str);
113
+ }
114
+
22
115
  // ── Approval gate constants ────────────────────────────────────────────────────
23
116
 
24
117
  // Tools that require approval depending on security mode
@@ -491,6 +584,55 @@ export class Harness extends EventEmitter {
491
584
  file_edit: "diff",
492
585
  git: "preview",
493
586
  };
587
+
588
+ // Tool allow/deny patterns (from config)
589
+ this._allowedPatterns = config.allowedTools ? parseToolPatternList(config.allowedTools) : null; // null = allow all
590
+ this._disallowedPatterns = config.disallowedTools ? parseToolPatternList(config.disallowedTools) : [];
591
+ }
592
+
593
+ /**
594
+ * Set allowed tools (replaces current filter).
595
+ * @param {string|Array} patterns - Space-separated string or array of pattern strings
596
+ */
597
+ setAllowedTools(patterns) {
598
+ if (!patterns) { this._allowedPatterns = null; return; }
599
+ const str = Array.isArray(patterns) ? patterns.join(" ") : patterns;
600
+ this._allowedPatterns = parseToolPatternList(str);
601
+ }
602
+
603
+ /**
604
+ * Set disallowed tools.
605
+ * @param {string|Array} patterns
606
+ */
607
+ setDisallowedTools(patterns) {
608
+ if (!patterns) { this._disallowedPatterns = []; return; }
609
+ const str = Array.isArray(patterns) ? patterns.join(" ") : patterns;
610
+ this._disallowedPatterns = parseToolPatternList(str);
611
+ }
612
+
613
+ /**
614
+ * Check if a tool call is allowed by the current allow/deny filters.
615
+ * @param {string} toolName
616
+ * @param {object} args
617
+ * @returns {{ allowed: boolean, reason?: string }}
618
+ */
619
+ checkToolFilter(toolName, args) {
620
+ // Check disallowed patterns first (deny takes precedence)
621
+ for (const pattern of this._disallowedPatterns) {
622
+ if (matchesPattern(toolName, args, pattern)) {
623
+ return { allowed: false, reason: `Tool '${toolName}' is in the disallowed list.` };
624
+ }
625
+ }
626
+
627
+ // Check allowed patterns (if set)
628
+ if (this._allowedPatterns !== null && this._allowedPatterns.length > 0) {
629
+ const allowed = this._allowedPatterns.some(p => matchesPattern(toolName, args, p));
630
+ if (!allowed) {
631
+ return { allowed: false, reason: `Tool '${toolName}' is not in the allowed list.` };
632
+ }
633
+ }
634
+
635
+ return { allowed: true };
494
636
  }
495
637
 
496
638
  /**
@@ -510,6 +652,26 @@ export class Harness extends EventEmitter {
510
652
 
511
653
  const callStart = Date.now();
512
654
 
655
+ // ── 0. Tool allow/deny filter ────────────────────────────────────────────
656
+ const filterResult = this.checkToolFilter(toolName, args);
657
+ if (!filterResult.allowed) {
658
+ receipt.approved = false;
659
+ receipt.success = false;
660
+ receipt.error = filterResult.reason ?? `Tool '${toolName}' is not available.`;
661
+ receipt.duration = 0;
662
+ this.emit("tool:denied", { toolName, args, receipt, context, reason: "tool-filter" });
663
+ // Return a clear error that the LLM can understand
664
+ return new HarnessResult({
665
+ result: {
666
+ success: false,
667
+ error: filterResult.reason ?? `Tool '${toolName}' is not available in this session.`,
668
+ tool_not_available: true,
669
+ },
670
+ receipt,
671
+ denied: true,
672
+ });
673
+ }
674
+
513
675
  // ── 1. Permission check ──────────────────────────────────────────────────
514
676
  const permResult = await this.permissions.check(toolName, args, context);
515
677
  receipt.permissionLevel = permResult.level ?? "auto";
@@ -0,0 +1,122 @@
1
+ /**
2
+ * core/project-settings.mjs — Per-project configuration for Wispy
3
+ *
4
+ * Supports .wispy/settings.json in the project root (or any parent dir).
5
+ * Settings precedence (highest to lowest):
6
+ * 1. CLI flags
7
+ * 2. Project settings (.wispy/settings.json)
8
+ * 3. User profile settings (wispy -p <name>)
9
+ * 4. User config (~/.wispy/config.json)
10
+ * 5. Defaults
11
+ *
12
+ * Example .wispy/settings.json:
13
+ * {
14
+ * "model": "gpt-4o",
15
+ * "personality": "pragmatic",
16
+ * "effort": "high",
17
+ * "appendSystemPrompt": "This is a TypeScript project using Next.js 15.",
18
+ * "features": { "browser_integration": true },
19
+ * "tools": {
20
+ * "allow": ["run_command", "read_file", "write_file", "file_edit"],
21
+ * "deny": ["delete_file"]
22
+ * },
23
+ * "agents": {
24
+ * "styler": { "description": "CSS specialist", "prompt": "You are a CSS/Tailwind expert." }
25
+ * }
26
+ * }
27
+ */
28
+
29
+ import path from "node:path";
30
+ import { readFile } from "node:fs/promises";
31
+
32
+ const SETTINGS_FILENAME = "settings.json";
33
+ const SETTINGS_DIR = ".wispy";
34
+
35
+ /**
36
+ * Walk up from startDir looking for .wispy/settings.json.
37
+ * Returns parsed settings object (with _projectRoot added) or null.
38
+ *
39
+ * @param {string} startDir - Directory to start searching from (default: cwd)
40
+ * @returns {Promise<object|null>}
41
+ */
42
+ export async function findProjectSettings(startDir = process.cwd()) {
43
+ let dir = path.resolve(startDir);
44
+ const root = path.parse(dir).root;
45
+
46
+ while (true) {
47
+ const settingsPath = path.join(dir, SETTINGS_DIR, SETTINGS_FILENAME);
48
+ try {
49
+ const raw = await readFile(settingsPath, "utf8");
50
+ const settings = JSON.parse(raw);
51
+ if (settings && typeof settings === "object") {
52
+ settings._projectRoot = dir;
53
+ settings._settingsPath = settingsPath;
54
+ return settings;
55
+ }
56
+ } catch { /* not found or parse error, keep walking */ }
57
+
58
+ const parent = path.dirname(dir);
59
+ if (parent === dir || dir === root) break;
60
+ dir = parent;
61
+ }
62
+ return null;
63
+ }
64
+
65
+ /**
66
+ * Deep-merge settings with correct precedence.
67
+ * CLI flags > project settings > profile settings > base config > defaults.
68
+ *
69
+ * For object values (like "features", "tools", "agents"), performs shallow merge.
70
+ * For scalar values, higher-precedence wins.
71
+ *
72
+ * @param {object} base - Base user config (~/.wispy/config.json)
73
+ * @param {object|null} project - Project settings (.wispy/settings.json)
74
+ * @param {object|null} profile - Named profile from config.profiles
75
+ * @param {object} cli - CLI flag overrides
76
+ * @returns {object} Merged settings
77
+ */
78
+ export function mergeSettings(base = {}, project = null, profile = null, cli = {}) {
79
+ // Start with base config (strip profiles map to avoid leakage)
80
+ const { profiles: _profiles, ...baseClean } = base;
81
+ let merged = { ...baseClean };
82
+
83
+ // Apply profile on top (profile keys override base)
84
+ if (profile && typeof profile === "object") {
85
+ const { profiles: _pp, ...profileClean } = profile;
86
+ for (const [key, value] of Object.entries(profileClean)) {
87
+ if (value !== undefined) merged[key] = value;
88
+ }
89
+ }
90
+
91
+ // Apply project settings on top (project overrides profile + base)
92
+ if (project && typeof project === "object") {
93
+ for (const [key, value] of Object.entries(project)) {
94
+ if (key.startsWith("_")) continue; // skip internal metadata keys
95
+ if (value !== undefined && value !== null) {
96
+ // Deep merge objects (features, tools, agents)
97
+ if (
98
+ typeof value === "object" &&
99
+ !Array.isArray(value) &&
100
+ typeof merged[key] === "object" &&
101
+ !Array.isArray(merged[key]) &&
102
+ merged[key] !== null
103
+ ) {
104
+ merged[key] = { ...merged[key], ...value };
105
+ } else {
106
+ merged[key] = value;
107
+ }
108
+ }
109
+ }
110
+ }
111
+
112
+ // Apply CLI flags last (highest precedence)
113
+ if (cli && typeof cli === "object") {
114
+ for (const [key, value] of Object.entries(cli)) {
115
+ if (value !== undefined && value !== null) {
116
+ merged[key] = value;
117
+ }
118
+ }
119
+ }
120
+
121
+ return merged;
122
+ }
package/core/session.mjs CHANGED
@@ -23,7 +23,7 @@ import { readFile, writeFile, mkdir, readdir, stat } from "node:fs/promises";
23
23
  import { SESSIONS_DIR } from "./config.mjs";
24
24
 
25
25
  export class Session {
26
- constructor({ id, workstream = "default", channel = null, chatId = null, messages = [], createdAt = null }) {
26
+ constructor({ id, workstream = "default", channel = null, chatId = null, messages = [], createdAt = null, name = null, model = null, cwd = null }) {
27
27
  this.id = id;
28
28
  this.workstream = workstream;
29
29
  this.channel = channel;
@@ -31,14 +31,20 @@ export class Session {
31
31
  this.messages = messages;
32
32
  this.createdAt = createdAt ?? new Date().toISOString();
33
33
  this.updatedAt = new Date().toISOString();
34
+ this.name = name ?? null; // user-given display name
35
+ this.model = model ?? null; // model used in this session
36
+ this.cwd = cwd ?? process.cwd(); // working directory when session was created
34
37
  }
35
38
 
36
39
  toJSON() {
37
40
  return {
38
41
  id: this.id,
42
+ name: this.name,
39
43
  workstream: this.workstream,
40
44
  channel: this.channel,
41
45
  chatId: this.chatId,
46
+ model: this.model,
47
+ cwd: this.cwd,
42
48
  messages: this.messages,
43
49
  createdAt: this.createdAt,
44
50
  updatedAt: this.updatedAt,
@@ -55,9 +61,9 @@ export class SessionManager {
55
61
  /**
56
62
  * Create a new session
57
63
  */
58
- create({ workstream = "default", channel = null, chatId = null } = {}) {
64
+ create({ workstream = "default", channel = null, chatId = null, name = null, model = null, cwd = null } = {}) {
59
65
  const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
60
- const session = new Session({ id, workstream, channel, chatId });
66
+ const session = new Session({ id, workstream, channel, chatId, name, model, cwd });
61
67
  this._sessions.set(id, session);
62
68
  if (channel && chatId) {
63
69
  this._keyMap.set(`${channel}:${chatId}`, id);
@@ -136,6 +142,7 @@ export class SessionManager {
136
142
 
137
143
  results.push({
138
144
  id: data.id,
145
+ name: data.name ?? null,
139
146
  workstream: data.workstream ?? "default",
140
147
  channel: data.channel ?? null,
141
148
  chatId: data.chatId ?? null,
@@ -184,6 +191,8 @@ export class SessionManager {
184
191
  workstream: opts.workstream ?? source.workstream,
185
192
  channel: opts.channel ?? null,
186
193
  chatId: opts.chatId ?? null,
194
+ name: opts.name ?? null,
195
+ model: opts.model ?? source.model ?? null,
187
196
  });
188
197
 
189
198
  // Copy message history (deep copy)
@@ -196,6 +205,21 @@ export class SessionManager {
196
205
  return forked;
197
206
  }
198
207
 
208
+ /**
209
+ * Set (or update) the display name of a session.
210
+ * Persists immediately to disk.
211
+ *
212
+ * @param {string} id - Session ID
213
+ * @param {string} name - Display name
214
+ */
215
+ async setName(id, name) {
216
+ const session = this._sessions.get(id);
217
+ if (!session) throw new Error(`Session not found: ${id}`);
218
+ session.name = name;
219
+ session.updatedAt = new Date().toISOString();
220
+ await this.save(id);
221
+ }
222
+
199
223
  /**
200
224
  * Add a message to a session.
201
225
  */
@@ -327,6 +327,7 @@ ${bold("Wispy Commands:")}
327
327
  ${cyan("/search")} <keyword> Search across workstreams
328
328
  ${cyan("/skills")} List installed skills
329
329
  ${cyan("/sessions")} List sessions
330
+ ${cyan("/name")} <name> Give this session a display name (e.g. /name refactor-auth)
330
331
  ${cyan("/mcp")} [list|connect|disconnect|config|reload] MCP management
331
332
  ${cyan("/remember")} <text> Save text to main memory (MEMORY.md)
332
333
  ${cyan("/forget")} <key> Delete a memory file
@@ -1069,6 +1070,28 @@ ${bold("Permissions & Audit (v1.1):")}
1069
1070
  return true;
1070
1071
  }
1071
1072
 
1073
+ if (cmd.startsWith("/name")) {
1074
+ const nameParts = input.split(" ").slice(1);
1075
+ const sessionName = nameParts.join(" ").trim();
1076
+ if (!sessionName) {
1077
+ console.log(yellow("Usage: /name <display-name> (e.g. /name refactor-auth)"));
1078
+ return true;
1079
+ }
1080
+ // Find the active session and set its name
1081
+ try {
1082
+ const currentSession = engine.sessions?.list?.()?.find(Boolean);
1083
+ if (currentSession) {
1084
+ await engine.sessions.setName(currentSession.id, sessionName);
1085
+ console.log(green(`📎 Session named: "${sessionName}" (${currentSession.id})`));
1086
+ } else {
1087
+ console.log(yellow("No active session found."));
1088
+ }
1089
+ } catch (err) {
1090
+ console.log(red(`Failed to set session name: ${err.message}`));
1091
+ }
1092
+ return true;
1093
+ }
1094
+
1072
1095
  if (cmd === "/quit" || cmd === "/exit") {
1073
1096
  console.log(dim(`🌿 Bye! (${engine.providers.formatCost()})`));
1074
1097
  engine.destroy();
@@ -1293,6 +1316,7 @@ async function runRepl(engine) {
1293
1316
  noSave: true,
1294
1317
  dryRun: engine.dryRunMode ?? false,
1295
1318
  images: pendingImages,
1319
+ sessionName: process.env.WISPY_SESSION_NAME ?? null,
1296
1320
  onSkillLearned: (skill) => {
1297
1321
  console.log(cyan(`\n💡 Learned new skill: '${skill.name}' — use /${skill.name} next time`));
1298
1322
  },
@@ -1476,8 +1500,29 @@ if (args[0] && operatorCommands.has(args[0])) {
1476
1500
  process.exit(0);
1477
1501
  }
1478
1502
 
1479
- // Initialize engine
1480
- const engine = new WispyEngine({ workstream: ACTIVE_WORKSTREAM });
1503
+ // Initialize engine — merge project settings + env flags
1504
+ let _engineConfig = { workstream: ACTIVE_WORKSTREAM };
1505
+ try {
1506
+ const { findProjectSettings, mergeSettings } = await import("../core/project-settings.mjs");
1507
+ const { loadConfigWithProfile } = await import("../core/config.mjs");
1508
+ const projectSettings = await findProjectSettings();
1509
+ if (projectSettings) {
1510
+ const baseConfig = await loadConfigWithProfile(process.env.WISPY_PROFILE ?? null);
1511
+ _engineConfig = mergeSettings(baseConfig, projectSettings, null, {
1512
+ ...(process.env.WISPY_SYSTEM_PROMPT ? { systemPrompt: process.env.WISPY_SYSTEM_PROMPT } : {}),
1513
+ ...(process.env.WISPY_APPEND_SYSTEM_PROMPT ? { appendSystemPrompt: process.env.WISPY_APPEND_SYSTEM_PROMPT } : {}),
1514
+ ...(process.env.WISPY_EFFORT ? { effort: process.env.WISPY_EFFORT } : {}),
1515
+ });
1516
+ _engineConfig.workstream = _engineConfig.workstream ?? ACTIVE_WORKSTREAM;
1517
+ } else {
1518
+ // No project settings, still pick up env flags
1519
+ if (process.env.WISPY_SYSTEM_PROMPT) _engineConfig.systemPrompt = process.env.WISPY_SYSTEM_PROMPT;
1520
+ if (process.env.WISPY_APPEND_SYSTEM_PROMPT) _engineConfig.appendSystemPrompt = process.env.WISPY_APPEND_SYSTEM_PROMPT;
1521
+ if (process.env.WISPY_EFFORT) _engineConfig.effort = process.env.WISPY_EFFORT;
1522
+ }
1523
+ } catch { /* project settings are optional */ }
1524
+
1525
+ const engine = new WispyEngine(_engineConfig);
1481
1526
  const initResult = await engine.init();
1482
1527
 
1483
1528
  if (!initResult) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wispy-cli",
3
- "version": "2.7.14",
3
+ "version": "2.7.16",
4
4
  "description": "🌿 Wispy — AI workspace assistant with trustworthy execution (harness, receipts, approvals, diffs)",
5
5
  "license": "MIT",
6
6
  "author": "Minseo & Poropo",