@tyvm/knowhow 0.0.110 → 0.0.112

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.
Files changed (54) hide show
  1. package/package.json +1 -1
  2. package/scripts/test-repetition-hint.ts +234 -0
  3. package/src/auth/browserLogin.ts +129 -3
  4. package/src/chat/CliChatService.ts +14 -6
  5. package/src/chat/modules/AgentModule.ts +4 -1
  6. package/src/chat/modules/ClipboardImageModule.ts +136 -0
  7. package/src/chat/modules/InternalChatModule.ts +3 -0
  8. package/src/chat/modules/RendererModule.ts +30 -2
  9. package/src/clients/xai.ts +20 -3
  10. package/src/login.ts +3 -2
  11. package/src/processors/CustomVariables.ts +175 -0
  12. package/src/services/EventService.ts +5 -33
  13. package/src/services/Mcp.ts +14 -1
  14. package/src/utils/http.ts +9 -2
  15. package/src/utils/index.ts +1 -0
  16. package/tests/fixtures/fake-secret.txt +1 -0
  17. package/tests/manual/modalities/xai.modalities.test.ts +1 -1
  18. package/tests/processors/CustomVariables.test.ts +416 -1
  19. package/ts_build/package.json +1 -1
  20. package/ts_build/src/auth/browserLogin.d.ts +2 -0
  21. package/ts_build/src/auth/browserLogin.js +91 -3
  22. package/ts_build/src/auth/browserLogin.js.map +1 -1
  23. package/ts_build/src/chat/CliChatService.js +9 -4
  24. package/ts_build/src/chat/CliChatService.js.map +1 -1
  25. package/ts_build/src/chat/modules/AgentModule.js +3 -1
  26. package/ts_build/src/chat/modules/AgentModule.js.map +1 -1
  27. package/ts_build/src/chat/modules/ClipboardImageModule.d.ts +15 -0
  28. package/ts_build/src/chat/modules/ClipboardImageModule.js +157 -0
  29. package/ts_build/src/chat/modules/ClipboardImageModule.js.map +1 -0
  30. package/ts_build/src/chat/modules/InternalChatModule.d.ts +1 -0
  31. package/ts_build/src/chat/modules/InternalChatModule.js +3 -0
  32. package/ts_build/src/chat/modules/InternalChatModule.js.map +1 -1
  33. package/ts_build/src/chat/modules/RendererModule.js +30 -1
  34. package/ts_build/src/chat/modules/RendererModule.js.map +1 -1
  35. package/ts_build/src/clients/xai.js +14 -2
  36. package/ts_build/src/clients/xai.js.map +1 -1
  37. package/ts_build/src/login.js +2 -2
  38. package/ts_build/src/login.js.map +1 -1
  39. package/ts_build/src/processors/CustomVariables.d.ts +10 -0
  40. package/ts_build/src/processors/CustomVariables.js +127 -0
  41. package/ts_build/src/processors/CustomVariables.js.map +1 -1
  42. package/ts_build/src/services/EventService.d.ts +0 -4
  43. package/ts_build/src/services/EventService.js +4 -15
  44. package/ts_build/src/services/EventService.js.map +1 -1
  45. package/ts_build/src/services/Mcp.js +9 -1
  46. package/ts_build/src/services/Mcp.js.map +1 -1
  47. package/ts_build/src/utils/http.d.ts +2 -1
  48. package/ts_build/src/utils/http.js +11 -2
  49. package/ts_build/src/utils/http.js.map +1 -1
  50. package/ts_build/src/utils/index.js.map +1 -1
  51. package/ts_build/tests/manual/modalities/xai.modalities.test.js +1 -1
  52. package/ts_build/tests/manual/modalities/xai.modalities.test.js.map +1 -1
  53. package/ts_build/tests/processors/CustomVariables.test.js +347 -0
  54. package/ts_build/tests/processors/CustomVariables.test.js.map +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyvm/knowhow",
3
- "version": "0.0.110",
3
+ "version": "0.0.112",
4
4
  "description": "ai cli with plugins and agents",
5
5
  "main": "ts_build/src/index.js",
6
6
  "bin": {
@@ -0,0 +1,234 @@
1
+ #!/usr/bin/env ts-node
2
+ /**
3
+ * Test script: runs the repetition hint processor logic against a real agent metadata file
4
+ * and prints whether the hint would fire and why/why not.
5
+ *
6
+ * Usage:
7
+ * npx ts-node scripts/test-repetition-hint.ts [path-to-metadata.json]
8
+ */
9
+
10
+ import * as fs from "fs";
11
+
12
+ const metadataPath =
13
+ process.argv[2] ||
14
+ "/Users/micah/dev/knowhow-web/.knowhow/processes/agents/1779684572-can-you-try-setting-mysql-postgres-this-sandbox-with/metadata.json";
15
+
16
+ interface ToolCall {
17
+ id: string;
18
+ type: string;
19
+ function: {
20
+ name: string;
21
+ arguments: string | Record<string, any>;
22
+ };
23
+ }
24
+
25
+ interface Message {
26
+ role: string;
27
+ content?: string;
28
+ tool_calls?: ToolCall[];
29
+ }
30
+
31
+ // ---- Replica of processor logic (mirrors CustomVariables.ts) ----
32
+
33
+ function extractStringValues(obj: any, results: string[] = []): string[] {
34
+ if (typeof obj === "string") {
35
+ results.push(obj);
36
+ } else if (Array.isArray(obj)) {
37
+ for (const item of obj) extractStringValues(item, results);
38
+ } else if (obj && typeof obj === "object") {
39
+ for (const val of Object.values(obj)) extractStringValues(val, results);
40
+ }
41
+ return results;
42
+ }
43
+
44
+ function getToolCallStrings(toolCall: ToolCall): string[] {
45
+ try {
46
+ const args = toolCall.function.arguments;
47
+ const parsed = typeof args === "string" ? JSON.parse(args) : args;
48
+ return extractStringValues(parsed);
49
+ } catch {
50
+ const args = toolCall.function.arguments;
51
+ return [typeof args === "string" ? args : JSON.stringify(args)];
52
+ }
53
+ }
54
+
55
+ function collectToolCallStrings(
56
+ messages: Message[],
57
+ minLength: number
58
+ ): Array<{ value: string; toolName: string }> {
59
+ const collected: Array<{ value: string; toolName: string }> = [];
60
+ for (const message of messages) {
61
+ if (!message.tool_calls) continue;
62
+ for (const toolCall of message.tool_calls) {
63
+ const strings = getToolCallStrings(toolCall);
64
+ for (const str of strings) {
65
+ if (str.length >= minLength) {
66
+ collected.push({ value: str, toolName: toolCall.function.name });
67
+ }
68
+ }
69
+ }
70
+ }
71
+ return collected;
72
+ }
73
+
74
+ function longestCommonSubstring(a: string, b: string, minLength: number): string | null {
75
+ let best = "";
76
+ for (let i = 0; i < a.length - minLength + 1; i++) {
77
+ for (let j = a.length; j > i + minLength - 1; j--) {
78
+ const sub = a.slice(i, j);
79
+ if (sub.length <= best.length) break; // already found longer, skip shorter
80
+ if (b.includes(sub)) {
81
+ best = sub;
82
+ break;
83
+ }
84
+ }
85
+ }
86
+ return best.length >= minLength ? best : null;
87
+ }
88
+
89
+ function runProcessor(
90
+ messages: Message[],
91
+ minLength = 50,
92
+ minRepetitions = 2,
93
+ minSubstringLength = 50
94
+ ): { wouldHint: boolean; repeatedTools: string[]; details: Map<string, { count: number; tools: Set<string> }> } {
95
+ const stringCounts = new Map<string, { count: number; tools: Set<string> }>();
96
+ const toolStrings = collectToolCallStrings(messages, minLength);
97
+
98
+ // Step 1: exact full-string matches
99
+ for (const { value, toolName } of toolStrings) {
100
+ const existing = stringCounts.get(value);
101
+ if (existing) {
102
+ existing.count++;
103
+ existing.tools.add(toolName);
104
+ } else {
105
+ stringCounts.set(value, { count: 1, tools: new Set([toolName]) });
106
+ }
107
+ }
108
+
109
+ // Step 2: repeated substrings across different full strings
110
+ // e.g. the same JWT embedded in many different commands
111
+ const substringCounts = new Map<string, { count: number; tools: Set<string> }>();
112
+ for (let i = 0; i < toolStrings.length; i++) {
113
+ for (let j = i + 1; j < toolStrings.length; j++) {
114
+ const a = toolStrings[i];
115
+ const b = toolStrings[j];
116
+ if (a.value === b.value) continue; // already handled by exact match
117
+ const common = longestCommonSubstring(a.value, b.value, minSubstringLength);
118
+ if (common) {
119
+ const existing = substringCounts.get(common);
120
+ if (existing) {
121
+ existing.count++;
122
+ existing.tools.add(a.toolName);
123
+ existing.tools.add(b.toolName);
124
+ } else {
125
+ substringCounts.set(common, { count: 1, tools: new Set([a.toolName, b.toolName]) });
126
+ }
127
+ }
128
+ }
129
+ }
130
+
131
+ // Merge substring counts: count = number of unique pairs, count+1 = number of occurrences
132
+ for (const [sub, info] of substringCounts.entries()) {
133
+ if (info.count + 1 >= minRepetitions && !stringCounts.has(sub)) {
134
+ stringCounts.set(sub, { count: info.count + 1, tools: info.tools });
135
+ }
136
+ }
137
+
138
+ // Find entries that exceed the repetition threshold
139
+ const repeatedTools: string[] = [];
140
+ for (const [, info] of stringCounts.entries()) {
141
+ if (info.count >= minRepetitions) {
142
+ for (const toolName of info.tools) {
143
+ if (!repeatedTools.includes(toolName)) repeatedTools.push(toolName);
144
+ }
145
+ }
146
+ }
147
+
148
+ return { wouldHint: repeatedTools.length > 0, repeatedTools, details: stringCounts };
149
+ }
150
+
151
+ // ---- Main ----
152
+
153
+ const raw = fs.readFileSync(metadataPath, "utf-8");
154
+ const metadata = JSON.parse(raw);
155
+ const threads: Message[][] = metadata.threads || [];
156
+
157
+ console.log(`\n=== Repetition Hint Processor Test ===`);
158
+ console.log(`File: ${metadataPath}`);
159
+ console.log(`Threads: ${threads.length}`);
160
+
161
+ for (let ti = 0; ti < threads.length; ti++) {
162
+ const thread = threads[ti];
163
+ const toolCallMsgs = thread.filter((m) => m.tool_calls && m.tool_calls.length > 0);
164
+ console.log(`\n--- Thread ${ti}: ${thread.length} messages, ${toolCallMsgs.length} with tool calls ---`);
165
+
166
+ // Run with OLD logic (exact matches only)
167
+ console.log(`\n[OLD Processor] exact full-string matches only, minLength=50, minRepetitions=2`);
168
+ const oldResult = runProcessor(thread, 50, 2, Infinity);
169
+ if (oldResult.wouldHint) {
170
+ console.log(`✅ Would hint! Repeated tools: ${oldResult.repeatedTools.join(", ")}`);
171
+ } else {
172
+ console.log(`❌ Would NOT hint (bug - missed embedded repetitions).`);
173
+ }
174
+
175
+ // Run with NEW logic (exact + substring)
176
+ console.log(`\n[NEW Processor] exact + substring matching, minLength=50, minRepetitions=2, minSubstringLength=50`);
177
+ const newResult = runProcessor(thread, 50, 2, 50);
178
+ if (newResult.wouldHint) {
179
+ console.log(`✅ Would hint! Repeated tools: ${newResult.repeatedTools.join(", ")}`);
180
+ // Show top repeated substrings
181
+ const repeated = Array.from(newResult.details.entries())
182
+ .filter(([, info]) => info.count >= 2)
183
+ .sort((a, b) => b[1].count - a[1].count)
184
+ .slice(0, 5);
185
+ console.log(`\n Top repeated values (count, tools, preview):`);
186
+ for (const [str, info] of repeated) {
187
+ console.log(` count=${info.count}, tools=${[...info.tools].join(",")}`);
188
+ console.log(` value=${JSON.stringify(str.slice(0, 120))}`);
189
+ }
190
+ } else {
191
+ console.log(`❌ Would NOT hint.`);
192
+ // Show top large strings for diagnosis
193
+ const toolStrings = collectToolCallStrings(thread, 50);
194
+ console.log(`\n Total large strings in tool calls: ${toolStrings.length}`);
195
+ const top = toolStrings.slice(0, 3);
196
+ for (const { value, toolName } of top) {
197
+ console.log(` tool=${toolName}, len=${value.length}, preview=${JSON.stringify(value.slice(0, 100))}`);
198
+ }
199
+ }
200
+
201
+ // Check for Bearer tokens specifically
202
+ console.log(`\n[Bearer Token Check]`);
203
+ const jwtMap = new Map<string, { count: number; tools: Set<string> }>();
204
+ const jwtPattern = /Bearer ([\w\-\.]+)/;
205
+ for (const msg of thread) {
206
+ if (!msg.tool_calls) continue;
207
+ for (const tc of msg.tool_calls) {
208
+ const args = typeof tc.function.arguments === "string"
209
+ ? tc.function.arguments
210
+ : JSON.stringify(tc.function.arguments);
211
+ const match = jwtPattern.exec(args);
212
+ if (match) {
213
+ const jwt = match[1];
214
+ const existing = jwtMap.get(jwt);
215
+ if (existing) {
216
+ existing.count++;
217
+ existing.tools.add(tc.function.name);
218
+ } else {
219
+ jwtMap.set(jwt, { count: 1, tools: new Set([tc.function.name]) });
220
+ }
221
+ }
222
+ }
223
+ }
224
+ if (jwtMap.size > 0) {
225
+ for (const [jwt, info] of jwtMap.entries()) {
226
+ console.log(` ⚠️ Bearer token appears ${info.count} times in: ${[...info.tools].join(", ")}`);
227
+ console.log(` ${jwt.slice(0, 80)}...`);
228
+ }
229
+ } else {
230
+ console.log(` No Bearer tokens found in tool calls.`);
231
+ }
232
+ }
233
+
234
+ console.log("\n=== Done ===\n");
@@ -3,6 +3,7 @@ import { exec } from "child_process";
3
3
  import { promisify } from "util";
4
4
  import * as os from "os";
5
5
  import * as fs from "fs";
6
+ import * as path from "path";
6
7
  import { KNOWHOW_API_URL } from "../services/KnowhowClient";
7
8
  import { Spinner } from "./spinner";
8
9
  import { BrowserLoginError } from "./errors";
@@ -21,6 +22,8 @@ interface SessionStatusResponse {
21
22
 
22
23
  interface RetrieveTokenResponse {
23
24
  jwt: string;
25
+ requiresDeviceConfirmation?: boolean;
26
+ jwtSessionId?: string;
24
27
  }
25
28
 
26
29
  export class BrowserLoginService {
@@ -102,9 +105,32 @@ export class BrowserLoginService {
102
105
  `${this.baseUrl}/api/cli-login/session/${sessionData.sessionId}/token`
103
106
  );
104
107
 
105
- const jwt = tokenResponse.data.jwt;
106
- await this.storeJwt(jwt);
108
+ const tokenData = tokenResponse.data as RetrieveTokenResponse;
107
109
  spinner.stop();
110
+
111
+ if (tokenData.requiresDeviceConfirmation) {
112
+ // Token was issued but the device needs confirmation via email code.
113
+ // Store it now so it's ready once confirmed.
114
+ if (tokenData.jwt) {
115
+ await this.storeJwt(tokenData.jwt);
116
+ }
117
+ console.log("\n⚠️ New device detected — device confirmation required!");
118
+ console.log("─────────────────────────────────────────────────────");
119
+ console.log("A confirmation code has been sent to your email.");
120
+ console.log("You must confirm this device in your browser to complete login.");
121
+ console.log("\nPlease check the browser window you just used to approve the CLI session.");
122
+ console.log("Enter the email code there to confirm this device.");
123
+ console.log("\nAlternatively, visit your settings page:");
124
+ console.log(` ${process.env.KNOWHOW_FRONTEND_URL || "https://knowhow.tyvm.ai"}/settings?tab=security`);
125
+ console.log("─────────────────────────────────────────────────────\n");
126
+
127
+ // Wait for the user to confirm the device — poll /api/users/me until
128
+ // the session becomes ACTIVE (device confirmed) or we time out.
129
+ await this.waitForDeviceConfirmation(tokenData.jwt);
130
+ return;
131
+ }
132
+
133
+ await this.storeJwt(tokenData.jwt);
108
134
  return;
109
135
  } else if (status.status.toLowerCase() === "denied") {
110
136
  throw new BrowserLoginError(
@@ -146,7 +172,8 @@ export class BrowserLoginService {
146
172
  try {
147
173
  const response = await http.post<CreateSessionResponse>(
148
174
  `${this.baseUrl}/api/cli-login/session`,
149
- {}
175
+ {},
176
+ { headers: { "User-Agent": getCliUserAgent() } }
150
177
  );
151
178
  return response.data;
152
179
  } catch (error) {
@@ -188,6 +215,83 @@ export class BrowserLoginService {
188
215
  return new Promise((resolve) => setTimeout(resolve, ms));
189
216
  }
190
217
 
218
+ /**
219
+ * Poll /api/users/me with the pending JWT until the device confirmation is
220
+ * completed (session becomes ACTIVE) or we time out (~10 minutes).
221
+ * Shows a spinner so the user knows the CLI is still waiting.
222
+ */
223
+ private async waitForDeviceConfirmation(jwt: string): Promise<void> {
224
+ const spinner = new Spinner();
225
+ spinner.start("Waiting for device confirmation");
226
+
227
+ let isCancelled = false;
228
+ const cancelHandler = () => {
229
+ isCancelled = true;
230
+ spinner.stop();
231
+ console.log("\n\nCancelled. Your token is stored — once you confirm the device, re-run your command.");
232
+ process.exit(0);
233
+ };
234
+ process.once("SIGINT", cancelHandler);
235
+
236
+ const maxAttempts = 120; // 10 minutes at 5-second intervals
237
+ let attempt = 0;
238
+
239
+ while (attempt < maxAttempts) {
240
+ attempt++;
241
+
242
+ // Sleep in small increments so SIGINT can be checked more responsively
243
+ for (let i = 0; i < 10; i++) {
244
+ await this.sleep(500);
245
+ if (isCancelled) return;
246
+ }
247
+
248
+ try {
249
+ const response = await http.get(`${this.baseUrl}/api/users/me`, {
250
+ headers: { Authorization: `Bearer ${jwt}` },
251
+ timeout: 10000,
252
+ });
253
+
254
+ if (response.status === 200) {
255
+ // Device confirmed — session is now ACTIVE
256
+ spinner.stop();
257
+ process.removeListener("SIGINT", cancelHandler);
258
+ console.log("✅ Device confirmed! You are now logged in.");
259
+ return;
260
+ }
261
+ } catch (error) {
262
+ if (http.isHttpError(error)) {
263
+ if (error.status === 403) {
264
+ // Still pending — keep waiting
265
+ continue;
266
+ }
267
+ if (error.status === 401) {
268
+ // 401 can mean:
269
+ // - Session not found yet (timing issue, check-device may not have run)
270
+ // - Session is PENDING_DEVICE_CONFIRMATION (some backend versions return 401)
271
+ // - Token was actually revoked/expired
272
+ // Keep polling for the first ~5 attempts before giving up, to handle timing issues.
273
+ if (attempt >= 10) {
274
+ spinner.stop();
275
+ process.removeListener("SIGINT", cancelHandler);
276
+ throw new BrowserLoginError(
277
+ "Token expired or revoked during device confirmation. Please run 'knowhow login' again.",
278
+ "TOKEN_EXPIRED"
279
+ );
280
+ }
281
+ continue;
282
+ }
283
+ }
284
+ }
285
+ }
286
+
287
+ spinner.stop();
288
+ process.removeListener("SIGINT", cancelHandler);
289
+ console.log("\n⏰ Timed out waiting for device confirmation.");
290
+ console.log("Your token is stored — once you confirm the device at:");
291
+ console.log(` ${process.env.KNOWHOW_FRONTEND_URL || "https://knowhow.tyvm.ai"}/settings?tab=security`);
292
+ console.log("you can re-run your command and it will work.\n");
293
+ }
294
+
191
295
  /**
192
296
  * Set up signal handlers for graceful shutdown
193
297
  */
@@ -201,6 +305,28 @@ export class BrowserLoginService {
201
305
  }
202
306
  }
203
307
 
308
+ /**
309
+ * Build a descriptive User-Agent string for CLI sessions so they show up
310
+ * with meaningful device info in the sessions UI (e.g. "Knowhow CLI on macOS").
311
+ */
312
+ export function getCliUserAgent(): string {
313
+ let cliVersion = "unknown";
314
+ try {
315
+ // __dirname is ts_build/src/auth/ at runtime, so go up 3 levels to package root
316
+ const pkgPath = path.resolve(__dirname, "../../../package.json");
317
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
318
+ cliVersion = pkg.version ?? "unknown";
319
+ } catch {
320
+ // ignore — version is cosmetic
321
+ }
322
+ const platform = os.platform();
323
+ const osName =
324
+ platform === "darwin" ? "macOS" :
325
+ platform === "win32" ? "Windows" :
326
+ platform === "linux" ? "Linux" : platform;
327
+ return `Knowhow CLI/${cliVersion} (${osName})`;
328
+ }
329
+
204
330
  /**
205
331
  * Utility function to open a URL in the default browser across different platforms
206
332
  */
@@ -216,12 +216,20 @@ export class CliChatService implements ChatService {
216
216
  } else {
217
217
  // Input starts with "/" but no matching command found - warn the user
218
218
  const availableCommands = this.getCommandsForActiveModes();
219
- console.log(
220
- `Unknown command "/${commandName}". Available commands: ${availableCommands
221
- .map((cmd) => `/${cmd.name}`)
222
- .join(", ")}`
223
- );
224
- return true;
219
+ // If the input looks like a filepath (contains path separators or file extensions),
220
+ // don't treat it as a failed command - let it fall through to modules
221
+ const looksLikeFilepath =
222
+ commandName.includes("/") ||
223
+ commandName.includes(".") ||
224
+ commandName.includes("\\");
225
+ if (!looksLikeFilepath) {
226
+ console.log(
227
+ `Unknown command "/${commandName}". Available commands: ${availableCommands
228
+ .map((cmd) => `/${cmd.name}`)
229
+ .join(", ")}`
230
+ );
231
+ return true;
232
+ }
225
233
  }
226
234
  }
227
235
 
@@ -756,10 +756,12 @@ export class AgentModule extends BaseChatModule {
756
756
  ),
757
757
  ];
758
758
 
759
+ const customVariables = new CustomVariables(agent.tools);
760
+
759
761
  agent.messageProcessor.setProcessors("pre_call", [
760
762
  new Base64ImageProcessor(agent.tools).createProcessor(),
761
763
  ...caching,
762
- new CustomVariables(agent.tools).createProcessor(),
764
+ customVariables.createProcessor(),
763
765
  ]);
764
766
 
765
767
  agent.messageProcessor.setProcessors("post_call", [
@@ -770,6 +772,7 @@ export class AgentModule extends BaseChatModule {
770
772
  agent.messageProcessor.setProcessors("post_tools", [
771
773
  new Base64ImageProcessor(agent.tools).createProcessor(),
772
774
  ...caching,
775
+ customVariables.createRepetitionHintProcessor(),
773
776
  ]);
774
777
 
775
778
  // Set up event listeners
@@ -0,0 +1,136 @@
1
+ import { exec } from "child_process";
2
+ import { promisify } from "util";
3
+ import * as fs from "fs";
4
+ import * as os from "os";
5
+ import * as path from "path";
6
+ import { ChatModule, ChatCommand, ChatMode, ChatContext } from "../types";
7
+ import { CliChatService } from "../CliChatService";
8
+
9
+ const execAsync = promisify(exec);
10
+
11
+ /**
12
+ * Attempts to capture an image from the system clipboard and save it to a temp file.
13
+ * Returns the filepath if successful, or null if the clipboard has no image.
14
+ */
15
+ export async function captureClipboardImage(): Promise<string | null> {
16
+ const tmpFile = path.join(os.tmpdir(), `knowhow-clipboard-${Date.now()}.png`);
17
+
18
+ const platform = process.platform;
19
+
20
+ try {
21
+ if (platform === "darwin") {
22
+ // macOS: use pngpaste if available, fall back to osascript
23
+ try {
24
+ await execAsync(`pngpaste "${tmpFile}"`);
25
+ if (fs.existsSync(tmpFile)) return tmpFile;
26
+ } catch {
27
+ // pngpaste not available or no image in clipboard, try osascript
28
+ const script = `
29
+ tell application "System Events"
30
+ set theImage to the clipboard as «class PNGf»
31
+ set fileRef to open for access POSIX file "${tmpFile}" with write permission
32
+ write theImage to fileRef
33
+ close access fileRef
34
+ end tell
35
+ `;
36
+ try {
37
+ await execAsync(`osascript -e '${script.replace(/'/g, "'\\''")}'`);
38
+ if (fs.existsSync(tmpFile)) return tmpFile;
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+ } else if (platform === "linux") {
44
+ // Linux: try xclip first, then xsel
45
+ try {
46
+ await execAsync(`xclip -selection clipboard -t image/png -o > "${tmpFile}"`);
47
+ if (fs.existsSync(tmpFile) && fs.statSync(tmpFile).size > 0) return tmpFile;
48
+ } catch {
49
+ try {
50
+ await execAsync(`xsel --clipboard --output > "${tmpFile}"`);
51
+ if (fs.existsSync(tmpFile) && fs.statSync(tmpFile).size > 0) return tmpFile;
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+ } else if (platform === "win32") {
57
+ // Windows: use PowerShell
58
+ const ps = `
59
+ Add-Type -AssemblyName System.Windows.Forms
60
+ $img = [System.Windows.Forms.Clipboard]::GetImage()
61
+ if ($img -ne $null) {
62
+ $img.Save('${tmpFile.replace(/\\/g, "\\\\")}')
63
+ Write-Output 'saved'
64
+ } else {
65
+ Write-Output 'no image'
66
+ }
67
+ `;
68
+ const { stdout } = await execAsync(`powershell -Command "${ps.replace(/"/g, '\\"')}"`);
69
+ if (stdout.trim() === "saved" && fs.existsSync(tmpFile)) return tmpFile;
70
+ }
71
+ } catch {
72
+ // Silently fail - clipboard doesn't contain an image
73
+ }
74
+
75
+ // Clean up empty file if created
76
+ try {
77
+ if (fs.existsSync(tmpFile) && fs.statSync(tmpFile).size === 0) {
78
+ fs.unlinkSync(tmpFile);
79
+ }
80
+ } catch { /* ignore */ }
81
+
82
+ return null;
83
+ }
84
+
85
+ export class ClipboardImageModule implements ChatModule {
86
+ name = "clipboard-image";
87
+ description = "Handles clipboard image paste detection and capture";
88
+ commands: ChatCommand[] = [];
89
+ modes: ChatMode[] = [];
90
+
91
+ getCommands(): ChatCommand[] {
92
+ return [
93
+ {
94
+ name: "paste",
95
+ description: "Capture image from clipboard and send to agent",
96
+ handler: this.handlePasteCommand.bind(this),
97
+ },
98
+ ];
99
+ }
100
+
101
+ getModes(): ChatMode[] {
102
+ return [];
103
+ }
104
+
105
+ async initialize(chatService: CliChatService): Promise<void> {
106
+ // Register /paste command
107
+ chatService.registerCommand({
108
+ name: "paste",
109
+ description: "Capture image from clipboard and send to agent",
110
+ handler: this.handlePasteCommand.bind(this),
111
+ });
112
+
113
+ }
114
+
115
+ private async handlePasteCommand(args: string[]): Promise<{ handled: boolean; contents?: string }> {
116
+ console.log("🔍 Checking clipboard for image...");
117
+ const filepath = await captureClipboardImage();
118
+
119
+ if (!filepath) {
120
+ console.log("No image found in clipboard. Copy an image first, then use /paste.");
121
+ return { handled: true };
122
+ }
123
+
124
+ console.log(`📋 Image captured: ${filepath}`);
125
+
126
+ // Return as not-handled so the filepath flows through to modules (AgentModule)
127
+ return { handled: false, contents: filepath };
128
+ }
129
+
130
+ async handleInput(input: string, context: ChatContext): Promise<boolean> {
131
+ return false; // This module only handles commands
132
+ }
133
+
134
+ async cleanup(): Promise<void> {
135
+ }
136
+ }
@@ -12,8 +12,10 @@ import { ShellCommandModule } from "./ShellCommandModule";
12
12
  import { RendererModule } from "./RendererModule";
13
13
  import { SessionsModule } from "./SessionsModule";
14
14
  import { RemoteSyncModule } from "./RemoteSyncModule";
15
+ import { ClipboardImageModule } from "./ClipboardImageModule";
15
16
 
16
17
  export class InternalChatModule implements ChatModule {
18
+ private clipboardImageModule = new ClipboardImageModule();
17
19
  private chatService?: CliChatService;
18
20
  name = "internal";
19
21
  description = "Internal chat module aggregating all functionality";
@@ -57,6 +59,7 @@ export class InternalChatModule implements ChatModule {
57
59
  await this.customCommandsModule.initialize(chatService);
58
60
  await this.remoteSyncModule.initialize(chatService);
59
61
  await this.shellCommandModule.initialize(chatService);
62
+ await this.clipboardImageModule.initialize(chatService);
60
63
 
61
64
  // Register our own commands (exit and multi) - not duplicated by BaseChatModule
62
65
  chatService.registerCommand({
@@ -6,6 +6,7 @@ import { ChatCommand, ChatMode, ChatContext, ChatService } from "../types";
6
6
  import { loadRenderer } from "../renderer/loadRenderer";
7
7
  import { ConsoleRenderer } from "../renderer";
8
8
  import { AgentModule } from "./AgentModule";
9
+ import { getConfig, updateConfig } from "../../config";
9
10
 
10
11
  const BUILTIN_RENDERERS = ["basic", "compact", "fancy"];
11
12
 
@@ -27,10 +28,25 @@ export class RendererModule extends BaseChatModule {
27
28
  async initialize(service: ChatService): Promise<void> {
28
29
  await super.initialize(service);
29
30
 
30
- // Initialize context.renderer with a default ConsoleRenderer if not already set
31
+ // Initialize context.renderer from config or default to ConsoleRenderer
31
32
  const context = service.getContext();
32
33
  if (!context.renderer) {
33
- service.setContext({ renderer: new ConsoleRenderer() });
34
+ try {
35
+ const config = await getConfig();
36
+ const savedName = config.chat?.renderer;
37
+ if (savedName && savedName !== "basic") {
38
+ const savedRenderer = await loadRenderer(savedName);
39
+ this.currentRendererName = savedName;
40
+ service.setContext({ renderer: savedRenderer });
41
+ } else {
42
+ service.setContext({ renderer: new ConsoleRenderer() });
43
+ if (savedName === "basic" || !savedName) {
44
+ this.currentRendererName = "basic";
45
+ }
46
+ }
47
+ } catch {
48
+ service.setContext({ renderer: new ConsoleRenderer() });
49
+ }
34
50
  }
35
51
  }
36
52
 
@@ -85,6 +101,18 @@ export class RendererModule extends BaseChatModule {
85
101
  this.chatService?.setContext({ renderer: newRenderer });
86
102
  this.currentRendererName = specifier;
87
103
 
104
+ // Persist renderer preference to config
105
+ try {
106
+ const config = await getConfig();
107
+ config.chat = {
108
+ ...config.chat,
109
+ renderer: specifier,
110
+ };
111
+ await updateConfig(config);
112
+ } catch (saveErr: any) {
113
+ console.warn(`⚠️ Could not save renderer preference: ${saveErr.message}`);
114
+ }
115
+
88
116
  // Rewire agent rendering event listeners to the new renderer so live
89
117
  // events are forwarded correctly even mid-session.
90
118
  // This works because wireAgentRendering() always reads `this.renderer`