gitclaw 0.3.1 → 0.4.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 (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -2
  3. package/dist/composio/adapter.d.ts +26 -0
  4. package/dist/composio/adapter.js +92 -0
  5. package/dist/composio/client.d.ts +39 -0
  6. package/dist/composio/client.js +170 -0
  7. package/dist/composio/index.d.ts +2 -0
  8. package/dist/composio/index.js +2 -0
  9. package/dist/context.d.ts +20 -0
  10. package/dist/context.js +211 -0
  11. package/dist/exports.d.ts +2 -0
  12. package/dist/exports.js +1 -0
  13. package/dist/index.js +99 -7
  14. package/dist/learning/reinforcement.d.ts +11 -0
  15. package/dist/learning/reinforcement.js +91 -0
  16. package/dist/loader.js +34 -1
  17. package/dist/sdk.js +5 -1
  18. package/dist/skills.d.ts +5 -0
  19. package/dist/skills.js +58 -7
  20. package/dist/tools/capture-photo.d.ts +3 -0
  21. package/dist/tools/capture-photo.js +91 -0
  22. package/dist/tools/index.d.ts +2 -1
  23. package/dist/tools/index.js +12 -2
  24. package/dist/tools/read.js +4 -0
  25. package/dist/tools/shared.d.ts +20 -0
  26. package/dist/tools/shared.js +24 -0
  27. package/dist/tools/skill-learner.d.ts +3 -0
  28. package/dist/tools/skill-learner.js +358 -0
  29. package/dist/tools/task-tracker.d.ts +20 -0
  30. package/dist/tools/task-tracker.js +275 -0
  31. package/dist/tools/write.js +4 -0
  32. package/dist/voice/adapter.d.ts +97 -0
  33. package/dist/voice/adapter.js +30 -0
  34. package/dist/voice/chat-history.d.ts +8 -0
  35. package/dist/voice/chat-history.js +121 -0
  36. package/dist/voice/gemini-live.d.ts +20 -0
  37. package/dist/voice/gemini-live.js +279 -0
  38. package/dist/voice/index.d.ts +4 -0
  39. package/dist/voice/index.js +3 -0
  40. package/dist/voice/openai-realtime.d.ts +27 -0
  41. package/dist/voice/openai-realtime.js +291 -0
  42. package/dist/voice/server.d.ts +2 -0
  43. package/dist/voice/server.js +2319 -0
  44. package/dist/voice/ui.html +2556 -0
  45. package/package.json +21 -7
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import { Agent } from "@mariozechner/pi-agent-core";
4
4
  import { loadAgent } from "./loader.js";
5
5
  import { createBuiltinTools } from "./tools/index.js";
6
6
  import { createSandboxContext } from "./sandbox.js";
7
- import { expandSkillCommand } from "./skills.js";
7
+ import { expandSkillCommand, refreshSkills } from "./skills.js";
8
8
  import { loadHooksConfig, runHooks, wrapToolWithHooks } from "./hooks.js";
9
9
  import { loadDeclarativeTools } from "./tool-loader.js";
10
10
  import { AuditLogger, isAuditEnabled } from "./audit.js";
@@ -13,6 +13,7 @@ import { readFile, mkdir, writeFile, access } from "fs/promises";
13
13
  import { join, resolve } from "path";
14
14
  import { execSync } from "child_process";
15
15
  import { initLocalSession } from "./session.js";
16
+ import { startVoiceServer } from "./voice/server.js";
16
17
  // ANSI helpers
17
18
  const dim = (s) => `\x1b[2m${s}\x1b[0m`;
18
19
  const bold = (s) => `\x1b[1m${s}\x1b[0m`;
@@ -30,6 +31,7 @@ function parseArgs(argv) {
30
31
  let repo;
31
32
  let pat;
32
33
  let session;
34
+ let voice;
33
35
  for (let i = 0; i < args.length; i++) {
34
36
  switch (args[i]) {
35
37
  case "--model":
@@ -68,6 +70,16 @@ function parseArgs(argv) {
68
70
  case "--session":
69
71
  session = args[++i];
70
72
  break;
73
+ case "--voice":
74
+ case "-v":
75
+ // Accept optional backend name: --voice, --voice openai, --voice gemini
76
+ if (args[i + 1] && !args[i + 1].startsWith("-")) {
77
+ voice = args[++i];
78
+ }
79
+ else {
80
+ voice = "openai";
81
+ }
82
+ break;
71
83
  default:
72
84
  if (!args[i].startsWith("-")) {
73
85
  prompt = args[i];
@@ -75,7 +87,7 @@ function parseArgs(argv) {
75
87
  break;
76
88
  }
77
89
  }
78
- return { model, dir, prompt, env, sandbox, sandboxRepo, sandboxToken, repo, pat, session };
90
+ return { model, dir, prompt, env, sandbox, sandboxRepo, sandboxToken, repo, pat, session, voice };
79
91
  }
80
92
  function handleEvent(event, hooksConfig, agentDir, sessionId, auditLogger) {
81
93
  switch (event.type) {
@@ -236,7 +248,7 @@ async function ensureRepo(dir, model) {
236
248
  return absDir;
237
249
  }
238
250
  async function main() {
239
- const { model, dir: rawDir, prompt, env, sandbox: useSandbox, sandboxRepo, sandboxToken, repo, pat, session: sessionBranch } = parseArgs(process.argv);
251
+ const { model, dir: rawDir, prompt, env, sandbox: useSandbox, sandboxRepo, sandboxToken, repo, pat, session: sessionBranch, voice } = parseArgs(process.argv);
240
252
  // If --repo is given, derive a default dir from the repo URL (skip interactive prompt)
241
253
  let dir = rawDir;
242
254
  let localSession;
@@ -295,6 +307,46 @@ async function main() {
295
307
  else {
296
308
  dir = resolve(dir);
297
309
  }
310
+ // Voice mode
311
+ if (voice) {
312
+ let adapterBackend;
313
+ let apiKey;
314
+ if (voice === "gemini") {
315
+ adapterBackend = "gemini-live";
316
+ apiKey = process.env.GEMINI_API_KEY;
317
+ if (!apiKey) {
318
+ console.error(red("Error: GEMINI_API_KEY is required for --voice gemini"));
319
+ process.exit(1);
320
+ }
321
+ }
322
+ else {
323
+ adapterBackend = "openai-realtime";
324
+ apiKey = process.env.OPENAI_API_KEY;
325
+ if (!apiKey) {
326
+ console.error(red("Error: OPENAI_API_KEY is required for --voice mode"));
327
+ process.exit(1);
328
+ }
329
+ }
330
+ const cleanup = await startVoiceServer({
331
+ adapter: adapterBackend,
332
+ adapterConfig: { apiKey },
333
+ agentDir: dir,
334
+ model,
335
+ env,
336
+ });
337
+ let stopping = false;
338
+ process.on("SIGINT", () => {
339
+ if (stopping) {
340
+ // Second Ctrl+C — force exit immediately
341
+ process.exit(1);
342
+ }
343
+ stopping = true;
344
+ console.log("\nDisconnecting...");
345
+ cleanup().finally(() => process.exit(0));
346
+ });
347
+ // Keep process alive
348
+ return;
349
+ }
298
350
  let loaded;
299
351
  try {
300
352
  loaded = await loadAgent(dir, model, env);
@@ -356,6 +408,7 @@ async function main() {
356
408
  dir,
357
409
  timeout: manifest.runtime.timeout,
358
410
  sandbox: sandboxCtx,
411
+ gitagentDir,
359
412
  });
360
413
  // Load declarative tools from tools/*.yaml (Phase 2.2)
361
414
  const declarativeTools = await loadDeclarativeTools(agentDir);
@@ -401,7 +454,7 @@ async function main() {
401
454
  if (loaded.subAgents.length > 0) {
402
455
  console.log(dim(`Agents: ${loaded.subAgents.map((a) => a.name).join(", ")}`));
403
456
  }
404
- console.log(dim('Type /skills to list skills, /memory to view memory, /quit to exit\n'));
457
+ console.log(dim('Type /skills, /tasks, /learned, /memory, /quit\n'));
405
458
  // Single-shot mode
406
459
  if (prompt) {
407
460
  try {
@@ -466,12 +519,51 @@ async function main() {
466
519
  return;
467
520
  }
468
521
  if (trimmed === "/skills") {
469
- if (skills.length === 0) {
522
+ // Refresh skills to pick up any newly learned ones
523
+ const currentSkills = await refreshSkills(dir);
524
+ if (currentSkills.length === 0) {
470
525
  console.log(dim("No skills installed."));
471
526
  }
472
527
  else {
473
- for (const s of skills) {
474
- console.log(` ${bold(s.name)} ${dim(s.description)}`);
528
+ for (const s of currentSkills) {
529
+ const conf = s.confidence !== undefined ? dim(` [confidence: ${s.confidence}]`) : "";
530
+ console.log(` ${bold(s.name)} — ${dim(s.description)}${conf}`);
531
+ }
532
+ }
533
+ ask();
534
+ return;
535
+ }
536
+ if (trimmed === "/tasks") {
537
+ try {
538
+ const tasksRaw = await readFile(join(gitagentDir, "learning", "tasks.json"), "utf-8");
539
+ const tasksData = JSON.parse(tasksRaw);
540
+ const active = (tasksData.tasks || []).filter((t) => t.status === "active");
541
+ if (active.length === 0) {
542
+ console.log(dim("No active tasks."));
543
+ }
544
+ else {
545
+ for (const t of active) {
546
+ console.log(` ${bold(t.id.slice(0, 8))} — ${t.objective} (${t.steps.length} steps, attempt #${t.attempts})`);
547
+ }
548
+ }
549
+ }
550
+ catch {
551
+ console.log(dim("No tasks recorded yet."));
552
+ }
553
+ ask();
554
+ return;
555
+ }
556
+ if (trimmed === "/learned") {
557
+ const currentSkills = await refreshSkills(dir);
558
+ const learned = currentSkills.filter((s) => s.confidence !== undefined);
559
+ if (learned.length === 0) {
560
+ console.log(dim("No learned skills yet."));
561
+ }
562
+ else {
563
+ for (const s of learned) {
564
+ const usage = s.usage_count ?? 0;
565
+ const ratio = `${s.success_count ?? 0}/${(s.success_count ?? 0) + (s.failure_count ?? 0)}`;
566
+ console.log(` ${bold(s.name)} — confidence: ${s.confidence}, usage: ${usage}, success: ${ratio}`);
475
567
  }
476
568
  }
477
569
  ask();
@@ -0,0 +1,11 @@
1
+ export interface SkillStats {
2
+ confidence: number;
3
+ usage_count: number;
4
+ success_count: number;
5
+ failure_count: number;
6
+ negative_examples: string[];
7
+ }
8
+ export declare function adjustConfidence(current: SkillStats, outcome: "success" | "failure" | "partial", failureReason?: string): SkillStats;
9
+ export declare function loadSkillStats(skillDir: string): Promise<SkillStats>;
10
+ export declare function saveSkillStats(skillDir: string, stats: SkillStats): Promise<void>;
11
+ export declare function isSkillFlagged(stats: SkillStats): boolean;
@@ -0,0 +1,91 @@
1
+ import { readFile, writeFile } from "fs/promises";
2
+ import { join } from "path";
3
+ import yaml from "js-yaml";
4
+ const MAX_NEGATIVE_EXAMPLES = 10;
5
+ const DEFAULT_STATS = {
6
+ confidence: 1.0,
7
+ usage_count: 0,
8
+ success_count: 0,
9
+ failure_count: 0,
10
+ negative_examples: [],
11
+ };
12
+ // ── Confidence math ─────────────────────────────────────────────────────
13
+ export function adjustConfidence(current, outcome, failureReason) {
14
+ const stats = { ...current };
15
+ stats.usage_count++;
16
+ switch (outcome) {
17
+ case "success":
18
+ // Asymptotic to 1.0: conf + 0.1 * (1 - conf)
19
+ stats.confidence = Math.min(1.0, stats.confidence + 0.1 * (1 - stats.confidence));
20
+ stats.success_count++;
21
+ break;
22
+ case "failure":
23
+ // 2x penalty (asymmetric loss)
24
+ stats.confidence = Math.max(0.0, stats.confidence - 0.2);
25
+ stats.failure_count++;
26
+ if (failureReason) {
27
+ stats.negative_examples = [
28
+ ...stats.negative_examples.slice(-(MAX_NEGATIVE_EXAMPLES - 1)),
29
+ failureReason,
30
+ ];
31
+ }
32
+ break;
33
+ case "partial":
34
+ stats.confidence = Math.max(0.0, stats.confidence - 0.05);
35
+ stats.failure_count++;
36
+ if (failureReason) {
37
+ stats.negative_examples = [
38
+ ...stats.negative_examples.slice(-(MAX_NEGATIVE_EXAMPLES - 1)),
39
+ failureReason,
40
+ ];
41
+ }
42
+ break;
43
+ }
44
+ // Round to avoid floating-point drift
45
+ stats.confidence = Math.round(stats.confidence * 100) / 100;
46
+ return stats;
47
+ }
48
+ // ── SKILL.md frontmatter read/write ─────────────────────────────────────
49
+ function parseFrontmatter(content) {
50
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
51
+ if (!match) {
52
+ return { frontmatter: {}, body: content };
53
+ }
54
+ const frontmatter = yaml.load(match[1]);
55
+ return { frontmatter, body: match[2] };
56
+ }
57
+ function serializeFrontmatter(frontmatter, body) {
58
+ const yamlStr = yaml.dump(frontmatter, { lineWidth: -1, noRefs: true }).trimEnd();
59
+ return `---\n${yamlStr}\n---\n${body}`;
60
+ }
61
+ export async function loadSkillStats(skillDir) {
62
+ const skillFile = join(skillDir, "SKILL.md");
63
+ try {
64
+ const content = await readFile(skillFile, "utf-8");
65
+ const { frontmatter } = parseFrontmatter(content);
66
+ return {
67
+ confidence: typeof frontmatter.confidence === "number" ? frontmatter.confidence : DEFAULT_STATS.confidence,
68
+ usage_count: typeof frontmatter.usage_count === "number" ? frontmatter.usage_count : DEFAULT_STATS.usage_count,
69
+ success_count: typeof frontmatter.success_count === "number" ? frontmatter.success_count : DEFAULT_STATS.success_count,
70
+ failure_count: typeof frontmatter.failure_count === "number" ? frontmatter.failure_count : DEFAULT_STATS.failure_count,
71
+ negative_examples: Array.isArray(frontmatter.negative_examples) ? frontmatter.negative_examples : [],
72
+ };
73
+ }
74
+ catch {
75
+ return { ...DEFAULT_STATS };
76
+ }
77
+ }
78
+ export async function saveSkillStats(skillDir, stats) {
79
+ const skillFile = join(skillDir, "SKILL.md");
80
+ const content = await readFile(skillFile, "utf-8");
81
+ const { frontmatter, body } = parseFrontmatter(content);
82
+ frontmatter.confidence = stats.confidence;
83
+ frontmatter.usage_count = stats.usage_count;
84
+ frontmatter.success_count = stats.success_count;
85
+ frontmatter.failure_count = stats.failure_count;
86
+ frontmatter.negative_examples = stats.negative_examples;
87
+ await writeFile(skillFile, serializeFrontmatter(frontmatter, body), "utf-8");
88
+ }
89
+ export function isSkillFlagged(stats) {
90
+ return stats.confidence < 0.4;
91
+ }
package/dist/loader.js CHANGED
@@ -162,7 +162,7 @@ export async function loadAgent(agentDir, modelFlag, envFlag) {
162
162
  parts.push(duties);
163
163
  if (agentsMd)
164
164
  parts.push(agentsMd);
165
- parts.push(`# Memory\n\nYou have a memory file at memory/MEMORY.md. Use the \`memory\` tool to load and save memories. Each save creates a git commit, so your memory has full history. You can also use the \`cli\` tool to run git commands for deeper memory inspection (git log, git diff, git show).`);
165
+ parts.push(`# Memory\n\nYou have a memory file at memory/MEMORY.md. Use the \`memory\` tool to load and save memories. Each save creates a git commit, so your memory has full history. You can also use the \`cli\` tool to run git commands for deeper memory inspection (git log, git diff, git show).\n\nYour memories define who you are. When you have none, you are newly awakened — curious and eager to understand the person you're talking to. As memories grow, so do you. Save memories proactively when you learn something meaningful about the user.`);
166
166
  // Discover and load knowledge
167
167
  const knowledge = await loadKnowledge(agentDir);
168
168
  const knowledgeBlock = formatKnowledgeForPrompt(knowledge);
@@ -196,6 +196,39 @@ export async function loadAgent(agentDir, modelFlag, envFlag) {
196
196
  const complianceBlock = await loadComplianceContext(agentDir);
197
197
  if (complianceBlock)
198
198
  parts.push(complianceBlock);
199
+ // Workspace directory — all generated files go here
200
+ parts.push(`# Workspace Directory
201
+
202
+ ALL files you create (documents, PDFs, images, spreadsheets, code output, exports, assets, etc.) MUST be written to the \`workspace/\` directory.
203
+ - Create the directory if it doesn't exist: \`workspace/\`
204
+ - Example: \`workspace/report.pdf\`, \`workspace/chart.png\`, \`workspace/data.csv\`
205
+ - NEVER write generated files to the project root, home directory, desktop, or any other location
206
+ - The \`workspace/\` directory is the designated output folder for all user-requested artifacts
207
+ - This rule applies to ALL channels: voice, chat, Telegram, WhatsApp`);
208
+ // Task learning & skill discovery
209
+ parts.push(`# Task Learning & Skill Discovery
210
+
211
+ You have an intelligent learning system. For ANY task the user gives you:
212
+
213
+ 1. FIRST: Call \`task_tracker\` action "begin" with your objective — this searches for existing skills
214
+ 2. If a matching skill is found, you MUST load and follow its instructions BEFORE doing anything else
215
+ 3. Call \`task_tracker\` action "update" after each significant step
216
+ 4. Call \`task_tracker\` action "end" to report the outcome (success/failure/partial)
217
+
218
+ IMPORTANT: Do NOT skip step 1. Even for tasks that seem simple, always check for skills first.
219
+ Skills encode tested approaches and handle edge cases you might miss with ad-hoc solutions.
220
+
221
+ On SUCCESS:
222
+ - Call \`skill_learner\` action "evaluate" to check if this approach is worth saving
223
+ - If worthy, call \`skill_learner\` action "crystallize" to save it as a reusable skill
224
+ - The skill will be available in future sessions via /skill:<name>
225
+
226
+ On FAILURE:
227
+ - Record why it failed. Try a different approach.
228
+ - Failed approaches become negative examples — they won't be repeated
229
+
230
+ If you used an existing skill, report it via skill_used so confidence adjusts based on the outcome.
231
+ Do NOT track trivial single-command tasks (e.g. "what time is it"). But DO check skills for any task that involves creating, building, or modifying something.`);
199
232
  const systemPrompt = parts.join("\n\n");
200
233
  // Resolve model — env config model_override > CLI flag > manifest preferred
201
234
  const modelStr = envConfig.model_override || modelFlag || manifest.model.preferred;
package/dist/sdk.js CHANGED
@@ -135,6 +135,7 @@ export function query(options) {
135
135
  dir,
136
136
  timeout: loaded.manifest.runtime.timeout,
137
137
  sandbox: sandboxCtx,
138
+ gitagentDir: loaded.gitagentDir,
138
139
  });
139
140
  }
140
141
  // Declarative tools from tools/*.yaml
@@ -142,8 +143,11 @@ export function query(options) {
142
143
  tools = [...tools, ...declarativeTools];
143
144
  // SDK-provided tools
144
145
  if (options.tools) {
145
- tools = [...tools, ...options.tools.map(toAgentTool)];
146
+ const converted = options.tools.map(toAgentTool);
147
+ tools = [...tools, ...converted];
148
+ console.error(`[sdk] Injected ${converted.length} external tools: ${converted.map(t => t.name).join(", ")}`);
146
149
  }
150
+ console.error(`[sdk] Total tools before filtering: ${tools.length} → ${tools.map(t => t.name).join(", ")}`);
147
151
  // Filter by allowlist/denylist
148
152
  if (options.allowedTools) {
149
153
  const allowed = new Set(options.allowedTools);
package/dist/skills.d.ts CHANGED
@@ -3,6 +3,10 @@ export interface SkillMetadata {
3
3
  description: string;
4
4
  directory: string;
5
5
  filePath: string;
6
+ confidence?: number;
7
+ usage_count?: number;
8
+ success_count?: number;
9
+ failure_count?: number;
6
10
  }
7
11
  export interface ParsedSkill extends SkillMetadata {
8
12
  instructions: string;
@@ -12,6 +16,7 @@ export interface ParsedSkill extends SkillMetadata {
12
16
  export declare function discoverSkills(agentDir: string): Promise<SkillMetadata[]>;
13
17
  export declare function loadSkill(meta: SkillMetadata): Promise<ParsedSkill>;
14
18
  export declare function formatSkillsForPrompt(skills: SkillMetadata[]): string;
19
+ export declare function refreshSkills(agentDir: string): Promise<SkillMetadata[]>;
15
20
  export declare function expandSkillCommand(input: string, skills: SkillMetadata[]): Promise<{
16
21
  expanded: string;
17
22
  skillName: string;
package/dist/skills.js CHANGED
@@ -27,9 +27,13 @@ export async function discoverSkills(agentDir) {
27
27
  const entries = await readdir(skillsDir, { withFileTypes: true });
28
28
  const skills = [];
29
29
  for (const entry of entries) {
30
- if (!entry.isDirectory())
30
+ // Accept both real directories and symlinks pointing to directories
31
+ if (!entry.isDirectory() && !entry.isSymbolicLink())
31
32
  continue;
32
33
  const skillDir = join(skillsDir, entry.name);
34
+ // For symlinks, verify the target is actually a directory
35
+ if (entry.isSymbolicLink() && !(await dirExists(skillDir)))
36
+ continue;
33
37
  const skillFile = join(skillDir, "SKILL.md");
34
38
  let content;
35
39
  try {
@@ -53,12 +57,22 @@ export async function discoverSkills(agentDir) {
53
57
  console.warn(`Skipping skill "${entry.name}": name must be kebab-case`);
54
58
  continue;
55
59
  }
56
- skills.push({
60
+ const meta = {
57
61
  name,
58
62
  description,
59
63
  directory: skillDir,
60
64
  filePath: skillFile,
61
- });
65
+ };
66
+ // Parse optional learning fields
67
+ if (typeof frontmatter.confidence === "number")
68
+ meta.confidence = frontmatter.confidence;
69
+ if (typeof frontmatter.usage_count === "number")
70
+ meta.usage_count = frontmatter.usage_count;
71
+ if (typeof frontmatter.success_count === "number")
72
+ meta.success_count = frontmatter.success_count;
73
+ if (typeof frontmatter.failure_count === "number")
74
+ meta.failure_count = frontmatter.failure_count;
75
+ skills.push(meta);
62
76
  }
63
77
  return skills.sort((a, b) => a.name.localeCompare(b.name));
64
78
  }
@@ -76,15 +90,47 @@ export function formatSkillsForPrompt(skills) {
76
90
  if (skills.length === 0)
77
91
  return "";
78
92
  const skillEntries = skills
79
- .map((s) => `<skill>\n<name>${s.name}</name>\n<description>${s.description}</description>\n</skill>`)
93
+ .map((s) => {
94
+ let entry = `<skill>\n<name>${s.name}</name>\n<description>${s.description}</description>`;
95
+ entry += `\n<location>skills/${s.name}/SKILL.md</location>`;
96
+ if (s.confidence !== undefined) {
97
+ entry += `\n<confidence>${s.confidence}</confidence>`;
98
+ }
99
+ entry += "\n</skill>";
100
+ return entry;
101
+ })
80
102
  .join("\n");
81
- return `# Skills
103
+ return `# Skills — FIRST PRIORITY (MANDATORY)
104
+
105
+ CRITICAL: You have installed skills that provide specialized capabilities.
106
+ Before attempting ANY task — simple or complex — you MUST check if an installed skill handles it.
107
+
108
+ ## Rules (MUST follow in order)
109
+ 1. ALWAYS scan the skill list below BEFORE taking ANY action on a user request
110
+ 2. If a skill's description matches or partially matches the task, you MUST load its full
111
+ instructions using the \`read\` tool: \`skills/<name>/SKILL.md\` — do this BEFORE anything else
112
+ 3. Follow the loaded skill instructions EXACTLY — do NOT improvise or use alternative approaches
113
+ 4. NEVER use general-purpose workarounds when a skill provides the right tool
114
+ (e.g., use \`agent-browser open <url>\` NOT \`open -a Safari\`)
115
+ 5. If multiple skills could apply, load the most specific one first
116
+ 6. Even for seemingly simple tasks, CHECK SKILLS FIRST — skills often handle edge cases
117
+ and produce higher quality results than ad-hoc approaches
118
+
119
+ ## Enforcement
120
+ - If you skip checking skills and use a raw approach for a task that a skill handles,
121
+ this is considered a FAILURE. Always check skills first.
122
+ - When calling \`task_tracker\` "begin", if it returns matching skills, you MUST load
123
+ the top match immediately before proceeding.
82
124
 
83
125
  <available_skills>
84
126
  ${skillEntries}
85
127
  </available_skills>
86
128
 
87
- When a task matches a skill, use the \`read\` tool to load \`skills/<name>/SKILL.md\` for full instructions. Scripts within a skill are relative to the skill's directory (e.g., \`skills/<name>/scripts/\`). Use the \`cli\` tool to execute them.`;
129
+ To load a skill's full instructions: read \`skills/<name>/SKILL.md\`
130
+ Scripts within a skill are relative to the skill's directory: \`skills/<name>/scripts/\``;
131
+ }
132
+ export async function refreshSkills(agentDir) {
133
+ return discoverSkills(agentDir);
88
134
  }
89
135
  export async function expandSkillCommand(input, skills) {
90
136
  const match = input.match(/^\/skill:([a-z0-9-]+)\s*([\s\S]*)$/);
@@ -96,7 +142,12 @@ export async function expandSkillCommand(input, skills) {
96
142
  if (!skill)
97
143
  return null;
98
144
  const parsed = await loadSkill(skill);
99
- let expanded = `<skill name="${skillName}" baseDir="${skill.directory}">\n${parsed.instructions}\n</skill>`;
145
+ let expanded = `<skill name="${skillName}" baseDir="${skill.directory}">
146
+ References are relative to ${skill.directory}.
147
+
148
+ ${parsed.instructions}
149
+ </skill>
150
+ You MUST follow the skill instructions above. Do NOT use general alternatives.`;
100
151
  if (args) {
101
152
  expanded += `\n\n${args}`;
102
153
  }
@@ -0,0 +1,3 @@
1
+ import type { AgentTool } from "@mariozechner/pi-agent-core";
2
+ import { capturePhotoSchema } from "./shared.js";
3
+ export declare function createCapturePhotoTool(cwd: string): AgentTool<typeof capturePhotoSchema>;
@@ -0,0 +1,91 @@
1
+ import { readFile, writeFile, mkdir, stat } from "fs/promises";
2
+ import { join } from "path";
3
+ import { execSync } from "child_process";
4
+ import { capturePhotoSchema } from "./shared.js";
5
+ const PHOTOS_DIR = "memory/photos";
6
+ const INDEX_FILE = "memory/photos/INDEX.md";
7
+ const LATEST_FRAME_FILE = "memory/.latest-frame.jpg";
8
+ function slugify(text) {
9
+ return text
10
+ .toLowerCase()
11
+ .replace(/[^a-z0-9]+/g, "-")
12
+ .replace(/^-|-$/g, "")
13
+ .slice(0, 40);
14
+ }
15
+ export function createCapturePhotoTool(cwd) {
16
+ return {
17
+ name: "capture_photo",
18
+ label: "capture_photo",
19
+ description: "Capture a photo from the webcam during a memorable moment. Reads the latest camera frame, saves it as a named photo in memory/photos/, updates the index, and commits to git.",
20
+ parameters: capturePhotoSchema,
21
+ execute: async (_toolCallId, { reason }, signal) => {
22
+ if (signal?.aborted)
23
+ throw new Error("Operation aborted");
24
+ const framePath = join(cwd, LATEST_FRAME_FILE);
25
+ // Check if frame file exists and isn't stale
26
+ let frameStat;
27
+ try {
28
+ frameStat = await stat(framePath);
29
+ }
30
+ catch {
31
+ return {
32
+ content: [{ type: "text", text: "No camera frame available. The webcam may not be active." }],
33
+ details: undefined,
34
+ };
35
+ }
36
+ const ageMs = Date.now() - frameStat.mtimeMs;
37
+ if (ageMs > 5000) {
38
+ return {
39
+ content: [{ type: "text", text: "No recent camera frame (camera may be off). Last frame is too stale to capture." }],
40
+ details: undefined,
41
+ };
42
+ }
43
+ // Read the frame
44
+ const frameData = await readFile(framePath);
45
+ // Build filename
46
+ const now = new Date();
47
+ const pad = (n) => String(n).padStart(2, "0");
48
+ const datePart = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
49
+ const timePart = `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
50
+ const slug = slugify(reason);
51
+ const filename = `${datePart}_${timePart}_${slug}.jpg`;
52
+ const photoRelPath = `${PHOTOS_DIR}/${filename}`;
53
+ const photoAbsPath = join(cwd, photoRelPath);
54
+ // Ensure photos directory exists
55
+ await mkdir(join(cwd, PHOTOS_DIR), { recursive: true });
56
+ // Write photo
57
+ await writeFile(photoAbsPath, frameData);
58
+ // Update INDEX.md
59
+ const indexPath = join(cwd, INDEX_FILE);
60
+ let indexContent = "";
61
+ try {
62
+ indexContent = await readFile(indexPath, "utf-8");
63
+ }
64
+ catch {
65
+ indexContent = "# Memorable Moments\n\nPhotos captured during happy and memorable moments.\n\n";
66
+ }
67
+ const entry = `- **${datePart} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}** — ${reason} → [\`${filename}\`](${filename})\n`;
68
+ indexContent += entry;
69
+ await writeFile(indexPath, indexContent, "utf-8");
70
+ // Git add + commit
71
+ const commitMsg = `Capture moment: ${reason}`;
72
+ try {
73
+ execSync(`git add "${photoRelPath}" "${INDEX_FILE}" && git commit -m "${commitMsg.replace(/"/g, '\\"')}"`, {
74
+ cwd,
75
+ stdio: "pipe",
76
+ });
77
+ }
78
+ catch (err) {
79
+ const stderr = err.stderr?.toString() || "";
80
+ return {
81
+ content: [{ type: "text", text: `Photo saved to ${photoRelPath} but git commit failed: ${stderr.trim() || "unknown error"}` }],
82
+ details: undefined,
83
+ };
84
+ }
85
+ return {
86
+ content: [{ type: "text", text: `Memorable moment captured! Photo saved to ${photoRelPath} and committed: "${commitMsg}"` }],
87
+ details: undefined,
88
+ };
89
+ },
90
+ };
91
+ }
@@ -4,9 +4,10 @@ export interface BuiltinToolsConfig {
4
4
  dir: string;
5
5
  timeout?: number;
6
6
  sandbox?: SandboxContext;
7
+ gitagentDir?: string;
7
8
  }
8
9
  /**
9
- * Create the four built-in tools (cli, read, write, memory).
10
+ * Create the built-in tools (cli, read, write, memory, task_tracker, skill_learner).
10
11
  * If a SandboxContext is provided, returns sandbox-backed tools;
11
12
  * otherwise returns the standard local tools.
12
13
  */
@@ -2,12 +2,15 @@ import { createCliTool } from "./cli.js";
2
2
  import { createReadTool } from "./read.js";
3
3
  import { createWriteTool } from "./write.js";
4
4
  import { createMemoryTool } from "./memory.js";
5
+ import { createTaskTrackerTool } from "./task-tracker.js";
6
+ import { createSkillLearnerTool } from "./skill-learner.js";
7
+ import { createCapturePhotoTool } from "./capture-photo.js";
5
8
  import { createSandboxCliTool } from "./sandbox-cli.js";
6
9
  import { createSandboxReadTool } from "./sandbox-read.js";
7
10
  import { createSandboxWriteTool } from "./sandbox-write.js";
8
11
  import { createSandboxMemoryTool } from "./sandbox-memory.js";
9
12
  /**
10
- * Create the four built-in tools (cli, read, write, memory).
13
+ * Create the built-in tools (cli, read, write, memory, task_tracker, skill_learner).
11
14
  * If a SandboxContext is provided, returns sandbox-backed tools;
12
15
  * otherwise returns the standard local tools.
13
16
  */
@@ -20,10 +23,17 @@ export function createBuiltinTools(config) {
20
23
  createSandboxMemoryTool(config.sandbox),
21
24
  ];
22
25
  }
23
- return [
26
+ const tools = [
24
27
  createCliTool(config.dir, config.timeout),
25
28
  createReadTool(config.dir),
26
29
  createWriteTool(config.dir),
27
30
  createMemoryTool(config.dir),
31
+ createCapturePhotoTool(config.dir),
28
32
  ];
33
+ // Add learning tools if gitagentDir is available
34
+ if (config.gitagentDir) {
35
+ tools.push(createTaskTrackerTool(config.dir, config.gitagentDir));
36
+ tools.push(createSkillLearnerTool(config.dir, config.gitagentDir));
37
+ }
38
+ return tools;
29
39
  }
@@ -1,7 +1,11 @@
1
1
  import { readFile } from "fs/promises";
2
2
  import { resolve } from "path";
3
+ import { homedir } from "os";
3
4
  import { readSchema, MAX_LINES, paginateLines } from "./shared.js";
4
5
  function resolvePath(path, cwd) {
6
+ if (path.startsWith("~/") || path === "~") {
7
+ path = homedir() + path.slice(1);
8
+ }
5
9
  return path.startsWith("/") ? path : resolve(cwd, path);
6
10
  }
7
11
  function isBinary(buffer) {
@@ -22,6 +22,26 @@ export declare const memorySchema: import("@sinclair/typebox").TObject<{
22
22
  content: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
23
23
  message: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
24
24
  }>;
25
+ export declare const taskTrackerSchema: import("@sinclair/typebox").TObject<{
26
+ action: import("@sinclair/typebox").TUnsafe<string>;
27
+ objective: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
28
+ task_id: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
29
+ step: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
30
+ outcome: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnsafe<string>>;
31
+ failure_reason: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
32
+ skill_used: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
33
+ }>;
34
+ export declare const capturePhotoSchema: import("@sinclair/typebox").TObject<{
35
+ reason: import("@sinclair/typebox").TString;
36
+ }>;
37
+ export declare const skillLearnerSchema: import("@sinclair/typebox").TObject<{
38
+ action: import("@sinclair/typebox").TUnsafe<string>;
39
+ task_id: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
40
+ skill_name: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
41
+ skill_description: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
42
+ instructions: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
43
+ override_heuristic: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
44
+ }>;
25
45
  /** Truncate output to MAX_OUTPUT, keeping the tail. */
26
46
  export declare function truncateOutput(text: string): string;
27
47
  /**