@phuetz/code-buddy 1.2.0 → 1.3.1

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 (104) hide show
  1. package/README.md +119 -24
  2. package/dist/agent/autonomous/agentic-coding-contract.d.ts +6 -6
  3. package/dist/agent/base-agent.d.ts +4 -0
  4. package/dist/agent/base-agent.js +6 -0
  5. package/dist/agent/facades/infrastructure-facade.d.ts +9 -2
  6. package/dist/agent/facades/infrastructure-facade.js +15 -6
  7. package/dist/agent/self-improvement/authored-artifact-gate.d.ts +18 -0
  8. package/dist/agent/self-improvement/authored-artifact-gate.js +42 -0
  9. package/dist/agent/self-improvement/authored-tool-runtime.d.ts +27 -0
  10. package/dist/agent/self-improvement/authored-tool-runtime.js +57 -0
  11. package/dist/agent/self-improvement/authored-tool-store.d.ts +24 -0
  12. package/dist/agent/self-improvement/authored-tool-store.js +57 -0
  13. package/dist/agent/self-improvement/llm-tool-proposer.d.ts +41 -0
  14. package/dist/agent/self-improvement/llm-tool-proposer.js +136 -0
  15. package/dist/agent/self-improvement/sandbox-scorer.d.ts +17 -0
  16. package/dist/agent/self-improvement/sandbox-scorer.js +43 -0
  17. package/dist/agent/self-improvement/self-knowledge.d.ts +8 -0
  18. package/dist/agent/self-improvement/self-knowledge.js +24 -0
  19. package/dist/agent/self-improvement/skill-benchmark.d.ts +9 -0
  20. package/dist/agent/self-improvement/skill-benchmark.js +22 -0
  21. package/dist/agent/self-improvement/skill-consolidator.d.ts +71 -0
  22. package/dist/agent/self-improvement/skill-consolidator.js +137 -0
  23. package/dist/agent/self-improvement/skill-engine.d.ts +42 -0
  24. package/dist/agent/self-improvement/skill-engine.js +87 -0
  25. package/dist/agent/self-improvement/skill-gate.d.ts +19 -0
  26. package/dist/agent/self-improvement/skill-gate.js +62 -0
  27. package/dist/agent/self-improvement/skill-mutator.d.ts +74 -0
  28. package/dist/agent/self-improvement/skill-mutator.js +223 -0
  29. package/dist/agent/self-improvement/skill-proposer.d.ts +40 -0
  30. package/dist/agent/self-improvement/skill-proposer.js +82 -0
  31. package/dist/agent/self-improvement/skill-types.d.ts +41 -0
  32. package/dist/agent/self-improvement/skill-types.js +13 -0
  33. package/dist/agent/self-improvement/tool-benchmark.d.ts +10 -0
  34. package/dist/agent/self-improvement/tool-benchmark.js +37 -0
  35. package/dist/agent/self-improvement/tool-engine.d.ts +54 -0
  36. package/dist/agent/self-improvement/tool-engine.js +101 -0
  37. package/dist/agent/self-improvement/tool-gate.d.ts +20 -0
  38. package/dist/agent/self-improvement/tool-gate.js +78 -0
  39. package/dist/agent/self-improvement/tool-proposer.d.ts +31 -0
  40. package/dist/agent/self-improvement/tool-proposer.js +34 -0
  41. package/dist/agent/self-improvement/tool-skill-mutator.d.ts +40 -0
  42. package/dist/agent/self-improvement/tool-skill-mutator.js +79 -0
  43. package/dist/agent/self-improvement/tool-types.d.ts +48 -0
  44. package/dist/agent/self-improvement/tool-types.js +9 -0
  45. package/dist/agent/self-improvement/types.d.ts +3 -1
  46. package/dist/agent/tool-handler.js +3 -0
  47. package/dist/codebuddy/providers/provider-chatgpt-responses.js +6 -1
  48. package/dist/codebuddy/tools.d.ts +7 -0
  49. package/dist/codebuddy/tools.js +40 -0
  50. package/dist/commands/cli/improve-command.js +123 -0
  51. package/dist/commands/enhanced-command-handler.js +1 -1
  52. package/dist/commands/handlers/missing-handlers.d.ts +1 -1
  53. package/dist/commands/handlers/missing-handlers.js +26 -3
  54. package/dist/commands/skills-cli/index.js +123 -0
  55. package/dist/commands/slash/builtin-commands.js +1 -1
  56. package/dist/companion/percepts.js +11 -1
  57. package/dist/context/bootstrap-loader.js +6 -23
  58. package/dist/context/import-directive-parser.d.ts +4 -0
  59. package/dist/context/import-directive-parser.js +51 -6
  60. package/dist/context/instruction-excludes.d.ts +30 -1
  61. package/dist/context/instruction-excludes.js +71 -1
  62. package/dist/context/jit-context.d.ts +8 -10
  63. package/dist/context/jit-context.js +28 -106
  64. package/dist/context/project-context.d.ts +90 -0
  65. package/dist/context/project-context.js +295 -0
  66. package/dist/daemon/autonomous-loop.d.ts +31 -1
  67. package/dist/daemon/autonomous-loop.js +80 -2
  68. package/dist/harness/contract.d.ts +28 -28
  69. package/dist/identity/identity-manager.js +3 -2
  70. package/dist/index.js +17 -1
  71. package/dist/mcp/mcp-resources.js +2 -3
  72. package/dist/sensory/dreaming.d.ts +45 -0
  73. package/dist/sensory/dreaming.js +114 -0
  74. package/dist/sensory/heartbeat-scheduler.d.ts +38 -0
  75. package/dist/sensory/heartbeat-scheduler.js +72 -0
  76. package/dist/sensory/reactions.d.ts +24 -0
  77. package/dist/sensory/reactions.js +31 -0
  78. package/dist/sensory/screen-reaction.d.ts +23 -0
  79. package/dist/sensory/screen-reaction.js +59 -0
  80. package/dist/sensory/sensory-bridge.d.ts +23 -0
  81. package/dist/sensory/sensory-bridge.js +85 -0
  82. package/dist/sensory/sensory-memory.d.ts +20 -0
  83. package/dist/sensory/sensory-memory.js +39 -0
  84. package/dist/sensory/speech-reaction.d.ts +21 -0
  85. package/dist/sensory/speech-reaction.js +83 -0
  86. package/dist/sensory/vision-reaction.d.ts +31 -0
  87. package/dist/sensory/vision-reaction.js +74 -0
  88. package/dist/server/index.js +89 -0
  89. package/dist/services/prompt-builder.d.ts +10 -0
  90. package/dist/services/prompt-builder.js +75 -9
  91. package/dist/skills/parser.js +3 -0
  92. package/dist/skills/skill-importer.d.ts +58 -0
  93. package/dist/skills/skill-importer.js +261 -0
  94. package/dist/skills/skill-sources.d.ts +20 -0
  95. package/dist/skills/skill-sources.js +102 -0
  96. package/dist/skills/types.d.ts +6 -0
  97. package/dist/tools/register-tool-handler.d.ts +25 -0
  98. package/dist/tools/register-tool-handler.js +100 -0
  99. package/dist/tools/registry.d.ts +6 -0
  100. package/dist/tools/registry.js +8 -0
  101. package/dist/utils/init-project.d.ts +7 -0
  102. package/dist/utils/init-project.js +37 -0
  103. package/dist/utils/settings-manager.d.ts +12 -0
  104. package/package.json +2 -2
@@ -844,6 +844,10 @@ function createApp(config) {
844
844
  app.use(errorHandler);
845
845
  return app;
846
846
  }
847
+ /** Guards the sensory layer against double-wiring on a second in-process start. */
848
+ let sensoryWired = false;
849
+ /** Teardown fns for the sensory layer (bridge close, listener unsubscribes, scheduler stop). */
850
+ let sensoryTeardown = [];
847
851
  /**
848
852
  * Start the server
849
853
  */
@@ -1018,6 +1022,80 @@ export async function startServer(userConfig = {}) {
1018
1022
  logger.info(`Metrics Dashboard: ${baseUrl}/api/metrics/dashboard`);
1019
1023
  logger.info(`Docs: ${baseUrl}/api/docs`);
1020
1024
  logger.info(`WebSocket: ${config.websocketEnabled ? 'Enabled (/ws)' : 'Disabled'}`);
1025
+ // Sensory nervous-system bridge (opt-in): ingress for the Rust buddy-sense
1026
+ // daemon → internal event bus → reactions. Loopback-only.
1027
+ if (process.env.CODEBUDDY_SENSORY === 'true' && !sensoryWired) {
1028
+ sensoryWired = true; // wire once per process (a 2nd start would double listeners + re-bind the port)
1029
+ try {
1030
+ const { startSensoryBridge } = await import('../sensory/sensory-bridge.js');
1031
+ const { wireSensoryReactions } = await import('../sensory/reactions.js');
1032
+ const { getHeartbeatScheduler } = await import('../sensory/heartbeat-scheduler.js');
1033
+ const sensoryBridgeHandle = startSensoryBridge();
1034
+ const unwireReactions = wireSensoryReactions();
1035
+ sensoryTeardown.push(() => sensoryBridgeHandle.close(), unwireReactions);
1036
+ // Vision reaction (opt-in) — vision/motion → camera_analyze (local gemma).
1037
+ // Requires a shared token: a frame can trigger the webcam, so refuse to
1038
+ // wire it on an unauthenticated bridge.
1039
+ const sensoryToken = process.env.CODEBUDDY_SENSORY_TOKEN;
1040
+ {
1041
+ const { shouldWireVisionReaction, wireVisionReaction } = await import('../sensory/vision-reaction.js');
1042
+ if (shouldWireVisionReaction({ camera: process.env.CODEBUDDY_SENSORY_CAMERA, token: sensoryToken })) {
1043
+ sensoryTeardown.push(wireVisionReaction());
1044
+ logger.info('Sensory vision reaction: Enabled (vision/motion → camera_analyze)');
1045
+ }
1046
+ else if (process.env.CODEBUDDY_SENSORY_CAMERA === 'true') {
1047
+ logger.warn('Sensory vision reaction NOT enabled: set CODEBUDDY_SENSORY_TOKEN to allow camera triggering.');
1048
+ }
1049
+ }
1050
+ // Screen reaction (opt-in) — also token-gated (an injected analyzer could capture the desktop).
1051
+ if (process.env.CODEBUDDY_SENSORY_SCREEN === 'true') {
1052
+ if (sensoryToken) {
1053
+ const { wireScreenReaction } = await import('../sensory/screen-reaction.js');
1054
+ sensoryTeardown.push(wireScreenReaction());
1055
+ logger.info('Sensory screen reaction: Enabled (screen/change → percept)');
1056
+ }
1057
+ else {
1058
+ logger.warn('Sensory screen reaction NOT enabled: set CODEBUDDY_SENSORY_TOKEN.');
1059
+ }
1060
+ }
1061
+ // Speech reaction (opt-in) — speech_end → STT → 'hearing' percept (+ onHeard hook).
1062
+ if (process.env.CODEBUDDY_SENSORY_SPEECH === 'true') {
1063
+ const { wireSpeechReaction } = await import('../sensory/speech-reaction.js');
1064
+ sensoryTeardown.push(wireSpeechReaction());
1065
+ logger.info('Sensory speech reaction: Enabled (speech_end → STT → percept)');
1066
+ }
1067
+ // Privacy: camera/screen descriptions land in percepts.jsonl — warn if not encrypted at rest.
1068
+ if ((process.env.CODEBUDDY_SENSORY_CAMERA === 'true' || process.env.CODEBUDDY_SENSORY_SCREEN === 'true') &&
1069
+ !process.env.CODEBUDDY_COMPANION_ENCRYPTION_KEY &&
1070
+ !process.env.CODEBUDDY_MEMORY_KEY) {
1071
+ logger.warn('Sensory camera/screen percepts are written UNENCRYPTED — set CODEBUDDY_COMPANION_ENCRYPTION_KEY to encrypt scene/screen descriptions at rest.');
1072
+ }
1073
+ // Heartbeat pacemaker — heartbeats trigger periodic processing (every N beats).
1074
+ const heart = getHeartbeatScheduler();
1075
+ const everyBeats = Math.max(1, Number(process.env.CODEBUDDY_HEARTBEAT_EVERY ?? 10));
1076
+ heart.register({
1077
+ name: 'pacemaker-tick',
1078
+ everyBeats,
1079
+ handler: (ctx) => logger.info(`[heartbeat] pacemaker tick — beat ${ctx.beat} (load ${ctx.load1 ?? '?'})`),
1080
+ });
1081
+ // Dreaming — consolidate short-term sensory memory every N beats.
1082
+ const dreamEvery = Math.max(1, Number(process.env.CODEBUDDY_DREAM_EVERY ?? 30));
1083
+ heart.register({
1084
+ name: 'dreaming',
1085
+ everyBeats: dreamEvery,
1086
+ handler: async () => {
1087
+ const { runDreamingPass } = await import('../sensory/dreaming.js');
1088
+ await runDreamingPass();
1089
+ },
1090
+ });
1091
+ heart.start();
1092
+ sensoryTeardown.push(() => heart.stop());
1093
+ logger.info(`Sensory bridge: Enabled (buddy-sense → event bus; heartbeat treatments every ${everyBeats} beats)`);
1094
+ }
1095
+ catch (err) {
1096
+ logger.warn(`Sensory bridge failed to start: ${err instanceof Error ? err.message : String(err)}`);
1097
+ }
1098
+ }
1021
1099
  logger.info(`Auth: ${config.authEnabled ? 'Enabled' : 'Disabled'}`);
1022
1100
  if (!isLoopbackHost(config.host)) {
1023
1101
  logger.warn(`Server is bound to ${config.host} (non-loopback) and is reachable from the network. ` +
@@ -1072,6 +1150,17 @@ export async function startServer(userConfig = {}) {
1072
1150
  * Stop the server gracefully
1073
1151
  */
1074
1152
  export async function stopServer(server) {
1153
+ // Tear down the sensory layer (WS bridge, bus listeners, heartbeat scheduler) so
1154
+ // an in-process restart doesn't leak listeners or EADDRINUSE the bridge port.
1155
+ for (const teardown of sensoryTeardown.splice(0)) {
1156
+ try {
1157
+ await teardown();
1158
+ }
1159
+ catch {
1160
+ /* never throw on shutdown */
1161
+ }
1162
+ }
1163
+ sensoryWired = false;
1075
1164
  return new Promise((resolve, reject) => {
1076
1165
  // Phase (d).9 — cancel the heartbeat timer so it doesn't keep
1077
1166
  // emitting against a half-shut server. Idempotent.
@@ -12,6 +12,7 @@
12
12
  import { EnhancedMemory, PersistentMemoryManager } from "../memory/index.js";
13
13
  import { PromptCacheManager } from "../optimization/prompt-cache.js";
14
14
  import { MoltbotHooksManager } from "../hooks/moltbot-hooks.js";
15
+ import { type ContextRegistry } from "../context/project-context.js";
15
16
  import { type QueryComplexity } from "../agent/execution/query-classifier.js";
16
17
  export interface PromptBuilderConfig {
17
18
  yoloMode: boolean;
@@ -48,7 +49,16 @@ export declare class PromptBuilder {
48
49
  private memory?;
49
50
  private moltbotHooksManager?;
50
51
  private persistentMemory?;
52
+ /**
53
+ * Dedup registry for project-instruction files, recreated fresh at the start
54
+ * of each system-prompt build. The JIT context pass reads it (via
55
+ * `getContextRegistry()`) so files already injected at startup are not
56
+ * re-injected when a tool later touches a file in the same tree.
57
+ */
58
+ private contextRegistry;
51
59
  constructor(config: PromptBuilderConfig, promptCacheManager: PromptCacheManager, memory?: EnhancedMemory | undefined, moltbotHooksManager?: MoltbotHooksManager | undefined, persistentMemory?: PersistentMemoryManager | undefined);
60
+ /** The registry from the most recent system-prompt build (for the JIT pass). */
61
+ getContextRegistry(): ContextRegistry | null;
52
62
  /**
53
63
  * Build the system prompt for the agent. The optional `options`
54
64
  * parameter gates per-block injection. All gates default to `true`
@@ -13,6 +13,7 @@ import { logger } from "../utils/logger.js";
13
13
  import { getErrorMessage } from "../errors/index.js";
14
14
  import { getSystemPromptForMode, getPromptManager, autoSelectPromptId, getChatOnlySystemPrompt, } from "../prompts/index.js";
15
15
  import { getModelToolConfig } from "../config/model-tools.js";
16
+ import { resolveProjectContext, createContextRegistry, setActiveContextRegistry } from "../context/project-context.js";
16
17
  import { classifyQuery } from "../agent/execution/query-classifier.js";
17
18
  import { filterToolNames, getToolFilter, isToolNameAllowed, } from "../utils/tool-filter.js";
18
19
  const ALL_BLOCKS = {
@@ -65,6 +66,13 @@ export class PromptBuilder {
65
66
  memory;
66
67
  moltbotHooksManager;
67
68
  persistentMemory;
69
+ /**
70
+ * Dedup registry for project-instruction files, recreated fresh at the start
71
+ * of each system-prompt build. The JIT context pass reads it (via
72
+ * `getContextRegistry()`) so files already injected at startup are not
73
+ * re-injected when a tool later touches a file in the same tree.
74
+ */
75
+ contextRegistry = null;
68
76
  constructor(config, promptCacheManager, memory, moltbotHooksManager, persistentMemory) {
69
77
  this.config = config;
70
78
  this.promptCacheManager = promptCacheManager;
@@ -72,6 +80,10 @@ export class PromptBuilder {
72
80
  this.moltbotHooksManager = moltbotHooksManager;
73
81
  this.persistentMemory = persistentMemory;
74
82
  }
83
+ /** The registry from the most recent system-prompt build (for the JIT pass). */
84
+ getContextRegistry() {
85
+ return this.contextRegistry;
86
+ }
75
87
  /**
76
88
  * Build the system prompt for the agent. The optional `options`
77
89
  * parameter gates per-block injection. All gates default to `true`
@@ -248,18 +260,36 @@ export class PromptBuilder {
248
260
  logger.warn('Failed to inject execution-discipline block', { error: getErrorMessage(err) });
249
261
  }
250
262
  }
251
- // Inject bootstrap context files (BOOTSTRAP.md, AGENTS.md, SOUL.md, etc.)
263
+ // Inject project-instruction context (AGENTS.md / CODEBUDDY.md / CLAUDE.md
264
+ // / GEMINI.md / CONTEXT.md / INSTRUCTIONS.md) via the unified hierarchical
265
+ // loader, then soul/bootstrap files. A fresh dedup registry is created per
266
+ // build and reused by the JIT pass (`getContextRegistry`).
252
267
  if (gates.includeBootstrap) {
268
+ this.contextRegistry = createContextRegistry();
269
+ // Publish for the JIT pass so it skips files injected here at startup.
270
+ setActiveContextRegistry(this.contextRegistry);
271
+ try {
272
+ const ctx = resolveProjectContext({ cwd: this.config.cwd, registry: this.contextRegistry });
273
+ if (ctx.text) {
274
+ systemPrompt += '\n\n# Workspace Context\n\n' + ctx.text;
275
+ logger.debug(`Loaded project context from ${ctx.sources.length} file(s)`, {
276
+ sources: ctx.sources.map((s) => s.relPath),
277
+ chars: ctx.bytes,
278
+ truncated: ctx.truncated,
279
+ });
280
+ }
281
+ }
282
+ catch (err) {
283
+ logger.warn("Failed to load project context", { error: getErrorMessage(err) });
284
+ }
285
+ // Soul/identity bootstrap files (SOUL.md, USER.md, …) + PROJECT_KNOWLEDGE.md.
286
+ // Instruction files are handled above by the unified loader, not here.
253
287
  try {
254
288
  const { BootstrapLoader } = await import('../context/bootstrap-loader.js');
255
289
  const bootstrap = await new BootstrapLoader().load(this.config.cwd);
256
290
  if (bootstrap.content) {
257
- systemPrompt += '\n\n# Workspace Context\n\n' + bootstrap.content;
258
- logger.debug(`Loaded bootstrap context from ${bootstrap.sources.length} file(s)`, {
259
- sources: bootstrap.sources,
260
- chars: bootstrap.tokenCount,
261
- truncated: bootstrap.truncated,
262
- });
291
+ systemPrompt += '\n\n' + bootstrap.content;
292
+ logger.debug(`Loaded bootstrap/soul context from ${bootstrap.sources.length} file(s)`);
263
293
  }
264
294
  }
265
295
  catch (err) {
@@ -325,6 +355,31 @@ export class PromptBuilder {
325
355
  }
326
356
  catch { /* rules module optional */ }
327
357
  }
358
+ // Steer toward Code Explorer (gitnexus) when it is connected. Conditional:
359
+ // when Code Explorer is absent this injects nothing, so the built-in
360
+ // code_graph/codebase_map behaviour is unchanged. Presence is session-
361
+ // stable, so this stays in the cache-stable prefix.
362
+ try {
363
+ const { isCodeExplorerAvailable } = await import('../codebuddy/tools.js');
364
+ if (isCodeExplorerAvailable()) {
365
+ systemPrompt +=
366
+ `\n\n<code_explorer_priority>\n` +
367
+ `Code Explorer (gitnexus) is connected. For ANY question about code relationships — ` +
368
+ `callers/callees, blast radius / impact ("what breaks if I change X"), dead code, cycles, ` +
369
+ `coupling, complexity — PREFER its MCP tools (\`mcp__gitnexus__impact\`, ` +
370
+ `\`mcp__gitnexus__context\`, \`mcp__gitnexus__query\`, \`mcp__gitnexus__find_cycles\`, …) ` +
371
+ `over the built-in \`code_graph\` / \`codebase_map\`: the gitnexus graph is broader and more ` +
372
+ `complete (whole-repo, 14 languages).\n` +
373
+ `Usage: first call \`mcp__gitnexus__list_repos\` once to get the repo \`path\`/\`id\`, then call ` +
374
+ `\`mcp__gitnexus__impact\` with the REQUIRED \`target\` = the symbol name (e.g. \`target: "executePlan"\`, ` +
375
+ `optionally \`direction: "both"\`) and \`repo\` = that path; or \`mcp__gitnexus__context\` with \`name\` = ` +
376
+ `the symbol. Always include \`target\`/\`name\` — never call these tools with empty arguments. ` +
377
+ `Use the built-in \`code_graph\`/\`codebase_map\` only as a fallback if a gitnexus tool errors.\n` +
378
+ `</code_explorer_priority>`;
379
+ logger.debug('Injected Code Explorer priority directive');
380
+ }
381
+ }
382
+ catch { /* tools module optional */ }
328
383
  // Inject active skill prompt enhancement
329
384
  if (gates.includeSkills) {
330
385
  try {
@@ -338,10 +393,11 @@ export class PromptBuilder {
338
393
  }
339
394
  catch { /* skills module optional */ }
340
395
  }
341
- // Inject identity (SOUL.md, USER.md, AGENTS.md) — skip if already present to avoid duplication
396
+ // Inject identity (SOUL.md, USER.md, ) — skip if already present to avoid duplication.
397
+ // AGENTS.md/INSTRUCTIONS.md are owned by the unified loader, not identity.
342
398
  if (gates.includeIdentity) {
343
399
  try {
344
- if (!systemPrompt.includes('## SOUL.md') && !systemPrompt.includes('## AGENTS.md')) {
400
+ if (!systemPrompt.includes('## SOUL.md')) {
345
401
  const { getIdentityManager } = await import('../identity/identity-manager.js');
346
402
  const identityMgr = getIdentityManager();
347
403
  await identityMgr.load(this.config.cwd);
@@ -475,6 +531,16 @@ Lessons complement \`remember\`: \`remember\` stores facts (preferences, decisio
475
531
  </lessons_directive>`;
476
532
  logger.debug('Injected lessons directive into system prompt');
477
533
  }
534
+ // Self-knowledge — only when self-improvement is explicitly opted in. Makes
535
+ // the agent aware it can author its own tools/skills (and the hard `src/` limit).
536
+ if (process.env.CODEBUDDY_SELF_IMPROVE === 'true') {
537
+ try {
538
+ const { buildSelfKnowledgeBlock } = await import('../agent/self-improvement/self-knowledge.js');
539
+ systemPrompt += `\n\n<self_knowledge>\n${buildSelfKnowledgeBlock()}\n</self_knowledge>`;
540
+ logger.debug('Injected self-knowledge block into system prompt');
541
+ }
542
+ catch { /* self-improvement module optional */ }
543
+ }
478
544
  if (this.config.memoryEnabled && gates.includeUserModelDirective) {
479
545
  systemPrompt += `\n\n<user_model_directive>
480
546
  You have a persistent user model that builds a deepening profile of who you are, your traits, preferences, expertise, and working style.
@@ -92,6 +92,9 @@ function parseMetadata(yamlContent, sourcePath) {
92
92
  // CC11: context fork and disable-model-invocation
93
93
  contextFork: parsed.context === 'fork' || parsed.contextFork === true,
94
94
  disableModelInvocation: parsed['disable-model-invocation'] === true || parsed.disableModelInvocation === true,
95
+ pinned: parsed.pinned === true,
96
+ imported: parsed.imported === true,
97
+ source: parsed.source,
95
98
  };
96
99
  }
97
100
  catch (error) {
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Skill importer — bring EXTERNAL skills (a Hermes repo, any skills directory)
3
+ * into Code Buddy, safely. External skills are untrusted and get injected into the
4
+ * agent's context, so the firewall gates every one (SKILL.md + its scripts) before
5
+ * install. Hermes nests 1–3 levels but our registry walks 1 level → flatten; Hermes
6
+ * tags live under `metadata.hermes.tags` but our discovery scores top-level `tags` +
7
+ * `nativeEngine.triggers` → remap, else an imported skill is invisible to matching.
8
+ *
9
+ * Imported skills are namespaced `imported-*` (distinct provenance; never touched by
10
+ * the self-improvement engine) and installed flat (1 level) under a tier root.
11
+ *
12
+ * @module skills/skill-importer
13
+ */
14
+ export declare const IMPORTED_PREFIX = "imported-";
15
+ export interface ImportOptions {
16
+ /** Tier dir to install under. Default ~/.codebuddy/skills/managed. */
17
+ destRoot?: string;
18
+ /** Provenance label written to frontmatter (e.g. "hermes"). */
19
+ source?: string;
20
+ /** When true, scan + report but write nothing. */
21
+ dryRun?: boolean;
22
+ /** Import skills the firewall flags as 'review' (default: skip them). */
23
+ includeReview?: boolean;
24
+ /** Overwrite an existing imported-<name> (default: skip). */
25
+ overwrite?: boolean;
26
+ /** Only import skills whose source path contains this substring. */
27
+ category?: string;
28
+ /** Pin imported skills so curation leaves them alone (default true). */
29
+ pinByDefault?: boolean;
30
+ }
31
+ export interface ImportedSkill {
32
+ name: string;
33
+ sourcePath: string;
34
+ verdict: string;
35
+ }
36
+ export interface SkippedSkill {
37
+ sourcePath: string;
38
+ reason: string;
39
+ verdict?: string;
40
+ }
41
+ export interface ImportReport {
42
+ imported: ImportedSkill[];
43
+ quarantined: SkippedSkill[];
44
+ review: SkippedSkill[];
45
+ skipped: SkippedSkill[];
46
+ total: number;
47
+ dryRun: boolean;
48
+ }
49
+ /** Recursively find skill directories (those containing a SKILL.md). Skips operational dirs. */
50
+ export declare function findSkillDirs(root: string): string[];
51
+ /** Build a Code-Buddy-shaped SKILL.md from a raw (e.g. Hermes) frontmatter object + body. */
52
+ export declare function remapSkill(rawFm: Record<string, unknown>, body: string, opts: {
53
+ slug: string;
54
+ source: string;
55
+ pinned: boolean;
56
+ }): string;
57
+ /** Import skills from a directory. Pure-ish: writes nothing when dryRun. */
58
+ export declare function importSkills(sourceDir: string, options?: ImportOptions): ImportReport;
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Skill importer — bring EXTERNAL skills (a Hermes repo, any skills directory)
3
+ * into Code Buddy, safely. External skills are untrusted and get injected into the
4
+ * agent's context, so the firewall gates every one (SKILL.md + its scripts) before
5
+ * install. Hermes nests 1–3 levels but our registry walks 1 level → flatten; Hermes
6
+ * tags live under `metadata.hermes.tags` but our discovery scores top-level `tags` +
7
+ * `nativeEngine.triggers` → remap, else an imported skill is invisible to matching.
8
+ *
9
+ * Imported skills are namespaced `imported-*` (distinct provenance; never touched by
10
+ * the self-improvement engine) and installed flat (1 level) under a tier root.
11
+ *
12
+ * @module skills/skill-importer
13
+ */
14
+ import fs from 'fs';
15
+ import path from 'path';
16
+ import os from 'os';
17
+ import * as yaml from 'yaml';
18
+ import { scanSkillFirewall } from '../security/skill-scanner.js';
19
+ import { parseSkillFile, validateSkill } from './parser.js';
20
+ import { logger } from '../utils/logger.js';
21
+ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/;
22
+ const SUPPORT_DIRS = ['references', 'templates', 'scripts', 'assets', 'workflows', 'tests'];
23
+ const SKIP_DIRS = new Set(['.git', 'index-cache', '.archive', 'node_modules', '.sources-cache']);
24
+ export const IMPORTED_PREFIX = 'imported-';
25
+ function defaultDestRoot() {
26
+ return path.join(os.homedir(), '.codebuddy', 'skills', 'managed');
27
+ }
28
+ /** Recursively find skill directories (those containing a SKILL.md). Skips operational dirs. */
29
+ export function findSkillDirs(root) {
30
+ const out = [];
31
+ const walk = (dir) => {
32
+ let entries;
33
+ try {
34
+ entries = fs.readdirSync(dir, { withFileTypes: true });
35
+ }
36
+ catch {
37
+ return;
38
+ }
39
+ if (entries.some((e) => e.isFile() && e.name.toLowerCase() === 'skill.md')) {
40
+ out.push(dir);
41
+ // don't descend into a skill's own support dirs
42
+ return;
43
+ }
44
+ for (const e of entries) {
45
+ if (!e.isDirectory() || e.name.startsWith('.') || SKIP_DIRS.has(e.name))
46
+ continue;
47
+ walk(path.join(dir, e.name));
48
+ }
49
+ };
50
+ walk(root);
51
+ return out;
52
+ }
53
+ function slugify(raw) {
54
+ const base = String(raw).trim().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
55
+ return base.startsWith(IMPORTED_PREFIX) ? base : `${IMPORTED_PREFIX}${base || 'skill'}`;
56
+ }
57
+ function normalizeTags(raw) {
58
+ const list = Array.isArray(raw) ? raw : [];
59
+ const seen = new Set();
60
+ const out = [];
61
+ for (const t of list) {
62
+ const s = String(t).trim().toLowerCase();
63
+ if (s && !seen.has(s)) {
64
+ seen.add(s);
65
+ out.push(s);
66
+ }
67
+ }
68
+ return out;
69
+ }
70
+ const STOPWORDS = new Set([
71
+ 'the', 'and', 'for', 'with', 'your', 'via', 'into', 'from', 'that', 'this', 'use', 'using', 'create',
72
+ 'creates', 'creating', 'a', 'an', 'or', 'to', 'of', 'in', 'on', 'as', 'by', 'it', 'is', 'are', 'add',
73
+ 'list', 'get', 'set', 'run', 'when', 'how', 'you', 'can', 'will', 'their', 'them', 'they', 'about',
74
+ ]);
75
+ /** Derive discovery triggers (the primary matcher) from the name + tags + description keywords. */
76
+ function deriveTriggers(rawName, tags, description = '') {
77
+ const nameWords = String(rawName).toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length >= 3);
78
+ const descWords = String(description)
79
+ .toLowerCase()
80
+ .split(/[^a-z0-9]+/)
81
+ .filter((w) => w.length >= 4 && !STOPWORDS.has(w));
82
+ const seen = new Set();
83
+ const out = [];
84
+ for (const t of [String(rawName).toLowerCase(), ...tags, ...nameWords, ...descWords]) {
85
+ if (t && !seen.has(t)) {
86
+ seen.add(t);
87
+ out.push(t);
88
+ }
89
+ if (out.length >= 12)
90
+ break;
91
+ }
92
+ return out;
93
+ }
94
+ /** Extract discovery tags from any source layout: top-level `tags`, or `metadata.<source>.tags`. */
95
+ function extractTags(rawFm) {
96
+ if (Array.isArray(rawFm.tags))
97
+ return rawFm.tags;
98
+ const meta = rawFm.metadata;
99
+ if (meta && typeof meta === 'object') {
100
+ for (const v of Object.values(meta)) {
101
+ if (v && typeof v === 'object' && Array.isArray(v.tags)) {
102
+ return v.tags;
103
+ }
104
+ }
105
+ }
106
+ return [];
107
+ }
108
+ /** Map prerequisites/requires from any source → our SkillRequirements.tools. */
109
+ function extractRequiresTools(rawFm) {
110
+ const out = new Set();
111
+ const add = (v) => {
112
+ if (Array.isArray(v))
113
+ for (const x of v)
114
+ if (typeof x === 'string')
115
+ out.add(x);
116
+ };
117
+ // Hermes: prerequisites.commands
118
+ const prereq = rawFm.prerequisites;
119
+ if (prereq)
120
+ add(prereq.commands);
121
+ // OpenClaw (and others): metadata.<source>.requires.bins
122
+ const meta = rawFm.metadata;
123
+ if (meta && typeof meta === 'object') {
124
+ for (const v of Object.values(meta)) {
125
+ const req = v?.requires;
126
+ if (req)
127
+ add(req.bins);
128
+ }
129
+ }
130
+ return [...out];
131
+ }
132
+ /** Build a Code-Buddy-shaped SKILL.md from a raw (e.g. Hermes) frontmatter object + body. */
133
+ export function remapSkill(rawFm, body, opts) {
134
+ const description = String(rawFm.description ?? '').trim() || `Imported skill ${opts.slug}`;
135
+ const tags = normalizeTags(extractTags(rawFm));
136
+ const requiresTools = extractRequiresTools(rawFm);
137
+ const author = Array.isArray(rawFm.author) ? rawFm.author.join(', ') : rawFm.author;
138
+ const meta = {
139
+ name: opts.slug,
140
+ description,
141
+ ...(rawFm.version ? { version: rawFm.version } : {}),
142
+ ...(author ? { author } : {}),
143
+ ...(rawFm.license ? { license: rawFm.license } : {}),
144
+ ...(rawFm.platforms ? { platforms: rawFm.platforms } : {}),
145
+ tags,
146
+ nativeEngine: { triggers: deriveTriggers(String(rawFm.name ?? opts.slug), tags, description) },
147
+ ...(requiresTools.length ? { requires: { tools: requiresTools } } : {}),
148
+ imported: true,
149
+ source: opts.source,
150
+ ...(opts.pinned ? { pinned: true } : {}),
151
+ };
152
+ return `---\n${yaml.stringify(meta)}---\n\n${body.trim()}\n`;
153
+ }
154
+ function copySupportDirs(srcDir, destDir) {
155
+ for (const sub of SUPPORT_DIRS) {
156
+ const from = path.join(srcDir, sub);
157
+ if (fs.existsSync(from) && fs.statSync(from).isDirectory()) {
158
+ fs.cpSync(from, path.join(destDir, sub), {
159
+ recursive: true,
160
+ // never follow symlinks out of the source tree
161
+ filter: (s) => {
162
+ try {
163
+ return !fs.lstatSync(s).isSymbolicLink();
164
+ }
165
+ catch {
166
+ return false;
167
+ }
168
+ },
169
+ });
170
+ }
171
+ }
172
+ }
173
+ /** Import skills from a directory. Pure-ish: writes nothing when dryRun. */
174
+ export function importSkills(sourceDir, options = {}) {
175
+ const destRoot = options.destRoot ?? defaultDestRoot();
176
+ const source = options.source ?? 'import';
177
+ const dryRun = options.dryRun ?? false;
178
+ const pinByDefault = options.pinByDefault ?? true;
179
+ const report = { imported: [], quarantined: [], review: [], skipped: [], total: 0, dryRun };
180
+ const skillDirs = findSkillDirs(sourceDir);
181
+ report.total = skillDirs.length;
182
+ for (const skillDir of skillDirs) {
183
+ const rel = path.relative(sourceDir, skillDir);
184
+ if (options.category && !rel.includes(options.category)) {
185
+ report.skipped.push({ sourcePath: rel, reason: 'filtered by --category' });
186
+ continue;
187
+ }
188
+ const skillMd = fs.existsSync(path.join(skillDir, 'SKILL.md'))
189
+ ? path.join(skillDir, 'SKILL.md')
190
+ : path.join(skillDir, 'skill.md');
191
+ const content = fs.readFileSync(skillMd, 'utf-8');
192
+ // Compatibility check.
193
+ try {
194
+ const skill = parseSkillFile(content, skillMd, 'managed');
195
+ const v = validateSkill(skill);
196
+ if (!v.valid) {
197
+ report.skipped.push({ sourcePath: rel, reason: `invalid: ${v.errors.join('; ')}` });
198
+ continue;
199
+ }
200
+ }
201
+ catch (err) {
202
+ report.skipped.push({ sourcePath: rel, reason: `parse error: ${err instanceof Error ? err.message : String(err)}` });
203
+ continue;
204
+ }
205
+ // Firewall gate (scans SKILL.md + scripts/support files recursively).
206
+ const fw = scanSkillFirewall(skillDir);
207
+ if (fw.quarantineRequired) {
208
+ report.quarantined.push({ sourcePath: rel, reason: fw.summary, verdict: String(fw.verdict) });
209
+ continue;
210
+ }
211
+ if (String(fw.verdict) === 'review' && !options.includeReview) {
212
+ report.review.push({ sourcePath: rel, reason: fw.summary, verdict: 'review' });
213
+ continue;
214
+ }
215
+ // Remap + flatten + install.
216
+ const m = content.match(FRONTMATTER_RE);
217
+ if (!m) {
218
+ report.skipped.push({ sourcePath: rel, reason: 'missing frontmatter' });
219
+ continue;
220
+ }
221
+ let rawFm;
222
+ try {
223
+ rawFm = (yaml.parse(m[1]) ?? {});
224
+ }
225
+ catch {
226
+ report.skipped.push({ sourcePath: rel, reason: 'unparseable frontmatter' });
227
+ continue;
228
+ }
229
+ const slug = slugify(String(rawFm.name ?? path.basename(skillDir)));
230
+ const destDir = path.join(destRoot, slug);
231
+ if (fs.existsSync(destDir) && !options.overwrite) {
232
+ report.skipped.push({ sourcePath: rel, reason: `conflict: ${slug} already imported` });
233
+ continue;
234
+ }
235
+ if (!dryRun) {
236
+ try {
237
+ fs.mkdirSync(destDir, { recursive: true });
238
+ fs.writeFileSync(path.join(destDir, 'SKILL.md'), remapSkill(rawFm, m[2], { slug, source, pinned: pinByDefault }), 'utf-8');
239
+ copySupportDirs(skillDir, destDir);
240
+ }
241
+ catch (err) {
242
+ report.skipped.push({ sourcePath: rel, reason: `write error: ${err instanceof Error ? err.message : String(err)}` });
243
+ continue;
244
+ }
245
+ }
246
+ report.imported.push({ name: slug, sourcePath: rel, verdict: String(fw.verdict) });
247
+ }
248
+ if (!dryRun && report.imported.length > 0) {
249
+ void (async () => {
250
+ try {
251
+ const { getSkillRegistry } = await import('./registry.js');
252
+ await getSkillRegistry().reloadAll();
253
+ }
254
+ catch (err) {
255
+ logger.debug(`skill reload after import failed: ${err instanceof Error ? err.message : String(err)}`);
256
+ }
257
+ })();
258
+ }
259
+ return report;
260
+ }
261
+ //# sourceMappingURL=skill-importer.js.map
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Skill sources — a small "referential" of named places skills can be imported
3
+ * from. A source is a local directory or a git repo. Persisted to
4
+ * `~/.codebuddy/skill-sources.json`. The importer resolves a source to a local
5
+ * directory (cloning/pulling a git source into a cache) and then runs the same
6
+ * firewall-gated import path.
7
+ *
8
+ * @module skills/skill-sources
9
+ */
10
+ export interface SkillSource {
11
+ name: string;
12
+ type: 'dir' | 'git';
13
+ location: string;
14
+ }
15
+ export declare function listSources(): SkillSource[];
16
+ export declare function getSource(name: string): SkillSource | undefined;
17
+ export declare function addSource(name: string, location: string, type?: 'dir' | 'git'): SkillSource;
18
+ export declare function removeSource(name: string): boolean;
19
+ /** Resolve a source to a local directory (clone/pull a git source into the cache). */
20
+ export declare function resolveSourceDir(source: SkillSource): string;