wispy-cli 2.7.10 → 2.7.11

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