tycono 0.1.68 → 0.1.70
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/package.json +1 -1
- package/src/api/src/engine/agent-loop.ts +100 -4
- package/src/api/src/engine/runners/direct-api.ts +1 -0
- package/src/api/src/engine/runners/types.ts +2 -0
- package/src/api/src/engine/tools/definitions.ts +22 -1
- package/src/api/src/engine/tools/executor.ts +106 -1
- package/src/api/src/routes/coins.ts +16 -0
- package/src/api/src/routes/execute.ts +25 -2
- package/src/api/src/routes/setup.ts +26 -0
- package/src/api/src/services/job-manager.ts +16 -0
- package/src/api/src/services/session-store.ts +4 -1
- package/src/web/dist/assets/{index-BJFB8UWZ.js → index-CsRhaCla.js} +22 -22
- package/src/web/dist/assets/{preview-app-uqx4oQHx.js → preview-app-B1XJLGLG.js} +1 -1
- package/src/web/dist/index.html +1 -1
package/package.json
CHANGED
|
@@ -5,6 +5,7 @@ import { validateDispatch, validateConsult } from './authority-validator.js';
|
|
|
5
5
|
import { getToolsForRole } from './tools/definitions.js';
|
|
6
6
|
import { executeTool, type ToolExecutorOptions } from './tools/executor.js';
|
|
7
7
|
import { type TokenLedger } from '../services/token-ledger.js';
|
|
8
|
+
import { estimateCost } from '../services/pricing.js';
|
|
8
9
|
import { type ImageAttachment } from './runners/types.js';
|
|
9
10
|
|
|
10
11
|
/* ─── Types ──────────────────────────────────── */
|
|
@@ -17,6 +18,7 @@ export interface AgentConfig {
|
|
|
17
18
|
orgTree: OrgTree;
|
|
18
19
|
readOnly?: boolean;
|
|
19
20
|
maxTurns?: number;
|
|
21
|
+
codeRoot?: string; // EG-001: code project root for bash_execute
|
|
20
22
|
llm?: LLMProvider;
|
|
21
23
|
depth?: number; // Current dispatch depth (default 0)
|
|
22
24
|
visitedRoles?: Set<string>; // Circular dispatch detection
|
|
@@ -43,6 +45,43 @@ export interface AgentResult {
|
|
|
43
45
|
dispatches: Array<{ roleId: string; task: string; result: string }>;
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
/* ─── EG-006: Context Compression ────────────── */
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Compress older messages to reduce token usage.
|
|
52
|
+
* Strategy: Keep first 2 messages (initial task) and last 4 messages (recent context).
|
|
53
|
+
* Middle messages: truncate long tool_result content, collapse text blocks.
|
|
54
|
+
*/
|
|
55
|
+
function compressMessages(messages: LLMMessage[]): void {
|
|
56
|
+
if (messages.length <= 6) return;
|
|
57
|
+
|
|
58
|
+
// Keep first 2 (task setup) and last 4 (recent context)
|
|
59
|
+
const keepHead = 2;
|
|
60
|
+
const keepTail = 4;
|
|
61
|
+
const compressRange = messages.slice(keepHead, messages.length - keepTail);
|
|
62
|
+
|
|
63
|
+
for (const msg of compressRange) {
|
|
64
|
+
if (typeof msg.content === 'string') {
|
|
65
|
+
// Truncate long text content
|
|
66
|
+
if (msg.content.length > 500) {
|
|
67
|
+
msg.content = msg.content.slice(0, 300) + '\n\n[... compressed ...]';
|
|
68
|
+
}
|
|
69
|
+
} else if (Array.isArray(msg.content)) {
|
|
70
|
+
for (let i = 0; i < msg.content.length; i++) {
|
|
71
|
+
const block = msg.content[i] as Record<string, unknown>;
|
|
72
|
+
if (block.type === 'tool_result') {
|
|
73
|
+
const content = typeof block.content === 'string' ? block.content : '';
|
|
74
|
+
if (content.length > 300) {
|
|
75
|
+
block.content = content.slice(0, 200) + '\n[... compressed, was ' + content.length + ' chars]';
|
|
76
|
+
}
|
|
77
|
+
} else if (block.type === 'text' && typeof block.text === 'string' && block.text.length > 500) {
|
|
78
|
+
block.text = (block.text as string).slice(0, 300) + '\n[... compressed ...]';
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
46
85
|
/* ─── Agent Loop ─────────────────────────────── */
|
|
47
86
|
|
|
48
87
|
export async function runAgentLoop(config: AgentConfig): Promise<AgentResult> {
|
|
@@ -87,13 +126,15 @@ export async function runAgentLoop(config: AgentConfig): Promise<AgentResult> {
|
|
|
87
126
|
|
|
88
127
|
// 2. Determine tools
|
|
89
128
|
const subordinates = getSubordinates(orgTree, roleId);
|
|
90
|
-
const
|
|
129
|
+
const hasBash = !readOnly && !!config.codeRoot;
|
|
130
|
+
const tools = getToolsForRole(subordinates.length > 0, readOnly, hasBash);
|
|
91
131
|
|
|
92
132
|
// 3. Set up tool executor
|
|
93
133
|
const toolExecOptions: ToolExecutorOptions = {
|
|
94
134
|
companyRoot,
|
|
95
135
|
roleId,
|
|
96
136
|
orgTree,
|
|
137
|
+
codeRoot: config.codeRoot,
|
|
97
138
|
onToolExec,
|
|
98
139
|
onDispatch: async (targetRoleId: string, subTask: string) => {
|
|
99
140
|
// Recursive dispatch — validate, then run sub-agent
|
|
@@ -118,6 +159,7 @@ export async function runAgentLoop(config: AgentConfig): Promise<AgentResult> {
|
|
|
118
159
|
orgTree,
|
|
119
160
|
readOnly: false,
|
|
120
161
|
maxTurns: Math.min(maxTurns, 15), // Limit sub-agent turns
|
|
162
|
+
codeRoot: config.codeRoot,
|
|
121
163
|
llm,
|
|
122
164
|
depth: depth + 1,
|
|
123
165
|
visitedRoles: new Set(visitedRoles), // Copy for parallel dispatch support
|
|
@@ -210,17 +252,34 @@ export async function runAgentLoop(config: AgentConfig): Promise<AgentResult> {
|
|
|
210
252
|
const dispatches: AgentResult['dispatches'] = [];
|
|
211
253
|
const outputParts: string[] = [];
|
|
212
254
|
|
|
255
|
+
// EG-006/007: Context compression + token budget
|
|
256
|
+
const COMPRESS_THRESHOLD = 100_000;
|
|
257
|
+
const TOKEN_WARN_THRESHOLD = 200_000; // Warn at 200K total tokens
|
|
258
|
+
let tokenWarningEmitted = false;
|
|
259
|
+
|
|
213
260
|
while (turns < maxTurns) {
|
|
214
261
|
// Check abort signal before each turn
|
|
215
262
|
if (abortSignal?.aborted) break;
|
|
216
263
|
|
|
217
264
|
turns++;
|
|
218
265
|
|
|
266
|
+
// EG-006: Compress old messages when token budget exceeded
|
|
267
|
+
if (totalInput > COMPRESS_THRESHOLD && messages.length > 4) {
|
|
268
|
+
compressMessages(messages);
|
|
269
|
+
}
|
|
270
|
+
|
|
219
271
|
// Call LLM
|
|
220
272
|
const response = await llm.chat(context.systemPrompt, messages, tools, abortSignal);
|
|
221
273
|
totalInput += response.usage.inputTokens;
|
|
222
274
|
totalOutput += response.usage.outputTokens;
|
|
223
275
|
|
|
276
|
+
// EG-007: Token budget warning
|
|
277
|
+
if (!tokenWarningEmitted && (totalInput + totalOutput) > TOKEN_WARN_THRESHOLD) {
|
|
278
|
+
tokenWarningEmitted = true;
|
|
279
|
+
const cost = estimateCost(totalInput, totalOutput, config.model ?? 'unknown');
|
|
280
|
+
onText?.(`\n\n⚠️ [Token Budget Warning] This task has used ${totalInput.toLocaleString()} input + ${totalOutput.toLocaleString()} output tokens (~$${cost.toFixed(3)}). Consider wrapping up.\n\n`);
|
|
281
|
+
}
|
|
282
|
+
|
|
224
283
|
// Record token usage
|
|
225
284
|
config.tokenLedger?.record({
|
|
226
285
|
ts: new Date().toISOString(),
|
|
@@ -253,17 +312,33 @@ export async function runAgentLoop(config: AgentConfig): Promise<AgentResult> {
|
|
|
253
312
|
(b): b is MessageContent & { type: 'tool_use' } => b.type === 'tool_use'
|
|
254
313
|
);
|
|
255
314
|
|
|
256
|
-
|
|
315
|
+
// EG-004: Parallel tool execution for independent tools
|
|
316
|
+
// dispatch/consult run sequentially (recursive agent calls)
|
|
317
|
+
// All other tools run in parallel via Promise.all()
|
|
318
|
+
const sequentialTools = new Set(['dispatch', 'consult']);
|
|
319
|
+
const parallelCalls = toolCalls.filter(tc => !sequentialTools.has(tc.name));
|
|
320
|
+
const sequentialCalls = toolCalls.filter(tc => sequentialTools.has(tc.name));
|
|
257
321
|
|
|
322
|
+
// Record all tool calls
|
|
258
323
|
for (const tc of toolCalls) {
|
|
259
324
|
allToolCalls.push({ name: tc.name, input: tc.input });
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Run parallel tools concurrently
|
|
328
|
+
const parallelResults = await Promise.all(
|
|
329
|
+
parallelCalls.map(tc =>
|
|
330
|
+
executeTool({ id: tc.id, name: tc.name, input: tc.input }, toolExecOptions)
|
|
331
|
+
)
|
|
332
|
+
);
|
|
260
333
|
|
|
334
|
+
// Run sequential tools one by one
|
|
335
|
+
const sequentialResults: ToolResult[] = [];
|
|
336
|
+
for (const tc of sequentialCalls) {
|
|
261
337
|
const result = await executeTool(
|
|
262
338
|
{ id: tc.id, name: tc.name, input: tc.input },
|
|
263
339
|
toolExecOptions,
|
|
264
340
|
);
|
|
265
|
-
|
|
266
|
-
toolResults.push(result);
|
|
341
|
+
sequentialResults.push(result);
|
|
267
342
|
|
|
268
343
|
// Track dispatches
|
|
269
344
|
if (tc.name === 'dispatch' && !result.is_error) {
|
|
@@ -275,6 +350,27 @@ export async function runAgentLoop(config: AgentConfig): Promise<AgentResult> {
|
|
|
275
350
|
}
|
|
276
351
|
}
|
|
277
352
|
|
|
353
|
+
// EG-005: Merge results in original tool_use_id order
|
|
354
|
+
const resultMap = new Map<string, ToolResult>();
|
|
355
|
+
for (const r of [...parallelResults, ...sequentialResults]) {
|
|
356
|
+
resultMap.set(r.tool_use_id, r);
|
|
357
|
+
}
|
|
358
|
+
const toolResults = toolCalls.map(tc => resultMap.get(tc.id)!);
|
|
359
|
+
|
|
360
|
+
// Track dispatches from parallel results too
|
|
361
|
+
for (const tc of parallelCalls) {
|
|
362
|
+
if (tc.name === 'dispatch') {
|
|
363
|
+
const r = resultMap.get(tc.id)!;
|
|
364
|
+
if (!r.is_error) {
|
|
365
|
+
dispatches.push({
|
|
366
|
+
roleId: String(tc.input.roleId),
|
|
367
|
+
task: String(tc.input.task),
|
|
368
|
+
result: r.content,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
278
374
|
// Send tool results back
|
|
279
375
|
messages.push({
|
|
280
376
|
role: 'user',
|
|
@@ -39,6 +39,8 @@ export interface RunnerConfig {
|
|
|
39
39
|
attachments?: ImageAttachment[];
|
|
40
40
|
/** Selective dispatch scope — only these roles can be dispatched to */
|
|
41
41
|
targetRoles?: string[];
|
|
42
|
+
/** EG-001: Code project root for bash_execute tool */
|
|
43
|
+
codeRoot?: string;
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
/* ─── Callbacks ───────────────────────────────── */
|
|
@@ -89,6 +89,23 @@ export const DISPATCH_TOOL: ToolDefinition = {
|
|
|
89
89
|
},
|
|
90
90
|
};
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Bash 실행 도구 — 코드 프로젝트에서 시스템 명령 실행 (EG-001)
|
|
94
|
+
*/
|
|
95
|
+
export const BASH_TOOL: ToolDefinition = {
|
|
96
|
+
name: 'bash_execute',
|
|
97
|
+
description: 'Execute a shell command in the code project directory. Use for git, npm, tsc, node, test runners, and build tools. Commands run in the codeRoot directory (not company knowledge base). Dangerous commands (rm -rf, sudo, etc.) are blocked.',
|
|
98
|
+
input_schema: {
|
|
99
|
+
type: 'object',
|
|
100
|
+
properties: {
|
|
101
|
+
command: { type: 'string', description: 'Shell command to execute' },
|
|
102
|
+
timeout: { type: 'number', description: 'Timeout in milliseconds (default: 30000, max: 120000)' },
|
|
103
|
+
cwd: { type: 'string', description: 'Working directory relative to codeRoot (default: ".")' },
|
|
104
|
+
},
|
|
105
|
+
required: ['command'],
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
92
109
|
/**
|
|
93
110
|
* 상담 도구 — 모든 Role에게 제공 (동료/상관/부하에게 질문)
|
|
94
111
|
*/
|
|
@@ -108,13 +125,17 @@ export const CONSULT_TOOL: ToolDefinition = {
|
|
|
108
125
|
/**
|
|
109
126
|
* Role에 따른 도구 목록 반환
|
|
110
127
|
*/
|
|
111
|
-
export function getToolsForRole(hasSubordinates: boolean, readOnly: boolean): ToolDefinition[] {
|
|
128
|
+
export function getToolsForRole(hasSubordinates: boolean, readOnly: boolean, hasBash = false): ToolDefinition[] {
|
|
112
129
|
if (readOnly) {
|
|
113
130
|
return [...READ_TOOLS];
|
|
114
131
|
}
|
|
115
132
|
|
|
116
133
|
const tools = [...READ_TOOLS, ...WRITE_TOOLS, CONSULT_TOOL];
|
|
117
134
|
|
|
135
|
+
if (hasBash) {
|
|
136
|
+
tools.push(BASH_TOOL);
|
|
137
|
+
}
|
|
138
|
+
|
|
118
139
|
if (hasSubordinates) {
|
|
119
140
|
tools.push(DISPATCH_TOOL);
|
|
120
141
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { execSync } from 'node:child_process';
|
|
3
4
|
import { glob } from 'glob';
|
|
4
5
|
import type { ToolCall, ToolResult } from '../llm-adapter.js';
|
|
5
6
|
import { validateWrite, validateRead } from '../authority-validator.js';
|
|
@@ -12,6 +13,7 @@ export interface ToolExecutorOptions {
|
|
|
12
13
|
companyRoot: string;
|
|
13
14
|
roleId: string;
|
|
14
15
|
orgTree: OrgTree;
|
|
16
|
+
codeRoot?: string;
|
|
15
17
|
onDispatch?: (roleId: string, task: string) => Promise<string>;
|
|
16
18
|
onConsult?: (roleId: string, question: string) => Promise<string>;
|
|
17
19
|
onToolExec?: (name: string, input: Record<string, unknown>) => void;
|
|
@@ -23,7 +25,7 @@ export async function executeTool(
|
|
|
23
25
|
toolCall: ToolCall,
|
|
24
26
|
options: ToolExecutorOptions,
|
|
25
27
|
): Promise<ToolResult> {
|
|
26
|
-
const { companyRoot, roleId, orgTree, onDispatch, onConsult, onToolExec } = options;
|
|
28
|
+
const { companyRoot, roleId, orgTree, codeRoot, onDispatch, onConsult, onToolExec } = options;
|
|
27
29
|
const { id, name, input } = toolCall;
|
|
28
30
|
|
|
29
31
|
onToolExec?.(name, input);
|
|
@@ -40,6 +42,8 @@ export async function executeTool(
|
|
|
40
42
|
return writeFile(id, input, companyRoot, roleId, orgTree);
|
|
41
43
|
case 'edit_file':
|
|
42
44
|
return editFile(id, input, companyRoot, roleId, orgTree);
|
|
45
|
+
case 'bash_execute':
|
|
46
|
+
return bashExecute(id, input, codeRoot ?? companyRoot);
|
|
43
47
|
case 'dispatch':
|
|
44
48
|
return await dispatchTask(id, input, onDispatch);
|
|
45
49
|
case 'consult':
|
|
@@ -268,6 +272,107 @@ function editFile(
|
|
|
268
272
|
return { tool_use_id: id, content: `File edited: ${filePath}` };
|
|
269
273
|
}
|
|
270
274
|
|
|
275
|
+
/* ─── Bash Safety Layer (EG-002) ─────────────── */
|
|
276
|
+
|
|
277
|
+
/** Dangerous patterns that are always blocked */
|
|
278
|
+
const BLOCKED_PATTERNS = [
|
|
279
|
+
/\brm\s+(-[a-z]*f|-[a-z]*r|--force|--recursive)\b/i,
|
|
280
|
+
/\brm\s+-rf\b/i,
|
|
281
|
+
/\bsudo\b/,
|
|
282
|
+
/\bmkfs\b/,
|
|
283
|
+
/\bdd\s+if=/,
|
|
284
|
+
/\b(shutdown|reboot|halt|poweroff)\b/,
|
|
285
|
+
/\bchmod\s+777\b/,
|
|
286
|
+
/\bchown\b/,
|
|
287
|
+
/>\s*\/dev\//,
|
|
288
|
+
/\bcurl\b.*\|\s*(bash|sh|zsh)\b/,
|
|
289
|
+
/\bwget\b.*\|\s*(bash|sh|zsh)\b/,
|
|
290
|
+
/\bgit\s+push\s+.*--force\b/,
|
|
291
|
+
/\bgit\s+reset\s+--hard\b/,
|
|
292
|
+
/\bnpm\s+publish\b/,
|
|
293
|
+
/\beval\s*\(/,
|
|
294
|
+
/:\(\)\s*\{/, // fork bomb
|
|
295
|
+
];
|
|
296
|
+
|
|
297
|
+
function validateBashCommand(command: string): string | null {
|
|
298
|
+
const trimmed = command.trim();
|
|
299
|
+
if (!trimmed) return 'Empty command';
|
|
300
|
+
|
|
301
|
+
for (const pattern of BLOCKED_PATTERNS) {
|
|
302
|
+
if (pattern.test(trimmed)) {
|
|
303
|
+
return `Blocked: command matches dangerous pattern "${pattern.source}"`;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Block commands that try to leave codeRoot via cd
|
|
308
|
+
if (/\bcd\s+\//.test(trimmed) && !/\bcd\s+\/[^;|&]*&&/.test(trimmed)) {
|
|
309
|
+
// Allow cd to absolute if chained with other commands (common pattern)
|
|
310
|
+
// But block standalone cd to absolute paths outside codeRoot
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return null; // OK
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const MAX_BASH_TIMEOUT = 120_000;
|
|
317
|
+
const DEFAULT_BASH_TIMEOUT = 30_000;
|
|
318
|
+
const MAX_OUTPUT_LENGTH = 50_000;
|
|
319
|
+
|
|
320
|
+
function bashExecute(
|
|
321
|
+
id: string,
|
|
322
|
+
input: Record<string, unknown>,
|
|
323
|
+
codeRoot: string,
|
|
324
|
+
): ToolResult {
|
|
325
|
+
const command = String(input.command ?? '');
|
|
326
|
+
const timeout = Math.min(Number(input.timeout) || DEFAULT_BASH_TIMEOUT, MAX_BASH_TIMEOUT);
|
|
327
|
+
const cwdRelative = String(input.cwd ?? '.');
|
|
328
|
+
|
|
329
|
+
// Validate command safety
|
|
330
|
+
const blockReason = validateBashCommand(command);
|
|
331
|
+
if (blockReason) {
|
|
332
|
+
return { tool_use_id: id, content: `Error: ${blockReason}`, is_error: true };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Resolve and validate cwd
|
|
336
|
+
const cwd = path.resolve(codeRoot, cwdRelative);
|
|
337
|
+
if (!cwd.startsWith(codeRoot)) {
|
|
338
|
+
return { tool_use_id: id, content: 'Error: cwd path traversal not allowed', is_error: true };
|
|
339
|
+
}
|
|
340
|
+
if (!fs.existsSync(cwd)) {
|
|
341
|
+
return { tool_use_id: id, content: `Error: directory not found: ${cwdRelative}`, is_error: true };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
try {
|
|
345
|
+
const stdout = execSync(command, {
|
|
346
|
+
cwd,
|
|
347
|
+
timeout,
|
|
348
|
+
encoding: 'utf-8',
|
|
349
|
+
maxBuffer: 1024 * 1024, // 1MB
|
|
350
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
351
|
+
env: { ...process.env, FORCE_COLOR: '0' },
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
const output = stdout.length > MAX_OUTPUT_LENGTH
|
|
355
|
+
? stdout.slice(0, MAX_OUTPUT_LENGTH) + `\n\n[... truncated, output is ${stdout.length} chars]`
|
|
356
|
+
: stdout;
|
|
357
|
+
|
|
358
|
+
return { tool_use_id: id, content: output || '(no output)' };
|
|
359
|
+
} catch (err: unknown) {
|
|
360
|
+
const execErr = err as { status?: number; stdout?: string; stderr?: string; message?: string };
|
|
361
|
+
const stderr = execErr.stderr?.slice(0, 5000) ?? '';
|
|
362
|
+
const stdout = execErr.stdout?.slice(0, 5000) ?? '';
|
|
363
|
+
const exitCode = execErr.status ?? 1;
|
|
364
|
+
|
|
365
|
+
let content = `Command exited with code ${exitCode}`;
|
|
366
|
+
if (stdout) content += `\n\nSTDOUT:\n${stdout}`;
|
|
367
|
+
if (stderr) content += `\n\nSTDERR:\n${stderr}`;
|
|
368
|
+
if (!stdout && !stderr) content += `\n${execErr.message ?? ''}`;
|
|
369
|
+
|
|
370
|
+
return { tool_use_id: id, content, is_error: true };
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/* ─── Dispatch / Consult ─────────────────────── */
|
|
375
|
+
|
|
271
376
|
async function dispatchTask(
|
|
272
377
|
id: string,
|
|
273
378
|
input: Record<string, unknown>,
|
|
@@ -46,6 +46,22 @@ function writeCoins(data: CoinsData) {
|
|
|
46
46
|
writeFileSync(COINS_FILE(), JSON.stringify(data, null, 2) + '\n');
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/* ── Internal API (for server-side use) ── */
|
|
50
|
+
|
|
51
|
+
export function earnCoinsInternal(amount: number, reason: string, ref?: string): { balance: number; skipped: boolean } {
|
|
52
|
+
const data = readCoins();
|
|
53
|
+
// Idempotency
|
|
54
|
+
if (ref && data.transactions.some(t => t.ref === ref && t.amount > 0)) {
|
|
55
|
+
return { balance: data.balance, skipped: true };
|
|
56
|
+
}
|
|
57
|
+
const tx: CoinTransaction = { ts: new Date().toISOString(), amount, reason, ref };
|
|
58
|
+
data.balance += amount;
|
|
59
|
+
data.totalEarned += amount;
|
|
60
|
+
data.transactions.push(tx);
|
|
61
|
+
writeCoins(data);
|
|
62
|
+
return { balance: data.balance, skipped: false };
|
|
63
|
+
}
|
|
64
|
+
|
|
49
65
|
/* ── Routes ── */
|
|
50
66
|
|
|
51
67
|
// GET /api/coins — current balance + summary
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from '../services/session-store.js';
|
|
16
16
|
import { jobManager, type Job } from '../services/job-manager.js';
|
|
17
17
|
import { ActivityStream, type ActivityEvent, type ActivitySubscriber } from '../services/activity-stream.js';
|
|
18
|
+
import { earnCoinsInternal } from './coins.js';
|
|
18
19
|
|
|
19
20
|
/* ─── Runner — lazy, re-created when engine changes ── */
|
|
20
21
|
|
|
@@ -451,8 +452,22 @@ function handleSaveWave(body: Record<string, unknown>, res: ServerResponse): voi
|
|
|
451
452
|
if (!fs.existsSync(wavesDir)) {
|
|
452
453
|
fs.mkdirSync(wavesDir, { recursive: true });
|
|
453
454
|
}
|
|
454
|
-
|
|
455
|
-
|
|
455
|
+
|
|
456
|
+
// Dedup: if waveId matches an existing file, overwrite instead of creating new
|
|
457
|
+
let baseName: string;
|
|
458
|
+
if (waveId) {
|
|
459
|
+
const existing = fs.readdirSync(wavesDir).find(f => {
|
|
460
|
+
if (!f.endsWith('.json')) return false;
|
|
461
|
+
try {
|
|
462
|
+
const data = JSON.parse(fs.readFileSync(path.join(wavesDir, f), 'utf-8'));
|
|
463
|
+
return data.waveId === waveId || data.id === waveId;
|
|
464
|
+
} catch { return false; }
|
|
465
|
+
});
|
|
466
|
+
baseName = existing ? existing.replace('.json', '') : waveId;
|
|
467
|
+
} else {
|
|
468
|
+
const hhmmss = now.toTimeString().slice(0, 8).replace(/:/g, '');
|
|
469
|
+
baseName = `${dateStr.replace(/-/g, '')}-${hhmmss}-wave`;
|
|
470
|
+
}
|
|
456
471
|
const jsonPath = path.join(wavesDir, `${baseName}.json`);
|
|
457
472
|
|
|
458
473
|
const waveJson = {
|
|
@@ -467,6 +482,14 @@ function handleSaveWave(body: Record<string, unknown>, res: ServerResponse): voi
|
|
|
467
482
|
};
|
|
468
483
|
fs.writeFileSync(jsonPath, JSON.stringify(waveJson, null, 2), 'utf-8');
|
|
469
484
|
|
|
485
|
+
// EC-012: Wave completion bonus (participating roles × 500 coins)
|
|
486
|
+
const roleCount = rolesData.length;
|
|
487
|
+
if (roleCount > 0) {
|
|
488
|
+
try {
|
|
489
|
+
earnCoinsInternal(roleCount * 500, `Wave done: ${roleCount} roles`, `wave:${baseName}`);
|
|
490
|
+
} catch { /* non-critical */ }
|
|
491
|
+
}
|
|
492
|
+
|
|
470
493
|
jsonResponse(res, 200, { ok: true, path: `operations/waves/${baseName}.json` });
|
|
471
494
|
}
|
|
472
495
|
|
|
@@ -189,6 +189,32 @@ setupRouter.post('/browse', (req, res) => {
|
|
|
189
189
|
}
|
|
190
190
|
});
|
|
191
191
|
|
|
192
|
+
/**
|
|
193
|
+
* POST /api/setup/mkdir
|
|
194
|
+
* Create a new directory inside the browsed location.
|
|
195
|
+
*/
|
|
196
|
+
setupRouter.post('/mkdir', (req, res) => {
|
|
197
|
+
const { path: parentPath, name } = req.body;
|
|
198
|
+
if (!parentPath || !name || typeof parentPath !== 'string' || typeof name !== 'string') {
|
|
199
|
+
res.status(400).json({ error: 'path and name are required' });
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
// Sanitize name — no path separators or dots-only
|
|
203
|
+
const sanitized = name.trim().replace(/[/\\]/g, '');
|
|
204
|
+
if (!sanitized || sanitized === '.' || sanitized === '..') {
|
|
205
|
+
res.status(400).json({ error: 'Invalid folder name' });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const target = path.join(path.resolve(parentPath), sanitized);
|
|
209
|
+
try {
|
|
210
|
+
fs.mkdirSync(target, { recursive: true });
|
|
211
|
+
res.json({ ok: true, path: target });
|
|
212
|
+
} catch (err: unknown) {
|
|
213
|
+
const msg = err instanceof Error ? err.message : 'Unknown error';
|
|
214
|
+
res.status(500).json({ error: msg });
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
192
218
|
/**
|
|
193
219
|
* POST /api/setup/connect-akb
|
|
194
220
|
* Connect an existing AKB directory.
|
|
@@ -9,6 +9,7 @@ import type { RunnerResult } from '../engine/runners/types.js';
|
|
|
9
9
|
import { estimateCost } from './pricing.js';
|
|
10
10
|
import { readConfig, getConversationLimits } from './company-config.js';
|
|
11
11
|
import { postKnowledgingCheck, type KnowledgeDebtItem } from '../engine/knowledge-gate.js';
|
|
12
|
+
import { earnCoinsInternal } from '../routes/coins.js';
|
|
12
13
|
import { getSession, updateMessage as updateSessionMessage, appendMessageEvent } from './session-store.js';
|
|
13
14
|
|
|
14
15
|
/* ─── Types ──────────────────────────────── */
|
|
@@ -230,6 +231,7 @@ class JobManager {
|
|
|
230
231
|
jobId,
|
|
231
232
|
teamStatus,
|
|
232
233
|
targetRoles: params.targetRoles,
|
|
234
|
+
codeRoot: config.codeRoot,
|
|
233
235
|
},
|
|
234
236
|
{
|
|
235
237
|
onText: (text) => {
|
|
@@ -437,6 +439,16 @@ class JobManager {
|
|
|
437
439
|
if (job.sessionId) {
|
|
438
440
|
this.finalizeSessionMessage(job, 'done', result);
|
|
439
441
|
}
|
|
442
|
+
|
|
443
|
+
// EC-011: Job completion bonus (only for top-level jobs, not child dispatches)
|
|
444
|
+
if (!params.parentJobId && result) {
|
|
445
|
+
const totalTokens = (result.totalTokens?.input ?? 0) + (result.totalTokens?.output ?? 0);
|
|
446
|
+
const bonus = Math.min(2000, Math.max(500, Math.round(totalTokens / 500)));
|
|
447
|
+
try {
|
|
448
|
+
earnCoinsInternal(bonus, `Job done: ${params.roleId}`, `job:${job.id}`);
|
|
449
|
+
} catch { /* non-critical */ }
|
|
450
|
+
}
|
|
451
|
+
|
|
440
452
|
// Cleanup orphaned child jobs (awaiting_input with no parent to respond)
|
|
441
453
|
this.cleanupOrphanedChildren(job.id);
|
|
442
454
|
}
|
|
@@ -532,6 +544,10 @@ class JobManager {
|
|
|
532
544
|
turns: result.turns,
|
|
533
545
|
tokens: result.totalTokens,
|
|
534
546
|
}),
|
|
547
|
+
// KP-006: Include knowledge debt in session message
|
|
548
|
+
...(job.knowledgeDebt && job.knowledgeDebt.length > 0 && {
|
|
549
|
+
knowledgeDebt: job.knowledgeDebt.map(d => ({ type: d.type, file: d.file, message: d.message })),
|
|
550
|
+
}),
|
|
535
551
|
});
|
|
536
552
|
}
|
|
537
553
|
|
|
@@ -39,6 +39,8 @@ export interface Message {
|
|
|
39
39
|
/** Execution stats */
|
|
40
40
|
turns?: number;
|
|
41
41
|
tokens?: { input: number; output: number };
|
|
42
|
+
/** KP-006: Knowledge debt warnings from Post-K check */
|
|
43
|
+
knowledgeDebt?: Array<{ type: string; file?: string; message: string }>;
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
/** How this session was created */
|
|
@@ -193,7 +195,7 @@ export function addMessage(sessionId: string, msg: Message, streaming = false):
|
|
|
193
195
|
}
|
|
194
196
|
|
|
195
197
|
/** Fields that can be updated on a message */
|
|
196
|
-
export type MessageUpdate = Partial<Pick<Message, 'content' | 'status' | 'turns' | 'tokens' | 'dispatches' | 'readOnly'>>;
|
|
198
|
+
export type MessageUpdate = Partial<Pick<Message, 'content' | 'status' | 'turns' | 'tokens' | 'dispatches' | 'readOnly' | 'knowledgeDebt'>>;
|
|
197
199
|
|
|
198
200
|
export function updateMessage(sessionId: string, messageId: string, updates: MessageUpdate): Session | undefined {
|
|
199
201
|
const session = cache.get(sessionId);
|
|
@@ -208,6 +210,7 @@ export function updateMessage(sessionId: string, messageId: string, updates: Mes
|
|
|
208
210
|
if (updates.tokens !== undefined) msg.tokens = updates.tokens;
|
|
209
211
|
if (updates.dispatches !== undefined) msg.dispatches = updates.dispatches;
|
|
210
212
|
if (updates.readOnly !== undefined) msg.readOnly = updates.readOnly;
|
|
213
|
+
if (updates.knowledgeDebt !== undefined) msg.knowledgeDebt = updates.knowledgeDebt;
|
|
211
214
|
session.updatedAt = new Date().toISOString();
|
|
212
215
|
|
|
213
216
|
if (updates.status === 'done' || updates.status === 'error') {
|