@tyvm/knowhow 0.0.128 → 0.0.130

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 (183) hide show
  1. package/package.json +5 -4
  2. package/scripts/cache-miss-debug.ts +365 -0
  3. package/src/agents/base/base.ts +159 -59
  4. package/src/agents/tools/execCommand.ts +26 -6
  5. package/src/agents/tools/index.ts +1 -0
  6. package/src/agents/tools/list.ts +2 -0
  7. package/src/agents/tools/minimal/callTool.ts +45 -0
  8. package/src/agents/tools/minimal/definitions.ts +50 -0
  9. package/src/agents/tools/minimal/index.ts +3 -0
  10. package/src/agents/tools/minimal/inspectTools.ts +27 -0
  11. package/src/agents/tools/skills.ts +79 -0
  12. package/src/chat/modules/AgentModule.ts +111 -9
  13. package/src/cli.ts +4 -0
  14. package/src/clients/anthropic.ts +59 -1
  15. package/src/clients/contextLimits.ts +9 -0
  16. package/src/clients/index.ts +6 -0
  17. package/src/clients/meta.ts +18 -0
  18. package/src/clients/openai.ts +28 -5
  19. package/src/clients/pricing/anthropic.ts +13 -0
  20. package/src/clients/pricing/fireworks.ts +18 -12
  21. package/src/clients/pricing/index.ts +1 -0
  22. package/src/clients/pricing/meta.ts +12 -0
  23. package/src/clients/pricing/openai.ts +16 -0
  24. package/src/clients/pricing/xai.ts +3 -0
  25. package/src/clients/types.ts +27 -4
  26. package/src/clients/xai.ts +2 -2
  27. package/src/commands/agent.ts +19 -0
  28. package/src/commands/behaviors.ts +47 -0
  29. package/src/commands/services.ts +7 -3
  30. package/src/commands/skills.ts +71 -0
  31. package/src/index.ts +2 -0
  32. package/src/plugins/PluginBase.ts +0 -2
  33. package/src/plugins/SkillsPlugin.ts +11 -124
  34. package/src/plugins/language.ts +0 -1
  35. package/src/plugins/plugins.ts +24 -13
  36. package/src/plugins/types.ts +8 -2
  37. package/src/processors/MinimalToolsMessageProcessor.ts +113 -0
  38. package/src/processors/TokenCompressor.ts +21 -4
  39. package/src/processors/index.ts +7 -1
  40. package/src/services/AgentSyncFs.ts +49 -0
  41. package/src/services/AgentSyncKnowhowWeb.ts +1 -1
  42. package/src/services/BehaviorsService.ts +432 -0
  43. package/src/services/KnowhowClient.ts +62 -1
  44. package/src/services/LazyToolsService.ts +12 -0
  45. package/src/services/MinimalToolsService.ts +167 -0
  46. package/src/services/SkillsService.ts +622 -0
  47. package/src/services/Tools.ts +11 -0
  48. package/src/services/index.ts +9 -2
  49. package/src/services/modules/types.ts +2 -0
  50. package/src/worker.ts +7 -0
  51. package/tests/processors/TokenCompressor.test.ts +45 -0
  52. package/tests/unit/services/MinimalToolsService.test.ts +424 -0
  53. package/ts_build/package.json +5 -4
  54. package/ts_build/src/agents/base/base.d.ts +13 -2
  55. package/ts_build/src/agents/base/base.js +91 -35
  56. package/ts_build/src/agents/base/base.js.map +1 -1
  57. package/ts_build/src/agents/tools/execCommand.d.ts +1 -1
  58. package/ts_build/src/agents/tools/execCommand.js +18 -5
  59. package/ts_build/src/agents/tools/execCommand.js.map +1 -1
  60. package/ts_build/src/agents/tools/index.d.ts +1 -0
  61. package/ts_build/src/agents/tools/index.js +1 -0
  62. package/ts_build/src/agents/tools/index.js.map +1 -1
  63. package/ts_build/src/agents/tools/list.js +2 -0
  64. package/ts_build/src/agents/tools/list.js.map +1 -1
  65. package/ts_build/src/agents/tools/minimal/callTool.d.ts +5 -0
  66. package/ts_build/src/agents/tools/minimal/callTool.js +28 -0
  67. package/ts_build/src/agents/tools/minimal/callTool.js.map +1 -0
  68. package/ts_build/src/agents/tools/minimal/definitions.d.ts +4 -0
  69. package/ts_build/src/agents/tools/minimal/definitions.js +46 -0
  70. package/ts_build/src/agents/tools/minimal/definitions.js.map +1 -0
  71. package/ts_build/src/agents/tools/minimal/index.d.ts +3 -0
  72. package/ts_build/src/agents/tools/minimal/index.js +20 -0
  73. package/ts_build/src/agents/tools/minimal/index.js.map +1 -0
  74. package/ts_build/src/agents/tools/minimal/inspectTools.d.ts +2 -0
  75. package/ts_build/src/agents/tools/minimal/inspectTools.js +15 -0
  76. package/ts_build/src/agents/tools/minimal/inspectTools.js.map +1 -0
  77. package/ts_build/src/agents/tools/skills.d.ts +33 -0
  78. package/ts_build/src/agents/tools/skills.js +62 -0
  79. package/ts_build/src/agents/tools/skills.js.map +1 -0
  80. package/ts_build/src/chat/modules/AgentModule.d.ts +2 -0
  81. package/ts_build/src/chat/modules/AgentModule.js +77 -2
  82. package/ts_build/src/chat/modules/AgentModule.js.map +1 -1
  83. package/ts_build/src/chat/modules/CustomCommandsModule.d.ts +1 -1
  84. package/ts_build/src/cli.js +4 -0
  85. package/ts_build/src/cli.js.map +1 -1
  86. package/ts_build/src/clients/anthropic.d.ts +2 -0
  87. package/ts_build/src/clients/anthropic.js +35 -0
  88. package/ts_build/src/clients/anthropic.js.map +1 -1
  89. package/ts_build/src/clients/contextLimits.js +8 -0
  90. package/ts_build/src/clients/contextLimits.js.map +1 -1
  91. package/ts_build/src/clients/index.d.ts +1 -0
  92. package/ts_build/src/clients/index.js +6 -0
  93. package/ts_build/src/clients/index.js.map +1 -1
  94. package/ts_build/src/clients/meta.d.ts +4 -0
  95. package/ts_build/src/clients/meta.js +15 -0
  96. package/ts_build/src/clients/meta.js.map +1 -0
  97. package/ts_build/src/clients/openai.d.ts +1 -0
  98. package/ts_build/src/clients/openai.js +25 -5
  99. package/ts_build/src/clients/openai.js.map +1 -1
  100. package/ts_build/src/clients/pricing/anthropic.d.ts +4 -0
  101. package/ts_build/src/clients/pricing/anthropic.js +12 -0
  102. package/ts_build/src/clients/pricing/anthropic.js.map +1 -1
  103. package/ts_build/src/clients/pricing/fireworks.d.ts +2 -5
  104. package/ts_build/src/clients/pricing/fireworks.js +13 -11
  105. package/ts_build/src/clients/pricing/fireworks.js.map +1 -1
  106. package/ts_build/src/clients/pricing/index.d.ts +1 -0
  107. package/ts_build/src/clients/pricing/index.js +5 -2
  108. package/ts_build/src/clients/pricing/index.js.map +1 -1
  109. package/ts_build/src/clients/pricing/meta.d.ts +6 -0
  110. package/ts_build/src/clients/pricing/meta.js +8 -0
  111. package/ts_build/src/clients/pricing/meta.js.map +1 -0
  112. package/ts_build/src/clients/pricing/openai.d.ts +3 -0
  113. package/ts_build/src/clients/pricing/openai.js +15 -0
  114. package/ts_build/src/clients/pricing/openai.js.map +1 -1
  115. package/ts_build/src/clients/pricing/xai.d.ts +1 -0
  116. package/ts_build/src/clients/pricing/xai.js +3 -0
  117. package/ts_build/src/clients/pricing/xai.js.map +1 -1
  118. package/ts_build/src/clients/types.d.ts +6 -1
  119. package/ts_build/src/clients/xai.js +1 -1
  120. package/ts_build/src/clients/xai.js.map +1 -1
  121. package/ts_build/src/commands/agent.js +16 -0
  122. package/ts_build/src/commands/agent.js.map +1 -1
  123. package/ts_build/src/commands/behaviors.d.ts +2 -0
  124. package/ts_build/src/commands/behaviors.js +43 -0
  125. package/ts_build/src/commands/behaviors.js.map +1 -0
  126. package/ts_build/src/commands/services.js +4 -2
  127. package/ts_build/src/commands/services.js.map +1 -1
  128. package/ts_build/src/commands/skills.d.ts +2 -0
  129. package/ts_build/src/commands/skills.js +61 -0
  130. package/ts_build/src/commands/skills.js.map +1 -0
  131. package/ts_build/src/index.d.ts +2 -0
  132. package/ts_build/src/index.js +5 -1
  133. package/ts_build/src/index.js.map +1 -1
  134. package/ts_build/src/plugins/PluginBase.d.ts +0 -2
  135. package/ts_build/src/plugins/PluginBase.js.map +1 -1
  136. package/ts_build/src/plugins/SkillsPlugin.d.ts +1 -6
  137. package/ts_build/src/plugins/SkillsPlugin.js +6 -135
  138. package/ts_build/src/plugins/SkillsPlugin.js.map +1 -1
  139. package/ts_build/src/plugins/language.js.map +1 -1
  140. package/ts_build/src/plugins/plugins.d.ts +4 -1
  141. package/ts_build/src/plugins/plugins.js +17 -11
  142. package/ts_build/src/plugins/plugins.js.map +1 -1
  143. package/ts_build/src/plugins/types.d.ts +3 -2
  144. package/ts_build/src/processors/MinimalToolsMessageProcessor.d.ts +7 -0
  145. package/ts_build/src/processors/MinimalToolsMessageProcessor.js +61 -0
  146. package/ts_build/src/processors/MinimalToolsMessageProcessor.js.map +1 -0
  147. package/ts_build/src/processors/TokenCompressor.js +5 -2
  148. package/ts_build/src/processors/TokenCompressor.js.map +1 -1
  149. package/ts_build/src/processors/index.d.ts +2 -1
  150. package/ts_build/src/processors/index.js +3 -1
  151. package/ts_build/src/processors/index.js.map +1 -1
  152. package/ts_build/src/services/AgentSyncFs.d.ts +2 -0
  153. package/ts_build/src/services/AgentSyncFs.js +30 -0
  154. package/ts_build/src/services/AgentSyncFs.js.map +1 -1
  155. package/ts_build/src/services/AgentSyncKnowhowWeb.js +1 -1
  156. package/ts_build/src/services/BehaviorsService.d.ts +46 -0
  157. package/ts_build/src/services/BehaviorsService.js +358 -0
  158. package/ts_build/src/services/BehaviorsService.js.map +1 -0
  159. package/ts_build/src/services/KnowhowClient.d.ts +9 -1
  160. package/ts_build/src/services/KnowhowClient.js +20 -0
  161. package/ts_build/src/services/KnowhowClient.js.map +1 -1
  162. package/ts_build/src/services/LazyToolsService.js.map +1 -1
  163. package/ts_build/src/services/MinimalToolsService.d.ts +14 -0
  164. package/ts_build/src/services/MinimalToolsService.js +80 -0
  165. package/ts_build/src/services/MinimalToolsService.js.map +1 -0
  166. package/ts_build/src/services/SkillsService.d.ts +53 -0
  167. package/ts_build/src/services/SkillsService.js +478 -0
  168. package/ts_build/src/services/SkillsService.js.map +1 -0
  169. package/ts_build/src/services/Tools.d.ts +2 -0
  170. package/ts_build/src/services/Tools.js.map +1 -1
  171. package/ts_build/src/services/index.d.ts +4 -0
  172. package/ts_build/src/services/index.js +9 -2
  173. package/ts_build/src/services/index.js.map +1 -1
  174. package/ts_build/src/services/modules/index.d.ts +4 -2
  175. package/ts_build/src/services/modules/types.d.ts +2 -0
  176. package/ts_build/src/types.d.ts +8 -0
  177. package/ts_build/src/worker.js +5 -0
  178. package/ts_build/src/worker.js.map +1 -1
  179. package/ts_build/tests/processors/TokenCompressor.test.js +27 -0
  180. package/ts_build/tests/processors/TokenCompressor.test.js.map +1 -1
  181. package/ts_build/tests/unit/services/MinimalToolsService.test.d.ts +1 -0
  182. package/ts_build/tests/unit/services/MinimalToolsService.test.js +320 -0
  183. package/ts_build/tests/unit/services/MinimalToolsService.test.js.map +1 -0
@@ -0,0 +1,113 @@
1
+ import { Message, ToolCall } from "../clients/types";
2
+ import { MessageProcessorFunction } from "../services/MessageProcessor";
3
+
4
+ /**
5
+ * MinimalToolsMessageProcessor
6
+ *
7
+ * Works with MinimalToolsService to unwrap `callTool(name, args)` invocations
8
+ * in the message history, rewriting them as if the real tool had been called
9
+ * directly.
10
+ *
11
+ * This is registered on the `pre_tools` lifecycle so it runs right before tool
12
+ * calls are dispatched. It rewrites the last assistant message's tool_calls so
13
+ * that:
14
+ * callTool({ name: "readFile", args: { filePath: "..." } })
15
+ * becomes:
16
+ * readFile({ filePath: "..." })
17
+ *
18
+ * This means:
19
+ * 1. The `tools:` array sent to the AI never needs to change (cache stable).
20
+ * 2. The agent terminates correctly when callTool("finalAnswer", ...) is called,
21
+ * because after transformation the required tool name matches.
22
+ * 3. The conversation history looks clean — the model sees native tool calls in
23
+ * subsequent turns rather than nested callTool wrappers.
24
+ */
25
+ export class MinimalToolsMessageProcessor {
26
+ /**
27
+ * Unwraps a single callTool ToolCall into the real underlying ToolCall.
28
+ * Returns null if this is not a callTool invocation.
29
+ */
30
+ private unwrapCallTool(toolCall: ToolCall): ToolCall | null {
31
+ if (toolCall.function.name !== "callTool") {
32
+ return null;
33
+ }
34
+
35
+ let parsed: { name?: string; args?: Record<string, any> };
36
+ try {
37
+ parsed =
38
+ typeof toolCall.function.arguments === "string"
39
+ ? JSON.parse(toolCall.function.arguments)
40
+ : toolCall.function.arguments;
41
+ } catch {
42
+ return null;
43
+ }
44
+
45
+ const { name, args } = parsed;
46
+ if (!name || typeof name !== "string") {
47
+ return null;
48
+ }
49
+
50
+ return {
51
+ id: toolCall.id,
52
+ type: "function",
53
+ function: {
54
+ name,
55
+ arguments: JSON.stringify(args ?? {}),
56
+ },
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Processes the modified messages array in-place:
62
+ * - Finds assistant messages whose tool_calls contain callTool invocations.
63
+ * - Replaces those callTool entries with the real underlying tool calls.
64
+ *
65
+ * Only the most-recent assistant message (the one just added by the AI) is
66
+ * rewritten — older messages are left untouched so caching is not affected.
67
+ */
68
+ private processMessages(
69
+ _originalMessages: Message[],
70
+ modifiedMessages: Message[]
71
+ ): void {
72
+ // Walk backwards to find the last assistant message with tool_calls
73
+ for (let i = modifiedMessages.length - 1; i >= 0; i--) {
74
+ const msg = modifiedMessages[i];
75
+ if (msg.role !== "assistant" || !msg.tool_calls?.length) {
76
+ continue;
77
+ }
78
+
79
+ let didUnwrap = false;
80
+ const newToolCalls: ToolCall[] = msg.tool_calls.map((tc) => {
81
+ const unwrapped = this.unwrapCallTool(tc);
82
+ if (unwrapped) {
83
+ didUnwrap = true;
84
+ return unwrapped;
85
+ }
86
+ return tc;
87
+ });
88
+
89
+ if (didUnwrap) {
90
+ msg.tool_calls = newToolCalls;
91
+ }
92
+
93
+ // Only process the last assistant message
94
+ break;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Returns a MessageProcessorFunction suitable for registering on the
100
+ * `pre_tools` lifecycle of a MessageProcessor.
101
+ */
102
+ createProcessor(): MessageProcessorFunction {
103
+ return (
104
+ originalMessages: Message[],
105
+ modifiedMessages: Message[]
106
+ ): void => {
107
+ this.processMessages(originalMessages, modifiedMessages);
108
+ };
109
+ }
110
+ }
111
+
112
+ export const globalMinimalToolsMessageProcessor =
113
+ new MinimalToolsMessageProcessor();
@@ -155,14 +155,26 @@ export class TokenCompressor implements JsonCompressorStorage {
155
155
  * Check if content is already compressed
156
156
  */
157
157
  private isAlreadyCompressed(content: string): boolean {
158
- // Check for compressed string markers
159
- if (content.includes("[COMPRESSED_STRING")) {
158
+ // Check for a compressed-string marker, but ONLY when the content *starts*
159
+ // with it. Compressor output always begins with "[COMPRESSED_STRING ...".
160
+ // A naive `.includes()` produced false positives for large payloads (e.g. a
161
+ // usage.json / message-history dump) that merely CONTAIN that substring deep
162
+ // inside their nested data — those files were left completely uncompressed
163
+ // because this guard incorrectly reported them as already compressed.
164
+ if (content.trimStart().startsWith("[COMPRESSED_STRING")) {
160
165
  return true;
161
166
  }
162
167
 
163
168
  // Check for compressed JSON structure with schema key
169
+ // Only treat this as compressed when `_schema_key` is an actual TOP-LEVEL
170
+ // key of the parsed object — not merely present somewhere in the string.
164
171
  const parsed = this.tryParseJson(content);
165
- if (parsed && parsed._schema_key && typeof parsed._schema_key === "string") {
172
+ if (
173
+ parsed &&
174
+ typeof parsed === "object" &&
175
+ !Array.isArray(parsed) &&
176
+ typeof parsed._schema_key === "string"
177
+ ) {
166
178
  return true;
167
179
  }
168
180
 
@@ -296,7 +308,12 @@ export class TokenCompressor implements JsonCompressorStorage {
296
308
  return async (originalMessages: Message[], modifiedMessages: Message[]) => {
297
309
  // Find the index of the last assistant message so we only compress
298
310
  // the most recent batch of tool responses (after the last agent output).
299
- // This prevents re-compressing already-cached historical messages.
311
+ // This prevents re-compressing already-cached historical messages, which
312
+ // would break prompt caching. Newly-added tool responses (the "current
313
+ // batch") are the ones that need compressing before they reach the model,
314
+ // and they appear after the last assistant message at the moment this
315
+ // processor runs (post_tools / pre_call), when the fresh tool response is
316
+ // still the tail of the message list.
300
317
  let lastAssistantIndex = -1;
301
318
  for (let i = modifiedMessages.length - 1; i >= 0; i--) {
302
319
  if (modifiedMessages[i].role === "assistant") {
@@ -1,7 +1,13 @@
1
1
  export { Base64ImageProcessor } from "./Base64ImageDetector";
2
2
  export { CustomVariables } from "./CustomVariables";
3
3
  export { TokenCompressor } from "./TokenCompressor";
4
- export { JsonCompressor, JsonSchema, CompressionMetadata, JsonCompressorStorage } from "./JsonCompressor";
4
+ export {
5
+ JsonCompressor,
6
+ JsonSchema,
7
+ CompressionMetadata,
8
+ JsonCompressorStorage,
9
+ } from "./JsonCompressor";
5
10
  export { ToolResponseCache } from "./ToolResponseCache";
6
11
  export { XmlToolCallProcessor } from "./XmlToolCallProcessor";
7
12
  export { HarmonyToolProcessor } from "./HarmonyToolProcessor";
13
+ export { MinimalToolsMessageProcessor } from "./MinimalToolsMessageProcessor";
@@ -33,6 +33,7 @@ export class AgentSyncFs {
33
33
  private agent: BaseAgent | undefined;
34
34
  private threadUpdateHandler: ((...args: any[]) => void) | undefined;
35
35
  private doneHandler: ((...args: any[]) => void) | undefined;
36
+ private tokenUsageHandler: ((...args: any[]) => void) | undefined;
36
37
  /**
37
38
  * Tracks the most recent in-flight filesystem metadata update.
38
39
  * The done handler awaits this before finalizing, preventing a race where
@@ -118,6 +119,33 @@ export class AgentSyncFs {
118
119
  }
119
120
  }
120
121
 
122
+ /**
123
+ * Append a token usage entry (including the full message chain sent for
124
+ * that call) to usage.json. This is intended for debugging cache-hit
125
+ * issues, where comparing the message chains and usage values between
126
+ * consecutive calls can reveal when a cache prefix was invalidated.
127
+ */
128
+ private async appendUsageEntry(entry: any): Promise<void> {
129
+ if (!this.taskPath) return;
130
+
131
+ try {
132
+ const usagePath = path.join(this.taskPath, "usage.json");
133
+ let entries: any[] = [];
134
+
135
+ try {
136
+ const existingData = await fs.readFile(usagePath, "utf8");
137
+ entries = JSON.parse(existingData);
138
+ } catch {
139
+ // File doesn't exist or is invalid, start fresh
140
+ }
141
+
142
+ entries.push(entry);
143
+ await fs.writeFile(usagePath, JSON.stringify(entries, null, 2), "utf8");
144
+ } catch (error) {
145
+ console.error(`❌ Failed to append usage entry:`, error);
146
+ }
147
+ }
148
+
121
149
  /**
122
150
  * Update metadata file with current agent state
123
151
  */
@@ -352,6 +380,20 @@ export class AgentSyncFs {
352
380
  this.threadUpdateHandler
353
381
  );
354
382
 
383
+ // Listen to token usage events to record per-call usage + message chain
384
+ // (store reference for cleanup). This is written to usage.json so that
385
+ // cache-hit/cache-miss issues can be debugged by comparing the message
386
+ // chain and usage values sent/received on consecutive AI calls.
387
+ this.tokenUsageHandler = (usage: any) => {
388
+ this.appendUsageEntry(usage).catch((error) => {
389
+ console.error(`❌ Error recording token usage:`, error);
390
+ });
391
+ };
392
+ agent.agentEvents.on(
393
+ agent.eventTypes.tokenUsage,
394
+ this.tokenUsageHandler
395
+ );
396
+
355
397
  // Listen to completion event to finalize task (store reference for cleanup)
356
398
  this.doneHandler = (result: string) => {
357
399
  if (!this.taskId) {
@@ -495,6 +537,13 @@ export class AgentSyncFs {
495
537
  );
496
538
  this.threadUpdateHandler = undefined;
497
539
  }
540
+ if (this.tokenUsageHandler) {
541
+ this.agent.agentEvents.removeListener(
542
+ this.agent.eventTypes.tokenUsage,
543
+ this.tokenUsageHandler
544
+ );
545
+ this.tokenUsageHandler = undefined;
546
+ }
498
547
  if (this.doneHandler) {
499
548
  this.agent.agentEvents.removeListener(
500
549
  this.agent.eventTypes.done,
@@ -89,7 +89,7 @@ export class AgentSyncKnowhowWeb {
89
89
  try {
90
90
  await this.client.updateChatTask(knowhowTaskId, {
91
91
  threads: agent.getThreads(),
92
- totalCostUsd: agent.getTotalCostUsd(),
92
+ totalUsdCost: agent.getTotalCostUsd(),
93
93
  inProgress,
94
94
  ...(result ? { result } : {}),
95
95
  });
@@ -0,0 +1,432 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import * as os from "os";
4
+ import { KnowhowSimpleClient } from "./KnowhowClient";
5
+ import { createHash } from "crypto";
6
+ import { getConfig } from "../config";
7
+
8
+ const BEHAVIORS_DIR = ".knowhow/behaviors";
9
+ const LOCAL_SKILLS_DIRS = [".agents/skills", ".knowhow/skills"];
10
+ const GLOBAL_SKILLS_DIR = path.join(os.homedir(), ".knowhow", "skills");
11
+
12
+ export interface Behavior {
13
+ id?: string;
14
+ name: string;
15
+ description: string;
16
+ instructions: string;
17
+ textTrigger?: string;
18
+ semanticTriggerText?: string;
19
+ mcpServers?: string;
20
+ tools?: string;
21
+ embeddings?: string;
22
+ model?: string;
23
+ toolsDisabled?: boolean;
24
+ isPublic?: boolean;
25
+ isSkill?: boolean;
26
+ platform?: boolean;
27
+ }
28
+
29
+ /**
30
+ * In-memory singleton for behaviors/skills.
31
+ * Call initFromDisk() once at startup; tools then read from memory.
32
+ */
33
+ export class BehaviorsService {
34
+ private behaviors: Behavior[] = [];
35
+ private skills: Behavior[] = [];
36
+ public client = new KnowhowSimpleClient();
37
+
38
+ /**
39
+ * Load behaviors (and derive skills) from disk into memory.
40
+ * @param filePaths - optional explicit file paths to load; defaults to all files in BEHAVIORS_DIR
41
+ */
42
+ initFromDisk(filePaths?: string[]): void {
43
+ if (filePaths && filePaths.length > 0) {
44
+ const loaded: Behavior[] = [];
45
+ for (const fp of filePaths) {
46
+ try {
47
+ const raw = fs.readFileSync(fp, "utf-8");
48
+ const behavior = fp.endsWith(".md")
49
+ ? this.parseMdBehavior(raw)
50
+ : (JSON.parse(raw) as Behavior);
51
+ loaded.push(behavior);
52
+ } catch {
53
+ // skip malformed files
54
+ }
55
+ }
56
+ this.behaviors = loaded;
57
+ } else {
58
+ this.behaviors = this.loadLocal();
59
+ }
60
+
61
+ this.skills = this.behaviors.filter((b) => b.isSkill === true);
62
+ }
63
+
64
+ async initFromConfig() {
65
+ // Load skills/behaviors from disk into memory (respects config.skills file list if set)
66
+ let config: { skills?: string[] } = {};
67
+ try {
68
+ config = await getConfig();
69
+ } catch {
70
+ /* no config file */
71
+ }
72
+ const skills = config.skills || [];
73
+ this.initFromDisk(skills);
74
+ }
75
+
76
+ /** Initialize only skills subset into memory. */
77
+ initSkills(skills: Behavior[]): void {
78
+ this.skills = skills;
79
+ }
80
+
81
+ initBehaviors(behaviors: Behavior[]): void {
82
+ this.behaviors = behaviors;
83
+ }
84
+
85
+ /** Return all in-memory behaviors. */
86
+ getBehaviors(): Behavior[] {
87
+ return this.behaviors;
88
+ }
89
+
90
+ /** Return all in-memory skills (behaviors where isSkill === true). */
91
+ listSkills(): Behavior[] {
92
+ return this.skills;
93
+ }
94
+
95
+ /** Find a skill by name (exact then partial match). Returns undefined if not found. */
96
+ findSkill(name: string): Behavior | undefined {
97
+ const lower = name.toLowerCase();
98
+ return (
99
+ this.skills.find((s) => s.name.toLowerCase() === lower) ??
100
+ this.skills.find(
101
+ (s) =>
102
+ s.name.toLowerCase().includes(lower) ||
103
+ lower.includes(s.name.toLowerCase())
104
+ )
105
+ );
106
+ }
107
+
108
+ /** Singleton instance — import this everywhere instead of constructing BehaviorService directly. */
109
+
110
+ /**
111
+ * Load all behaviors from local disk (.knowhow/behaviors/).
112
+ * Supports both .json and .md files.
113
+ * Also scans .agents/skills/ and .knowhow/skills/ for SKILL.md files,
114
+ * treating them as skills (isSkill: true) regardless of frontmatter.
115
+ */
116
+ loadLocal(opts: { skillsOnly?: boolean } = {}): Behavior[] {
117
+ const results: Behavior[] = [];
118
+
119
+ // Load from .knowhow/behaviors/ (existing behaviors/skills with explicit isSkill flag)
120
+ if (fs.existsSync(BEHAVIORS_DIR)) {
121
+ const files = fs
122
+ .readdirSync(BEHAVIORS_DIR)
123
+ .filter((f) => f.endsWith(".json") || f.endsWith(".md"));
124
+
125
+ for (const file of files) {
126
+ try {
127
+ const filePath = path.join(BEHAVIORS_DIR, file);
128
+ const raw = fs.readFileSync(filePath, "utf-8");
129
+ const behavior = file.endsWith(".md")
130
+ ? this.parseMdBehavior(raw)
131
+ : (JSON.parse(raw) as Behavior);
132
+ if (opts.skillsOnly && !behavior.isSkill) continue;
133
+ results.push(behavior);
134
+ } catch {
135
+ // skip malformed files
136
+ }
137
+ }
138
+ }
139
+
140
+ // Load from .agents/skills/<name>/SKILL.md and .knowhow/skills/<name>/SKILL.md
141
+ // Also load from global ~/.knowhow/skills/<name>/SKILL.md
142
+ // Files here are always treated as skills (isSkill: true)
143
+ const allSkillsDirs = [
144
+ ...LOCAL_SKILLS_DIRS,
145
+ GLOBAL_SKILLS_DIR,
146
+ ];
147
+ for (const skillsDir of allSkillsDirs) {
148
+ if (!fs.existsSync(skillsDir)) continue;
149
+
150
+ const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
151
+ for (const entry of entries) {
152
+ if (!entry.isDirectory()) continue;
153
+ const skillFile = path.join(skillsDir, entry.name, "SKILL.md");
154
+ if (!fs.existsSync(skillFile)) continue;
155
+ try {
156
+ const raw = fs.readFileSync(skillFile, "utf-8");
157
+ const behavior = this.parseMdBehavior(raw, { isSkill: true });
158
+ results.push(behavior);
159
+ } catch {
160
+ // skip malformed files
161
+ }
162
+ }
163
+ }
164
+
165
+ return results;
166
+ }
167
+
168
+ /**
169
+ * Match a behavior from local disk by textTrigger or name prefix.
170
+ * Does NOT hit the network.
171
+ */
172
+ matchBehaviorLocal(userInput: string): Behavior | null {
173
+ const behaviors = this.loadLocal();
174
+ const lower = userInput.toLowerCase();
175
+
176
+ // Try exact text trigger match first
177
+ for (const b of behaviors) {
178
+ if (b.textTrigger && lower.startsWith(b.textTrigger.toLowerCase())) {
179
+ return b;
180
+ }
181
+ }
182
+
183
+ // Try partial text trigger match
184
+ for (const b of behaviors) {
185
+ if (b.textTrigger && lower.includes(b.textTrigger.toLowerCase())) {
186
+ return b;
187
+ }
188
+ }
189
+
190
+ return null;
191
+ }
192
+
193
+ async list(opts: { skillsOnly?: boolean; includeInternal?: boolean } = {}) {
194
+ const behaviors = opts.skillsOnly
195
+ ? await this.client.getOrgSkills(opts.includeInternal ?? true)
196
+ : await this.client.getOrgBehaviors();
197
+
198
+ const items = behaviors.data;
199
+ if (items.length === 0) {
200
+ console.log("No behaviors found.");
201
+ return;
202
+ }
203
+
204
+ for (const b of items) {
205
+ const type = b.isSkill ? "[skill]" : "[behavior]";
206
+ const platformLabel = b.platform ? " [platform]" : "";
207
+ console.log(`${type}${platformLabel} ${b.name} — ${b.description || ""}`);
208
+ }
209
+ }
210
+
211
+ async download(
212
+ opts: {
213
+ skillsOnly?: boolean;
214
+ includeInternal?: boolean;
215
+ md?: boolean;
216
+ } = {}
217
+ ) {
218
+ fs.mkdirSync(BEHAVIORS_DIR, { recursive: true });
219
+
220
+ const behaviors = opts.skillsOnly
221
+ ? await this.client.getOrgSkills(opts.includeInternal ?? true)
222
+ : await this.client.getOrgBehaviors();
223
+
224
+ const items = behaviors.data;
225
+ let created = 0;
226
+ let updated = 0;
227
+ let unchanged = 0;
228
+
229
+ for (const b of items) {
230
+ const name = (b.name as string) || "unnamed";
231
+ const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
232
+ const filePath = path.join(
233
+ BEHAVIORS_DIR,
234
+ `${slug}.${opts.md ? "md" : "json"}`
235
+ );
236
+
237
+ if (opts.md) {
238
+ const frontmatterLines: string[] = [
239
+ "---",
240
+ `name: ${name}`,
241
+ `description: ${(b.description as string) || ""}`,
242
+ ];
243
+ if (b.model) frontmatterLines.push(`model: ${b.model}`);
244
+ if (b.textTrigger)
245
+ frontmatterLines.push(`textTrigger: ${b.textTrigger}`);
246
+ if (b.semanticTriggerText)
247
+ frontmatterLines.push(
248
+ `semanticTriggerText: ${b.semanticTriggerText}`
249
+ );
250
+ if (b.isSkill !== undefined)
251
+ frontmatterLines.push(`isSkill: ${b.isSkill}`);
252
+ if (b.isPublic !== undefined)
253
+ frontmatterLines.push(`isPublic: ${b.isPublic}`);
254
+ if (b.mcpServers) frontmatterLines.push(`mcpServers: ${b.mcpServers}`);
255
+ if (b.tools) frontmatterLines.push(`tools: ${b.tools}`);
256
+ if (b.embeddings) frontmatterLines.push(`embeddings: ${b.embeddings}`);
257
+ frontmatterLines.push(`id: ${b.id}`);
258
+ frontmatterLines.push("---");
259
+
260
+ const content = `${frontmatterLines.join("\n")}\n\n${
261
+ (b.instructions as string) || ""
262
+ }`;
263
+ const changed = this.writeIfChanged(filePath, content);
264
+ if (changed === "created") {
265
+ created++;
266
+ console.log(`✨ Created: ${filePath}`);
267
+ } else if (changed === "updated") {
268
+ updated++;
269
+ console.log(`📝 Updated: ${filePath}`);
270
+ } else {
271
+ unchanged++;
272
+ }
273
+ } else {
274
+ const data: Behavior = {
275
+ id: b.id as string,
276
+ name,
277
+ description: (b.description as string) || "",
278
+ instructions: (b.instructions as string) || "",
279
+ textTrigger: b.textTrigger as string | undefined,
280
+ semanticTriggerText: b.semanticTriggerText as string | undefined,
281
+ mcpServers: b.mcpServers as string | undefined,
282
+ tools: b.tools as string | undefined,
283
+ embeddings: b.embeddings as string | undefined,
284
+ model: b.model as string | undefined,
285
+ toolsDisabled: b.toolsDisabled as boolean | undefined,
286
+ isPublic: b.isPublic as boolean | undefined,
287
+ isSkill: b.isSkill as boolean | undefined,
288
+ platform: b.platform as boolean | undefined,
289
+ };
290
+ const content = JSON.stringify(data, null, 2);
291
+ const changed = this.writeIfChanged(filePath, content);
292
+ if (changed === "created") {
293
+ created++;
294
+ console.log(`✨ Created: ${filePath}`);
295
+ } else if (changed === "updated") {
296
+ updated++;
297
+ console.log(`📝 Updated: ${filePath}`);
298
+ } else {
299
+ unchanged++;
300
+ }
301
+ }
302
+ }
303
+
304
+ console.log(
305
+ `\nSync complete: ${created} created, ${updated} updated, ${unchanged} unchanged → ${BEHAVIORS_DIR}/`
306
+ );
307
+ }
308
+
309
+ /**
310
+ * Write content to filePath only if it differs from what's there already.
311
+ * Returns "created" | "updated" | "unchanged".
312
+ */
313
+ private writeIfChanged(
314
+ filePath: string,
315
+ content: string
316
+ ): "created" | "updated" | "unchanged" {
317
+ const hash = (s: string) => createHash("sha256").update(s).digest("hex");
318
+
319
+ if (!fs.existsSync(filePath)) {
320
+ fs.writeFileSync(filePath, content);
321
+ return "created";
322
+ }
323
+ const existing = fs.readFileSync(filePath, "utf-8");
324
+ if (hash(existing) !== hash(content)) {
325
+ fs.writeFileSync(filePath, content);
326
+ return "updated";
327
+ }
328
+ return "unchanged";
329
+ }
330
+
331
+ parseMdBehavior(content: string, defaults: Partial<Behavior> = {}): Behavior {
332
+ const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
333
+ if (!match)
334
+ throw new Error("Invalid behavior markdown: missing frontmatter");
335
+
336
+ const meta: Record<string, string> = {};
337
+ for (const line of match[1].split("\n")) {
338
+ const colonIdx = line.indexOf(":");
339
+ if (colonIdx > 0) {
340
+ const key = line.slice(0, colonIdx).trim();
341
+ const val = line.slice(colonIdx + 1).trim();
342
+ meta[key] = val;
343
+ }
344
+ }
345
+
346
+ return {
347
+ id: meta.id,
348
+ name: meta.name || "",
349
+ description: meta.description || "",
350
+ instructions: match[2].trim(),
351
+ textTrigger: meta.textTrigger || undefined,
352
+ semanticTriggerText: meta.semanticTriggerText || undefined,
353
+ model: meta.model || undefined,
354
+ mcpServers: meta.mcpServers || undefined,
355
+ tools: meta.tools || undefined,
356
+ embeddings: meta.embeddings || undefined,
357
+ isSkill: meta.isSkill === "true" || defaults.isSkill === true,
358
+ isPublic: meta.isPublic === "true",
359
+ };
360
+ }
361
+
362
+ async upload() {
363
+ if (!fs.existsSync(BEHAVIORS_DIR)) {
364
+ console.error(
365
+ "No .knowhow/behaviors/ directory found. Run 'knowhow behaviors download' first."
366
+ );
367
+ return;
368
+ }
369
+
370
+ const files = fs
371
+ .readdirSync(BEHAVIORS_DIR)
372
+ .filter((f) => f.endsWith(".json") || f.endsWith(".md"));
373
+
374
+ for (const file of files) {
375
+ const filePath = path.join(BEHAVIORS_DIR, file);
376
+ const rawContent = fs.readFileSync(filePath, "utf-8");
377
+
378
+ let data: Behavior;
379
+ if (file.endsWith(".md")) {
380
+ data = this.parseMdBehavior(rawContent);
381
+ } else {
382
+ data = JSON.parse(rawContent) as Behavior;
383
+ }
384
+
385
+ if (data.id) {
386
+ await this.client.updateOrgBehavior(
387
+ data.id,
388
+ data as unknown as Record<string, unknown>
389
+ );
390
+ console.log(`Updated: ${data.name}`);
391
+ } else {
392
+ await this.client.createOrgBehavior(
393
+ data as unknown as Record<string, unknown>
394
+ );
395
+ console.log(`Created: ${data.name}`);
396
+ }
397
+ }
398
+
399
+ console.log(`Uploaded ${files.length} behaviors.`);
400
+ }
401
+
402
+ /**
403
+ * Try to match a behavior by text trigger.
404
+ * Returns the first matched behavior or null.
405
+ */
406
+ async matchBehavior(userInput: string): Promise<Behavior | null> {
407
+ try {
408
+ const result = await this.client.getTriggeredBehaviors(userInput);
409
+ const triggered = result.data.triggered;
410
+ if (triggered && triggered.length > 0) {
411
+ const b = triggered[0];
412
+ return {
413
+ id: b.id as string,
414
+ name: (b.name as string) || "",
415
+ description: (b.description as string) || "",
416
+ instructions: (b.instructions as string) || "",
417
+ textTrigger: b.textTrigger as string | undefined,
418
+ semanticTriggerText: b.semanticTriggerText as string | undefined,
419
+ model: b.model as string | undefined,
420
+ mcpServers: b.mcpServers as string | undefined,
421
+ tools: b.tools as string | undefined,
422
+ embeddings: b.embeddings as string | undefined,
423
+ isSkill: b.isSkill as boolean | undefined,
424
+ isPublic: b.isPublic as boolean | undefined,
425
+ };
426
+ }
427
+ } catch {
428
+ // Not authenticated or backend unavailable — skip behavior matching
429
+ }
430
+ return null;
431
+ }
432
+ }