@tyvm/knowhow 0.0.100 → 0.0.102
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/jest.manual.config.js +19 -0
- package/package.json +2 -1
- package/src/agents/base/base.ts +61 -4
- package/src/cli.ts +22 -1
- package/src/clients/contextLimits.ts +0 -2
- package/src/clients/pricing/anthropic.ts +0 -6
- package/src/clients/pricing/google.ts +0 -4
- package/src/clients/pricing/xai.ts +11 -0
- package/src/cloudWorker.ts +48 -30
- package/src/fileSync.ts +153 -9
- package/src/hashes.ts +52 -0
- package/src/login.ts +1 -4
- package/src/services/KnowhowClient.ts +8 -3
- package/src/services/Mcp.ts +17 -3
- package/src/services/S3.ts +15 -5
- package/src/types.ts +17 -17
- package/tests/manual/clients/completions.json +454 -0
- package/tests/manual/clients/completions.test.ts +166 -0
- package/ts_build/package.json +2 -1
- package/ts_build/src/agents/base/base.d.ts +1 -0
- package/ts_build/src/agents/base/base.js +37 -4
- package/ts_build/src/agents/base/base.js.map +1 -1
- package/ts_build/src/cli.js +11 -1
- package/ts_build/src/cli.js.map +1 -1
- package/ts_build/src/clients/anthropic.d.ts +0 -6
- package/ts_build/src/clients/contextLimits.js +0 -2
- package/ts_build/src/clients/contextLimits.js.map +1 -1
- package/ts_build/src/clients/pricing/anthropic.d.ts +0 -6
- package/ts_build/src/clients/pricing/anthropic.js +0 -6
- package/ts_build/src/clients/pricing/anthropic.js.map +1 -1
- package/ts_build/src/clients/pricing/google.js +0 -4
- package/ts_build/src/clients/pricing/google.js.map +1 -1
- package/ts_build/src/clients/pricing/xai.d.ts +10 -0
- package/ts_build/src/clients/pricing/xai.js +10 -0
- package/ts_build/src/clients/pricing/xai.js.map +1 -1
- package/ts_build/src/clients/xai.d.ts +13 -4
- package/ts_build/src/cloudWorker.js +38 -25
- package/ts_build/src/cloudWorker.js.map +1 -1
- package/ts_build/src/fileSync.d.ts +3 -0
- package/ts_build/src/fileSync.js +104 -6
- package/ts_build/src/fileSync.js.map +1 -1
- package/ts_build/src/hashes.d.ts +4 -0
- package/ts_build/src/hashes.js +34 -0
- package/ts_build/src/hashes.js.map +1 -1
- package/ts_build/src/login.js +1 -4
- package/ts_build/src/login.js.map +1 -1
- package/ts_build/src/services/KnowhowClient.d.ts +4 -1
- package/ts_build/src/services/KnowhowClient.js +4 -1
- package/ts_build/src/services/KnowhowClient.js.map +1 -1
- package/ts_build/src/services/Mcp.js +11 -3
- package/ts_build/src/services/Mcp.js.map +1 -1
- package/ts_build/src/services/S3.js +14 -4
- package/ts_build/src/services/S3.js.map +1 -1
- package/ts_build/src/types.d.ts +2 -2
- package/ts_build/src/types.js +12 -13
- package/ts_build/src/types.js.map +1 -1
- package/ts_build/tests/manual/clients/completions.test.d.ts +1 -0
- package/ts_build/tests/manual/clients/completions.test.js +135 -0
- package/ts_build/tests/manual/clients/completions.test.js.map +1 -0
- package/test-ai-completion.ts +0 -39
- package/test-mcp-args.ts +0 -71
- package/test-tools-service.ts +0 -45
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
extensionsToTreatAsEsm: ['.ts'],
|
|
3
|
+
moduleNameMapper: {
|
|
4
|
+
'^(\\.{1,2}/.*)\\.js$': '$1',
|
|
5
|
+
},
|
|
6
|
+
transform: {
|
|
7
|
+
'^.+\\.ts?$': [
|
|
8
|
+
'ts-jest',
|
|
9
|
+
{
|
|
10
|
+
useESM: true,
|
|
11
|
+
},
|
|
12
|
+
],
|
|
13
|
+
},
|
|
14
|
+
testEnvironment: 'node',
|
|
15
|
+
testRegex: '/tests/manual/.*\.(test|spec)\.(ts|tsx|js)$',
|
|
16
|
+
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
|
|
17
|
+
modulePathIgnorePatterns: ["ts_build", "benchmarks"],
|
|
18
|
+
testPathIgnorePatterns: [],
|
|
19
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tyvm/knowhow",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.102",
|
|
4
4
|
"description": "ai cli with plugins and agents",
|
|
5
5
|
"main": "ts_build/src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"scripts": {
|
|
10
10
|
"test": "jest --testTimeout 300000",
|
|
11
11
|
"test:debug": "node --inspect-brk ../../node_modules/jest/bin/jest.js --detectOpenHandles --forceExit --testTimeout 300000",
|
|
12
|
+
"build": "npm run compile",
|
|
12
13
|
"compile": "npm run clean && tsc",
|
|
13
14
|
"clean": "rm -rf ts_build",
|
|
14
15
|
"node:build": "bash scripts/build-for-node.sh",
|
package/src/agents/base/base.ts
CHANGED
|
@@ -64,7 +64,13 @@ export abstract class BaseAgent implements IAgent {
|
|
|
64
64
|
protected compressMinMessages = 30;
|
|
65
65
|
|
|
66
66
|
protected threads = [] as Message[][];
|
|
67
|
+
|
|
68
|
+
// Message from users
|
|
67
69
|
protected pendingUserMessages = [] as Message[];
|
|
70
|
+
|
|
71
|
+
// Internal messages
|
|
72
|
+
protected pendingMessages = [] as Message[];
|
|
73
|
+
|
|
68
74
|
protected taskBreakdown = "";
|
|
69
75
|
protected summaries = [] as string[];
|
|
70
76
|
protected currentTaskId: string | null = null;
|
|
@@ -538,10 +544,14 @@ export abstract class BaseAgent implements IAgent {
|
|
|
538
544
|
|
|
539
545
|
async kill() {
|
|
540
546
|
this.log("Killing agent");
|
|
547
|
+
if (this.status === this.eventTypes.kill || this.status === this.eventTypes.done) {
|
|
548
|
+
this.log("Agent is already being killed or done, ignoring duplicate kill()", "warn");
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
541
551
|
this.agentEvents.emit(this.eventTypes.kill, this);
|
|
542
552
|
this.status = this.eventTypes.kill;
|
|
543
553
|
|
|
544
|
-
this.
|
|
554
|
+
this.addPendingMessage({
|
|
545
555
|
role: "user",
|
|
546
556
|
content: `<Workflow>The user has requested the task to end, please call ${this.requiredToolNames} with a report of your ending state</Workflow>`,
|
|
547
557
|
} as Message);
|
|
@@ -599,6 +609,11 @@ export abstract class BaseAgent implements IAgent {
|
|
|
599
609
|
this.pendingUserMessages = [];
|
|
600
610
|
}
|
|
601
611
|
|
|
612
|
+
if (this.pendingMessages.length) {
|
|
613
|
+
messages.push(...this.pendingMessages);
|
|
614
|
+
this.pendingMessages = [];
|
|
615
|
+
}
|
|
616
|
+
|
|
602
617
|
messages = this.formatInputMessages(messages);
|
|
603
618
|
this.updateCurrentThread(messages);
|
|
604
619
|
const isMissingTool = this.isRequiredToolMissing();
|
|
@@ -619,6 +634,17 @@ export abstract class BaseAgent implements IAgent {
|
|
|
619
634
|
tool_choice: "auto",
|
|
620
635
|
});
|
|
621
636
|
|
|
637
|
+
// If the agent was paused while the completion was in-flight, wait here
|
|
638
|
+
// before processing tool calls. This allows the user to send messages
|
|
639
|
+
// (via addPendingUserMessage) and prevents the agent from proceeding to
|
|
640
|
+
// tool calls (e.g. finalAnswer) without seeing those interactions.
|
|
641
|
+
if (this.status === this.eventTypes.pause) {
|
|
642
|
+
this.log(
|
|
643
|
+
"Agent was paused after completion, waiting before processing tool calls"
|
|
644
|
+
);
|
|
645
|
+
await this.unpaused();
|
|
646
|
+
}
|
|
647
|
+
|
|
622
648
|
if (response?.usd_cost === undefined) {
|
|
623
649
|
this.log(
|
|
624
650
|
`Response cost is undefined: ${JSON.stringify(response, null, 2)}`,
|
|
@@ -662,6 +688,13 @@ export abstract class BaseAgent implements IAgent {
|
|
|
662
688
|
this.updateCurrentThread(messages);
|
|
663
689
|
|
|
664
690
|
for (const toolCall of toolCalls) {
|
|
691
|
+
if (this.status === this.eventTypes.pause) {
|
|
692
|
+
this.log(
|
|
693
|
+
"Agent was paused before tool call, waiting before processing tool calls"
|
|
694
|
+
);
|
|
695
|
+
await this.unpaused();
|
|
696
|
+
}
|
|
697
|
+
|
|
665
698
|
const toolMessages = await this.processToolMessages(toolCall);
|
|
666
699
|
// Add the tool responses to the thread
|
|
667
700
|
messages.push(...(toolMessages as Message[]));
|
|
@@ -676,6 +709,18 @@ export abstract class BaseAgent implements IAgent {
|
|
|
676
709
|
);
|
|
677
710
|
|
|
678
711
|
if (finalMessage) {
|
|
712
|
+
// If user added pending messages after finalAnswer was called,
|
|
713
|
+
// continue running to respond to that feedback instead of returning
|
|
714
|
+
if (this.pendingUserMessages.length > 0) {
|
|
715
|
+
this.log(
|
|
716
|
+
"finalAnswer called but pending user messages exist, continuing to respond to feedback"
|
|
717
|
+
);
|
|
718
|
+
messages.push(...this.pendingUserMessages);
|
|
719
|
+
this.pendingUserMessages = [];
|
|
720
|
+
this.updateCurrentThread(messages);
|
|
721
|
+
return this.call(userInput, messages);
|
|
722
|
+
}
|
|
723
|
+
|
|
679
724
|
// Emit task completion event for plugins (like GitPlugin)
|
|
680
725
|
this.events.emit(this.eventTypes.agentTaskComplete, {
|
|
681
726
|
taskId:
|
|
@@ -685,6 +730,7 @@ export abstract class BaseAgent implements IAgent {
|
|
|
685
730
|
result: finalMessage.content || "Done",
|
|
686
731
|
});
|
|
687
732
|
const doneMsg = finalMessage.content || "Done";
|
|
733
|
+
|
|
688
734
|
this.agentEvents.emit(this.eventTypes.done, doneMsg);
|
|
689
735
|
this.status = this.eventTypes.done;
|
|
690
736
|
return doneMsg;
|
|
@@ -876,21 +922,32 @@ export abstract class BaseAgent implements IAgent {
|
|
|
876
922
|
});
|
|
877
923
|
}
|
|
878
924
|
|
|
925
|
+
// A new message from system, non blocking
|
|
879
926
|
addPendingMessage(message: Message) {
|
|
880
927
|
if (this.status === this.eventTypes.done) {
|
|
881
928
|
this.log("Agent is done, cannot take more messages", "warn");
|
|
882
929
|
} else {
|
|
883
|
-
const pendingMessages = this.
|
|
930
|
+
const pendingMessages = this.pendingMessages.map((m) => m.content);
|
|
884
931
|
if (pendingMessages.includes(message.content)) {
|
|
885
932
|
// Ignore messages we already have queue'd up
|
|
886
933
|
return;
|
|
887
934
|
}
|
|
888
|
-
this.
|
|
935
|
+
this.pendingMessages.push(message);
|
|
889
936
|
}
|
|
890
937
|
}
|
|
891
938
|
|
|
939
|
+
// A new message from users, blocks completion
|
|
892
940
|
addPendingUserMessage(message: Message) {
|
|
893
|
-
this.
|
|
941
|
+
if (this.status === this.eventTypes.done) {
|
|
942
|
+
this.log("Agent is done, cannot take more messages", "warn");
|
|
943
|
+
} else {
|
|
944
|
+
const pendingMessages = this.pendingUserMessages.map((m) => m.content);
|
|
945
|
+
if (pendingMessages.includes(message.content)) {
|
|
946
|
+
// Ignore messages we already have queue'd up
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
this.pendingUserMessages.push(message);
|
|
950
|
+
}
|
|
894
951
|
this.events.emit(this.eventTypes.userSay, message.content);
|
|
895
952
|
}
|
|
896
953
|
|
package/src/cli.ts
CHANGED
|
@@ -38,6 +38,20 @@ import { readPromptFile } from "./ai";
|
|
|
38
38
|
import { SetupModule } from "./chat/modules/SetupModule";
|
|
39
39
|
import { CliChatService } from "./chat/CliChatService";
|
|
40
40
|
|
|
41
|
+
// Handle unhandled promise rejections gracefully — particularly from MCP SDK
|
|
42
|
+
// which fires errors via event emitters that can bypass Promise.allSettled.
|
|
43
|
+
// Without this, a single failing MCP server (e.g. expired Notion token) will
|
|
44
|
+
// crash the entire CLI with an unhandled rejection.
|
|
45
|
+
process.on("unhandledRejection", (reason: unknown) => {
|
|
46
|
+
const message =
|
|
47
|
+
reason instanceof Error ? reason.message : String(reason);
|
|
48
|
+
// Only warn — don't exit. The MCP connect errors are recoverable;
|
|
49
|
+
// the server will simply be unavailable but others continue working.
|
|
50
|
+
console.warn(
|
|
51
|
+
`⚠ Unhandled MCP/async error (non-fatal): ${message}`
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
41
55
|
async function setupServices() {
|
|
42
56
|
const { Agents, Mcp, Clients, Tools: OldTools } = services();
|
|
43
57
|
const Tools = new LazyToolsService(); // eslint-disable-line no-shadow
|
|
@@ -74,7 +88,14 @@ async function setupServices() {
|
|
|
74
88
|
Agents.setAgentContext(agentContext);
|
|
75
89
|
|
|
76
90
|
console.log("🔌 Connecting to MCP...");
|
|
77
|
-
|
|
91
|
+
try {
|
|
92
|
+
await Mcp.connectToConfigured(Tools);
|
|
93
|
+
} catch (mcpError) {
|
|
94
|
+
const msg = mcpError instanceof Error ? mcpError.message : String(mcpError);
|
|
95
|
+
console.warn(
|
|
96
|
+
`⚠ Some MCP servers failed to connect (continuing without them): ${msg}`
|
|
97
|
+
);
|
|
98
|
+
}
|
|
78
99
|
console.log("Connecting to clients...");
|
|
79
100
|
await Clients.registerConfiguredModels();
|
|
80
101
|
console.log("✓ Services are set up and ready to go!");
|
|
@@ -52,7 +52,6 @@ export const ContextLimits: Record<string, number> = {
|
|
|
52
52
|
[Models.anthropic.Haiku4_5]: 200_000,
|
|
53
53
|
[Models.anthropic.Sonnet3_7]: 200_000,
|
|
54
54
|
[Models.anthropic.Sonnet3_5]: 200_000,
|
|
55
|
-
[Models.anthropic.Haiku3_5]: 200_000,
|
|
56
55
|
[Models.anthropic.Opus3]: 200_000,
|
|
57
56
|
[Models.anthropic.Haiku3]: 200_000,
|
|
58
57
|
|
|
@@ -74,7 +73,6 @@ export const ContextLimits: Record<string, number> = {
|
|
|
74
73
|
[Models.google.Gemini_25_Pro_TTS]: 1_000_000,
|
|
75
74
|
[Models.google.Gemini_20_Flash]: 1_000_000,
|
|
76
75
|
[Models.google.Gemini_20_Flash_Preview_Image_Generation]: 1_000_000,
|
|
77
|
-
[Models.google.Gemini_20_Flash_Lite]: 1_000_000,
|
|
78
76
|
[Models.google.Gemini_20_Flash_Live]: 1_000_000,
|
|
79
77
|
[Models.google.Gemini_20_Flash_TTS]: 1_000_000,
|
|
80
78
|
[Models.google.Gemini_15_Flash]: 1_000_000,
|
|
@@ -65,12 +65,6 @@ export const AnthropicTextPricing = {
|
|
|
65
65
|
cache_hit: 0.3,
|
|
66
66
|
output: 15.0,
|
|
67
67
|
},
|
|
68
|
-
[Models.anthropic.Haiku3_5]: {
|
|
69
|
-
input: 0.8,
|
|
70
|
-
cache_write: 1.0,
|
|
71
|
-
cache_hit: 0.08,
|
|
72
|
-
output: 4.0,
|
|
73
|
-
},
|
|
74
68
|
[Models.anthropic.Opus3]: {
|
|
75
69
|
input: 15.0,
|
|
76
70
|
cache_write: 18.75,
|
|
@@ -174,10 +174,6 @@ export const GeminiPricing: Record<string, GeminiModelPricing> = {
|
|
|
174
174
|
output: 0.4,
|
|
175
175
|
image_generation: 0.039,
|
|
176
176
|
},
|
|
177
|
-
[Models.google.Gemini_20_Flash_Lite]: {
|
|
178
|
-
input: 0.075,
|
|
179
|
-
output: 0.3,
|
|
180
|
-
},
|
|
181
177
|
|
|
182
178
|
// ── Gemini 1.5 (legacy) ───────────────────────────────────────────────────
|
|
183
179
|
[Models.google.Gemini_15_Flash]: {
|
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import { Models } from "../../types";
|
|
2
2
|
|
|
3
3
|
export const XaiTextPricing = {
|
|
4
|
+
|
|
5
|
+
[Models.xai.Grok_4_20_Reasoning]: {
|
|
6
|
+
input: 2.0,
|
|
7
|
+
cache_hit: 0.20,
|
|
8
|
+
output: 6.0,
|
|
9
|
+
},
|
|
10
|
+
[Models.xai.Grok_4_20_NonReasoning]: {
|
|
11
|
+
input: 2.0,
|
|
12
|
+
cache_hit: 0.20,
|
|
13
|
+
output: 6.0,
|
|
14
|
+
},
|
|
4
15
|
[Models.xai.Grok4_1_Fast_NonReasoning]: {
|
|
5
16
|
input: 0.2,
|
|
6
17
|
cache_hit: 0.05,
|
package/src/cloudWorker.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import * as path from "path";
|
|
3
|
-
import { glob } from "glob";
|
|
4
3
|
import { KnowhowSimpleClient, KNOWHOW_API_URL } from "./services/KnowhowClient";
|
|
5
4
|
import { loadJwt } from "./login";
|
|
6
5
|
import { getConfig, updateConfig, getLanguageConfig } from "./config";
|
|
@@ -23,6 +22,26 @@ interface FileToSync {
|
|
|
23
22
|
localPath: string;
|
|
24
23
|
remotePath: string;
|
|
25
24
|
downloadLocalPath?: string; // override localPath used when worker downloads the file
|
|
25
|
+
isDirectory?: boolean; // true if this represents a whole directory
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Recursively list all files in a local directory, returning relative paths
|
|
30
|
+
*/
|
|
31
|
+
function listFilesRecursively(dir: string): string[] {
|
|
32
|
+
const results: string[] = [];
|
|
33
|
+
if (!fs.existsSync(dir)) return results;
|
|
34
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
35
|
+
for (const entry of entries) {
|
|
36
|
+
if (entry.isDirectory()) {
|
|
37
|
+
listFilesRecursively(path.join(dir, entry.name)).forEach((f) =>
|
|
38
|
+
results.push(entry.name + "/" + f)
|
|
39
|
+
);
|
|
40
|
+
} else {
|
|
41
|
+
results.push(entry.name);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return results;
|
|
26
45
|
}
|
|
27
46
|
|
|
28
47
|
/**
|
|
@@ -48,6 +67,8 @@ function buildWorkerConfigJson(config: Config, files: { remotePath: string; loca
|
|
|
48
67
|
|
|
49
68
|
/**
|
|
50
69
|
* Collect all files from the .knowhow directory that should be synced
|
|
70
|
+
* Uses directory-level entries where possible so the worker config stays compact
|
|
71
|
+
* and the folder upload/download feature handles individual files automatically.
|
|
51
72
|
*/
|
|
52
73
|
async function collectFilesToSync(projectName: string): Promise<FileToSync[]> {
|
|
53
74
|
const filesToSync: FileToSync[] = [];
|
|
@@ -59,39 +80,24 @@ async function collectFilesToSync(projectName: string): Promise<FileToSync[]> {
|
|
|
59
80
|
}
|
|
60
81
|
};
|
|
61
82
|
|
|
83
|
+
// Helper to add a directory entry if it exists (trailing slash = directory mode)
|
|
84
|
+
const addDirIfExists = (localPath: string, remotePath: string) => {
|
|
85
|
+
if (fs.existsSync(localPath)) {
|
|
86
|
+
filesToSync.push({ localPath: localPath + "/", remotePath: remotePath + "/", isDirectory: true });
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
62
90
|
// .knowhow/language.json
|
|
63
91
|
addIfExists(".knowhow/language.json", `${projectName}/.knowhow/language.json`);
|
|
64
92
|
|
|
65
93
|
// .knowhow/hashes.json
|
|
66
94
|
addIfExists(".knowhow/hashes.json", `${projectName}/.knowhow/hashes.json`);
|
|
67
95
|
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
filesToSync.push({ localPath: filePath, remotePath });
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// .knowhow/scripts/**/* (if exists)
|
|
77
|
-
if (fs.existsSync(".knowhow/scripts")) {
|
|
78
|
-
const scriptFiles = await glob(".knowhow/scripts/**/*", { nodir: true });
|
|
79
|
-
for (const filePath of scriptFiles) {
|
|
80
|
-
const relativeToDotKnowhow = filePath.replace(/^\.knowhow\//, "");
|
|
81
|
-
const remotePath = `${projectName}/.knowhow/${relativeToDotKnowhow}`;
|
|
82
|
-
filesToSync.push({ localPath: filePath, remotePath });
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// .knowhow/skills/**/* (if exists)
|
|
87
|
-
if (fs.existsSync(".knowhow/skills")) {
|
|
88
|
-
const skillFiles = await glob(".knowhow/skills/**/*", { nodir: true });
|
|
89
|
-
for (const filePath of skillFiles) {
|
|
90
|
-
const relativeToDotKnowhow = filePath.replace(/^\.knowhow\//, "");
|
|
91
|
-
const remotePath = `${projectName}/.knowhow/${relativeToDotKnowhow}`;
|
|
92
|
-
filesToSync.push({ localPath: filePath, remotePath });
|
|
93
|
-
}
|
|
94
|
-
}
|
|
96
|
+
// Directories — use trailing-slash entries so folder upload/download handles them
|
|
97
|
+
addDirIfExists(".knowhow/prompts", `${projectName}/.knowhow/prompts`);
|
|
98
|
+
addDirIfExists(".knowhow/scripts", `${projectName}/.knowhow/scripts`);
|
|
99
|
+
addDirIfExists(".knowhow/skills", `${projectName}/.knowhow/skills`);
|
|
100
|
+
addDirIfExists(".knowhow/tasks", `${projectName}/.knowhow/tasks`);
|
|
95
101
|
|
|
96
102
|
return filesToSync;
|
|
97
103
|
}
|
|
@@ -264,8 +270,20 @@ export async function cloudWorker(options: CloudWorkerOptions) {
|
|
|
264
270
|
|
|
265
271
|
for (const file of allFiles) {
|
|
266
272
|
try {
|
|
267
|
-
|
|
268
|
-
|
|
273
|
+
if (file.isDirectory) {
|
|
274
|
+
// Upload all files recursively in the local directory
|
|
275
|
+
const localDir = file.localPath.endsWith("/") ? file.localPath : file.localPath + "/";
|
|
276
|
+
const remoteDir = file.remotePath.endsWith("/") ? file.remotePath : file.remotePath + "/";
|
|
277
|
+
const relFiles = listFilesRecursively(localDir);
|
|
278
|
+
console.log(` 📁 Uploading directory ${localDir} → ${remoteDir} (${relFiles.length} files)`);
|
|
279
|
+
for (const relFile of relFiles) {
|
|
280
|
+
await uploadSingleFile(client, AwsS3, localDir + relFile, remoteDir + relFile, dryRun);
|
|
281
|
+
successCount++;
|
|
282
|
+
}
|
|
283
|
+
} else {
|
|
284
|
+
await uploadSingleFile(client, AwsS3, file.localPath, file.remotePath, dryRun);
|
|
285
|
+
successCount++;
|
|
286
|
+
}
|
|
269
287
|
} catch (error) {
|
|
270
288
|
console.error(` ❌ Failed to upload ${file.localPath}: ${error.message}`);
|
|
271
289
|
failCount++;
|
package/src/fileSync.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { loadJwt } from "./login";
|
|
|
5
5
|
import { getConfig } from "./config";
|
|
6
6
|
import { services } from "./services";
|
|
7
7
|
import { S3Service } from "./services/S3";
|
|
8
|
+
import { getHashes, hasFileChangedSinceUpload, saveUploadHash, isLocalFileMatchingRemote } from "./hashes";
|
|
8
9
|
|
|
9
10
|
export interface FileSyncOptions {
|
|
10
11
|
upload?: boolean;
|
|
@@ -14,6 +15,33 @@ export interface FileSyncOptions {
|
|
|
14
15
|
dryRun?: boolean;
|
|
15
16
|
}
|
|
16
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Returns true if the path looks like a directory (ends with /)
|
|
20
|
+
*/
|
|
21
|
+
function isDirectoryPath(p: string): boolean {
|
|
22
|
+
return p.endsWith("/");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Recursively list all files in a local directory, returning relative paths
|
|
27
|
+
*/
|
|
28
|
+
function listFilesRecursively(dir: string): string[] {
|
|
29
|
+
const results: string[] = [];
|
|
30
|
+
if (!fs.existsSync(dir)) return results;
|
|
31
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
32
|
+
for (const entry of entries) {
|
|
33
|
+
if (entry.isDirectory()) {
|
|
34
|
+
const subFiles = listFilesRecursively(path.join(dir, entry.name));
|
|
35
|
+
for (const f of subFiles) {
|
|
36
|
+
results.push(entry.name + "/" + f);
|
|
37
|
+
}
|
|
38
|
+
} else {
|
|
39
|
+
results.push(entry.name);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return results;
|
|
43
|
+
}
|
|
44
|
+
|
|
17
45
|
/**
|
|
18
46
|
* Sync files between local filesystem and Knowhow FS
|
|
19
47
|
*/
|
|
@@ -67,11 +95,21 @@ export async function fileSync(options: FileSyncOptions = {}) {
|
|
|
67
95
|
|
|
68
96
|
try {
|
|
69
97
|
if (actualDirection === "download") {
|
|
70
|
-
|
|
71
|
-
|
|
98
|
+
if (isDirectoryPath(remotePath) || isDirectoryPath(localPath)) {
|
|
99
|
+
const count = await downloadDirectory(client, AwsS3, remotePath, localPath, dryRun);
|
|
100
|
+
successCount += count;
|
|
101
|
+
} else {
|
|
102
|
+
await downloadFile(client, AwsS3, remotePath, localPath, dryRun);
|
|
103
|
+
successCount++;
|
|
104
|
+
}
|
|
72
105
|
} else if (actualDirection === "upload") {
|
|
73
|
-
|
|
74
|
-
|
|
106
|
+
if (isDirectoryPath(remotePath) || isDirectoryPath(localPath)) {
|
|
107
|
+
const count = await uploadDirectory(client, AwsS3, remotePath, localPath, dryRun);
|
|
108
|
+
successCount += count;
|
|
109
|
+
} else {
|
|
110
|
+
await uploadFile(client, AwsS3, remotePath, localPath, dryRun);
|
|
111
|
+
successCount++;
|
|
112
|
+
}
|
|
75
113
|
}
|
|
76
114
|
} catch (error) {
|
|
77
115
|
console.error(`❌ Failed to sync ${remotePath}: ${error.message}`);
|
|
@@ -88,6 +126,7 @@ export async function fileSync(options: FileSyncOptions = {}) {
|
|
|
88
126
|
}
|
|
89
127
|
}
|
|
90
128
|
|
|
129
|
+
|
|
91
130
|
/**
|
|
92
131
|
* Download a file from Knowhow FS to local filesystem
|
|
93
132
|
*/
|
|
@@ -106,10 +145,14 @@ async function downloadFile(
|
|
|
106
145
|
}
|
|
107
146
|
|
|
108
147
|
try {
|
|
109
|
-
// Get presigned download URL
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
148
|
+
// Get presigned download URL + remote checksum
|
|
149
|
+
const { downloadUrl, checksumSHA256 } = await client.getOrgFilePresignedDownloadUrl(remotePath);
|
|
150
|
+
|
|
151
|
+
// Skip if local file matches remote checksum
|
|
152
|
+
if (isLocalFileMatchingRemote(localPath, checksumSHA256)) {
|
|
153
|
+
console.log(` ✓ Skipping ${localPath} (matches remote checksum)`);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
113
156
|
|
|
114
157
|
// Ensure parent directory exists
|
|
115
158
|
const dir = path.dirname(localPath);
|
|
@@ -118,7 +161,7 @@ async function downloadFile(
|
|
|
118
161
|
}
|
|
119
162
|
|
|
120
163
|
// Download file using presigned URL
|
|
121
|
-
await s3Service.downloadFromPresignedUrl(
|
|
164
|
+
await s3Service.downloadFromPresignedUrl(downloadUrl, localPath);
|
|
122
165
|
|
|
123
166
|
// Get file size for logging
|
|
124
167
|
const stats = fs.statSync(localPath);
|
|
@@ -151,6 +194,14 @@ async function uploadFile(
|
|
|
151
194
|
return;
|
|
152
195
|
}
|
|
153
196
|
|
|
197
|
+
// Skip upload if file hasn't changed since last upload
|
|
198
|
+
const hashes = await getHashes();
|
|
199
|
+
const changed = await hasFileChangedSinceUpload(localPath, hashes);
|
|
200
|
+
if (!changed) {
|
|
201
|
+
console.log(` ✓ Skipping ${localPath} (unchanged since last upload)`);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
154
205
|
// Get presigned upload URL
|
|
155
206
|
const presignedUrl = await client.getOrgFilePresignedUploadUrl(remotePath);
|
|
156
207
|
|
|
@@ -160,6 +211,99 @@ async function uploadFile(
|
|
|
160
211
|
// Notify backend that upload is complete to update the updatedAt timestamp
|
|
161
212
|
await client.markOrgFileUploadComplete(remotePath);
|
|
162
213
|
|
|
214
|
+
// Save upload hash so we can skip unchanged files next time
|
|
215
|
+
await saveUploadHash(localPath);
|
|
216
|
+
|
|
163
217
|
const stats = fs.statSync(localPath);
|
|
164
218
|
console.log(` ✓ Uploaded ${stats.size} bytes`);
|
|
165
219
|
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Upload all files from a local directory to a remote directory path
|
|
223
|
+
*/
|
|
224
|
+
export async function uploadDirectory(
|
|
225
|
+
client: KnowhowSimpleClient,
|
|
226
|
+
s3Service: S3Service,
|
|
227
|
+
remotePath: string,
|
|
228
|
+
localPath: string,
|
|
229
|
+
dryRun: boolean
|
|
230
|
+
): Promise<number> {
|
|
231
|
+
// Normalize paths to end with /
|
|
232
|
+
const remoteDir = remotePath.endsWith("/") ? remotePath : remotePath + "/";
|
|
233
|
+
const localDir = localPath.endsWith("/") ? localPath : localPath + "/";
|
|
234
|
+
|
|
235
|
+
console.log(`⬆️ Uploading directory ${localDir} → ${remoteDir}`);
|
|
236
|
+
|
|
237
|
+
if (!fs.existsSync(localDir)) {
|
|
238
|
+
console.warn(` ⚠️ Local directory not found: ${localDir}`);
|
|
239
|
+
return 0;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Find all files recursively in the local directory
|
|
243
|
+
const localFiles = listFilesRecursively(localDir);
|
|
244
|
+
|
|
245
|
+
if (localFiles.length === 0) {
|
|
246
|
+
console.log(` ⚠️ No local files found under ${localDir}`);
|
|
247
|
+
return 0;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
console.log(` Found ${localFiles.length} local file(s)`);
|
|
251
|
+
|
|
252
|
+
let count = 0;
|
|
253
|
+
for (const relFile of localFiles) {
|
|
254
|
+
const localFilePath = localDir + relFile;
|
|
255
|
+
const remoteFilePath = remoteDir + relFile;
|
|
256
|
+
await uploadFile(client, s3Service, remoteFilePath, localFilePath, dryRun);
|
|
257
|
+
count++;
|
|
258
|
+
}
|
|
259
|
+
return count;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Download all files from a remote directory path to a local directory
|
|
264
|
+
*/
|
|
265
|
+
async function downloadDirectory(
|
|
266
|
+
client: KnowhowSimpleClient,
|
|
267
|
+
s3Service: S3Service,
|
|
268
|
+
remotePath: string,
|
|
269
|
+
localPath: string,
|
|
270
|
+
dryRun: boolean
|
|
271
|
+
): Promise<number> {
|
|
272
|
+
// Normalize paths to end with /
|
|
273
|
+
const remoteDir = remotePath.endsWith("/") ? remotePath : remotePath + "/";
|
|
274
|
+
const localDir = localPath.endsWith("/") ? localPath : localPath + "/";
|
|
275
|
+
|
|
276
|
+
console.log(`⬇️ Downloading directory ${remoteDir} → ${localDir}`);
|
|
277
|
+
|
|
278
|
+
// List all org files and find those in the remote directory
|
|
279
|
+
const response = await client.listOrgFiles();
|
|
280
|
+
const allFiles = response.data;
|
|
281
|
+
|
|
282
|
+
// Find files where the full path starts with remoteDir
|
|
283
|
+
const matchingFiles = allFiles.filter((f) => {
|
|
284
|
+
const fullPath = f.folderPath.endsWith("/")
|
|
285
|
+
? f.folderPath + f.fileName
|
|
286
|
+
: f.folderPath + "/" + f.fileName;
|
|
287
|
+
return fullPath.startsWith(remoteDir);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
if (matchingFiles.length === 0) {
|
|
291
|
+
console.log(` ⚠️ No remote files found under ${remoteDir}`);
|
|
292
|
+
return 0;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
console.log(` Found ${matchingFiles.length} remote file(s)`);
|
|
296
|
+
|
|
297
|
+
let count = 0;
|
|
298
|
+
for (const f of matchingFiles) {
|
|
299
|
+
const fullRemotePath = f.folderPath.endsWith("/")
|
|
300
|
+
? f.folderPath + f.fileName
|
|
301
|
+
: f.folderPath + "/" + f.fileName;
|
|
302
|
+
// Strip the base remote dir prefix to get relative path
|
|
303
|
+
const relativePath = fullRemotePath.slice(remoteDir.length);
|
|
304
|
+
const localFilePath = localDir + relativePath;
|
|
305
|
+
await downloadFile(client, s3Service, fullRemotePath, localFilePath, dryRun);
|
|
306
|
+
count++;
|
|
307
|
+
}
|
|
308
|
+
return count;
|
|
309
|
+
}
|
package/src/hashes.ts
CHANGED
|
@@ -70,3 +70,55 @@ export async function saveAllFileHashes(files: string[], promptHash: string) {
|
|
|
70
70
|
|
|
71
71
|
await saveHashes(hashes);
|
|
72
72
|
}
|
|
73
|
+
|
|
74
|
+
const UPLOAD_KEY = "upload";
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Returns true if the file has changed since the last successful upload
|
|
78
|
+
* (or if it has never been uploaded before)
|
|
79
|
+
*/
|
|
80
|
+
export async function hasFileChangedSinceUpload(
|
|
81
|
+
localPath: string,
|
|
82
|
+
hashes: any
|
|
83
|
+
): Promise<boolean> {
|
|
84
|
+
if (!fs.existsSync(localPath)) return true;
|
|
85
|
+
const content = fs.readFileSync(localPath);
|
|
86
|
+
const currentHash = crypto.createHash("md5").update(content).digest("hex");
|
|
87
|
+
return hashes[localPath]?.[UPLOAD_KEY] !== currentHash;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Saves the hash of the file at the time of a successful upload
|
|
92
|
+
*/
|
|
93
|
+
export async function saveUploadHash(localPath: string) {
|
|
94
|
+
const hashes = await getHashes();
|
|
95
|
+
const content = fs.readFileSync(localPath);
|
|
96
|
+
const currentHash = crypto.createHash("md5").update(content).digest("hex");
|
|
97
|
+
if (!hashes[localPath]) {
|
|
98
|
+
hashes[localPath] = { fileHash: currentHash, promptHash: "" };
|
|
99
|
+
}
|
|
100
|
+
hashes[localPath][UPLOAD_KEY] = currentHash;
|
|
101
|
+
await saveHashes(hashes);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Compute SHA-256 of a local file, returned as base64 (matches S3 encoding)
|
|
106
|
+
*/
|
|
107
|
+
export function computeSHA256Base64(filePath: string): string {
|
|
108
|
+
const content = fs.readFileSync(filePath);
|
|
109
|
+
return crypto.createHash("sha256").update(content).digest("base64");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Returns true if the local file's SHA-256 matches the remote checksum,
|
|
114
|
+
* meaning the file is up-to-date and download can be skipped.
|
|
115
|
+
*/
|
|
116
|
+
export function isLocalFileMatchingRemote(
|
|
117
|
+
localPath: string,
|
|
118
|
+
remoteChecksumSHA256: string | null
|
|
119
|
+
): boolean {
|
|
120
|
+
if (!remoteChecksumSHA256) return false;
|
|
121
|
+
if (!fs.existsSync(localPath)) return false;
|
|
122
|
+
const localChecksum = computeSHA256Base64(localPath);
|
|
123
|
+
return localChecksum === remoteChecksumSHA256;
|
|
124
|
+
}
|
package/src/login.ts
CHANGED
|
@@ -49,20 +49,17 @@ export async function login(jwtFlag?: boolean): Promise<void> {
|
|
|
49
49
|
);
|
|
50
50
|
|
|
51
51
|
const config = await getConfig();
|
|
52
|
-
const proxyUrl = KNOWHOW_API_URL + "/api/proxy";
|
|
53
52
|
|
|
54
53
|
if (!config.modelProviders) {
|
|
55
54
|
config.modelProviders = [];
|
|
56
55
|
}
|
|
57
56
|
|
|
58
57
|
const hasProvider = config.modelProviders.find(
|
|
59
|
-
(provider) => provider.provider === "knowhow"
|
|
58
|
+
(provider) => provider.provider === "knowhow"
|
|
60
59
|
);
|
|
61
60
|
if (!hasProvider) {
|
|
62
61
|
config.modelProviders.push({
|
|
63
62
|
provider: "knowhow",
|
|
64
|
-
url: proxyUrl,
|
|
65
|
-
jwtFile: ".knowhow/.jwt",
|
|
66
63
|
});
|
|
67
64
|
|
|
68
65
|
await updateConfig(config);
|