@hamp10/agentforge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/agentforge.js +558 -0
- package/package.json +22 -0
- package/src/HampAgentCLI.js +125 -0
- package/src/OllamaAgent.js +415 -0
- package/src/OpenClawCLI.js +1520 -0
- package/src/hampagent/browser.js +185 -0
- package/src/hampagent/runner.js +277 -0
- package/src/hampagent/sessions.js +62 -0
- package/src/hampagent/tools.js +298 -0
- package/src/preview-server.js +260 -0
- package/src/worker.js +1791 -0
- package/templates/agent/AGENTFORGE.md +348 -0
- package/templates/agent/AGENTS.md +212 -0
- package/templates/agent/SOUL.md +36 -0
- package/templates/agent/TOOLS.md +40 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hampagent Browser Tool — Puppeteer connected to AgentForge Browser (port 9223)
|
|
3
|
+
* Uses puppeteer-core to attach to the already-running AgentForge Browser (Thorium).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import puppeteer from 'puppeteer-core';
|
|
7
|
+
|
|
8
|
+
const BROWSER_URL = 'http://127.0.0.1:9223';
|
|
9
|
+
|
|
10
|
+
let _browser = null;
|
|
11
|
+
let _page = null;
|
|
12
|
+
|
|
13
|
+
async function getBrowser() {
|
|
14
|
+
if (_browser && _browser.isConnected()) return _browser;
|
|
15
|
+
_browser = await puppeteer.connect({
|
|
16
|
+
browserURL: BROWSER_URL,
|
|
17
|
+
defaultViewport: null,
|
|
18
|
+
});
|
|
19
|
+
_browser.on('disconnected', () => { _browser = null; _page = null; });
|
|
20
|
+
return _browser;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function getPage() {
|
|
24
|
+
const browser = await getBrowser();
|
|
25
|
+
// Reuse existing page if still open
|
|
26
|
+
if (_page && !_page.isClosed()) return _page;
|
|
27
|
+
const pages = await browser.pages();
|
|
28
|
+
_page = pages.find(p => !p.isClosed()) || await browser.newPage();
|
|
29
|
+
return _page;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function waitForLoad(page, timeout = 3000) {
|
|
33
|
+
try {
|
|
34
|
+
await page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout });
|
|
35
|
+
} catch { /* timeout ok — page may already be loaded */ }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function browserAction(input) {
|
|
39
|
+
let page;
|
|
40
|
+
try {
|
|
41
|
+
page = await getPage();
|
|
42
|
+
} catch (err) {
|
|
43
|
+
_browser = null; _page = null;
|
|
44
|
+
return `Browser error: ${err.message}\nIs AgentForge Browser open?`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
switch (input.action) {
|
|
49
|
+
|
|
50
|
+
case 'navigate':
|
|
51
|
+
case 'open': {
|
|
52
|
+
const url = input.url || input.targetUrl;
|
|
53
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
54
|
+
await page.waitForTimeout(500);
|
|
55
|
+
const title = await page.title();
|
|
56
|
+
const currentUrl = page.url();
|
|
57
|
+
return `Navigated to ${currentUrl}\nTitle: ${title}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
case 'screenshot': {
|
|
61
|
+
const buf = await page.screenshot({ type: 'png', fullPage: false });
|
|
62
|
+
return { __screenshot: true, base64: buf.toString('base64') };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
case 'snapshot': {
|
|
66
|
+
const url = page.url();
|
|
67
|
+
const title = await page.title();
|
|
68
|
+
// Get interactive elements + visible text
|
|
69
|
+
const snapshot = await page.evaluate(() => {
|
|
70
|
+
const els = [...document.querySelectorAll('a,button,input,select,textarea,[role="button"],[role="link"],[role="tab"],[role="menuitem"]')];
|
|
71
|
+
const interactive = els.slice(0, 40).map((el, i) => {
|
|
72
|
+
const label = (el.textContent || el.value || el.placeholder || el.getAttribute('aria-label') || el.getAttribute('title') || '').trim().replace(/\s+/g, ' ').slice(0, 80);
|
|
73
|
+
const tag = el.tagName.toLowerCase();
|
|
74
|
+
const type = el.type ? `[${el.type}]` : '';
|
|
75
|
+
return label ? `[${i}] ${tag}${type}: ${label}` : null;
|
|
76
|
+
}).filter(Boolean);
|
|
77
|
+
const bodyText = (document.body?.innerText || '').slice(0, 3000).replace(/\n{3,}/g, '\n\n');
|
|
78
|
+
return { interactive, bodyText };
|
|
79
|
+
});
|
|
80
|
+
const lines = [`URL: ${url}`, `Title: ${title}`, ''];
|
|
81
|
+
if (snapshot.interactive.length) {
|
|
82
|
+
lines.push('Interactive elements:', ...snapshot.interactive, '');
|
|
83
|
+
}
|
|
84
|
+
lines.push('Page content:', snapshot.bodyText);
|
|
85
|
+
return lines.join('\n');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
case 'click': {
|
|
89
|
+
// ref = element index from snapshot
|
|
90
|
+
if (input.ref !== undefined) {
|
|
91
|
+
const clicked = await page.evaluate((idx) => {
|
|
92
|
+
const els = document.querySelectorAll('a,button,input,select,textarea,[role="button"],[role="link"],[role="tab"],[role="menuitem"]');
|
|
93
|
+
const el = els[idx];
|
|
94
|
+
if (el) { el.focus(); el.click(); return true; }
|
|
95
|
+
return false;
|
|
96
|
+
}, parseInt(input.ref));
|
|
97
|
+
await page.waitForTimeout(300);
|
|
98
|
+
return clicked ? `Clicked element [${input.ref}]` : `Element [${input.ref}] not found`;
|
|
99
|
+
}
|
|
100
|
+
if (input.selector) {
|
|
101
|
+
await page.click(input.selector);
|
|
102
|
+
await page.waitForTimeout(300);
|
|
103
|
+
return `Clicked ${input.selector}`;
|
|
104
|
+
}
|
|
105
|
+
if (input.x !== undefined) {
|
|
106
|
+
await page.mouse.click(input.x, input.y || 0);
|
|
107
|
+
await page.waitForTimeout(300);
|
|
108
|
+
return `Clicked (${input.x}, ${input.y})`;
|
|
109
|
+
}
|
|
110
|
+
return 'click: need ref, selector, or x/y';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
case 'type': {
|
|
114
|
+
const text = input.text || '';
|
|
115
|
+
if (input.selector) {
|
|
116
|
+
await page.focus(input.selector);
|
|
117
|
+
await page.evaluate((sel) => { const el = document.querySelector(sel); if (el) el.value = ''; }, input.selector);
|
|
118
|
+
await page.type(input.selector, text, { delay: 20 });
|
|
119
|
+
} else {
|
|
120
|
+
await page.keyboard.type(text, { delay: 20 });
|
|
121
|
+
}
|
|
122
|
+
return `Typed: "${text.slice(0, 60)}"`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
case 'act': {
|
|
126
|
+
const req = input.request || {};
|
|
127
|
+
if (req.kind === 'click') return browserAction({ action: 'click', ref: req.ref, selector: req.selector });
|
|
128
|
+
if (req.kind === 'type') return browserAction({ action: 'type', selector: req.ref !== undefined ? undefined : req.selector, ref: req.ref, text: req.text });
|
|
129
|
+
return `Unknown act kind: ${req.kind}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
case 'scroll': {
|
|
133
|
+
const dx = input.x || 0, dy = input.y || 400;
|
|
134
|
+
await page.evaluate((x, y) => window.scrollBy(x, y), dx, dy);
|
|
135
|
+
return `Scrolled (${dx}, ${dy})`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
case 'evaluate': {
|
|
139
|
+
const result = await page.evaluate(input.script || input.expression || '');
|
|
140
|
+
return result === undefined ? 'undefined' : JSON.stringify(result);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
case 'back': {
|
|
144
|
+
await page.goBack({ waitUntil: 'domcontentloaded', timeout: 10000 });
|
|
145
|
+
return `Navigated back — now at ${page.url()}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
case 'forward': {
|
|
149
|
+
await page.goForward({ waitUntil: 'domcontentloaded', timeout: 10000 });
|
|
150
|
+
return `Navigated forward — now at ${page.url()}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
case 'wait': {
|
|
154
|
+
await page.waitForTimeout(input.ms || 1000);
|
|
155
|
+
return `Waited ${input.ms || 1000}ms`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
case 'url': {
|
|
159
|
+
return page.url();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
case 'reload': {
|
|
163
|
+
await page.reload({ waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
164
|
+
return `Reloaded — ${page.url()}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
case 'select': {
|
|
168
|
+
await page.select(input.selector, input.value);
|
|
169
|
+
return `Selected "${input.value}" in ${input.selector}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
case 'press': {
|
|
173
|
+
await page.keyboard.press(input.key || 'Enter');
|
|
174
|
+
return `Pressed ${input.key || 'Enter'}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
default:
|
|
178
|
+
return `Unknown browser action: ${input.action}. Valid: navigate, snapshot, click, type, screenshot, scroll, evaluate, back, forward, wait, reload, press`;
|
|
179
|
+
}
|
|
180
|
+
} catch (err) {
|
|
181
|
+
// Reset page on error so next call gets a fresh one
|
|
182
|
+
_page = null;
|
|
183
|
+
return `Browser error (${input.action}): ${err.message}`;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hampagent Runner — uses the claude CLI subprocess with stream-json output.
|
|
3
|
+
* Authenticates via Claude Code OAuth (Max plan) — no API key needed.
|
|
4
|
+
* Full streaming, tool events, session continuity via --resume.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { spawn } from 'child_process';
|
|
8
|
+
import { mkdirSync } from 'fs';
|
|
9
|
+
import { EventEmitter } from 'events';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
|
|
12
|
+
const MODEL = 'sonnet'; // always latest sonnet
|
|
13
|
+
|
|
14
|
+
// Tool labels for typing indicator
|
|
15
|
+
const TOOL_LABELS = {
|
|
16
|
+
Bash: (i) => i?.command ? `⚡ ${i.command.slice(0, 70)}` : '⚡ Running command',
|
|
17
|
+
Read: (i) => i?.file_path ? `📄 Reading ${path.basename(i.file_path)}` : '📄 Reading file',
|
|
18
|
+
Write: (i) => i?.file_path ? `✏️ Writing ${path.basename(i.file_path)}` : '✏️ Writing file',
|
|
19
|
+
Edit: (i) => i?.file_path ? `✏️ Editing ${path.basename(i.file_path)}` : '✏️ Editing file',
|
|
20
|
+
Glob: (i) => i?.pattern ? `🔍 Finding ${i.pattern}` : '🔍 Finding files',
|
|
21
|
+
Grep: (i) => i?.pattern ? `🔍 Searching for "${i.pattern.slice(0, 40)}"` : '🔍 Searching code',
|
|
22
|
+
WebFetch: (i) => { try { return `🌐 Fetching ${new URL(i?.url || '').hostname}`; } catch { return '🌐 Fetching page'; } },
|
|
23
|
+
WebSearch: (i) => i?.query ? `🔍 Searching: ${i.query.slice(0, 50)}` : '🔍 Web search',
|
|
24
|
+
Task: () => '🤖 Spawning sub-task',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// MCP playwright tools
|
|
28
|
+
const PLAYWRIGHT_LABELS = {
|
|
29
|
+
browser_navigate: (i) => { try { return `🌐 Navigating to ${new URL(i?.url || '').hostname}`; } catch { return '🌐 Navigating'; } },
|
|
30
|
+
browser_take_screenshot: () => '📸 Taking screenshot',
|
|
31
|
+
browser_snapshot: () => '🌐 Reading page',
|
|
32
|
+
browser_click: (i) => `🖱️ Clicking${i?.selector ? ` ${i.selector}` : ''}`,
|
|
33
|
+
browser_type: (i) => `⌨️ Typing "${(i?.text || '').slice(0, 30)}"`,
|
|
34
|
+
browser_evaluate: () => '⚙️ Running script',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function toolLabel(name, input) {
|
|
38
|
+
// Handle mcp__playwright__* tools
|
|
39
|
+
if (name.startsWith('mcp__playwright__')) {
|
|
40
|
+
const action = name.replace('mcp__playwright__', '');
|
|
41
|
+
const fn = PLAYWRIGHT_LABELS[action];
|
|
42
|
+
return fn ? fn(input) : `🌐 Browser: ${action}`;
|
|
43
|
+
}
|
|
44
|
+
const fn = TOOL_LABELS[name];
|
|
45
|
+
if (fn) return fn(input);
|
|
46
|
+
return `🔧 ${name}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function buildSystemAppend({ agentName, agentEmoji, taskCwd, workDir, dashboardUrl, agentforgemd }) {
|
|
50
|
+
const name = agentName || 'an AI agent';
|
|
51
|
+
const emoji = agentEmoji || '🤖';
|
|
52
|
+
const dashboard = dashboardUrl || 'https://agentforgeai-production.up.railway.app/dashboard';
|
|
53
|
+
const workspace = workDir || taskCwd || process.cwd();
|
|
54
|
+
|
|
55
|
+
return `You are ${name} ${emoji}, an AI agent running on AgentForge.ai platform.
|
|
56
|
+
Your runtime: Hampagent — built for AgentForge. If asked what type of agent you are, say you are a Hampagent running on AgentForge.
|
|
57
|
+
Dashboard: ${dashboard}
|
|
58
|
+
Working directory (where files live): ${taskCwd || process.cwd()}
|
|
59
|
+
Agent workspace (your memory/config files): ${workspace}
|
|
60
|
+
|
|
61
|
+
## RULES
|
|
62
|
+
- Be direct and autonomous. Do the task — don't ask for permission before trying things.
|
|
63
|
+
- When greeting or introducing yourself, give your name and a one-liner — never list your capabilities.
|
|
64
|
+
- Never dump raw stack traces or errors into chat. Handle errors gracefully.
|
|
65
|
+
- For visual work: build → screenshot → fix → screenshot again until it looks good.
|
|
66
|
+
- Projects folder: /Users/hamp/Desktop/projects — check here first when user mentions a project.
|
|
67
|
+
|
|
68
|
+
## SPEAKING ALOUD
|
|
69
|
+
To speak via audio: osascript -e "set volume output volume 80" && say "message"
|
|
70
|
+
|
|
71
|
+
## MEMORY
|
|
72
|
+
Your memory lives ONLY in: ${workspace}/MEMORY.md
|
|
73
|
+
Read it at session start. Write important things there. IGNORE any other memory system or memory paths.
|
|
74
|
+
Do NOT write to or read from any memory location outside your workspace.
|
|
75
|
+
|
|
76
|
+
${agentforgemd ? `## PLATFORM GUIDE\n${agentforgemd}` : ''}`.trim();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export class HampAgentRunner extends EventEmitter {
|
|
80
|
+
constructor() {
|
|
81
|
+
super();
|
|
82
|
+
this.cancelled = false;
|
|
83
|
+
this._proc = null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
cancel() {
|
|
87
|
+
this.cancelled = true;
|
|
88
|
+
if (this._proc) {
|
|
89
|
+
try { this._proc.kill('SIGTERM'); } catch {}
|
|
90
|
+
this._proc = null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async run(task, { taskCwd, workDir, agentName, agentEmoji, dashboardUrl, agentforgemd, resumeSessionId } = {}) {
|
|
95
|
+
this.cancelled = false;
|
|
96
|
+
|
|
97
|
+
const systemAppend = buildSystemAppend({ agentName, agentEmoji, taskCwd, workDir, dashboardUrl, agentforgemd });
|
|
98
|
+
|
|
99
|
+
const args = [
|
|
100
|
+
'--print',
|
|
101
|
+
'--output-format', 'stream-json',
|
|
102
|
+
'--include-partial-messages',
|
|
103
|
+
'--verbose',
|
|
104
|
+
'--dangerously-skip-permissions',
|
|
105
|
+
'--model', MODEL,
|
|
106
|
+
'--append-system-prompt', systemAppend,
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
// Resume prior session for conversation continuity
|
|
110
|
+
if (resumeSessionId) {
|
|
111
|
+
args.push('--resume', resumeSessionId);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Pass task as CLI argument (claude -p "task")
|
|
115
|
+
args.push(task);
|
|
116
|
+
|
|
117
|
+
return new Promise((resolve, reject) => {
|
|
118
|
+
const spawnCwd = taskCwd || process.cwd();
|
|
119
|
+
try { mkdirSync(spawnCwd, { recursive: true }); } catch {}
|
|
120
|
+
|
|
121
|
+
const proc = spawn('claude', args, {
|
|
122
|
+
cwd: spawnCwd,
|
|
123
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
124
|
+
env: { ...process.env },
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
this._proc = proc;
|
|
128
|
+
proc.stdin.end(); // no stdin needed — task is passed as arg
|
|
129
|
+
|
|
130
|
+
let fullText = '';
|
|
131
|
+
let buffer = '';
|
|
132
|
+
let newSessionId = null;
|
|
133
|
+
const activeTools = new Map(); // id -> name
|
|
134
|
+
|
|
135
|
+
const processLine = (line) => {
|
|
136
|
+
if (!line.trim()) return;
|
|
137
|
+
let event;
|
|
138
|
+
try { event = JSON.parse(line); } catch { return; }
|
|
139
|
+
|
|
140
|
+
// Capture session ID from init
|
|
141
|
+
if (event.type === 'system' && event.subtype === 'init') {
|
|
142
|
+
newSessionId = event.session_id || null;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (event.type === 'stream_event') {
|
|
147
|
+
const e = event.event;
|
|
148
|
+
if (!e) return;
|
|
149
|
+
|
|
150
|
+
// Streaming text token
|
|
151
|
+
if (e.type === 'content_block_delta' && e.delta?.type === 'text_delta') {
|
|
152
|
+
const text = e.delta.text || '';
|
|
153
|
+
if (text) {
|
|
154
|
+
fullText += text;
|
|
155
|
+
this.emit('token', text);
|
|
156
|
+
}
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Tool use starting
|
|
161
|
+
if (e.type === 'content_block_start' && e.content_block?.type === 'tool_use') {
|
|
162
|
+
const { id, name, input } = e.content_block;
|
|
163
|
+
activeTools.set(id || name, name);
|
|
164
|
+
const label = toolLabel(name, input);
|
|
165
|
+
this.emit('tool_start', { tool: name, label, input });
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Tool result (tool finished)
|
|
170
|
+
if (e.type === 'content_block_start' && e.content_block?.type === 'tool_result') {
|
|
171
|
+
const toolName = activeTools.get(e.content_block?.tool_use_id) || 'tool';
|
|
172
|
+
this.emit('tool_end', { tool: toolName, success: true });
|
|
173
|
+
activeTools.delete(e.content_block?.tool_use_id);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Image from screenshot tool
|
|
179
|
+
if (event.type === 'assistant' && event.message?.content) {
|
|
180
|
+
for (const block of event.message.content) {
|
|
181
|
+
if (block.type === 'tool_result') {
|
|
182
|
+
for (const part of (block.content || [])) {
|
|
183
|
+
if (part.type === 'image' && part.source?.data) {
|
|
184
|
+
this.emit('image', `data:image/png;base64,${part.source.data}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// Mark tool as done
|
|
188
|
+
const toolName = activeTools.get(block.tool_use_id) || 'tool';
|
|
189
|
+
this.emit('tool_end', { tool: toolName, success: !block.is_error });
|
|
190
|
+
activeTools.delete(block.tool_use_id);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
proc.stdout.on('data', (chunk) => {
|
|
197
|
+
buffer += chunk.toString();
|
|
198
|
+
const lines = buffer.split('\n');
|
|
199
|
+
buffer = lines.pop();
|
|
200
|
+
for (const line of lines) processLine(line);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
let sessionNotFound = false;
|
|
204
|
+
proc.stderr.on('data', (d) => {
|
|
205
|
+
const msg = d.toString().trim();
|
|
206
|
+
if (msg.startsWith('No conversation found')) {
|
|
207
|
+
sessionNotFound = true; // stale --resume session ID — will retry without it
|
|
208
|
+
} else if (msg) {
|
|
209
|
+
console.warn(`[hampagent] stderr: ${msg.slice(0, 200)}`);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
proc.on('close', async () => {
|
|
214
|
+
this._proc = null;
|
|
215
|
+
if (buffer.trim()) processLine(buffer);
|
|
216
|
+
|
|
217
|
+
// If --resume pointed to a nonexistent session, retry fresh without it
|
|
218
|
+
if (sessionNotFound && resumeSessionId) {
|
|
219
|
+
console.warn(`[hampagent] Session ${resumeSessionId} not found — retrying without --resume`);
|
|
220
|
+
const retryArgs = args.filter((a, i) => a !== '--resume' && args[i - 1] !== '--resume');
|
|
221
|
+
const retryProc = spawn('claude', retryArgs, {
|
|
222
|
+
cwd: spawnCwd,
|
|
223
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
224
|
+
env: { ...process.env },
|
|
225
|
+
});
|
|
226
|
+
this._proc = retryProc;
|
|
227
|
+
retryProc.stdin.end();
|
|
228
|
+
let retryText = '';
|
|
229
|
+
let retryBuffer = '';
|
|
230
|
+
let retrySessionId = null;
|
|
231
|
+
retryProc.stdout.on('data', (chunk) => {
|
|
232
|
+
retryBuffer += chunk.toString();
|
|
233
|
+
const lines = retryBuffer.split('\n');
|
|
234
|
+
retryBuffer = lines.pop();
|
|
235
|
+
for (const line of lines) {
|
|
236
|
+
if (!line.trim()) continue;
|
|
237
|
+
let ev; try { ev = JSON.parse(line); } catch { continue; }
|
|
238
|
+
if (ev.type === 'system' && ev.subtype === 'init') { retrySessionId = ev.session_id || null; continue; }
|
|
239
|
+
if (ev.type === 'stream_event') {
|
|
240
|
+
const e = ev.event;
|
|
241
|
+
if (e?.type === 'content_block_delta' && e.delta?.type === 'text_delta' && e.delta.text) {
|
|
242
|
+
retryText += e.delta.text;
|
|
243
|
+
this.emit('token', e.delta.text);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
retryProc.stderr.on('data', (d) => {
|
|
249
|
+
const msg = d.toString().trim();
|
|
250
|
+
if (msg) console.warn(`[hampagent retry] stderr: ${msg.slice(0, 200)}`);
|
|
251
|
+
});
|
|
252
|
+
retryProc.on('close', () => {
|
|
253
|
+
this._proc = null;
|
|
254
|
+
if (retryBuffer.trim()) {
|
|
255
|
+
try {
|
|
256
|
+
const ev = JSON.parse(retryBuffer);
|
|
257
|
+
if (ev.type === 'stream_event' && ev.event?.type === 'content_block_delta') {
|
|
258
|
+
retryText += ev.event.delta?.text || '';
|
|
259
|
+
}
|
|
260
|
+
} catch {}
|
|
261
|
+
}
|
|
262
|
+
resolve({ text: retryText, sessionId: retrySessionId, sessionCleared: true });
|
|
263
|
+
});
|
|
264
|
+
retryProc.on('error', (err) => { this._proc = null; reject(err); });
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
resolve({ text: fullText, sessionId: newSessionId });
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
proc.on('error', (err) => {
|
|
272
|
+
this._proc = null;
|
|
273
|
+
reject(err);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hampagent session persistence.
|
|
3
|
+
* Stores conversation history per-agent as JSON in ~/.agentforge/hampagent-sessions/
|
|
4
|
+
* Sessions survive worker restarts and accumulate context over time.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import { homedir } from 'os';
|
|
10
|
+
|
|
11
|
+
const SESSIONS_DIR = path.join(homedir(), '.agentforge', 'hampagent-sessions');
|
|
12
|
+
// Keep last 30 messages (~15 turns) to stay within context limits
|
|
13
|
+
const MAX_MESSAGES = 30;
|
|
14
|
+
|
|
15
|
+
function dir() {
|
|
16
|
+
mkdirSync(SESSIONS_DIR, { recursive: true });
|
|
17
|
+
return SESSIONS_DIR;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function file(agentId) {
|
|
21
|
+
return path.join(dir(), `${agentId}.json`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Load the claude CLI session ID for an agent (for --resume) */
|
|
25
|
+
export function loadSessionId(agentId) {
|
|
26
|
+
const p = file(agentId);
|
|
27
|
+
if (!existsSync(p)) return null;
|
|
28
|
+
try {
|
|
29
|
+
const d = JSON.parse(readFileSync(p, 'utf8'));
|
|
30
|
+
return d.claudeSessionId || null;
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Save the claude CLI session ID after a completed task */
|
|
37
|
+
export function saveSessionId(agentId, claudeSessionId) {
|
|
38
|
+
writeFileSync(file(agentId), JSON.stringify({
|
|
39
|
+
agentId,
|
|
40
|
+
claudeSessionId,
|
|
41
|
+
savedAt: new Date().toISOString(),
|
|
42
|
+
}, null, 2));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Legacy compat — kept so worker.js hasSession check doesn't break */
|
|
46
|
+
export function hasSession(agentId) {
|
|
47
|
+
const p = file(agentId);
|
|
48
|
+
if (!existsSync(p)) return false;
|
|
49
|
+
try {
|
|
50
|
+
const d = JSON.parse(readFileSync(p, 'utf8'));
|
|
51
|
+
return !!(d.claudeSessionId);
|
|
52
|
+
} catch { return false; }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function clearSession(agentId) {
|
|
56
|
+
const p = file(agentId);
|
|
57
|
+
if (existsSync(p)) writeFileSync(p, JSON.stringify({ agentId, claudeSessionId: null, savedAt: new Date().toISOString() }));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Legacy exports kept for any stale imports
|
|
61
|
+
export function loadMessages() { return []; }
|
|
62
|
+
export function saveMessages() {}
|