@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.
- package/package.json +1 -1
- package/scripts/test-repetition-hint.ts +234 -0
- package/src/auth/browserLogin.ts +129 -3
- package/src/chat/CliChatService.ts +14 -6
- package/src/chat/modules/AgentModule.ts +4 -1
- package/src/chat/modules/ClipboardImageModule.ts +136 -0
- package/src/chat/modules/InternalChatModule.ts +3 -0
- package/src/chat/modules/RendererModule.ts +30 -2
- package/src/clients/xai.ts +20 -3
- package/src/login.ts +3 -2
- package/src/processors/CustomVariables.ts +175 -0
- package/src/services/EventService.ts +5 -33
- package/src/services/Mcp.ts +14 -1
- package/src/utils/http.ts +9 -2
- package/src/utils/index.ts +1 -0
- package/tests/fixtures/fake-secret.txt +1 -0
- package/tests/manual/modalities/xai.modalities.test.ts +1 -1
- package/tests/processors/CustomVariables.test.ts +416 -1
- package/ts_build/package.json +1 -1
- package/ts_build/src/auth/browserLogin.d.ts +2 -0
- package/ts_build/src/auth/browserLogin.js +91 -3
- package/ts_build/src/auth/browserLogin.js.map +1 -1
- package/ts_build/src/chat/CliChatService.js +9 -4
- package/ts_build/src/chat/CliChatService.js.map +1 -1
- package/ts_build/src/chat/modules/AgentModule.js +3 -1
- package/ts_build/src/chat/modules/AgentModule.js.map +1 -1
- package/ts_build/src/chat/modules/ClipboardImageModule.d.ts +15 -0
- package/ts_build/src/chat/modules/ClipboardImageModule.js +157 -0
- package/ts_build/src/chat/modules/ClipboardImageModule.js.map +1 -0
- package/ts_build/src/chat/modules/InternalChatModule.d.ts +1 -0
- package/ts_build/src/chat/modules/InternalChatModule.js +3 -0
- package/ts_build/src/chat/modules/InternalChatModule.js.map +1 -1
- package/ts_build/src/chat/modules/RendererModule.js +30 -1
- package/ts_build/src/chat/modules/RendererModule.js.map +1 -1
- package/ts_build/src/clients/xai.js +14 -2
- package/ts_build/src/clients/xai.js.map +1 -1
- package/ts_build/src/login.js +2 -2
- package/ts_build/src/login.js.map +1 -1
- package/ts_build/src/processors/CustomVariables.d.ts +10 -0
- package/ts_build/src/processors/CustomVariables.js +127 -0
- package/ts_build/src/processors/CustomVariables.js.map +1 -1
- package/ts_build/src/services/EventService.d.ts +0 -4
- package/ts_build/src/services/EventService.js +4 -15
- package/ts_build/src/services/EventService.js.map +1 -1
- package/ts_build/src/services/Mcp.js +9 -1
- package/ts_build/src/services/Mcp.js.map +1 -1
- package/ts_build/src/utils/http.d.ts +2 -1
- package/ts_build/src/utils/http.js +11 -2
- package/ts_build/src/utils/http.js.map +1 -1
- package/ts_build/src/utils/index.js.map +1 -1
- package/ts_build/tests/manual/modalities/xai.modalities.test.js +1 -1
- package/ts_build/tests/manual/modalities/xai.modalities.test.js.map +1 -1
- package/ts_build/tests/processors/CustomVariables.test.js +347 -0
- package/ts_build/tests/processors/CustomVariables.test.js.map +1 -1
package/package.json
CHANGED
|
@@ -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");
|
package/src/auth/browserLogin.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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
|
|
31
|
+
// Initialize context.renderer from config or default to ConsoleRenderer
|
|
31
32
|
const context = service.getContext();
|
|
32
33
|
if (!context.renderer) {
|
|
33
|
-
|
|
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`
|