diff --git a/bun.lock b/bun.lock index 75682e8..e828f3d 100644 --- a/bun.lock +++ b/bun.lock @@ -17,6 +17,7 @@ }, "devDependencies": { "@types/node": "^25.6.0", + "@upulse/workflows": "workspace:*", "bun-types": "latest", "typescript": "^6.0.2", }, @@ -60,6 +61,16 @@ "@uncaged/pulse": ">=0.1.0", }, }, + "packages/pulse-workflows": { + "name": "@upulse/workflows", + "version": "0.1.0", + "dependencies": { + "@uncaged/pulse": "workspace:*", + }, + "devDependencies": { + "bun-types": "^1.3.12", + }, + }, "packages/pulseflare": { "name": "@uncaged/pulseflare", "version": "0.1.0", @@ -259,6 +270,8 @@ "@uncaged/upulse": ["@uncaged/upulse@workspace:packages/upulse"], + "@upulse/workflows": ["@upulse/workflows@workspace:packages/pulse-workflows"], + "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], diff --git a/packages/pulse-workflows/.gitignore b/packages/pulse-workflows/.gitignore new file mode 100644 index 0000000..849ddff --- /dev/null +++ b/packages/pulse-workflows/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/packages/pulse-workflows/package.json b/packages/pulse-workflows/package.json new file mode 100644 index 0000000..7ad38d2 --- /dev/null +++ b/packages/pulse-workflows/package.json @@ -0,0 +1,19 @@ +{ + "name": "@upulse/workflows", + "version": "0.1.0", + "description": "Built-in workflow definitions for Pulse (coding, report, etc.)", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": ["dist"], + "scripts": { + "build": "bun x tsc", + "test": "bun test" + }, + "dependencies": { + "@uncaged/pulse": "workspace:*" + }, + "devDependencies": { + "bun-types": "^1.3.12" + } +} diff --git a/packages/pulse-workflows/src/index.d.ts b/packages/pulse-workflows/src/index.d.ts new file mode 100644 index 0000000..3797786 --- /dev/null +++ b/packages/pulse-workflows/src/index.d.ts @@ -0,0 +1,16 @@ +/** + * @upulse/workflows โ€” Built-in workflow definitions for Pulse. + * + * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) + */ +export { type ArchitectMeta, type CloserMeta, type CoderMeta, type CodingRoles, createCodingWorkflow, type ReviewerMeta, } from './workflows/coding.js'; +export { type AutoTesterMeta, type CreateTddCodingWorkflowOpts, createTddCodingWorkflow, type ManualTesterMeta, type TddCloserMeta, type TddCoderMeta, type TddCodingRoles, type TddReviewerMeta, type TestCoderMeta, type TestPlannerMeta, type TestReviewerMeta, } from './workflows/coding-tdd.js'; +export { type AnalystMeta, createReportWorkflow, type RendererMeta, type ReportRoles, } from './workflows/report.js'; +export { checkCursorHealth, type CursorHealthOptions, type CursorHealthResult, } from './workflows/cursor-health.js'; +export { createWerewolfWorkflow, type CreateWerewolfWorkflowOpts, type DaySpeechMeta, filterChainForPlayer, type GameEndMeta, type GameState, type HunterShotMeta, type Identity, parseGameState, type Player, type SeerCheckMeta, type VoteMeta, type WerewolfRoles, type WitchActionMeta, type WolfNightMeta, createPlayers, } from './workflows/werewolf.js'; +export { type AnalysisResult, createAnalystRole } from './roles/analyst-llm.js'; +export { createArchitectRole } from './roles/architect-llm.js'; +export { createCoderRole } from './roles/coder-cursor.js'; +export { createRendererRole } from './roles/renderer-template.js'; +export { createRendererRole as createLlmRendererRole } from './roles/renderer-llm.js'; +export { createReviewerRole } from './roles/reviewer-cursor.js'; diff --git a/packages/pulse-workflows/src/index.js b/packages/pulse-workflows/src/index.js new file mode 100644 index 0000000..4bac64d --- /dev/null +++ b/packages/pulse-workflows/src/index.js @@ -0,0 +1,18 @@ +/** + * @upulse/workflows โ€” Built-in workflow definitions for Pulse. + * + * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) + */ +// โ”€โ”€ Workflows โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +export { createCodingWorkflow, } from './workflows/coding.js'; +export { createTddCodingWorkflow, } from './workflows/coding-tdd.js'; +export { createReportWorkflow, } from './workflows/report.js'; +export { checkCursorHealth, } from './workflows/cursor-health.js'; +export { createWerewolfWorkflow, filterChainForPlayer, parseGameState, createPlayers, } from './workflows/werewolf.js'; +// โ”€โ”€ Roles โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +export { createAnalystRole } from './roles/analyst-llm.js'; +export { createArchitectRole } from './roles/architect-llm.js'; +export { createCoderRole } from './roles/coder-cursor.js'; +export { createRendererRole } from './roles/renderer-template.js'; +export { createRendererRole as createLlmRendererRole } from './roles/renderer-llm.js'; +export { createReviewerRole } from './roles/reviewer-cursor.js'; diff --git a/packages/pulse-workflows/src/index.ts b/packages/pulse-workflows/src/index.ts new file mode 100644 index 0000000..4ddea87 --- /dev/null +++ b/packages/pulse-workflows/src/index.ts @@ -0,0 +1,71 @@ +/** + * @upulse/workflows โ€” Built-in workflow definitions for Pulse. + * + * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) + */ + +// โ”€โ”€ Workflows โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export { + type ArchitectMeta, + type CloserMeta, + type CoderMeta, + type CodingRoles, + createCodingWorkflow, + type ReviewerMeta, +} from './workflows/coding.js'; + +export { + type AutoTesterMeta, + type CreateTddCodingWorkflowOpts, + createTddCodingWorkflow, + type ManualTesterMeta, + type TddCloserMeta, + type TddCoderMeta, + type TddCodingRoles, + type TddReviewerMeta, + type TestCoderMeta, + type TestPlannerMeta, + type TestReviewerMeta, +} from './workflows/coding-tdd.js'; + +export { + type AnalystMeta, + createReportWorkflow, + type RendererMeta, + type ReportRoles, +} from './workflows/report.js'; + +export { + checkCursorHealth, + type CursorHealthOptions, + type CursorHealthResult, +} from './workflows/cursor-health.js'; + +export { + createWerewolfWorkflow, + type CreateWerewolfWorkflowOpts, + type DaySpeechMeta, + filterChainForPlayer, + type GameEndMeta, + type GameState, + type HunterShotMeta, + type Identity, + parseGameState, + type Player, + type SeerCheckMeta, + type VoteMeta, + type WerewolfRoles, + type WitchActionMeta, + type WolfNightMeta, + createPlayers, +} from './workflows/werewolf.js'; + +// โ”€โ”€ Roles โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export { type AnalysisResult, createAnalystRole } from './roles/analyst-llm.js'; +export { createArchitectRole } from './roles/architect-llm.js'; +export { createCoderRole } from './roles/coder-cursor.js'; +export { createRendererRole } from './roles/renderer-template.js'; +export { createRendererRole as createLlmRendererRole } from './roles/renderer-llm.js'; +export { createReviewerRole } from './roles/reviewer-cursor.js'; diff --git a/packages/pulse-workflows/src/roles/analyst-llm.d.ts b/packages/pulse-workflows/src/roles/analyst-llm.d.ts new file mode 100644 index 0000000..127f194 --- /dev/null +++ b/packages/pulse-workflows/src/roles/analyst-llm.d.ts @@ -0,0 +1,30 @@ +/** + * Analyst role โ€” LLM reads workflow timeline JSON and produces structured analysis. + * Built on createToolRole factory (shared sandwich pattern). + * + * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) + */ +import type { LlmClient } from '@uncaged/pulse'; +import type { AnalystMeta } from '../workflows/report.js'; +import type { Role } from '@uncaged/pulse'; +export interface AnalysisResult { + score: number; + summary: string; + highlights: Array<{ + text: string; + refEventId?: number; + }>; + issues: Array<{ + text: string; + refEventId?: number; + }>; + suggestions: string[]; + roleAnalysis: Array<{ + eventId: number; + role: string; + durationMs?: number; + verdict: 'good' | 'slow' | 'problematic'; + comment: string; + }>; +} +export declare function createAnalystRole(llm: LlmClient): Role; diff --git a/packages/pulse-workflows/src/roles/analyst-llm.js b/packages/pulse-workflows/src/roles/analyst-llm.js new file mode 100644 index 0000000..9bb7e95 --- /dev/null +++ b/packages/pulse-workflows/src/roles/analyst-llm.js @@ -0,0 +1,118 @@ +/** + * Analyst role โ€” LLM reads workflow timeline JSON and produces structured analysis. + * Built on createToolRole factory (shared sandwich pattern). + * + * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) + */ +import { createToolRole } from '@uncaged/pulse'; +const SYSTEM_PROMPT = `You are a workflow execution analyst. You receive a workflow timeline (JSON with events array) and produce a structured analysis. + +Analyze: +1. Overall quality and efficiency +2. Each role's performance (duration, output quality) +3. Issues: retries, slow steps, failures, rejected reviews +4. Concrete improvement suggestions + +Use the extract_analysis tool to output your findings. Reference specific eventId values from the timeline. + +IMPORTANT: All text fields (summary, highlights, issues, suggestions, comments) MUST be in Chinese (ไธญๆ–‡).`; +const ANALYSIS_TOOL = { + type: 'function', + function: { + name: 'extract_analysis', + description: 'Extract structured workflow analysis', + parameters: { + type: 'object', + properties: { + score: { + type: 'number', + minimum: 1, + maximum: 10, + description: 'Overall score 1-10 (10=perfect)', + }, + summary: { + type: 'string', + description: 'One-sentence summary of the workflow execution (in Chinese)', + }, + highlights: { + type: 'array', + items: { + type: 'object', + properties: { + text: { type: 'string' }, + refEventId: { type: 'number' }, + }, + required: ['text'], + }, + }, + issues: { + type: 'array', + items: { + type: 'object', + properties: { + text: { type: 'string' }, + refEventId: { type: 'number' }, + }, + required: ['text'], + }, + }, + suggestions: { + type: 'array', + items: { type: 'string' }, + }, + roleAnalysis: { + type: 'array', + items: { + type: 'object', + properties: { + eventId: { type: 'number' }, + role: { type: 'string' }, + durationMs: { type: 'number' }, + verdict: { + type: 'string', + enum: ['good', 'slow', 'problematic'], + }, + comment: { type: 'string' }, + }, + required: ['eventId', 'role', 'verdict', 'comment'], + }, + }, + }, + required: [ + 'score', + 'summary', + 'highlights', + 'issues', + 'suggestions', + 'roleAnalysis', + ], + }, + }, +}; +const DEFAULT_ANALYSIS = { + score: 5, + summary: 'ๅˆ†ๆž่งฃๆžๅคฑ่ดฅ', + highlights: [], + issues: [{ text: 'ๅˆ†ๆž่พ“ๅ‡บๆ— ๆณ•่งฃๆž' }], + suggestions: [], + roleAnalysis: [], +}; +export function createAnalystRole(llm) { + return createToolRole(llm, { + systemPrompt: SYSTEM_PROMPT, + buildUserMessage: (chain) => { + const startMsg = chain.find((m) => m.role === '__start__'); + return `Workflow timeline:\n\n${startMsg?.content ?? '{}'}`; + }, + tool: ANALYSIS_TOOL, + defaultResult: DEFAULT_ANALYSIS, + toRoleResult: (analysis) => ({ + content: JSON.stringify(analysis), + meta: { + score: analysis.score, + highlights: analysis.highlights.map((h) => h.text), + issues: analysis.issues.map((i) => i.text), + }, + }), + }); +} diff --git a/packages/pulse/src/workflows/roles/analyst-llm.ts b/packages/pulse-workflows/src/roles/analyst-llm.ts similarity index 94% rename from packages/pulse/src/workflows/roles/analyst-llm.ts rename to packages/pulse-workflows/src/roles/analyst-llm.ts index 38f6d9e..6320fd6 100644 --- a/packages/pulse/src/workflows/roles/analyst-llm.ts +++ b/packages/pulse-workflows/src/roles/analyst-llm.ts @@ -5,10 +5,10 @@ * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) */ -import type { LlmClient } from '../../llm-client.js'; -import type { AnalystMeta } from '../report.js'; -import type { Role } from '../workflow-type.js'; -import { createToolRole } from './llm-role-factory.js'; +import type { LlmClient } from '@uncaged/pulse'; +import type { AnalystMeta } from '../workflows/report.js'; +import type { Role } from '@uncaged/pulse'; +import { createToolRole } from '@uncaged/pulse'; const SYSTEM_PROMPT = `You are a workflow execution analyst. You receive a workflow timeline (JSON with events array) and produce a structured analysis. diff --git a/packages/pulse-workflows/src/roles/architect-llm.d.ts b/packages/pulse-workflows/src/roles/architect-llm.d.ts new file mode 100644 index 0000000..bd59660 --- /dev/null +++ b/packages/pulse-workflows/src/roles/architect-llm.d.ts @@ -0,0 +1,12 @@ +/** + * Architect role โ€” LLM analyzes coding task and outputs structured design. + * Built on createLlmRole factory (shared sandwich pattern). + * + * Outputs: targetFiles, changes (file โ†’ description), verification criteria. + * + * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) + */ +import type { LlmClient } from '@uncaged/pulse'; +import type { ArchitectMeta } from '../workflows/coding.js'; +import type { Role } from '@uncaged/pulse'; +export declare function createArchitectRole(llmClient: LlmClient): Role; diff --git a/packages/pulse-workflows/src/roles/architect-llm.js b/packages/pulse-workflows/src/roles/architect-llm.js new file mode 100644 index 0000000..7ca415a --- /dev/null +++ b/packages/pulse-workflows/src/roles/architect-llm.js @@ -0,0 +1,51 @@ +/** + * Architect role โ€” LLM analyzes coding task and outputs structured design. + * Built on createLlmRole factory (shared sandwich pattern). + * + * Outputs: targetFiles, changes (file โ†’ description), verification criteria. + * + * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) + */ +import { createLlmRole } from '@uncaged/pulse'; +export function createArchitectRole(llmClient) { + return createLlmRole(llmClient, { + systemPrompt: `You are a software architect. Analyze the task and produce a structured design. +Reply in JSON with these exact fields: +{ + "analysis": "high-level summary", + "targetFiles": ["file1.ts", "file2.ts"], + "changes": { "file1.ts": "description of what to change", "file2.ts": "..." }, + "verification": "how to verify the changes are correct (e.g. test commands, manual checks)" +}`, + buildUserMessage: (chain) => { + const startMsg = chain.find((m) => m.role === '__start__'); + const title = startMsg?.meta?.title ?? 'unknown'; + const description = startMsg?.content ?? ''; + const repoDir = startMsg?.meta?.repoDir ?? ''; + return `Task: ${title}\nDescription: ${description}\nRepo: ${repoDir}`; + }, + parseResponse: (resp) => { + let parsed; + try { + parsed = JSON.parse(resp.content ?? '{}'); + } + catch { + parsed = { analysis: resp.content ?? 'No analysis' }; + } + const targetFiles = parsed.targetFiles ?? []; + const changes = parsed.changes ?? {}; + if (Object.keys(changes).length === 0) { + for (const f of targetFiles) + changes[f] = 'Update this file'; + } + return { + content: parsed.analysis ?? 'No analysis', + meta: { + targetFiles, + changes, + verification: parsed.verification ?? 'Run tests and verify manually', + }, + }; + }, + }); +} diff --git a/packages/pulse/src/workflows/roles/architect-llm.test.ts b/packages/pulse-workflows/src/roles/architect-llm.test.ts similarity index 94% rename from packages/pulse/src/workflows/roles/architect-llm.test.ts rename to packages/pulse-workflows/src/roles/architect-llm.test.ts index 68c1c8e..6427346 100644 --- a/packages/pulse/src/workflows/roles/architect-llm.test.ts +++ b/packages/pulse-workflows/src/roles/architect-llm.test.ts @@ -5,8 +5,8 @@ */ import { describe, expect, it } from 'bun:test'; -import type { LlmClient } from '../../llm-client.js'; -import type { WorkflowMessage } from '../workflow-type.js'; +import type { LlmClient } from '@uncaged/pulse'; +import type { WorkflowMessage } from '@uncaged/pulse'; import { createArchitectRole } from './architect-llm.js'; describe('createArchitectRole', () => { diff --git a/packages/pulse/src/workflows/roles/architect-llm.ts b/packages/pulse-workflows/src/roles/architect-llm.ts similarity index 90% rename from packages/pulse/src/workflows/roles/architect-llm.ts rename to packages/pulse-workflows/src/roles/architect-llm.ts index 16780d4..89c0b0e 100644 --- a/packages/pulse/src/workflows/roles/architect-llm.ts +++ b/packages/pulse-workflows/src/roles/architect-llm.ts @@ -7,10 +7,10 @@ * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) */ -import type { LlmClient } from '../../llm-client.js'; -import type { ArchitectMeta } from '../coding.js'; -import type { Role } from '../workflow-type.js'; -import { createLlmRole } from './llm-role-factory.js'; +import type { LlmClient } from '@uncaged/pulse'; +import type { ArchitectMeta } from '../workflows/coding.js'; +import type { Role } from '@uncaged/pulse'; +import { createLlmRole } from '@uncaged/pulse'; export function createArchitectRole(llmClient: LlmClient): Role { return createLlmRole(llmClient, { diff --git a/packages/pulse-workflows/src/roles/coder-cursor.d.ts b/packages/pulse-workflows/src/roles/coder-cursor.d.ts new file mode 100644 index 0000000..d0d5f75 --- /dev/null +++ b/packages/pulse-workflows/src/roles/coder-cursor.d.ts @@ -0,0 +1,14 @@ +/** + * Coder role โ€” LLM-Agent-LLM sandwich via agent executor. + * + * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) + */ +import type { LlmClient } from '@uncaged/pulse'; +import type { CoderMeta } from '../workflows/coding.js'; +import type { Role } from '@uncaged/pulse'; +import { type AgentRunner } from '@uncaged/pulse'; +export declare function createCoderRole(opts: { + agentBin?: string; + agent?: AgentRunner; + llm: LlmClient; +}): Role; diff --git a/packages/pulse-workflows/src/roles/coder-cursor.js b/packages/pulse-workflows/src/roles/coder-cursor.js new file mode 100644 index 0000000..e810004 --- /dev/null +++ b/packages/pulse-workflows/src/roles/coder-cursor.js @@ -0,0 +1,91 @@ +/** + * Coder role โ€” LLM-Agent-LLM sandwich via agent executor. + * + * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) + */ +import { createAgentExecutorRole, createCursorRunner, } from '@uncaged/pulse'; +export function createCoderRole(opts) { + const agent = opts.agent ?? + createCursorRunner({ + agentBin: opts.agentBin ?? `${process.env.HOME}/.local/bin/agent`, + }); + return createAgentExecutorRole(agent, opts.llm, { + prepPrompt: (chain, topicId) => { + const startMsg = chain.find((m) => m.role === '__start__'); + const title = startMsg?.meta?.title ?? topicId; + const description = startMsg?.content ?? ''; + const repoDir = startMsg?.meta?.repoDir ?? '/tmp'; + const architectMsg = chain.find((m) => m.role === 'architect'); + const lastReviewerMsg = [...chain] + .reverse() + .find((m) => m.role === 'reviewer'); + const changes = architectMsg?.meta?.changes ?? {}; + const verification = architectMsg?.meta?.verification ?? ''; + const targetFiles = architectMsg?.meta?.targetFiles ?? []; + const changesSection = Object.entries(changes) + .map(([file, desc]) => `- **${file}**: ${desc}`) + .join('\n'); + let rejectionSection = ''; + if (lastReviewerMsg?.meta?.verdict === 'rejected') { + const reasons = lastReviewerMsg.meta.rejectionReason ?? []; + const reasonText = reasons.length > 0 + ? reasons.map((r, i) => `${i + 1}. ${r}`).join('\n') + : 'No reason given'; + rejectionSection = `\n## Previous Review Rejection\n${reasonText}\n\nFix the issues above before proceeding.\n`; + } + const prompt = `## Task: ${title} + +${description} + +## Architect Analysis +${architectMsg?.content ?? 'None'} + +## Target Files +${targetFiles.join(', ') || 'Not specified'} + +## Per-File Changes +${changesSection || 'Not specified'} + +## Verification Criteria +${verification || 'Not specified'} +${rejectionSection} +## Instructions +Implement the changes as described above. Do NOT modify any existing test files. +Run the verification criteria to confirm correctness. Commit your changes.`; + return { prompt, cwd: repoDir }; + }, + parseMeta: { + system: 'Extract structured metadata from this coding agent report. Call the extract_coder_meta tool.', + tool: { + type: 'function', + function: { + name: 'extract_coder_meta', + description: 'Extract coder metadata from agent output', + parameters: { + type: 'object', + properties: { + filesChanged: { + type: 'array', + items: { type: 'string' }, + description: 'List of files created or modified', + }, + testsPassed: { + type: 'boolean', + description: 'Whether tests passed successfully', + }, + }, + required: ['filesChanged', 'testsPassed'], + }, + }, + }, + parse: (args) => { + const parsed = JSON.parse(args); + return { + filesChanged: parsed.filesChanged ?? [], + testsPassed: parsed.testsPassed ?? false, + }; + }, + defaultMeta: () => ({ filesChanged: [], testsPassed: false }), + }, + }); +} diff --git a/packages/pulse/src/workflows/roles/coder-cursor.test.ts b/packages/pulse-workflows/src/roles/coder-cursor.test.ts similarity index 95% rename from packages/pulse/src/workflows/roles/coder-cursor.test.ts rename to packages/pulse-workflows/src/roles/coder-cursor.test.ts index 6e92fa6..339322f 100644 --- a/packages/pulse/src/workflows/roles/coder-cursor.test.ts +++ b/packages/pulse-workflows/src/roles/coder-cursor.test.ts @@ -8,8 +8,8 @@ import { afterEach, describe, expect, it } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { createStore, type PulseStore } from '../../store.js'; -import type { WorkflowMessage } from '../workflow-type.js'; +import { createStore, type PulseStore } from '@uncaged/pulse'; +import type { WorkflowMessage } from '@uncaged/pulse'; describe('coder-cursor role', () => { let store: PulseStore; diff --git a/packages/pulse/src/workflows/roles/coder-cursor.ts b/packages/pulse-workflows/src/roles/coder-cursor.ts similarity index 95% rename from packages/pulse/src/workflows/roles/coder-cursor.ts rename to packages/pulse-workflows/src/roles/coder-cursor.ts index 78f4bce..77f264e 100644 --- a/packages/pulse/src/workflows/roles/coder-cursor.ts +++ b/packages/pulse-workflows/src/roles/coder-cursor.ts @@ -4,14 +4,14 @@ * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) */ -import type { LlmClient } from '../../llm-client.js'; -import type { CoderMeta } from '../coding.js'; -import type { Role } from '../workflow-type.js'; +import type { LlmClient } from '@uncaged/pulse'; +import type { CoderMeta } from '../workflows/coding.js'; +import type { Role } from '@uncaged/pulse'; import { type AgentRunner, createAgentExecutorRole, createCursorRunner, -} from './agent-executor.js'; +} from '@uncaged/pulse'; export function createCoderRole(opts: { agentBin?: string; diff --git a/packages/pulse-workflows/src/roles/renderer-llm.d.ts b/packages/pulse-workflows/src/roles/renderer-llm.d.ts new file mode 100644 index 0000000..877a360 --- /dev/null +++ b/packages/pulse-workflows/src/roles/renderer-llm.d.ts @@ -0,0 +1,9 @@ +/** + * Renderer role โ€” LLM turns analyst output into a beautiful single-file HTML report. + * + * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) + */ +import type { LlmClient } from '@uncaged/pulse'; +import type { RendererMeta } from '../workflows/report.js'; +import type { Role } from '@uncaged/pulse'; +export declare function createRendererRole(llm: LlmClient): Role; diff --git a/packages/pulse-workflows/src/roles/renderer-llm.js b/packages/pulse-workflows/src/roles/renderer-llm.js new file mode 100644 index 0000000..15d96c6 --- /dev/null +++ b/packages/pulse-workflows/src/roles/renderer-llm.js @@ -0,0 +1,57 @@ +/** + * Renderer role โ€” LLM turns analyst output into a beautiful single-file HTML report. + * + * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) + */ +const SYSTEM_PROMPT = `You are a talented web designer who creates beautiful single-file HTML reports. + +You receive: +1. A workflow analysis (from the analyst) +2. The raw timeline data (from __start__) + +Generate a COMPLETE, self-contained HTML file with: +- Inline CSS (no external dependencies) +- A clean, modern design with a tasteful color palette +- A horizontal Gantt-style timeline bar showing each role's duration proportionally +- Role cards with: name, duration, status icon, meta highlights +- A summary header: task name, total time, score badge, pass/fail +- The analyst's highlights (green) and issues (red) as styled callouts +- Responsive layout (looks good on mobile too) +- Subtle animations (fade-in on scroll, progress bar fill) + +Design guidelines: +- Background: #0f172a (dark slate), cards: #1e293b, accent: #38bdf8 (sky blue) +- Font: system-ui, clean sans-serif +- Score badge: colored by score (green โ‰ฅ8, yellow โ‰ฅ5, red <5) +- Role timeline: horizontal bar chart, each segment a different color +- Keep it under 500 lines of HTML + +Output ONLY the complete HTML file, starting with and ending with . +Do NOT wrap it in markdown code blocks. Just raw HTML.`; +export function createRendererRole(llm) { + return async (chain) => { + const startMsg = chain.find((m) => m.role === '__start__'); + const analystMsg = chain.find((m) => m.role === 'analyst'); + const timelineJson = startMsg?.content ?? '{}'; + const analysis = analystMsg?.content ?? 'No analysis available.'; + const analysisMeta = analystMsg?.meta + ? JSON.stringify(analystMsg.meta) + : '{}'; + const resp = await llm.chat({ + messages: [ + { role: 'system', content: SYSTEM_PROMPT }, + { + role: 'user', + content: `## Timeline Data\n\`\`\`json\n${timelineJson}\n\`\`\`\n\n## Analyst Report\n${analysis}\n\n## Analyst Meta\n${analysisMeta}`, + }, + ], + }); + let html = resp.content ?? 'Report generation failed'; + // Strip markdown code fences if LLM wrapped it + html = html.replace(/^```html?\n?/i, '').replace(/\n?```$/i, ''); + return { + content: html, + meta: { format: 'html', bytes: html.length }, + }; + }; +} diff --git a/packages/pulse/src/workflows/roles/renderer-llm.ts b/packages/pulse-workflows/src/roles/renderer-llm.ts similarity index 93% rename from packages/pulse/src/workflows/roles/renderer-llm.ts rename to packages/pulse-workflows/src/roles/renderer-llm.ts index 077df16..dd22974 100644 --- a/packages/pulse/src/workflows/roles/renderer-llm.ts +++ b/packages/pulse-workflows/src/roles/renderer-llm.ts @@ -4,9 +4,9 @@ * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) */ -import type { LlmClient } from '../../llm-client.js'; -import type { RendererMeta } from '../report.js'; -import type { Role, RoleResult, WorkflowMessage } from '../workflow-type.js'; +import type { LlmClient } from '@uncaged/pulse'; +import type { RendererMeta } from '../workflows/report.js'; +import type { Role, RoleResult, WorkflowMessage } from '@uncaged/pulse'; const SYSTEM_PROMPT = `You are a talented web designer who creates beautiful single-file HTML reports. diff --git a/packages/pulse-workflows/src/roles/renderer-template.d.ts b/packages/pulse-workflows/src/roles/renderer-template.d.ts new file mode 100644 index 0000000..f7e5f68 --- /dev/null +++ b/packages/pulse-workflows/src/roles/renderer-template.d.ts @@ -0,0 +1,9 @@ +/** + * Renderer role โ€” pure code, no LLM. Generates a single-file HTML report + * from timeline JSON + analyst JSON. + * + * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) + */ +import type { RendererMeta } from '../workflows/report.js'; +import type { Role } from '@uncaged/pulse'; +export declare function createRendererRole(): Role; diff --git a/packages/pulse-workflows/src/roles/renderer-template.js b/packages/pulse-workflows/src/roles/renderer-template.js new file mode 100644 index 0000000..cde7e2f --- /dev/null +++ b/packages/pulse-workflows/src/roles/renderer-template.js @@ -0,0 +1,236 @@ +/** + * Renderer role โ€” pure code, no LLM. Generates a single-file HTML report + * from timeline JSON + analyst JSON. + * + * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) + */ +const ROLE_COLORS = { + __start__: '#94a3b8', + architect: '#a78bfa', + coder: '#38bdf8', + reviewer: '#34d399', + closer: '#f472b6', + analyst: '#fbbf24', + renderer: '#fb923c', +}; +const VERDICT_COLORS = { + good: '#34d399', + slow: '#fbbf24', + problematic: '#f87171', +}; +function esc(s) { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} +function fmtDuration(ms) { + if (ms < 1000) + return `${ms}ms`; + return `${(ms / 1000).toFixed(1)}s`; +} +function renderHtml(timeline, analysis) { + const roleMap = new Map(); + for (const ra of analysis.roleAnalysis) + roleMap.set(ra.eventId, ra); + // Build gantt bars + const ganttBars = timeline.events + .filter((e) => e.role !== '__start__') + .map((e) => { + const pct = timeline.totalMs > 0 ? (e.durationMs / timeline.totalMs) * 100 : 0; + const color = ROLE_COLORS[e.role] ?? '#64748b'; + const ra = roleMap.get(e.id); + const border = ra + ? `border: 2px solid ${VERDICT_COLORS[ra.verdict] ?? '#64748b'}` + : ''; + return `
${pct > 8 ? esc(e.role) : ''}
`; + }) + .join('\n'); + // Build event cards + const eventCards = timeline.events + .map((e) => { + const color = ROLE_COLORS[e.role] ?? '#64748b'; + const ra = roleMap.get(e.id); + const verdictBadge = ra + ? `${ra.verdict}` + : ''; + const comment = ra ? `

${esc(ra.comment)}

` : ''; + const metaStr = e.meta + ? `
${esc(JSON.stringify(e.meta, null, 1))}
` + : ''; + const contentPreview = e.content + ? `
ๅ†…ๅฎน้ข„่งˆ
${esc(e.content.slice(0, 500))}${e.content.length > 500 ? '...' : ''}
` + : ''; + return ` +
+
+ + ${esc(e.role)} + #${e.id} + ${fmtDuration(e.durationMs)} + ${verdictBadge} +
+ ${comment} + ${metaStr} + ${contentPreview} +
`; + }) + .join('\n'); + // Highlights & Issues + const highlightItems = analysis.highlights + .map((h) => { + const ref = h.refEventId + ? ` #${h.refEventId}` + : ''; + return `
  • โœ… ${esc(h.text)}${ref}
  • `; + }) + .join('\n'); + const issueItems = analysis.issues + .map((i) => { + const ref = i.refEventId + ? ` #${i.refEventId}` + : ''; + return `
  • โš ๏ธ ${esc(i.text)}${ref}
  • `; + }) + .join('\n'); + const suggestionItems = analysis.suggestions + .map((s) => `
  • ๐Ÿ’ก ${esc(s)}
  • `) + .join('\n'); + const scoreBg = analysis.score >= 8 + ? '#34d399' + : analysis.score >= 5 + ? '#fbbf24' + : '#f87171'; + return ` + + + + +ๅทฅไฝœๆตๆŠฅๅ‘Š: ${esc(timeline.key)} + + + +

    ๐Ÿ“Š ${esc(timeline.key)}

    +

    ๅทฅไฝœๆตๆ‰ง่กŒๆŠฅๅ‘Š ยท Pulse Council v2 ็”Ÿๆˆ

    + +
    +
    +
    ่ฏ„ๅˆ†
    +
    ${analysis.score}
    +
    +
    +
    ๆ€ป่€—ๆ—ถ
    +
    ${fmtDuration(timeline.totalMs)}
    +
    +
    +
    ๆญฅ้ชค
    +
    ${timeline.events.length}
    +
    +
    +
    ๆ€ป็ป“
    +
    ${esc(analysis.summary)}
    +
    +
    + +
    +

    ๆ—ถ้—ด็บฟ

    +
    + ${ganttBars} +
    +
    + +
    +

    ไบ‹ไปถ

    + ${eventCards} +
    + +
    +

    ไบฎ็‚น

    +
      ${highlightItems || '
    • ๆ— 
    • '}
    +
    + +
    +

    ้—ฎ้ข˜

    +
      ${issueItems || '
    • ๆ— 
    • '}
    +
    + +
    +

    ๆ”น่ฟ›ๅปบ่ฎฎ

    +
      ${suggestionItems || '
    • ๆ— 
    • '}
    +
    + +
    + ๅฐๆฉ˜ ๐ŸŠ ยท Pulse Council v2 ยท ${new Date().toISOString().slice(0, 19)}Z +
    + +`; +} +export function createRendererRole() { + return async (chain) => { + const startMsg = chain.find((m) => m.role === '__start__'); + const analystMsg = chain.find((m) => m.role === 'analyst'); + let timeline; + try { + timeline = JSON.parse(startMsg?.content ?? '{}'); + } + catch { + timeline = { key: 'unknown', totalMs: 0, events: [] }; + } + let analysis; + try { + analysis = JSON.parse(analystMsg?.content ?? '{}'); + } + catch { + analysis = { + score: 0, + summary: 'Failed to parse analysis', + highlights: [], + issues: [], + suggestions: [], + roleAnalysis: [], + }; + } + const html = renderHtml(timeline, analysis); + return { + content: html, + meta: { format: 'html', bytes: html.length }, + }; + }; +} diff --git a/packages/pulse/src/workflows/roles/renderer-template.ts b/packages/pulse-workflows/src/roles/renderer-template.ts similarity index 98% rename from packages/pulse/src/workflows/roles/renderer-template.ts rename to packages/pulse-workflows/src/roles/renderer-template.ts index a528843..6645b34 100644 --- a/packages/pulse/src/workflows/roles/renderer-template.ts +++ b/packages/pulse-workflows/src/roles/renderer-template.ts @@ -5,8 +5,8 @@ * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) */ -import type { RendererMeta } from '../report.js'; -import type { Role, RoleResult, WorkflowMessage } from '../workflow-type.js'; +import type { RendererMeta } from '../workflows/report.js'; +import type { Role, RoleResult, WorkflowMessage } from '@uncaged/pulse'; import type { AnalysisResult } from './analyst-llm.js'; interface TimelineEvent { diff --git a/packages/pulse-workflows/src/roles/reviewer-cursor.d.ts b/packages/pulse-workflows/src/roles/reviewer-cursor.d.ts new file mode 100644 index 0000000..c815c0d --- /dev/null +++ b/packages/pulse-workflows/src/roles/reviewer-cursor.d.ts @@ -0,0 +1,17 @@ +/** + * Reviewer role โ€” LLM-Agent-LLM sandwich via agent executor. + * + * Compares coder's actual changes against architect's design (changes + verification). + * Outputs verdict, rejectionReason (when rejected), and monotonic retryCount. + * + * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) + */ +import type { LlmClient } from '@uncaged/pulse'; +import type { ReviewerMeta } from '../workflows/coding.js'; +import type { Role } from '@uncaged/pulse'; +import { type AgentRunner } from '@uncaged/pulse'; +export declare function createReviewerRole(opts: { + agentBin?: string; + agent?: AgentRunner; + llm: LlmClient; +}): Role; diff --git a/packages/pulse-workflows/src/roles/reviewer-cursor.js b/packages/pulse-workflows/src/roles/reviewer-cursor.js new file mode 100644 index 0000000..374ab9b --- /dev/null +++ b/packages/pulse-workflows/src/roles/reviewer-cursor.js @@ -0,0 +1,112 @@ +/** + * Reviewer role โ€” LLM-Agent-LLM sandwich via agent executor. + * + * Compares coder's actual changes against architect's design (changes + verification). + * Outputs verdict, rejectionReason (when rejected), and monotonic retryCount. + * + * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) + */ +import { createAgentExecutorRole, createCursorRunner, } from '@uncaged/pulse'; +export function createReviewerRole(opts) { + const agent = opts.agent ?? + createCursorRunner({ + agentBin: opts.agentBin ?? `${process.env.HOME}/.local/bin/agent`, + }); + const innerRole = createAgentExecutorRole(agent, opts.llm, { + prepPrompt: (chain, topicId) => { + const startMsg = chain.find((m) => m.role === '__start__'); + const title = startMsg?.meta?.title ?? topicId; + const repoDir = startMsg?.meta?.repoDir ?? '/tmp'; + const architectMsg = chain.find((m) => m.role === 'architect'); + const coderMsg = [...chain].reverse().find((m) => m.role === 'coder'); + const reviewerMsgs = chain.filter((m) => m.role === 'reviewer'); + const retryCount = reviewerMsgs.length; + const architectChanges = architectMsg?.meta?.changes ?? {}; + const architectVerification = architectMsg?.meta?.verification ?? ''; + const changesSpec = Object.entries(architectChanges) + .map(([file, desc]) => `- **${file}**: ${desc}`) + .join('\n'); + const prompt = `## Code Review: ${title} (review round ${retryCount}) + +## Architect's Design Specification +### Expected Changes +${changesSpec || 'Not specified'} + +### Verification Criteria +${architectVerification || 'Not specified'} + +## What the Coder Actually Did +${coderMsg?.content ?? 'Unknown'} + +## Files Changed by Coder +${(coderMsg?.meta?.filesChanged ?? []).join(', ')} + +## Instructions +1. Compare the coder's actual changes against the architect's expected changes above. +2. Verify the coder addressed each file and change described in the spec. +3. Check if the verification criteria can be satisfied. +4. Review for correctness, security, and code quality. +Do NOT modify any files. Only output your review. +End with a clear verdict: APPROVED or REJECTED. +If REJECTED, explain specifically what is wrong or missing.`; + return { prompt, cwd: repoDir }; + }, + parseMeta: { + system: 'Extract the review verdict and rejection reason from this code review report. Call the extract_review_verdict tool.', + tool: { + type: 'function', + function: { + name: 'extract_review_verdict', + description: 'Extract review verdict from reviewer output', + parameters: { + type: 'object', + properties: { + verdict: { + type: 'string', + enum: ['approved', 'rejected'], + description: 'Final review verdict', + }, + rejectionReason: { + type: 'array', + items: { type: 'string' }, + description: 'List of rejection reasons (empty array if approved)', + }, + }, + required: ['verdict', 'rejectionReason'], + }, + }, + }, + parse: (args) => { + const parsed = JSON.parse(args); + const rawReason = parsed.rejectionReason; + const rejectionReason = Array.isArray(rawReason) + ? rawReason + : rawReason + ? [rawReason] + : []; + return { + verdict: parsed.verdict === 'rejected' ? 'rejected' : 'approved', + rejectionReason, + retryCount: 0, + }; + }, + defaultMeta: (output) => { + const tail = output.toLowerCase().slice(-200); + const isRejected = tail.includes('reject'); + return { + verdict: isRejected ? 'rejected' : 'approved', + rejectionReason: isRejected ? [output.slice(-500)] : [], + retryCount: 0, + }; + }, + }, + }); + return async (chain, topicId, store) => { + const result = await innerRole(chain, topicId, store); + const retryCount = chain.filter((m) => m.role === 'reviewer').length; + return { + ...result, + meta: result.meta ? { ...result.meta, retryCount } : result.meta, + }; + }; +} diff --git a/packages/pulse/src/workflows/roles/reviewer-cursor.test.ts b/packages/pulse-workflows/src/roles/reviewer-cursor.test.ts similarity index 96% rename from packages/pulse/src/workflows/roles/reviewer-cursor.test.ts rename to packages/pulse-workflows/src/roles/reviewer-cursor.test.ts index b296d3d..191d8d5 100644 --- a/packages/pulse/src/workflows/roles/reviewer-cursor.test.ts +++ b/packages/pulse-workflows/src/roles/reviewer-cursor.test.ts @@ -8,8 +8,8 @@ import { afterEach, describe, expect, it } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { createStore, type PulseStore } from '../../store.js'; -import type { WorkflowMessage } from '../workflow-type.js'; +import { createStore, type PulseStore } from '@uncaged/pulse'; +import type { WorkflowMessage } from '@uncaged/pulse'; describe('reviewer-cursor role', () => { let store: PulseStore; diff --git a/packages/pulse/src/workflows/roles/reviewer-cursor.ts b/packages/pulse-workflows/src/roles/reviewer-cursor.ts similarity index 96% rename from packages/pulse/src/workflows/roles/reviewer-cursor.ts rename to packages/pulse-workflows/src/roles/reviewer-cursor.ts index 5bd7a4a..7096d62 100644 --- a/packages/pulse/src/workflows/roles/reviewer-cursor.ts +++ b/packages/pulse-workflows/src/roles/reviewer-cursor.ts @@ -7,14 +7,14 @@ * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) */ -import type { LlmClient } from '../../llm-client.js'; -import type { ReviewerMeta } from '../coding.js'; -import type { Role } from '../workflow-type.js'; +import type { LlmClient } from '@uncaged/pulse'; +import type { ReviewerMeta } from '../workflows/coding.js'; +import type { Role } from '@uncaged/pulse'; import { type AgentRunner, createAgentExecutorRole, createCursorRunner, -} from './agent-executor.js'; +} from '@uncaged/pulse'; export function createReviewerRole(opts: { agentBin?: string; diff --git a/packages/pulse-workflows/src/workflows/coding-tdd.d.ts b/packages/pulse-workflows/src/workflows/coding-tdd.d.ts new file mode 100644 index 0000000..1397645 --- /dev/null +++ b/packages/pulse-workflows/src/workflows/coding-tdd.d.ts @@ -0,0 +1,64 @@ +/** + * TDD-driven coding workflow โ€” test-planner โ†’ โ€ฆ โ†’ closer. + * + * Pure roles + START/END automaton. Trigger: coding-tdd.__start__ + * + * ่ฎพ่ฎก้‡Œ็š„ role `type`๏ผˆllm / code / agent๏ผ‰ๅœจๅฎž็Žฐๅฑ‚็ปŸไธ€ไธบ `Role<โ€ฆ>`๏ผ›้ป˜่ฎคๅ…จ้ƒจไธบๅฏๆณจๅ…ฅ็š„ + * mock๏ผŒ็”Ÿไบง็Žฏๅขƒ้€š่ฟ‡ `CreateTddCodingWorkflowOpts` ไผ ๅ…ฅ `createLlmRole` / agent ๅทฅๅŽ‚็ญ‰ใ€‚ + * + * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) + */ +import { type Role, type WorkflowType } from '@uncaged/pulse'; +export type TestPlannerMeta = { + testPlan: string; + scenarios: string[]; +}; +export type TestReviewerMeta = { + verdict: 'approved' | 'rejected'; + feedback: string; +}; +export type TestCoderMeta = { + testFiles: string[]; + testCount: number; +}; +export type TddCoderMeta = { + filesChanged: string[]; + deploymentGuide: string; +}; +export type AutoTesterMeta = { + pass: boolean; + failedTests: string[]; + output: string; +}; +export type ManualTesterMeta = { + pass: boolean; + issues: string[]; +}; +export type TddReviewerMeta = { + verdict: 'approved' | 'rejected'; + comments: string; + codeQuality: string; + testQuality: string; +}; +export type TddCloserMeta = Record; +export type TddCodingRoles = { + 'test-planner': Role; + 'test-reviewer': Role; + 'test-coder': Role; + coder: Role; + 'auto-tester': Role; + 'manual-tester': Role; + reviewer: Role; + closer: Role; +}; +export type CreateTddCodingWorkflowOpts = { + testPlannerFn?: Role; + testReviewerFn?: Role; + testCoderFn?: Role; + coderFn?: Role; + autoTesterFn?: Role; + manualTesterFn?: Role; + reviewerFn?: Role; + closerFn?: Role; +}; +export declare function createTddCodingWorkflow(opts?: CreateTddCodingWorkflowOpts): WorkflowType; diff --git a/packages/pulse-workflows/src/workflows/coding-tdd.js b/packages/pulse-workflows/src/workflows/coding-tdd.js new file mode 100644 index 0000000..22722ef --- /dev/null +++ b/packages/pulse-workflows/src/workflows/coding-tdd.js @@ -0,0 +1,144 @@ +/** + * TDD-driven coding workflow โ€” test-planner โ†’ โ€ฆ โ†’ closer. + * + * Pure roles + START/END automaton. Trigger: coding-tdd.__start__ + * + * ่ฎพ่ฎก้‡Œ็š„ role `type`๏ผˆllm / code / agent๏ผ‰ๅœจๅฎž็Žฐๅฑ‚็ปŸไธ€ไธบ `Role<โ€ฆ>`๏ผ›้ป˜่ฎคๅ…จ้ƒจไธบๅฏๆณจๅ…ฅ็š„ + * mock๏ผŒ็”Ÿไบง็Žฏๅขƒ้€š่ฟ‡ `CreateTddCodingWorkflowOpts` ไผ ๅ…ฅ `createLlmRole` / agent ๅทฅๅŽ‚็ญ‰ใ€‚ + * + * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) + */ +import { END, START, } from '@uncaged/pulse'; +// โ”€โ”€ Default mock implementations โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +const defaultTestPlanner = async (chain) => { + const startMsg = chain.find((m) => m.role === '__start__'); + const title = startMsg?.meta?.title ?? 'task'; + return { + content: `[mock] Test plan draft for "${title}"`, + meta: { + testPlan: `# Tests for ${title}\n- Case A\n- Case B`, + scenarios: ['happy path', 'edge case'], + }, + }; +}; +const defaultTestReviewer = async (chain) => { + const planner = chain.find((m) => m.role === 'test-planner'); + return { + content: `[mock] Review of plan: ${planner?.content?.slice(0, 40) ?? ''}`, + meta: { verdict: 'approved', feedback: 'LGTM' }, + }; +}; +const defaultTestCoder = async () => ({ + content: '[mock] Red tests written', + meta: { + testFiles: ['src/foo.test.ts'], + testCount: 2, + }, +}); +const defaultCoder = async (chain) => { + const tc = chain.find((m) => m.role === 'test-coder'); + const n = tc?.meta?.testCount ?? 0; + return { + content: '[mock] Implementation', + meta: { + filesChanged: ['src/foo.ts'], + deploymentGuide: `Run tests (${n} cases); open http://localhost`, + }, + }; +}; +const defaultAutoTester = async () => ({ + content: '[mock] bun test output', + meta: { + pass: true, + failedTests: [], + output: 'all tests passed', + }, +}); +const defaultManualTester = async () => ({ + content: '[mock] Manual validation notes', + meta: { pass: true, issues: [] }, +}); +const defaultTddReviewer = async () => ({ + content: '[mock] Review complete', + meta: { + verdict: 'approved', + comments: 'ok', + codeQuality: 'good', + testQuality: 'good', + }, +}); +const defaultCloser = async (chain) => { + const startMsg = chain.find((m) => m.role === '__start__'); + const title = startMsg?.meta?.title ?? 'task'; + return { + content: `[mock] TDD workflow report: ${title}`, + meta: {}, + }; +}; +function tddCodingModerator(output, _topicId, remainingRounds) { + const emergency = remainingRounds !== undefined && remainingRounds <= 1; + if (output.role === START) + return 'test-planner'; + switch (output.role) { + case 'test-planner': + return 'test-reviewer'; + case 'test-reviewer': { + const verdict = output.meta?.verdict; + if (verdict === 'approved') + return 'test-coder'; + if (verdict === 'rejected') { + if (emergency) + return 'closer'; + return 'test-planner'; + } + return 'test-planner'; + } + case 'test-coder': + return 'coder'; + case 'coder': + return 'auto-tester'; + case 'auto-tester': { + if (output.meta?.pass) + return 'manual-tester'; + if (emergency) + return 'closer'; + return 'coder'; + } + case 'manual-tester': { + if (output.meta?.pass) + return 'reviewer'; + if (emergency) + return 'closer'; + return 'coder'; + } + case 'reviewer': { + if (emergency) + return 'closer'; + if (output.meta?.verdict === 'approved') + return 'closer'; + return 'coder'; + } + case 'closer': + return END; + default: + return END; + } +} +// โ”€โ”€ Factory โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +export function createTddCodingWorkflow(opts) { + return { + name: 'coding-tdd', + roles: { + 'test-planner': opts?.testPlannerFn ?? defaultTestPlanner, + 'test-reviewer': opts?.testReviewerFn ?? defaultTestReviewer, + 'test-coder': opts?.testCoderFn ?? defaultTestCoder, + coder: opts?.coderFn ?? defaultCoder, + 'auto-tester': opts?.autoTesterFn ?? defaultAutoTester, + 'manual-tester': opts?.manualTesterFn ?? defaultManualTester, + reviewer: opts?.reviewerFn ?? defaultTddReviewer, + closer: opts?.closerFn ?? defaultCloser, + }, + moderator: tddCodingModerator, + limits: { maxRounds: 25 }, + }; +} diff --git a/packages/pulse/src/workflows/coding-tdd.test.ts b/packages/pulse-workflows/src/workflows/coding-tdd.test.ts similarity index 98% rename from packages/pulse/src/workflows/coding-tdd.test.ts rename to packages/pulse-workflows/src/workflows/coding-tdd.test.ts index 78788f8..cdfe452 100644 --- a/packages/pulse/src/workflows/coding-tdd.test.ts +++ b/packages/pulse-workflows/src/workflows/coding-tdd.test.ts @@ -8,10 +8,10 @@ import { describe, expect, it } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { createStore, type PulseStore } from '../store.js'; +import { createStore, type PulseStore } from '@uncaged/pulse'; import { createTddCodingWorkflow } from './coding-tdd.js'; -import { createWorkflowRule } from './workflow-rule-adapter.js'; -import { END, START } from './workflow-type.js'; +import { createWorkflowRule } from '@uncaged/pulse'; +import { END, START } from '@uncaged/pulse'; describe('coding-tdd WorkflowType', () => { let store: PulseStore; diff --git a/packages/pulse/src/workflows/coding-tdd.ts b/packages/pulse-workflows/src/workflows/coding-tdd.ts similarity index 99% rename from packages/pulse/src/workflows/coding-tdd.ts rename to packages/pulse-workflows/src/workflows/coding-tdd.ts index b819ac3..b09a69b 100644 --- a/packages/pulse/src/workflows/coding-tdd.ts +++ b/packages/pulse-workflows/src/workflows/coding-tdd.ts @@ -15,7 +15,7 @@ import { type Role, START, type WorkflowType, -} from './workflow-type.js'; +} from '@uncaged/pulse'; // โ”€โ”€ Role Meta types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ diff --git a/packages/pulse-workflows/src/workflows/coding.d.ts b/packages/pulse-workflows/src/workflows/coding.d.ts new file mode 100644 index 0000000..da07f8d --- /dev/null +++ b/packages/pulse-workflows/src/workflows/coding.d.ts @@ -0,0 +1,36 @@ +/** + * CodingTask WorkflowType โ€” pure roles + START/END automaton. + * + * Roles: architect โ†’ coder โ†’ reviewer โ†’ closer + * Trigger: coding.__start__ (external) + * Each role returns { content, meta } โ€” adapter writes events. + * + * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) + */ +import { type Role, type WorkflowType } from '@uncaged/pulse'; +export type ArchitectMeta = { + targetFiles: string[]; + changes: Record; + verification: string; +}; +export type CoderMeta = { + filesChanged: string[]; + testsPassed: boolean; +}; +export type ReviewerMeta = { + verdict: 'approved' | 'rejected'; + rejectionReason: string[]; + retryCount: number; +}; +export type CloserMeta = null; +export type CodingRoles = { + architect: Role; + coder: Role; + reviewer: Role; + closer: Role; +}; +export declare function createCodingWorkflow(opts?: { + architectFn?: Role; + coderFn?: Role; + reviewerFn?: Role; +}): WorkflowType; diff --git a/packages/pulse-workflows/src/workflows/coding.js b/packages/pulse-workflows/src/workflows/coding.js new file mode 100644 index 0000000..12bbe8c --- /dev/null +++ b/packages/pulse-workflows/src/workflows/coding.js @@ -0,0 +1,87 @@ +/** + * CodingTask WorkflowType โ€” pure roles + START/END automaton. + * + * Roles: architect โ†’ coder โ†’ reviewer โ†’ closer + * Trigger: coding.__start__ (external) + * Each role returns { content, meta } โ€” adapter writes events. + * + * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) + */ +import { END, START, } from '@uncaged/pulse'; +// โ”€โ”€ Default mock implementations โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +const defaultArchitect = async (chain) => { + const startMsg = chain.find((m) => m.role === '__start__'); + const title = startMsg?.meta?.title ?? 'unknown'; + const targetFiles = ['src/main.ts', 'src/utils.ts']; + return { + content: `[mock] Analysis for "${title}": ${startMsg?.content ?? ''}`, + meta: { + targetFiles, + changes: { + 'src/main.ts': 'Update main entry point', + 'src/utils.ts': 'Add utility helpers', + }, + verification: 'Run `bun test` and verify all tests pass', + }, + }; +}; +const defaultCoder = async (chain) => { + const architectMsg = chain.find((m) => m.role === 'architect'); + const filesChanged = architectMsg?.meta?.targetFiles ?? [ + 'src/main.ts', + ]; + return { + content: '[mock] Implemented changes', + meta: { filesChanged, testsPassed: true }, + }; +}; +const defaultReviewer = async (chain) => { + const reviewerMsgs = chain.filter((m) => m.role === 'reviewer'); + const retryCount = reviewerMsgs.length; + return { + content: '[mock] Code looks good', + meta: { verdict: 'approved', rejectionReason: [], retryCount }, + }; +}; +const defaultCloser = async (chain) => { + const startMsg = chain.find((m) => m.role === '__start__'); + const title = startMsg?.meta?.title ?? 'unknown'; + return { + content: `Completed: ${title}`, + meta: null, + }; +}; +function codingModerator(output, _topicId, remainingRounds) { + if (output.role === START) + return 'architect'; + switch (output.role) { + case 'architect': + return 'coder'; + case 'coder': + return 'reviewer'; + case 'reviewer': { + if (remainingRounds !== undefined && remainingRounds <= 1) + return 'closer'; + const rejected = output.meta?.verdict === 'rejected'; + const retryCount = output.meta?.retryCount ?? 0; + return rejected && retryCount < 3 ? 'coder' : 'closer'; + } + case 'closer': + return END; + } + return END; +} +// โ”€โ”€ Factory โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +export function createCodingWorkflow(opts) { + return { + name: 'coding', + roles: { + architect: opts?.architectFn ?? defaultArchitect, + coder: opts?.coderFn ?? defaultCoder, + reviewer: opts?.reviewerFn ?? defaultReviewer, + closer: defaultCloser, + }, + moderator: codingModerator, + limits: { maxRounds: 15 }, + }; +} diff --git a/packages/pulse/src/workflows/coding.test.ts b/packages/pulse-workflows/src/workflows/coding.test.ts similarity index 98% rename from packages/pulse/src/workflows/coding.test.ts rename to packages/pulse-workflows/src/workflows/coding.test.ts index 7547270..9f76ec2 100644 --- a/packages/pulse/src/workflows/coding.test.ts +++ b/packages/pulse-workflows/src/workflows/coding.test.ts @@ -8,9 +8,9 @@ import { afterEach, describe, expect, it } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { createStore, type PulseStore } from '../store.js'; +import { createStore, type PulseStore } from '@uncaged/pulse'; import { createCodingWorkflow } from './coding.js'; -import { createWorkflowRule } from './workflow-rule-adapter.js'; +import { createWorkflowRule } from '@uncaged/pulse'; describe('CodingTask WorkflowType', () => { let store: PulseStore; diff --git a/packages/pulse/src/workflows/coding.ts b/packages/pulse-workflows/src/workflows/coding.ts similarity index 99% rename from packages/pulse/src/workflows/coding.ts rename to packages/pulse-workflows/src/workflows/coding.ts index 4177442..2f0cbe5 100644 --- a/packages/pulse/src/workflows/coding.ts +++ b/packages/pulse-workflows/src/workflows/coding.ts @@ -14,7 +14,7 @@ import { type Role, START, type WorkflowType, -} from './workflow-type.js'; +} from '@uncaged/pulse'; // โ”€โ”€ Role Meta types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ diff --git a/packages/pulse-workflows/src/workflows/cursor-health.d.ts b/packages/pulse-workflows/src/workflows/cursor-health.d.ts new file mode 100644 index 0000000..17d3867 --- /dev/null +++ b/packages/pulse-workflows/src/workflows/cursor-health.d.ts @@ -0,0 +1,24 @@ +/** + * Cursor Health Rule โ€” Cursor ็”จ้‡ๅ“จๅ…ต + * + * ็›‘ๆŽง Cursor AI ่ฐƒ็”จ้ข‘็އ๏ผŒ้˜ฒๆญข workflow ๆญปๅพช็Žฏ็ƒง้ขๅบฆใ€‚ + * ่ฏปๅ– ~/.cursor/ai-tracking/ai-code-tracking.db ๆฃ€ๆŸฅๆœ€่ฟ‘่ฐƒ็”จๆฌกๆ•ฐใ€‚ + * + * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) + */ +export interface CursorHealthResult { + recentCount: number; + threshold: number; + isHealthy: boolean; + windowMs: number; + checkedAt: number; +} +export interface CursorHealthOptions { + dbPath?: string; + windowMs?: number; + threshold?: number; +} +/** + * ๆฃ€ๆŸฅ Cursor AI ่ฐƒ็”จๅฅๅบท็Šถๆ€ + */ +export declare function checkCursorHealth(opts?: CursorHealthOptions): CursorHealthResult; diff --git a/packages/pulse-workflows/src/workflows/cursor-health.js b/packages/pulse-workflows/src/workflows/cursor-health.js new file mode 100644 index 0000000..0eca062 --- /dev/null +++ b/packages/pulse-workflows/src/workflows/cursor-health.js @@ -0,0 +1,41 @@ +/** + * Cursor Health Rule โ€” Cursor ็”จ้‡ๅ“จๅ…ต + * + * ็›‘ๆŽง Cursor AI ่ฐƒ็”จ้ข‘็އ๏ผŒ้˜ฒๆญข workflow ๆญปๅพช็Žฏ็ƒง้ขๅบฆใ€‚ + * ่ฏปๅ– ~/.cursor/ai-tracking/ai-code-tracking.db ๆฃ€ๆŸฅๆœ€่ฟ‘่ฐƒ็”จๆฌกๆ•ฐใ€‚ + * + * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) + */ +import { Database } from 'bun:sqlite'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +/** + * ๆฃ€ๆŸฅ Cursor AI ่ฐƒ็”จๅฅๅบท็Šถๆ€ + */ +export function checkCursorHealth(opts = {}) { + const { dbPath = join(homedir(), '.cursor', 'ai-tracking', 'ai-code-tracking.db'), windowMs = 15 * 60 * 1000, // 15 ๅˆ†้’Ÿ + threshold = 100, } = opts; + const checkedAt = Date.now(); + const windowStart = checkedAt - windowMs; + let recentCount = 0; + try { + const db = new Database(dbPath, { readonly: true }); + // ๆŸฅ่ฏขๆœ€่ฟ‘ๆ—ถ้—ด็ช—ๅฃๅ†…็š„่ฎฐๅฝ•ๆ•ฐ + const query = db.query('SELECT COUNT(*) as count FROM ai_code_hashes WHERE timestamp >= ?'); + const result = query.get(windowStart); + recentCount = result?.count ?? 0; + db.close(); + } + catch (error) { + // ๆ•ฐๆฎๅบ“ไธๅญ˜ๅœจๆˆ–ๆ— ๆณ•่ฎฟ้—ฎๆ—ถ๏ผŒ่ฎคไธบๆฒกๆœ‰่ฐƒ็”จ่ฎฐๅฝ• + console.warn('Failed to read Cursor tracking DB:', error); + recentCount = 0; + } + return { + recentCount, + threshold, + isHealthy: recentCount <= threshold, + windowMs, + checkedAt, + }; +} diff --git a/packages/pulse/src/workflows/cursor-health.ts b/packages/pulse-workflows/src/workflows/cursor-health.ts similarity index 100% rename from packages/pulse/src/workflows/cursor-health.ts rename to packages/pulse-workflows/src/workflows/cursor-health.ts diff --git a/packages/pulse-workflows/src/workflows/report.d.ts b/packages/pulse-workflows/src/workflows/report.d.ts new file mode 100644 index 0000000..8b179fd --- /dev/null +++ b/packages/pulse-workflows/src/workflows/report.d.ts @@ -0,0 +1,28 @@ +/** + * Report WorkflowType โ€” analyst โ†’ renderer. + * + * Pure LLM workflow: analyst reads timeline JSON and produces structured analysis, + * renderer turns it into a beautiful single-file HTML report. + * + * Trigger: report.__start__ with timeline JSON as content + * + * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) + */ +import { type Role, type WorkflowType } from '@uncaged/pulse'; +export type AnalystMeta = { + score: number; + highlights: string[]; + issues: string[]; +}; +export type RendererMeta = { + format: 'html'; + bytes: number; +}; +export type ReportRoles = { + analyst: Role; + renderer: Role; +}; +export declare function createReportWorkflow(opts?: { + analystFn?: Role; + rendererFn?: Role; +}): WorkflowType; diff --git a/packages/pulse-workflows/src/workflows/report.js b/packages/pulse-workflows/src/workflows/report.js new file mode 100644 index 0000000..26593fa --- /dev/null +++ b/packages/pulse-workflows/src/workflows/report.js @@ -0,0 +1,49 @@ +/** + * Report WorkflowType โ€” analyst โ†’ renderer. + * + * Pure LLM workflow: analyst reads timeline JSON and produces structured analysis, + * renderer turns it into a beautiful single-file HTML report. + * + * Trigger: report.__start__ with timeline JSON as content + * + * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) + */ +import { END, START, } from '@uncaged/pulse'; +// โ”€โ”€ Default mock implementations โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +const defaultAnalyst = async (chain) => { + const startMsg = chain.find((m) => m.role === '__start__'); + return { + content: `[mock] Analysis of workflow: ${startMsg?.content?.slice(0, 100) ?? 'unknown'}`, + meta: { score: 8, highlights: ['completed'], issues: [] }, + }; +}; +const defaultRenderer = async (chain) => { + const analystMsg = chain.find((m) => m.role === 'analyst'); + const html = `

    Report

    ${analystMsg?.content ?? ''}

    `; + return { + content: html, + meta: { format: 'html', bytes: html.length }, + }; +}; +function reportModerator(output, _topicId) { + if (output.role === START) + return 'analyst'; + switch (output.role) { + case 'analyst': + return 'renderer'; + case 'renderer': + return END; + } + return END; +} +// โ”€โ”€ Factory โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +export function createReportWorkflow(opts) { + return { + name: 'report', + roles: { + analyst: opts?.analystFn ?? defaultAnalyst, + renderer: opts?.rendererFn ?? defaultRenderer, + }, + moderator: reportModerator, + }; +} diff --git a/packages/pulse/src/workflows/report.test.ts b/packages/pulse-workflows/src/workflows/report.test.ts similarity index 93% rename from packages/pulse/src/workflows/report.test.ts rename to packages/pulse-workflows/src/workflows/report.test.ts index d0cb16b..0e71717 100644 --- a/packages/pulse/src/workflows/report.test.ts +++ b/packages/pulse-workflows/src/workflows/report.test.ts @@ -5,11 +5,11 @@ */ import { describe, expect, test } from 'bun:test'; -import { createStore, type Store } from '../store.js'; +import { createStore, type PulseStore } from '@uncaged/pulse'; import { createReportWorkflow } from './report.js'; -import { createWorkflowRule } from './workflow-rule-adapter.js'; +import { createWorkflowRule } from '@uncaged/pulse'; -function tmpStore(): { store: Store; cleanup: () => void } { +function tmpStore(): { store: PulseStore; cleanup: () => void } { const dir = `${require('node:os').tmpdir()}/report-wf-test-${Date.now()}`; require('node:fs').mkdirSync(dir, { recursive: true }); const store = createStore({ diff --git a/packages/pulse/src/workflows/report.ts b/packages/pulse-workflows/src/workflows/report.ts similarity index 98% rename from packages/pulse/src/workflows/report.ts rename to packages/pulse-workflows/src/workflows/report.ts index df709f2..5e9f860 100644 --- a/packages/pulse/src/workflows/report.ts +++ b/packages/pulse-workflows/src/workflows/report.ts @@ -15,7 +15,7 @@ import { type Role, START, type WorkflowType, -} from './workflow-type.js'; +} from '@uncaged/pulse'; // โ”€โ”€ Role Meta types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ diff --git a/packages/pulse-workflows/src/workflows/werewolf.d.ts b/packages/pulse-workflows/src/workflows/werewolf.d.ts new file mode 100644 index 0000000..017f871 --- /dev/null +++ b/packages/pulse-workflows/src/workflows/werewolf.d.ts @@ -0,0 +1,105 @@ +/** + * Werewolf (็‹ผไบบๆ€) workflow โ€” 9-player game with information asymmetry. + * + * Pure roles + START/END automaton. Trigger: werewolf.__start__ + * + * Roles are per-phase (not per-player). Each phase role iterates over + * the relevant players internally. Game state is rebuilt from chain + * (event sourcing via parseGameState). + * + * Default: all mock roles (no LLM). Inject real implementations via + * CreateWerewolfWorkflowOpts. + * + * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) + */ +import { type Role, type WorkflowMessage, type WorkflowType } from '@uncaged/pulse'; +export type Team = 'wolf' | 'good'; +export interface Identity { + name: string; + team: Team; + abilities?: string; +} +export interface Player { + id: string; + name: string; + identity: Identity; +} +export interface DeadPlayer extends Player { + cause: 'wolf-kill' | 'vote' | 'poison' | 'hunter-shot'; + day: number; +} +export interface GameState { + players: Player[]; + alive: Player[]; + dead: DeadPlayer[]; + day: number; + phase: string; + witchPotion: boolean; + witchPoison: boolean; + lastKill: string | null; + lastDeath: DeadPlayer | null; +} +export declare function createPlayers(): Player[]; +export declare function parseGameState(chain: WorkflowMessage[]): GameState; +export declare function filterChainForPlayer(chain: WorkflowMessage[], playerId: string, identity: Identity): WorkflowMessage[]; +export type WolfNightMeta = { + phase: 'wolf-night'; + targetId: string; + visibleTo?: undefined; +}; +export type SeerCheckMeta = { + phase: 'seer-check'; + targetId: string; + isWolf: boolean; + visibleTo: string[]; +}; +export type WitchActionMeta = { + phase: 'witch-action'; + saved: boolean; + poisonTarget: string | null; + visibleTo: string[]; + witchPotion: boolean; + witchPoison: boolean; +}; +export type DaySpeechMeta = { + phase: 'day-speech'; + speeches: Array<{ + playerId: string; + speech: string; + }>; +}; +export type VoteMeta = { + phase: 'vote'; + votes: Record; + eliminatedId: string | null; +}; +export type HunterShotMeta = { + phase: 'hunter-shot'; + shotTarget: string; +}; +export type GameEndMeta = { + phase: 'game-end'; + winner: Team; + summary: string; +}; +export type WerewolfRoles = { + 'wolf-night': Role; + 'seer-check': Role; + 'witch-action': Role; + 'day-speech': Role; + 'vote': Role; + 'hunter-shot': Role; + 'game-end': Role; +}; +declare function checkGameOver(alive: Player[]): boolean; +export type CreateWerewolfWorkflowOpts = { + wolfNightFn?: Role; + seerCheckFn?: Role; + witchActionFn?: Role; + daySpeechFn?: Role; + voteFn?: Role; + hunterShotFn?: Role; + gameEndFn?: Role; +}; +export declare function createWerewolfWorkflow(opts?: CreateWerewolfWorkflowOpts): WorkflowType; +export { checkGameOver }; diff --git a/packages/pulse-workflows/src/workflows/werewolf.js b/packages/pulse-workflows/src/workflows/werewolf.js new file mode 100644 index 0000000..50bcc3c --- /dev/null +++ b/packages/pulse-workflows/src/workflows/werewolf.js @@ -0,0 +1,510 @@ +/** + * Werewolf (็‹ผไบบๆ€) workflow โ€” 9-player game with information asymmetry. + * + * Pure roles + START/END automaton. Trigger: werewolf.__start__ + * + * Roles are per-phase (not per-player). Each phase role iterates over + * the relevant players internally. Game state is rebuilt from chain + * (event sourcing via parseGameState). + * + * Default: all mock roles (no LLM). Inject real implementations via + * CreateWerewolfWorkflowOpts. + * + * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) + */ +import { END, START, } from '@uncaged/pulse'; +// โ”€โ”€ Player setup (9 players) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +const IDENTITIES = [ + { name: '็‹ผไบบ', team: 'wolf' }, + { name: '็‹ผไบบ', team: 'wolf' }, + { name: '็‹ผไบบ', team: 'wolf' }, + { name: '้ข„่จ€ๅฎถ', team: 'good', abilities: 'ๆฏๆ™šๅฏๆŸฅ้ชŒไธ€ๅ็Žฉๅฎถ่บซไปฝ' }, + { name: 'ๅฅณๅทซ', team: 'good', abilities: 'ๆœ‰ไธ€็“ถ่งฃ่ฏๅ’Œไธ€็“ถๆฏ’่ฏ' }, + { name: '็ŒŽไบบ', team: 'good', abilities: 'ๆญปไบกๆ—ถๅฏๅผ€ๆžชๅธฆ่ตฐไธ€ไบบ' }, + { name: 'ๆ‘ๆฐ‘', team: 'good' }, + { name: 'ๆ‘ๆฐ‘', team: 'good' }, + { name: 'ๆ‘ๆฐ‘', team: 'good' }, +]; +export function createPlayers() { + return IDENTITIES.map((id, i) => ({ + id: `p${i + 1}`, + name: `็Žฉๅฎถ${i + 1}`, + identity: id, + })); +} +// โ”€โ”€ Game state from chain (event sourcing) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +export function parseGameState(chain) { + const players = createPlayers(); + const dead = []; + let day = 1; + let witchPotion = true; + let witchPoison = true; + let lastKill = null; + let lastDeath = null; + let phase = ''; + for (const msg of chain) { + const meta = msg.meta; + if (!meta) + continue; + if (meta.phase === 'wolf-night') { + phase = 'wolf-night'; + if (meta.targetId) + lastKill = meta.targetId; + } + if (meta.phase === 'witch-action') { + phase = 'witch-action'; + if (meta.saved === true) { + lastKill = null; + witchPotion = false; + } + if (meta.poisonTarget) { + witchPoison = false; + const pid = meta.poisonTarget; + const p = players.find(pp => pp.id === pid); + if (p && !dead.some(d => d.id === pid)) { + const dp = { ...p, cause: 'poison', day }; + dead.push(dp); + lastDeath = dp; + } + } + } + if (meta.phase === 'dawn') { + // Wolf kill resolves at dawn (after witch action) + if (lastKill) { + const p = players.find(pp => pp.id === lastKill); + if (p && !dead.some(d => d.id === lastKill)) { + const dp = { ...p, cause: 'wolf-kill', day }; + dead.push(dp); + lastDeath = dp; + } + } + lastKill = null; + } + if (meta.phase === 'day-speech') + phase = 'day-speech'; + if (meta.phase === 'vote') { + phase = 'vote'; + if (meta.eliminatedId) { + const pid = meta.eliminatedId; + const p = players.find(pp => pp.id === pid); + if (p && !dead.some(d => d.id === pid)) { + const dp = { ...p, cause: 'vote', day }; + dead.push(dp); + lastDeath = dp; + } + } + } + if (meta.phase === 'hunter-shot') { + phase = 'hunter-shot'; + if (meta.shotTarget) { + const pid = meta.shotTarget; + const p = players.find(pp => pp.id === pid); + if (p && !dead.some(d => d.id === pid)) { + const dp = { ...p, cause: 'hunter-shot', day }; + dead.push(dp); + lastDeath = dp; + } + } + } + if (meta.phase === 'new-night') { + day++; + lastKill = null; + } + if (meta.witchPotion === false) + witchPotion = false; + if (meta.witchPoison === false) + witchPoison = false; + } + const deadIds = new Set(dead.map(d => d.id)); + const alive = players.filter(p => !deadIds.has(p.id)); + return { players, alive, dead, day, phase, witchPotion, witchPoison, lastKill, lastDeath }; +} +// โ”€โ”€ Information visibility filter โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +export function filterChainForPlayer(chain, playerId, identity) { + return chain.filter(msg => { + const meta = msg.meta; + if (!meta) + return true; // system messages without meta are public + const phase = meta.phase; + const visibleTo = meta.visibleTo; + // Public phases + if (phase === 'day-speech' || phase === 'vote' || phase === 'death' || + phase === 'dawn' || phase === 'hunter-shot' || phase === 'game-end' || + phase === 'new-night') + return true; + // Wolf night: only wolves see + if (phase === 'wolf-night') + return identity.team === 'wolf'; + // Seer / witch: only visible to target player + if (phase === 'seer-check' || phase === 'witch-action') { + return visibleTo ? visibleTo.includes(playerId) : false; + } + // System messages with explicit visibility + if (phase === 'system') { + return visibleTo ? visibleTo.includes(playerId) : true; + } + return true; + }); +} +// โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function pick(arr) { + return arr[Math.floor(Math.random() * arr.length)]; +} +// โ”€โ”€ Default mock implementations โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +const defaultWolfNight = async (chain) => { + const state = parseGameState(chain); + const goodAlive = state.alive.filter(p => p.identity.team === 'good'); + const target = pick(goodAlive); + return { + content: `[็‹ผไบบๅคœๆ™š] ็‹ผไบบๅ†ณๅฎšๅ‡ปๆ€ ${target.name}`, + meta: { phase: 'wolf-night', targetId: target.id }, + }; +}; +const defaultSeerCheck = async (chain) => { + const state = parseGameState(chain); + const seer = state.alive.find(p => p.identity.name === '้ข„่จ€ๅฎถ'); + if (!seer) { + return { + content: '[้ข„่จ€ๅฎถๅทฒๆญป๏ผŒ่ทณ่ฟ‡]', + meta: { phase: 'seer-check', targetId: '', isWolf: false, visibleTo: [] }, + }; + } + const others = state.alive.filter(p => p.id !== seer.id); + const target = pick(others); + return { + content: `[้ข„่จ€ๅฎถๆŸฅ้ชŒ] ${target.name} ็š„่บซไปฝๆ˜ฏ${target.identity.team === 'wolf' ? '็‹ผไบบ' : 'ๅฅฝไบบ'}`, + meta: { + phase: 'seer-check', + targetId: target.id, + isWolf: target.identity.team === 'wolf', + visibleTo: [seer.id], + }, + }; +}; +const defaultWitchAction = async (chain) => { + const state = parseGameState(chain); + const witch = state.alive.find(p => p.identity.name === 'ๅฅณๅทซ'); + if (!witch) { + // Witch dead โ€” still emit dawn event with kill resolved + const died = []; + if (state.lastKill) + died.push(state.lastKill); + return { + content: `[ๅฅณๅทซๅทฒๆญป๏ผŒ่ทณ่ฟ‡] ๅคฉไบฎไบ†${died.length ? '๏ผŒๆœ‰ไบบๆญปไบก' : ''}`, + meta: { + phase: 'witch-action', + saved: false, + poisonTarget: null, + visibleTo: [], + witchPotion: state.witchPotion, + witchPoison: state.witchPoison, + }, + }; + } + let saved = false; + let poisonTarget = null; + // 50% chance to save if someone was killed and potion available + if (state.lastKill && state.witchPotion && Math.random() < 0.5) { + saved = true; + } + return { + content: `[ๅฅณๅทซ่กŒๅŠจ] ${saved ? 'ไฝฟ็”จไบ†่งฃ่ฏ' : 'ๆœชไฝฟ็”จ่งฃ่ฏ'}${poisonTarget ? `๏ผŒๆฏ’ๆ€ไบ†${poisonTarget}` : ''}`, + meta: { + phase: 'witch-action', + saved, + poisonTarget, + visibleTo: [witch.id], + witchPotion: saved ? false : state.witchPotion, + witchPoison: poisonTarget ? false : state.witchPoison, + }, + }; +}; +const defaultDaySpeech = async (chain) => { + const state = parseGameState(chain); + const speeches = state.alive.map(p => ({ + playerId: p.id, + speech: `ๆˆ‘ๆ˜ฏ${p.name}๏ผŒๆˆ‘่ง‰ๅพ—ๅœบไธŠๅฝขๅŠฟๅพˆๅคๆ‚๏ผŒๅคงๅฎถ่ฆ็†ๆ€งๅˆ†ๆžใ€‚`, + })); + return { + content: speeches.map(s => `ใ€${s.playerId}ใ€‘${s.speech}`).join('\n'), + meta: { phase: 'day-speech', speeches }, + }; +}; +const defaultVote = async (chain) => { + const state = parseGameState(chain); + const votes = {}; + for (const p of state.alive) { + const others = state.alive.filter(o => o.id !== p.id); + votes[p.id] = pick(others).id; + } + // Tally + const tally = {}; + for (const target of Object.values(votes)) { + tally[target] = (tally[target] || 0) + 1; + } + const maxVotes = Math.max(...Object.values(tally)); + const topIds = Object.entries(tally).filter(([, v]) => v === maxVotes).map(([k]) => k); + const eliminatedId = topIds.length === 1 ? topIds[0] : pick(topIds); + const eliminated = state.alive.find(p => p.id === eliminatedId); + return { + content: `[ๆŠ•็ฅจ็ป“ๆžœ] ${eliminated?.name ?? eliminatedId} ่ขซๆ”พ้€ๅ‡บๅฑ€`, + meta: { phase: 'vote', votes, eliminatedId }, + }; +}; +const defaultHunterShot = async (chain) => { + const state = parseGameState(chain); + // Hunter shoots a random alive player + const target = pick(state.alive); + return { + content: `[็ŒŽไบบๅผ€ๆžช] ็ŒŽไบบๅธฆ่ตฐไบ† ${target.name}`, + meta: { phase: 'hunter-shot', shotTarget: target.id }, + }; +}; +const defaultGameEnd = async (chain) => { + const state = parseGameState(chain); + const wolves = state.alive.filter(p => p.identity.team === 'wolf'); + const winner = wolves.length === 0 ? 'good' : 'wolf'; + return { + content: `[ๆธธๆˆ็ป“ๆŸ] ${winner === 'good' ? 'ๅฅฝไบบ้˜ต่ฅ' : '็‹ผไบบ้˜ต่ฅ'}่Žท่ƒœ๏ผ`, + meta: { + phase: 'game-end', + winner, + summary: `ๅญ˜ๆดป: ${state.alive.map(p => p.name).join(', ')}; ๆญปไบก: ${state.dead.map(d => `${d.name}(${d.cause})`).join(', ')}`, + }, + }; +}; +function werewolfModerator(output, _topicId, remainingRounds) { + const emergency = remainingRounds !== undefined && remainingRounds <= 1; + if (output.role === START) + return 'wolf-night'; + // Build state from meta for win-condition checks + const meta = output.meta; + switch (output.role) { + case 'wolf-night': + return 'seer-check'; + case 'seer-check': + return 'witch-action'; + case 'witch-action': + return 'day-speech'; + case 'day-speech': + return 'vote'; + case 'vote': { + // Check if game should end after vote + if (meta?.gameOver === true || emergency) + return 'game-end'; + // Check if hunter was eliminated + if (meta?.hunterTriggered === true) + return 'hunter-shot'; + return 'wolf-night'; + } + case 'hunter-shot': { + if (meta?.gameOver === true || emergency) + return 'game-end'; + return 'wolf-night'; + } + case 'game-end': + return END; + default: + return END; + } +} +// Wait โ€” the moderator doesn't have access to game state from the chain. +// It only gets meta from the last role output. So roles need to include +// win-condition info in their meta. Let me revise the approach: +// The vote role and hunter-shot role should check win conditions and set +// meta flags. Similarly wolf-night + witch-action combined produce deaths +// that day-speech should account for. +// +// Actually, looking at the adapter code, moderator only gets last role's meta. +// So each role that might end the game needs to put gameOver + hunterTriggered in meta. +// Let me redesign: make a "smart" moderator that rebuilds state from chain. +// But moderator only gets (output, topicId) โ€” no chain access. +// The design doc shows moderator reading meta.gameState. +// Solution: roles embed necessary routing info in meta. +// Revised approach: vote/hunter roles add gameOver and hunterTriggered flags. +// wolf-night already doesn't end game. witch-action doesn't either (dawn deaths +// are checked when entering day-speech... actually no, we need to check after +// night phases complete). +// +// Simpler: day-speech role checks win condition before speeches (night deaths resolved). +// If game over, it signals in meta. +// +// Actually let me look at this more carefully. The moderator transition is: +// wolf-night โ†’ seer-check โ†’ witch-action โ†’ day-speech โ†’ vote โ†’ wolf-night (loop) +// Win conditions are checked: after vote (good might have eliminated all wolves), +// and at the start of day (wolf kill at night might tip the balance). +// +// The cleanest approach: each role that resolves deaths includes gameOver flag. +// Moderator checks it to route to game-end. +// Let me just rewrite moderator and tweak the roles to include routing metadata. +// โ”€โ”€ Revised Moderator (final) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function werewolfModeratorFinal(output, _topicId, remainingRounds) { + const emergency = remainingRounds !== undefined && remainingRounds <= 1; + if (emergency) + return 'game-end'; + if (output.role === START) + return 'wolf-night'; + const meta = output.meta; + const gameOver = meta?.gameOver === true; + switch (output.role) { + case 'wolf-night': + return 'seer-check'; + case 'seer-check': + return 'witch-action'; + case 'witch-action': + // After witch action, check if night deaths end the game + if (gameOver) + return 'game-end'; + return 'day-speech'; + case 'day-speech': + return 'vote'; + case 'vote': { + if (gameOver) + return 'game-end'; + if (meta?.hunterTriggered === true) + return 'hunter-shot'; + return 'wolf-night'; + } + case 'hunter-shot': { + if (gameOver) + return 'game-end'; + return 'wolf-night'; + } + case 'game-end': + return END; + default: + return END; + } +} +// โ”€โ”€ Revised mock roles with routing metadata โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// We need roles to compute game-over and hunter-triggered flags. +// The witch-action role resolves night deaths, so it checks win condition. +// The vote role resolves day deaths, so it checks win condition + hunter trigger. +// The hunter-shot role resolves hunter death, so it checks win condition. +function checkGameOver(alive) { + const wolves = alive.filter(p => p.identity.team === 'wolf'); + if (wolves.length === 0) + return true; + if (wolves.length >= alive.length - wolves.length) + return true; + return false; +} +// Re-implement witch-action to resolve dawn deaths and check win condition +const mockWitchAction = async (chain) => { + const state = parseGameState(chain); + const witch = state.alive.find(p => p.identity.name === 'ๅฅณๅทซ'); + let saved = false; + let poisonTarget = null; + const witchId = witch?.id ?? ''; + if (witch) { + if (state.lastKill && state.witchPotion && Math.random() < 0.5) { + saved = true; + } + } + // Simulate dawn: compute who actually dies + const nightDead = []; + if (state.lastKill && !saved) + nightDead.push(state.lastKill); + if (poisonTarget) + nightDead.push(poisonTarget); + // Check win condition after night deaths + const deadIds = new Set([...state.dead.map(d => d.id), ...nightDead]); + const aliveAfter = state.players.filter(p => !deadIds.has(p.id)); + const gameOver = checkGameOver(aliveAfter); + return { + content: `[ๅฅณๅทซ่กŒๅŠจ] ${saved ? 'ไฝฟ็”จไบ†่งฃ่ฏ' : 'ๆœชไฝฟ็”จ่งฃ่ฏ'}`, + meta: { + phase: 'witch-action', + saved, + poisonTarget, + visibleTo: witchId ? [witchId] : [], + witchPotion: saved ? false : state.witchPotion, + witchPoison: poisonTarget ? false : state.witchPoison, + gameOver, + }, + }; +}; +const mockVote = async (chain) => { + // First resolve dawn deaths from last night + const statePreDawn = parseGameState(chain); + // We need to account for dawn deaths. The chain should already have witch-action + // which has saved/not-saved info. parseGameState handles lastKill resolution + // at dawn phase, but we haven't emitted a dawn event. + // Actually, parseGameState only processes dawn phase if there's a message with phase:'dawn'. + // Since we don't emit that, we need to handle it differently. + // Let's resolve: after witch-action, the wolf kill is still pending in lastKill + // unless saved. We need to consider it as death for voting purposes. + // Compute effective alive list (after night) + const nightDead = []; + if (statePreDawn.lastKill) { + // Check if witch saved + const witchMsg = [...chain].reverse().find(m => m.meta?.phase === 'witch-action'); + const witchSaved = witchMsg?.meta?.saved === true; + if (!witchSaved) + nightDead.push(statePreDawn.lastKill); + } + const deadSet = new Set([...statePreDawn.dead.map(d => d.id), ...nightDead]); + const effectiveAlive = statePreDawn.players.filter(p => !deadSet.has(p.id)); + const votes = {}; + for (const p of effectiveAlive) { + const others = effectiveAlive.filter(o => o.id !== p.id); + if (others.length > 0) + votes[p.id] = pick(others).id; + } + const tally = {}; + for (const target of Object.values(votes)) { + tally[target] = (tally[target] || 0) + 1; + } + let eliminatedId = null; + if (Object.keys(tally).length > 0) { + const maxVotes = Math.max(...Object.values(tally)); + const topIds = Object.entries(tally).filter(([, v]) => v === maxVotes).map(([k]) => k); + eliminatedId = topIds.length === 1 ? topIds[0] : pick(topIds); + } + const aliveAfterVote = effectiveAlive.filter(p => p.id !== eliminatedId); + const gameOver = checkGameOver(aliveAfterVote); + const eliminated = effectiveAlive.find(p => p.id === eliminatedId); + const hunterTriggered = eliminated?.identity.name === '็ŒŽไบบ'; + return { + content: `[ๆŠ•็ฅจ็ป“ๆžœ] ${eliminated?.name ?? 'ๆ— ไบบ'} ่ขซๆ”พ้€ๅ‡บๅฑ€`, + meta: { + phase: 'vote', + votes, + eliminatedId, + hunterTriggered, + gameOver, + }, + }; +}; +const mockHunterShot = async (chain) => { + const state = parseGameState(chain); + // Hunter can't shoot themselves; pick from alive (hunter is already dead from vote) + const target = pick(state.alive); + const aliveAfter = state.alive.filter(p => p.id !== target.id); + const gameOver = checkGameOver(aliveAfter); + return { + content: `[็ŒŽไบบๅผ€ๆžช] ็ŒŽไบบๅธฆ่ตฐไบ† ${target.name}`, + meta: { + phase: 'hunter-shot', + shotTarget: target.id, + gameOver, + }, + }; +}; +export function createWerewolfWorkflow(opts) { + return { + name: 'werewolf', + roles: { + 'wolf-night': opts?.wolfNightFn ?? defaultWolfNight, + 'seer-check': opts?.seerCheckFn ?? defaultSeerCheck, + 'witch-action': opts?.witchActionFn ?? mockWitchAction, + 'day-speech': opts?.daySpeechFn ?? defaultDaySpeech, + 'vote': opts?.voteFn ?? mockVote, + 'hunter-shot': opts?.hunterShotFn ?? mockHunterShot, + 'game-end': opts?.gameEndFn ?? defaultGameEnd, + }, + moderator: werewolfModeratorFinal, + limits: { maxRounds: 50 }, + }; +} +export { checkGameOver }; diff --git a/packages/pulse/src/workflows/werewolf.ts b/packages/pulse-workflows/src/workflows/werewolf.ts similarity index 99% rename from packages/pulse/src/workflows/werewolf.ts rename to packages/pulse-workflows/src/workflows/werewolf.ts index b45b098..14f09c3 100644 --- a/packages/pulse/src/workflows/werewolf.ts +++ b/packages/pulse-workflows/src/workflows/werewolf.ts @@ -20,7 +20,7 @@ import { START, type WorkflowMessage, type WorkflowType, -} from './workflow-type.js'; +} from '@uncaged/pulse'; // โ”€โ”€ Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ diff --git a/packages/pulse-workflows/tsconfig.json b/packages/pulse-workflows/tsconfig.json new file mode 100644 index 0000000..01e5bdf --- /dev/null +++ b/packages/pulse-workflows/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "types": ["bun-types"] + }, + "include": ["src"], + "exclude": ["**/*.test.ts"] +} diff --git a/packages/pulse/package.json b/packages/pulse/package.json index 9a0e4a9..fe24f70 100644 --- a/packages/pulse/package.json +++ b/packages/pulse/package.json @@ -1,7 +1,7 @@ { "name": "@uncaged/pulse", "version": "0.1.0", - "description": "Pulse core engine โ€” stateful reactive loop with S-combinator rule composition", + "description": "Pulse core engine \u2014 stateful reactive loop with S-combinator rule composition", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -29,7 +29,8 @@ "devDependencies": { "@types/node": "^25.6.0", "bun-types": "latest", - "typescript": "^6.0.2" + "typescript": "^6.0.2", + "@upulse/workflows": "workspace:*" }, "dependencies": { "jsonata": "^2.1.0", diff --git a/packages/pulse/src/bin/workflow-daemon.ts b/packages/pulse/src/bin/workflow-daemon.ts index 080f21f..a834757 100644 --- a/packages/pulse/src/bin/workflow-daemon.ts +++ b/packages/pulse/src/bin/workflow-daemon.ts @@ -14,16 +14,18 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; import { createOpenAiLlmClient } from '../llm-client.js'; import { createStore } from '../store.js'; -import { createCodingWorkflow } from '../workflows/coding.js'; +import { + createCodingWorkflow, + createReportWorkflow, + createAnalystRole, + createRendererRole, +} from '@upulse/workflows'; import { createWorkflowTicker } from '../workflows/index.js'; import { createMetaWorkflow } from '../workflows/meta.js'; import { createCursorRunner } from '../workflows/roles/agent-executor.js'; import { createMetaCoderRole } from '../workflows/roles/meta-coder-cursor.js'; import { createMetaTesterRole } from '../workflows/roles/meta-tester.js'; import { createMetaCheckerRole } from '../workflows/roles/meta-checker.js'; -import { createReportWorkflow } from '../workflows/report.js'; -import { createAnalystRole } from '../workflows/roles/analyst-llm.js'; -import { createRendererRole } from '../workflows/roles/renderer-template.js'; import { createWorkflowRule } from '../workflows/workflow-rule-adapter.js'; // โ”€โ”€ Config โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ diff --git a/packages/pulse/src/defs.d.ts b/packages/pulse/src/defs.d.ts new file mode 100644 index 0000000..5574946 --- /dev/null +++ b/packages/pulse/src/defs.d.ts @@ -0,0 +1,82 @@ +/** + * @uncaged/pulse โ€” Definition Layer (Phase 1) + * + * Append-only definition tables for objects, events, and projections. + * Content-addressed versioning with code_rev binding. + */ +import type { Database } from 'bun:sqlite'; +export interface ObjectDef { + name: string; + codeRev: string; + createdAt: number; +} +export interface EventDef { + hash: string; + name: string; + parentHash?: string; + schema?: any; + codeRev: string; + createdAt: number; +} +export interface ProjectionDef { + hash: string; + name: string; + parentHash?: string; + params?: any; + valueSchema?: any; + initialValue: any; + codeRev: string; + createdAt: number; + sources: Array<{ + eventKind: string; + eventKey?: string; + expression: string; + }>; +} +export interface ValidationResult { + valid: boolean; + result?: any; + error?: string; +} +/** + * Initialize the definition schema on an existing database connection. + * Use this when you manage the database lifecycle externally. + */ +export declare function initDefsSchema(db: Database): Promise; +export declare function registerObjectDef(db: Database, opts: { + name: string; + codeRev: string; +}): Promise; +export declare function getObjectDef(db: Database, name: string, codeRev: string): Promise; +export declare function registerEventDef(db: Database, opts: { + name: string; + schema?: any; + parentHash?: string; + codeRev: string; +}): Promise; +export declare function getEventDef(db: Database, name: string, codeRev: string): Promise; +export declare function listEventDefs(db: Database, opts: { + codeRev: string; +}): Promise; +export declare function registerProjectionDef(db: Database, opts: { + name: string; + params?: any; + valueSchema?: any; + initialValue: any; + sources: Array<{ + eventKind: string; + eventKey?: string; + expression: string; + }>; + parentHash?: string; + codeRev: string; +}): Promise; +export declare function getProjectionDef(db: Database, name: string, codeRev: string): Promise; +export declare function listProjectionDefs(db: Database, opts: { + codeRev: string; +}): Promise; +export declare function validateExpression(opts: { + expression: string; + initialValue: any; + mockEvent: any; +}): Promise; diff --git a/packages/pulse/src/defs.js b/packages/pulse/src/defs.js new file mode 100644 index 0000000..c91fdb7 --- /dev/null +++ b/packages/pulse/src/defs.js @@ -0,0 +1,322 @@ +/** + * @uncaged/pulse โ€” Definition Layer (Phase 1) + * + * Append-only definition tables for objects, events, and projections. + * Content-addressed versioning with code_rev binding. + */ +import { createHash } from 'node:crypto'; +import jsonata from 'jsonata'; +// โ”€โ”€ Database Schema โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +const OBJECT_DEFS_SCHEMA = ` + CREATE TABLE IF NOT EXISTS object_defs ( + name TEXT NOT NULL, + code_rev TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (name, code_rev) + ) +`; +const EVENT_DEFS_SCHEMA = ` + CREATE TABLE IF NOT EXISTS event_defs ( + hash TEXT NOT NULL, + name TEXT NOT NULL, + parent_hash TEXT, + schema TEXT, + code_rev TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (name, code_rev) + ); + CREATE INDEX IF NOT EXISTS idx_event_defs_hash ON event_defs(hash); +`; +const PROJECTION_DEFS_SCHEMA = ` + CREATE TABLE IF NOT EXISTS projection_defs ( + hash TEXT NOT NULL, + name TEXT NOT NULL, + parent_hash TEXT, + params TEXT, + value_schema TEXT, + initial_value TEXT NOT NULL, + code_rev TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (name, code_rev) + ); + CREATE INDEX IF NOT EXISTS idx_projection_defs_hash ON projection_defs(hash); +`; +const PROJECTION_DEF_SOURCES_SCHEMA = ` + CREATE TABLE IF NOT EXISTS projection_def_sources ( + projection_hash TEXT NOT NULL, + event_kind TEXT NOT NULL, + event_key TEXT, + expression TEXT NOT NULL, + FOREIGN KEY (projection_hash) REFERENCES projection_defs(hash) + ) +`; +// โ”€โ”€ Schema Initialization โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +/** + * Initialize the definition schema on an existing database connection. + * Use this when you manage the database lifecycle externally. + */ +export async function initDefsSchema(db) { + db.exec(OBJECT_DEFS_SCHEMA); + db.exec(EVENT_DEFS_SCHEMA); + db.exec(PROJECTION_DEFS_SCHEMA); + db.exec(PROJECTION_DEF_SOURCES_SCHEMA); +} +// โ”€โ”€ Hash Calculation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function calculateEventHash(name, schema) { + const content = name + JSON.stringify(schema || null); + return createHash('sha256').update(content).digest('hex'); +} +function calculateProjectionHash(name, params, valueSchema, initialValue, sources) { + const hashInput = JSON.stringify({ + name, + initialValue, + params: params || null, + valueSchema: valueSchema || null, + sources: sources + ? sources.map((s) => ({ + eventKind: s.eventKind, + eventKey: s.eventKey, + expression: s.expression, + })) + : null, + }); + return createHash('sha256').update(hashInput).digest('hex'); +} +// โ”€โ”€ Object Definitions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +const insertObjectDef = (db) => db.prepare(` + INSERT INTO object_defs (name, code_rev, created_at) + VALUES (?, ?, ?) +`); +const selectObjectDef = (db) => db.prepare(` + SELECT name, code_rev, created_at + FROM object_defs + WHERE name = ? AND code_rev = ? +`); +export async function registerObjectDef(db, opts) { + const createdAt = Date.now(); + try { + insertObjectDef(db).run(opts.name, opts.codeRev, createdAt); + } + catch (error) { + if (error.message.includes('UNIQUE constraint failed')) { + throw new Error(`Object definition already exists: ${opts.name}@${opts.codeRev}`); + } + throw error; + } + return { + name: opts.name, + codeRev: opts.codeRev, + createdAt, + }; +} +export async function getObjectDef(db, name, codeRev) { + const row = selectObjectDef(db).get(name, codeRev); + if (!row) + return null; + return { + name: row.name, + codeRev: row.code_rev, + createdAt: row.created_at, + }; +} +// โ”€โ”€ Event Definitions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +const insertEventDef = (db) => db.prepare(` + INSERT INTO event_defs (hash, name, parent_hash, schema, code_rev, created_at) + VALUES (?, ?, ?, ?, ?, ?) +`); +const selectEventDefByNameCodeRev = (db) => db.prepare(` + SELECT hash, name, parent_hash, schema, code_rev, created_at + FROM event_defs + WHERE name = ? AND code_rev = ? +`); +const selectEventDefsByCodeRev = (db) => db.prepare(` + SELECT hash, name, parent_hash, schema, code_rev, created_at + FROM event_defs + WHERE code_rev = ? + ORDER BY name +`); +export async function registerEventDef(db, opts) { + const hash = calculateEventHash(opts.name, opts.schema); + const createdAt = Date.now(); + try { + insertEventDef(db).run(hash, opts.name, opts.parentHash || null, opts.schema ? JSON.stringify(opts.schema) : null, opts.codeRev, createdAt); + } + catch (error) { + if (error.message.includes('UNIQUE constraint failed')) { + throw new Error(`Event definition already exists: ${opts.name}@${opts.codeRev}`); + } + throw error; + } + return { + hash, + name: opts.name, + parentHash: opts.parentHash, + schema: opts.schema, + codeRev: opts.codeRev, + createdAt, + }; +} +export async function getEventDef(db, name, codeRev) { + const row = selectEventDefByNameCodeRev(db).get(name, codeRev); + if (!row) + return null; + return { + hash: row.hash, + name: row.name, + parentHash: row.parent_hash || undefined, + schema: row.schema ? JSON.parse(row.schema) : undefined, + codeRev: row.code_rev, + createdAt: row.created_at, + }; +} +export async function listEventDefs(db, opts) { + const rows = selectEventDefsByCodeRev(db).all(opts.codeRev); + return rows.map((row) => ({ + hash: row.hash, + name: row.name, + parentHash: row.parent_hash || undefined, + schema: row.schema ? JSON.parse(row.schema) : undefined, + codeRev: row.code_rev, + createdAt: row.created_at, + })); +} +// โ”€โ”€ Projection Definitions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +const insertProjectionDef = (db) => db.prepare(` + INSERT INTO projection_defs (hash, name, parent_hash, params, value_schema, initial_value, code_rev, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) +`); +const insertProjectionDefSource = (db) => db.prepare(` + INSERT INTO projection_def_sources (projection_hash, event_kind, event_key, expression) + VALUES (?, ?, ?, ?) +`); +const selectProjectionDefByNameCodeRev = (db) => db.prepare(` + SELECT hash, name, parent_hash, params, value_schema, initial_value, code_rev, created_at + FROM projection_defs + WHERE name = ? AND code_rev = ? +`); +const selectProjectionDefsByCodeRev = (db) => db.prepare(` + SELECT hash, name, parent_hash, params, value_schema, initial_value, code_rev, created_at + FROM projection_defs + WHERE code_rev = ? + ORDER BY name +`); +const selectProjectionDefSources = (db) => db.prepare(` + SELECT event_kind, event_key, expression + FROM projection_def_sources + WHERE projection_hash = ? +`); +export async function registerProjectionDef(db, opts) { + const hash = calculateProjectionHash(opts.name, opts.params, opts.valueSchema, opts.initialValue, opts.sources); + const createdAt = Date.now(); + // Validate JSONata expressions with dry-run + for (const source of opts.sources) { + const mockEvent = { + kind: source.eventKind, + key: source.eventKey || 'test', + data: {}, + }; + const validation = await validateExpression({ + expression: source.expression, + initialValue: opts.initialValue, + mockEvent, + }); + if (!validation.valid) { + throw new Error(`Invalid JSONata expression for ${source.eventKind}: ${validation.error}`); + } + } + // Use transaction for atomic insertion + const transaction = db.transaction(() => { + try { + insertProjectionDef(db).run(hash, opts.name, opts.parentHash || null, opts.params ? JSON.stringify(opts.params) : null, opts.valueSchema ? JSON.stringify(opts.valueSchema) : null, JSON.stringify(opts.initialValue), opts.codeRev, createdAt); + // Insert sources + for (const source of opts.sources) { + insertProjectionDefSource(db).run(hash, source.eventKind, source.eventKey || null, source.expression); + } + } + catch (error) { + if (error.message.includes('UNIQUE constraint failed')) { + throw new Error(`Projection definition already exists: ${opts.name}@${opts.codeRev}`); + } + throw error; + } + }); + transaction(); + return { + hash, + name: opts.name, + parentHash: opts.parentHash, + params: opts.params, + valueSchema: opts.valueSchema, + initialValue: opts.initialValue, + sources: opts.sources, + codeRev: opts.codeRev, + createdAt, + }; +} +export async function getProjectionDef(db, name, codeRev) { + const row = selectProjectionDefByNameCodeRev(db).get(name, codeRev); + if (!row) + return null; + // Get sources + const sources = selectProjectionDefSources(db).all(row.hash); + return { + hash: row.hash, + name: row.name, + parentHash: row.parent_hash || undefined, + params: row.params ? JSON.parse(row.params) : undefined, + valueSchema: row.value_schema ? JSON.parse(row.value_schema) : undefined, + initialValue: JSON.parse(row.initial_value), + sources: sources.map((s) => ({ + eventKind: s.event_kind, + eventKey: s.event_key || undefined, + expression: s.expression, + })), + codeRev: row.code_rev, + createdAt: row.created_at, + }; +} +export async function listProjectionDefs(db, opts) { + const rows = selectProjectionDefsByCodeRev(db).all(opts.codeRev); + return rows.map((row) => { + const sources = selectProjectionDefSources(db).all(row.hash); + return { + hash: row.hash, + name: row.name, + parentHash: row.parent_hash || undefined, + params: row.params ? JSON.parse(row.params) : undefined, + valueSchema: row.value_schema ? JSON.parse(row.value_schema) : undefined, + initialValue: JSON.parse(row.initial_value), + sources: sources.map((s) => ({ + eventKind: s.event_kind, + eventKey: s.event_key || undefined, + expression: s.expression, + })), + codeRev: row.code_rev, + createdAt: row.created_at, + }; + }); +} +// โ”€โ”€ JSONata Expression Validation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +export async function validateExpression(opts) { + try { + const expr = jsonata(opts.expression); + // Test bindings: { state: initialValue, event: mockEvent, params: {} } + const testData = {}; // empty data + const testBindings = { + state: opts.initialValue, + event: opts.mockEvent, + params: {}, + }; + const result = await expr.evaluate(testData, testBindings); + return { + valid: true, + result, + }; + } + catch (error) { + return { + valid: false, + error: error.message, + }; + } +} diff --git a/packages/pulse/src/e2e/council-demo.ts b/packages/pulse/src/e2e/council-demo.ts index ad74fc4..af6bfc9 100644 --- a/packages/pulse/src/e2e/council-demo.ts +++ b/packages/pulse/src/e2e/council-demo.ts @@ -12,8 +12,8 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createOpenAiLlmClient } from '../llm-client.js'; import { createStore } from '../store.js'; -import { createCodingWorkflow } from '../workflows/coding.js'; -import { createArchitectRole } from '../workflows/roles/architect-llm.js'; +import { createCodingWorkflow } from '@upulse/workflows'; +import { createArchitectRole } from '@upulse/workflows'; import { createWorkflowRule } from '../workflows/workflow-rule-adapter.js'; const SEP = 'โ”€'.repeat(50); diff --git a/packages/pulse/src/e2e/council-v2-live.ts b/packages/pulse/src/e2e/council-v2-live.ts index c1bc103..a33ef85 100644 --- a/packages/pulse/src/e2e/council-v2-live.ts +++ b/packages/pulse/src/e2e/council-v2-live.ts @@ -14,9 +14,9 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { createStore } from '../index.js'; import { createOpenAiLlmClient } from '../llm-client.js'; -import { createCodingWorkflow } from '../workflows/coding.js'; -import { createCoderRole } from '../workflows/roles/coder-cursor.js'; -import { createReviewerRole } from '../workflows/roles/reviewer-cursor.js'; +import { createCodingWorkflow } from '@upulse/workflows'; +import { createCoderRole } from '@upulse/workflows'; +import { createReviewerRole } from '@upulse/workflows'; import { createWorkflowRule } from '../workflows/workflow-rule-adapter.js'; import type { WorkflowMessage } from '../workflows/workflow-type.js'; diff --git a/packages/pulse/src/e2e/report-live.ts b/packages/pulse/src/e2e/report-live.ts index 525e4b7..7bb47d0 100644 --- a/packages/pulse/src/e2e/report-live.ts +++ b/packages/pulse/src/e2e/report-live.ts @@ -15,9 +15,9 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createStore } from '../index.js'; import { createOpenAiLlmClient } from '../llm-client.js'; -import { createReportWorkflow } from '../workflows/report.js'; -import { createAnalystRole } from '../workflows/roles/analyst-llm.js'; -import { createRendererRole } from '../workflows/roles/renderer-template.js'; +import { createReportWorkflow } from '@upulse/workflows'; +import { createAnalystRole } from '@upulse/workflows'; +import { createRendererRole } from '@upulse/workflows'; import { createWorkflowRule } from '../workflows/workflow-rule-adapter.js'; // โ”€โ”€ Args โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ diff --git a/packages/pulse/src/e2e/t11-council-v2.test.ts b/packages/pulse/src/e2e/t11-council-v2.test.ts index c4244fa..a4339c0 100644 --- a/packages/pulse/src/e2e/t11-council-v2.test.ts +++ b/packages/pulse/src/e2e/t11-council-v2.test.ts @@ -9,7 +9,7 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createStore, type PulseStore } from '../store.js'; -import { createCodingWorkflow } from '../workflows/coding.js'; +import { createCodingWorkflow } from '@upulse/workflows'; import { createWorkflowRule } from '../workflows/workflow-rule-adapter.js'; describe('Council v2 E2E', () => { diff --git a/packages/pulse/src/e2e/werewolf-live.ts b/packages/pulse/src/e2e/werewolf-live.ts index 6a94399..0796a25 100644 --- a/packages/pulse/src/e2e/werewolf-live.ts +++ b/packages/pulse/src/e2e/werewolf-live.ts @@ -22,7 +22,7 @@ import { type Player, type Identity, type GameState, -} from '../workflows/werewolf.js'; +} from '@upulse/workflows'; import type { Role, WorkflowMessage } from '../workflows/workflow-type.js'; // โ”€โ”€ LLM Client โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ diff --git a/packages/pulse/src/e2e/werewolf-report.ts b/packages/pulse/src/e2e/werewolf-report.ts index 58b4088..0bb9caf 100644 --- a/packages/pulse/src/e2e/werewolf-report.ts +++ b/packages/pulse/src/e2e/werewolf-report.ts @@ -13,9 +13,9 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createScopedStore, createStore } from '../index.js'; import { createOpenAiLlmClient } from '../llm-client.js'; -import { createReportWorkflow } from '../workflows/report.js'; -import { createAnalystRole } from '../workflows/roles/analyst-llm.js'; -import { createRendererRole } from '../workflows/roles/renderer-template.js'; +import { createReportWorkflow } from '@upulse/workflows'; +import { createAnalystRole } from '@upulse/workflows'; +import { createRendererRole } from '@upulse/workflows'; import { createWorkflowRule } from '../workflows/workflow-rule-adapter.js'; // โ”€โ”€ Args โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ diff --git a/packages/pulse/src/executors/index.d.ts b/packages/pulse/src/executors/index.d.ts new file mode 100644 index 0000000..d0eb898 --- /dev/null +++ b/packages/pulse/src/executors/index.d.ts @@ -0,0 +1 @@ +export { executeSurvivalEffect, type SurvivalEffect, type SurvivalExecDeps, } from './survival.js'; diff --git a/packages/pulse/src/executors/index.js b/packages/pulse/src/executors/index.js new file mode 100644 index 0000000..4f0d858 --- /dev/null +++ b/packages/pulse/src/executors/index.js @@ -0,0 +1 @@ +export { executeSurvivalEffect, } from './survival.js'; diff --git a/packages/pulse/src/executors/survival.d.ts b/packages/pulse/src/executors/survival.d.ts new file mode 100644 index 0000000..f85c6b6 --- /dev/null +++ b/packages/pulse/src/executors/survival.d.ts @@ -0,0 +1,21 @@ +/** + * Survival effect executors + * + * Execute survival effects - all deterministic local commands. + */ +import { execFileSync as defaultExecFileSync, type execSync } from 'node:child_process'; +import type * as fs from 'node:fs'; +export interface SurvivalEffect { + type: string; + [key: string]: unknown; +} +/** Dependencies that can be injected for testing */ +export interface SurvivalExecDeps { + fs?: typeof fs; + execSyncFn?: typeof execSync; + execFileSyncFn?: typeof defaultExecFileSync; +} +/** + * Execute survival effects โ€” all deterministic local commands + */ +export declare function executeSurvivalEffect(effect: SurvivalEffect, deps?: SurvivalExecDeps): Promise; diff --git a/packages/pulse/src/executors/survival.js b/packages/pulse/src/executors/survival.js new file mode 100644 index 0000000..7eeaa32 --- /dev/null +++ b/packages/pulse/src/executors/survival.js @@ -0,0 +1,109 @@ +/** + * Survival effect executors + * + * Execute survival effects - all deterministic local commands. + */ +import { execFileSync as defaultExecFileSync, } from 'node:child_process'; +import * as os from 'node:os'; +import * as path from 'node:path'; +/** Allowlist pattern for safe shell arguments (service names, git refs, etc.) */ +const SAFE_ARG = /^[a-zA-Z0-9._/@:-]+$/; +/** + * Execute survival effects โ€” all deterministic local commands + */ +export async function executeSurvivalEffect(effect, deps) { + const execFileSync = deps?.execFileSyncFn ?? defaultExecFileSync; + switch (effect.type) { + case 'restart-service': { + const service = effect.service; + if (!SAFE_ARG.test(service)) { + console.error(`[survival] Rejected unsafe service name: ${service}`); + break; + } + try { + execFileSync('systemctl', ['restart', service], { timeout: 30000 }); + } + catch (err) { + console.error(`[survival] Failed to restart ${service}:`, err); + } + break; + } + case 'gc-vitals': { + // Handled at runPulse level with store access + // This effect is processed in runPulse layer, not here + break; + } + case 'clear-cache': { + const target = effect.target; + try { + if (target === 'journal') { + execFileSync('journalctl', ['--vacuum-size=100M'], { + timeout: 30000, + }); + } + else if (target === 'tmp') { + execFileSync('find', ['/tmp', '-type', 'f', '-mtime', '+1', '-delete'], { timeout: 30000, stdio: 'ignore' }); + } + } + catch (err) { + console.error(`[survival] Failed to clear ${target}:`, err); + } + break; + } + case 'rollback-code': { + const to = effect.to; + if (!SAFE_ARG.test(to)) { + console.error(`[survival] Rejected unsafe git ref: ${to}`); + break; + } + try { + execFileSync('git', ['checkout', to], { + cwd: path.join(os.homedir(), '.upulse', 'engine'), + timeout: 30000, + }); + } + catch (err) { + console.error(`[survival] Failed to rollback to ${to}:`, err); + } + break; + } + case 'rollback-config': { + // Three-layer rollback: Pulse โ†’ LiteLLM โ†’ OC Gateway + const configs = [ + ['pulse', path.join(os.homedir(), '.upulse', 'config.json')], + ['litellm', '/etc/litellm/config.yaml'], + ['openclaw', path.join(os.homedir(), '.openclaw', 'openclaw.json')], + ]; + for (const [name, configPath] of configs) { + try { + execFileSync('cp', [`${configPath}.bak`, configPath], { + timeout: 10000, + stdio: 'ignore', + }); + execFileSync('systemctl', ['restart', name], { timeout: 30000 }); + } + catch { } + } + break; + } + case 'notify-owner': { + const message = effect.message; + // Dual channel: OC Gateway โ†’ direct Telegram fallback + try { + // Channel 1: through OC Gateway + await fetch('http://localhost:18789/api/notify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message }), + signal: AbortSignal.timeout(3000), + }); + return; + } + catch { } + // Channel 2: direct Telegram (TODO: needs BOT_TOKEN and CHAT_ID config) + // Leave as TODO for now, will be configured later + console.log(`[survival] notify-owner: ${message}`); + break; + } + } +} diff --git a/packages/pulse/src/gc.d.ts b/packages/pulse/src/gc.d.ts new file mode 100644 index 0000000..5fd2d8d --- /dev/null +++ b/packages/pulse/src/gc.d.ts @@ -0,0 +1,71 @@ +/** + * gc.ts โ€” Automatic vitals GC: downsample + archive + CAS mark-and-sweep. + * + * Tiered retention (hardcoded defaults, configurable via GcConfig): + * < 1h: full resolution (keep all) + * 1hโ€“24h: 1 per 5 min + * 24hโ€“7d: 1 per 1 hour + * > 7d: hard delete (archive) + * + * Triggered by tick count, not setInterval. + * GC stats written to _system scope as kind="gc" events. + */ +import type { PulseStore } from './store.js'; +export interface GcTier { + /** Events older than this (ms) are candidates for this tier. */ + olderThanMs: number; + /** Keep one event per this interval (ms). null = hard delete. */ + intervalMs: number | null; +} +export interface GcConfig { + /** Enable automatic GC. Default: true. */ + enabled: boolean; + /** Run GC every N ticks. Default: 240 (~1h at 15s tick). */ + tickInterval: number; + /** Retention tiers, ordered by olderThanMs ascending. */ + tiers: GcTier[]; +} +export declare const DEFAULT_GC_CONFIG: GcConfig; +export interface GcResult { + downsampledCount: number; + archivedCount: number; + orphanObjectsCount: number; + durationMs: number; +} +/** + * Run GC on vitals store: downsample + archive. + * Returns stats about what was cleaned up. + */ +export declare function gcVitals(vitalsStore: PulseStore, config?: GcConfig): Promise<{ + downsampledCount: number; + archivedCount: number; +}>; +/** + * CAS mark-and-sweep: delete orphaned objects not referenced by any event. + * + * Scans all events in the given stores for hash references, + * then compares against files in objectsDir. + */ +export declare function gcOrphanObjects(stores: PulseStore[], objectsDir: string): Promise; +/** + * Full GC cycle: vitals downsample/archive + CAS orphan sweep. + * Writes a gc event to systemStore for observability. + */ +export declare function runGc(options: { + vitalsStore: PulseStore; + systemStore: PulseStore; + allStores: PulseStore[]; + objectsDir: string; + config?: GcConfig; +}): Promise; +/** + * Create a GC trigger that fires every N ticks. + * Returns a function to call after each tick. + */ +export declare function createGcTrigger(options: { + vitalsStore: PulseStore; + systemStore: PulseStore; + allStores: PulseStore[]; + objectsDir: string; + config?: GcConfig; +}): () => void; diff --git a/packages/pulse/src/gc.js b/packages/pulse/src/gc.js new file mode 100644 index 0000000..b780f27 --- /dev/null +++ b/packages/pulse/src/gc.js @@ -0,0 +1,181 @@ +/** + * gc.ts โ€” Automatic vitals GC: downsample + archive + CAS mark-and-sweep. + * + * Tiered retention (hardcoded defaults, configurable via GcConfig): + * < 1h: full resolution (keep all) + * 1hโ€“24h: 1 per 5 min + * 24hโ€“7d: 1 per 1 hour + * > 7d: hard delete (archive) + * + * Triggered by tick count, not setInterval. + * GC stats written to _system scope as kind="gc" events. + */ +import { readdirSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; +const ONE_HOUR = 3_600_000; +const ONE_DAY = 86_400_000; +const SEVEN_DAYS = 7 * ONE_DAY; +const FIVE_MIN = 300_000; +export const DEFAULT_GC_CONFIG = { + enabled: true, + tickInterval: 240, + tiers: [ + { olderThanMs: ONE_HOUR, intervalMs: FIVE_MIN }, + { olderThanMs: ONE_DAY, intervalMs: ONE_HOUR }, + { olderThanMs: SEVEN_DAYS, intervalMs: null }, + ], +}; +// โ”€โ”€ Core GC logic โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +/** + * Run GC on vitals store: downsample + archive. + * Returns stats about what was cleaned up. + */ +export async function gcVitals(vitalsStore, config = DEFAULT_GC_CONFIG) { + const now = Date.now(); + let downsampledCount = 0; + let archivedCount = 0; + // Sort tiers by olderThanMs descending so we process the oldest tier first. + // This ensures archive runs before downsample (no point downsampling data we'll delete). + const sortedTiers = [...config.tiers].sort((a, b) => b.olderThanMs - a.olderThanMs); + for (const tier of sortedTiers) { + const olderThan = now - tier.olderThanMs; + if (tier.intervalMs === null) { + // Hard delete (archive) + archivedCount += await vitalsStore.archiveEvents(olderThan); + } + else { + // Downsample: query distinct kind+key combos from vitals, then downsample each. + // We downsample all kinds present in vitals. + const kinds = await getDistinctKindKeys(vitalsStore, olderThan); + for (const { kind, key } of kinds) { + downsampledCount += await vitalsStore.downsampleEvents(kind, key, tier.intervalMs, olderThan); + } + } + } + return { downsampledCount, archivedCount }; +} +/** + * CAS mark-and-sweep: delete orphaned objects not referenced by any event. + * + * Scans all events in the given stores for hash references, + * then compares against files in objectsDir. + */ +export async function gcOrphanObjects(stores, objectsDir) { + // Mark: collect all hashes referenced by events + const referencedHashes = new Set(); + for (const store of stores) { + // Scan all events that have a hash field + // queryByKind with no kind filter doesn't exist, so we scan known kinds + for (const kind of [ + 'collect', + 'effect', + 'vital', + 'tick', + 'error', + 'promote', + 'rollback', + 'migrate', + 'init', + 'gc', + ]) { + const events = await store.queryByKind(kind, {}); + for (const event of events) { + if (event.hash) { + referencedHashes.add(event.hash); + } + } + } + } + // Sweep: compare against files in objectsDir + let orphanCount = 0; + let files; + try { + files = readdirSync(objectsDir); + } + catch { + return 0; // directory doesn't exist or not readable + } + for (const file of files) { + if (!file.endsWith('.json')) + continue; + const hash = file.slice(0, -5); // remove .json + if (!referencedHashes.has(hash)) { + try { + unlinkSync(join(objectsDir, file)); + orphanCount++; + } + catch { + // best effort โ€” file may have been removed concurrently + } + } + } + return orphanCount; +} +/** + * Full GC cycle: vitals downsample/archive + CAS orphan sweep. + * Writes a gc event to systemStore for observability. + */ +export async function runGc(options) { + const config = options.config ?? DEFAULT_GC_CONFIG; + const start = Date.now(); + const { downsampledCount, archivedCount } = await gcVitals(options.vitalsStore, config); + const orphanObjectsCount = await gcOrphanObjects(options.allStores, options.objectsDir); + const durationMs = Date.now() - start; + const result = { + downsampledCount, + archivedCount, + orphanObjectsCount, + durationMs, + }; + // Write GC stats event to _system scope + await options.systemStore.appendEvent({ + occurredAt: Date.now(), + kind: 'gc', + key: 'vitals', + meta: JSON.stringify(result), + }); + return result; +} +// โ”€โ”€ Tick-based GC trigger โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +/** + * Create a GC trigger that fires every N ticks. + * Returns a function to call after each tick. + */ +export function createGcTrigger(options) { + const config = options.config ?? DEFAULT_GC_CONFIG; + if (!config.enabled) + return () => { }; + let tickCount = 0; + return () => { + tickCount++; + if (tickCount >= config.tickInterval) { + tickCount = 0; + runGc({ ...options, config }).catch((err) => { + console.error('[pulse gc]', err); + }); + } + }; +} +// โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +/** + * Get distinct kind+key combos from a store for events older than a threshold. + * Used to know which series to downsample. + */ +async function getDistinctKindKeys(store, olderThan) { + // queryByKind returns events filtered by kind. + // For vitals, the main kind is 'vital' with key = watcher name. + // We also handle 'collect' kind in case vitals store has those. + const result = []; + const seen = new Set(); + for (const kind of ['vital', 'collect']) { + const events = await store.queryByKind(kind, { limit: 1000 }); + for (const event of events) { + const pair = `${kind}:${event.key ?? ''}`; + if (!seen.has(pair) && event.occurredAt < olderThan) { + seen.add(pair); + result.push({ kind, key: event.key ?? '' }); + } + } + } + return result; +} diff --git a/packages/pulse/src/index.d.ts b/packages/pulse/src/index.d.ts new file mode 100644 index 0000000..9365b68 --- /dev/null +++ b/packages/pulse/src/index.d.ts @@ -0,0 +1,236 @@ +/** + * @uncaged/pulse โ€” Core Engine + * + * A stateful reactive loop. All intelligence lives in the rules. + * + * Core atom: + * Rule = (prev, curr, inner) โ†’ Promise<[effects', tickMs']> + * + * Onion middleware model: each rule wraps the inner chain. + * The first rule in the array is the outermost layer. + * + * Composition: pulse = reduceRight rules base + */ +import { type GcConfig } from './gc.js'; +import type { EventRecord, PulseStore, ScopedStore } from './store.js'; +import { type WatcherDef } from './watcher.js'; +/** + * A sensed value โ€” latest known state of one sense dimension. + * refreshedAt: when this data was last collected (= collect event's occurred_at) + */ +export interface Sensed { + data: T; + refreshedAt: number; +} +/** + * Rule: the universal composition primitive. + * + * Onion middleware model. Each rule receives two snapshots and an inner + * continuation. The rule calls inner(prev, curr) to get the result from + * all inner layers, then can modify it before returning. + * + * Rules compose via onion (reduceRight): + * pulse = reduceRight rules base + * base = async () => [[], defaultTickMs] + * + * A rule can: + * - Call inner and modify the result (append, filter, adjust tickMs) + * - Short-circuit by not calling inner (bypass inner layers) + * - Pass through by returning inner's result unchanged + */ +export type Rule = (prev: S, curr: S, inner: (prev: S, curr: S) => Promise<[E[], number]>) => Promise<[E[], number]>; +/** + * Rule definition with projection dependencies. + * + * Declares which projections this rule depends on via scope/name paths. + * The engine will automatically read these projections and build the snapshot. + */ +export interface RuleDef { + name: string; + /** Projection dependencies, format "scope/name" like ["_vitals/cpu_usage", "neko/session_count"] */ + projections: string[]; + rule: Rule; +} +/** + * Executor: executes a batch of effects. + */ +export type Executor = (effects: E[]) => Promise; +/** @deprecated Use {@link Executor} instead. */ +export type Effector = Executor; +/** + * ChainableExecutor: processes effects and returns unhandled ones. + * + * Each ChainableExecutor in a chain receives the effects array, handles + * what it knows about, and returns the remaining unhandled effects. + * The next executor in the chain receives only the unhandled effects. + * + * Use chainExecutors() to compose a chain of ChainableExecutors into + * a single Executor compatible with runPulse. + */ +export type ChainableExecutor = (effects: E[]) => Promise; +/** + * Compose multiple ChainableExecutors into a single Executor. + * + * Each executor in the chain: + * 1. Receives effects (or remaining unhandled effects from the previous executor) + * 2. Returns the unhandled effects for the next executor + * + * If the final remaining effects are non-empty and no executor handled them, + * a console.warn is emitted. If executors array is empty and effects is + * non-empty, a warning is emitted immediately. + */ +export declare function chainExecutors(executors: ChainableExecutor[]): Executor; +/** + * Compose rules via onion middleware (reduceRight). + * + * Given rules [r1, r2, r3], produces: + * r1 wraps r2 wraps r3 wraps base + * + * r1 is outermost (executes first), r3 is innermost (closest to base). + * Each rule calls inner(prev, curr) to delegate to the next layer. + */ +export declare function composeRules(rules: Rule[], defaultTickMs?: number): (prev: S, curr: S) => Promise<[E[], number]>; +/** + * Find the effective version epoch โ€” the promote event that current runtime should use. + * If there's a rollback, use the promote event of the rolled-back-to version. + * If no rollback, use the latest promote event. + * If no promote at all, return null (cold start). + */ +export declare function findEffectiveEpoch(store: PulseStore): Promise; +/** + * Rebuild a Snapshot from the events table, respecting version epoch. + * When epoch is provided, only reads events after the epoch with matching code_rev. + * When epoch is null/undefined, falls back to latest collect per key. + * + * Overloads: + * - rebuildSnapshot(store, senseKeys, epoch?) โ€” reads from a single store + * - rebuildSnapshot({ system, vitals }, senseKeys, epoch?) โ€” reads vitals from separate store + * + * When options.workflowStore is provided and senseKeys includes 'pending-tasks', + * task projections (pending-tasks, agent-capability-stats) are folded from workflowStore. + * Falls back to options.systemStore for backward compatibility. + */ +export declare function rebuildSnapshot(storeOrStores: PulseStore | { + system: PulseStore; + vitals: PulseStore; +}, senseKeys: string[], epoch?: EventRecord | null, options?: { + systemStore?: PulseStore; + workflowStore?: PulseStore; +}): Promise; +/** + * Build snapshot from projections. + * Read each declared projection's current value, using "scope/name" as key. + * If projection doesn't exist, value is null (graceful degradation). + */ +export declare function buildSnapshotFromProjections(scopedStore: ScopedStore, projectionPaths: string[]): Promise; +/** + * Run the Pulse loop. + * + * rebuildSnapshot โ†’ pulse โ†’ execute โ†’ sleep โ†’ repeat + * + * All effects (including collect) go to execute. The runtime is + * completely unaware of collection logic โ€” that lives in execute. + * Cold-start is handled by rules: they see undefined senses and + * produce collect effects on the first tick. + */ +export declare function runPulse(options: { + scopedStore?: ScopedStore; + /** @deprecated Use {@link scopedStore} instead. */ + store?: PulseStore; + execute: Executor; + rules: Rule[]; + senseKeys: string[]; + defaultTickMs?: number; + codeRev?: string; + watchers?: WatcherDef[]; + /** Scan interval for the background executor loop (ms). Default 1000. */ + executorScanIntervalMs?: number; + /** GC configuration. Set `{ enabled: false }` to disable. Uses DEFAULT_GC_CONFIG if omitted. */ + gc?: Partial; + /** Objects directory path for CAS orphan cleanup. Required for CAS GC. */ + objectsDir?: string; + /** Adaptive tick frequency configuration */ + adaptiveTick?: { + /** Base tick interval in ms when active (default: 5000) */ + baseTickMs?: number; + /** Maximum tick interval in ms when idle (default: 300000) */ + maxTickMs?: number; + /** Backoff factor when idle (default: 2) */ + backoffFactor?: number; + /** Function to determine if there are active topics/work (optional) */ + hasActiveWork?: (snapshot: S) => boolean; + }; +}): Promise; +/** + * Run the Pulse loop with RuleDefs and projection-based snapshots (V2). + * + * Uses RuleDef + projections to drive the loop instead of senseKeys. + * Automatically folds projections and builds snapshots from declared dependencies. + */ +export declare function runPulseV2(options: { + scopedStore: ScopedStore; + execute: Executor; + ruleDefs: RuleDef[]; + defaultTickMs?: number; + codeRev: string; + watchers?: WatcherDef[]; + /** Scan interval for the background executor loop (ms). Default 1000. */ + executorScanIntervalMs?: number; +}): Promise; +/** + * Independent executor loop that scans for pending effect events + * and executes them asynchronously (fire-and-forget). + * + * Decouples effect execution from the senseยทthink tick loop: + * ticks write effect events, executorLoop picks them up and runs them. + */ +export declare function executorLoop(options: { + store: PulseStore; + execute: Executor; + scanIntervalMs?: number; + signal?: AbortSignal; +}): Promise; +/** + * Start the executor loop in the background. + * Returns the AbortController so the caller can stop it. + */ +export declare function startExecutorLoop(options: { + store: PulseStore; + execute: Executor; + scanIntervalMs?: number; + signal?: AbortSignal; +}): void; +/** + * Create a rule from an accessor + pure logic. + * Adaptation happens at construction time โ€” no need for contramap. + */ +export declare function createRule(accessor: (s: S) => T, logic: (prev: T, curr: T, inner: (prev: S, curr: S) => Promise<[E[], number]>) => Promise<[E[], number]>): Rule; +export { type CreateScopedStoreOptions, type CreateStoreOptions, createScopedStore, createStore, type EventRecord, type ObjectInstance, type PulseStore, type ScopedStore, } from './store.js'; +export { adaptiveInterval, clampTick, dedup, errorBackoff, } from './rules/builtin.js'; +export { startWatcher, type VitalWithData, type WakeCondition, type WatcherDef, type WatcherHandle, } from './watcher.js'; +export * from './watchers/index.js'; +export * from './rules/index.js'; +export { createOpenAiLlmClient, type LlmClient, type LlmMessage, type LlmResponse, type LlmTool, } from './llm-client.js'; +export { type AgentLoopRuleOptions, createAgentLoopRule, } from './rules/agent-loop.js'; +export { buildPersonasFromEvents } from './persona.js'; +export { createWorkflowTicker } from './workflows/index.js'; +export type { WorkflowRule, WorkflowTickResult, } from './workflows/workflow-rule-adapter.js'; +export { createWorkflowRule } from './workflows/workflow-rule-adapter.js'; +export { END, type MetaOf, type ModeratorInput, type Role, type RoleOutput, type RoleResult, START, type StartSignal, type WorkflowAction, type WorkflowMessage, type WorkflowType, } from './workflows/workflow-type.js'; +export { type AgentExecutorConfig, type AgentResult, type AgentRunner, createAgentExecutorRole, createCursorRunner, } from './workflows/roles/agent-executor.js'; +export { type LlmRoleConfig, type ToolRoleConfig, createLlmRole, createToolRole, } from './workflows/roles/llm-role-factory.js'; +export { type ScaffoldOptions, scaffoldWorkflow } from './workflows/scaffold.js'; +export * from './defs.js'; +export * from './executors/index.js'; +export type { GcConfig, GcResult, GcTier } from './gc.js'; +export { createGcTrigger, DEFAULT_GC_CONFIG, gcOrphanObjects, gcVitals, runGc, } from './gc.js'; +export * from './projection-engine.js'; +export type { ActiveProjectsData, AgentLoopTraceData, ContainerStatus, ContainerType, InflightBrokerData, LlmCallCompletedMeta, LlmCallStartedMeta, PendingTasksData, PersonaRegisteredMeta, PersonaState, PersonaUpdatedMeta, ProjectCreatedMeta, ProjectState, TaskAssignedMeta, TaskClosedMeta, TaskCreatedMeta, TaskRespondedMeta, TaskRoutingMeta, TaskState, TaskStatus, TaskType, ToolResponseMeta, TraceMessage, } from './task-events.js'; diff --git a/packages/pulse/src/index.js b/packages/pulse/src/index.js new file mode 100644 index 0000000..7394fd3 --- /dev/null +++ b/packages/pulse/src/index.js @@ -0,0 +1,672 @@ +/** + * @uncaged/pulse โ€” Core Engine + * + * A stateful reactive loop. All intelligence lives in the rules. + * + * Core atom: + * Rule = (prev, curr, inner) โ†’ Promise<[effects', tickMs']> + * + * Onion middleware model: each rule wraps the inner chain. + * The first rule in the array is the outermost layer. + * + * Composition: pulse = reduceRight rules base + */ +import { createGcTrigger, DEFAULT_GC_CONFIG } from './gc.js'; +import { foldAllProjections, getProjectionState } from './projection-engine.js'; +import { startWatcher } from './watcher.js'; +/** + * Compose multiple ChainableExecutors into a single Executor. + * + * Each executor in the chain: + * 1. Receives effects (or remaining unhandled effects from the previous executor) + * 2. Returns the unhandled effects for the next executor + * + * If the final remaining effects are non-empty and no executor handled them, + * a console.warn is emitted. If executors array is empty and effects is + * non-empty, a warning is emitted immediately. + */ +export function chainExecutors(executors) { + return async (effects) => { + if (executors.length === 0) { + if (effects.length > 0) { + console.warn(`[pulse] chainExecutors: ${effects.length} unhandled effects (no executors registered)`); + } + return; + } + let remaining = effects; + for (const executor of executors) { + remaining = await executor(remaining); + } + if (remaining.length > 0) { + console.warn(`[pulse] chainExecutors: ${remaining.length} unhandled effects after all executors`); + } + }; +} +// โ”€โ”€ Composition โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +/** + * Compose rules via onion middleware (reduceRight). + * + * Given rules [r1, r2, r3], produces: + * r1 wraps r2 wraps r3 wraps base + * + * r1 is outermost (executes first), r3 is innermost (closest to base). + * Each rule calls inner(prev, curr) to delegate to the next layer. + */ +export function composeRules(rules, defaultTickMs = 15000) { + const chain = rules.reduceRight((next, rule) => (prev, curr, inner) => rule(prev, curr, (p, c) => next(p, c, inner)), (prev, curr, inner) => inner(prev, curr)); + const base = async () => [ + [], + defaultTickMs, + ]; + return (prev, curr) => chain(prev, curr, base); +} +// โ”€โ”€ Version Epoch โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +/** + * Find the effective version epoch โ€” the promote event that current runtime should use. + * If there's a rollback, use the promote event of the rolled-back-to version. + * If no rollback, use the latest promote event. + * If no promote at all, return null (cold start). + */ +export async function findEffectiveEpoch(store) { + const rollback = await store.getLatest('rollback'); + if (rollback) { + // rollback.meta should contain { to: 'v1' } โ€” the code_rev to roll back to + let meta = {}; + try { + meta = rollback.meta ? JSON.parse(rollback.meta) : {}; + } + catch { + // Corrupted meta โ€” skip this rollback event, fall through to latest promote + return await store.getLatest('promote'); + } + const targetRev = meta.to || rollback.codeRev; + if (targetRev) { + return await store.getLatestWhere({ + kind: 'promote', + codeRev: targetRev, + }); + } + } + return await store.getLatest('promote'); +} +// โ”€โ”€ Snapshot Rebuild โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +/** + * Rebuild a Snapshot from the events table, respecting version epoch. + * When epoch is provided, only reads events after the epoch with matching code_rev. + * When epoch is null/undefined, falls back to latest collect per key. + * + * Overloads: + * - rebuildSnapshot(store, senseKeys, epoch?) โ€” reads from a single store + * - rebuildSnapshot({ system, vitals }, senseKeys, epoch?) โ€” reads vitals from separate store + * + * When options.workflowStore is provided and senseKeys includes 'pending-tasks', + * task projections (pending-tasks, agent-capability-stats) are folded from workflowStore. + * Falls back to options.systemStore for backward compatibility. + */ +export async function rebuildSnapshot(storeOrStores, senseKeys, epoch, options) { + const isMultiStore = typeof storeOrStores === 'object' && + 'system' in storeOrStores && + 'vitals' in storeOrStores; + const store = isMultiStore + ? storeOrStores.system + : storeOrStores; + const vitalsStore = isMultiStore + ? storeOrStores.vitals + : null; + const snapshot = { timestamp: Date.now() }; + const casMisses = []; + for (const key of senseKeys) { + // Priority 1: read latest vital from vitals store (if provided) + if (vitalsStore) { + const latestVital = await vitalsStore.getLatest('vital', key); + if (latestVital?.hash) { + const data = await vitalsStore.getObject(latestVital.hash); + if (data !== null) { + snapshot[key] = { + data, + refreshedAt: latestVital.occurredAt, + }; + continue; + } + casMisses.push(key); + } + } + // Priority 2: fallback to events table (migrate/init events) + if (epoch) { + const events = await store.getAfter(epoch.id, { + kind: 'collect', + key, + codeRev: epoch.codeRev ?? undefined, + }); + const latestCollect = events.length > 0 ? events[events.length - 1] : null; + if (latestCollect?.hash) { + const data = await store.getObject(latestCollect.hash); + if (data !== null) { + snapshot[key] = { + data, + refreshedAt: latestCollect.occurredAt, + }; + continue; + } + } + // Check migrate events + const migrateEvents = await store.getAfter(epoch.id, { + kind: 'migrate', + key, + codeRev: epoch.codeRev ?? undefined, + }); + if (migrateEvents.length > 0) { + const latestMigrate = migrateEvents[migrateEvents.length - 1]; + if (latestMigrate.hash) { + const data = await store.getObject(latestMigrate.hash); + if (data !== null) { + snapshot[key] = { + data, + refreshedAt: latestMigrate.occurredAt, + }; + continue; + } + } + } + // Check init events + const initEvents = await store.getAfter(epoch.id, { + kind: 'init', + key, + codeRev: epoch.codeRev ?? undefined, + }); + if (initEvents.length > 0) { + const latestInit = initEvents[initEvents.length - 1]; + if (latestInit.hash) { + const data = await store.getObject(latestInit.hash); + if (data !== null) { + snapshot[key] = { + data, + refreshedAt: latestInit.occurredAt, + }; + } + } + } + } + else { + // No epoch โ€” try latest collect from events table directly + const latest = await store.getLatest('collect', key); + if (latest?.hash) { + const data = await store.getObject(latest.hash); + if (data !== null) { + snapshot[key] = { + data, + refreshedAt: latest.occurredAt, + }; + } + } + } + } + // Surface CAS misses so rules can detect data integrity issues + if (casMisses.length > 0) { + snapshot['_error:cas_miss'] = { keys: casMisses, count: casMisses.length }; + } + return snapshot; +} +// โ”€โ”€ Projection-based Snapshot โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +/** + * Build snapshot from projections. + * Read each declared projection's current value, using "scope/name" as key. + * If projection doesn't exist, value is null (graceful degradation). + */ +export async function buildSnapshotFromProjections(scopedStore, projectionPaths) { + const snapshot = { timestamp: Date.now() }; + for (const projectionPath of projectionPaths) { + const [scopeName, projectionName] = projectionPath.split('/'); + if (!scopeName || !projectionName) { + console.warn(`[buildSnapshotFromProjections] Invalid projection path format: "${projectionPath}", expected "scope/name"`); + snapshot[projectionPath] = null; + continue; + } + try { + // Get scope database + const scopeDb = scopedStore.scopeDatabase(scopeName); + // Get projection state + const projectionState = await getProjectionState(scopeDb, projectionName); + if (projectionState) { + snapshot[projectionPath] = projectionState.value; + } + else { + console.warn(`[buildSnapshotFromProjections] Projection not found: "${projectionPath}"`); + snapshot[projectionPath] = null; + } + } + catch (error) { + console.warn(`[buildSnapshotFromProjections] Error reading projection "${projectionPath}":`, error); + snapshot[projectionPath] = null; + } + } + return snapshot; +} +// โ”€โ”€ Runtime โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +/** + * Run the Pulse loop. + * + * rebuildSnapshot โ†’ pulse โ†’ execute โ†’ sleep โ†’ repeat + * + * All effects (including collect) go to execute. The runtime is + * completely unaware of collection logic โ€” that lives in execute. + * Cold-start is handled by rules: they see undefined senses and + * produce collect effects on the first tick. + */ +export async function runPulse(options) { + const { execute, rules, senseKeys, defaultTickMs = 15000, codeRev } = options; + // โ”€โ”€ Adaptive Tick Configuration โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const adaptiveConfig = { + baseTickMs: 5000, + maxTickMs: 300000, + backoffFactor: 2, + hasActiveWork: undefined, + ...options.adaptiveTick, + }; + const { baseTickMs, maxTickMs, backoffFactor, hasActiveWork } = adaptiveConfig; + const systemStore = options.scopedStore + ? options.scopedStore.scope('_system') + : options.store; + const vitalsStore = options.scopedStore + ? options.scopedStore.scope('_vitals') + : options.store; + // Workflow scope: stores task lifecycle events (task-created, task-assigned, etc.) + const workflowStore = options.scopedStore + ? options.scopedStore.scope('workflows') + : undefined; + // โ”€โ”€ GC trigger โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const gcConfig = { ...DEFAULT_GC_CONFIG, ...options.gc }; + const allStores = [ + systemStore, + vitalsStore, + ...(workflowStore ? [workflowStore] : []), + ]; + const gcTick = createGcTrigger({ + vitalsStore, + systemStore, + allStores, + objectsDir: options.objectsDir ?? '', + config: gcConfig, + }); + const pulse = composeRules(rules, defaultTickMs); + // Determine version epoch (always from system store) + const epoch = await findEffectiveEpoch(systemStore); + let prev = await rebuildSnapshot({ system: systemStore, vitals: vitalsStore }, senseKeys, epoch, { systemStore, workflowStore }); + let tickMs = defaultTickMs; + // โ”€โ”€ Wake mechanism โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + let wakeResolve = null; + let pendingWake = false; + let ticking = false; + function wakeTick() { + if (ticking) { + pendingWake = true; + } + else if (wakeResolve) { + const resolve = wakeResolve; + wakeResolve = null; + resolve(); + } + } + function interruptibleSleep(ms) { + return new Promise((resolve) => { + const timer = setTimeout(() => { + wakeResolve = null; + resolve(); + }, ms); + wakeResolve = () => { + clearTimeout(timer); + resolve(); + }; + }); + } + // Start watchers โ€” they write to vitalsStore + for (const def of options.watchers ?? []) { + startWatcher(def, vitalsStore, wakeTick); + } + // Start executor loop โ€” picks up effect events and executes them asynchronously + const executorAbort = new AbortController(); + startExecutorLoop({ + store: systemStore, + execute, + scanIntervalMs: options.executorScanIntervalMs ?? 1000, + signal: executorAbort.signal, + }); + // Future: Rule scopes declaration + // Rules will be able to declare which scopes they need: + // createRule({ scopes: ['_system', '_vitals', 'neko'], accessor, decide }) + // rebuildSnapshot will then pull from declared scopes only. + while (true) { + await interruptibleSleep(tickMs); + ticking = true; + pendingWake = false; + const curr = await rebuildSnapshot({ system: systemStore, vitals: vitalsStore }, senseKeys, epoch, { systemStore, workflowStore }); + const tickStart = Date.now(); + const [effects, nextTickMs] = await pulse(prev, curr); + // Write effect events to store (fire-and-forget โ€” executorLoop picks them up) + if (effects.length > 0) { + for (const effect of effects) { + const effectHash = await systemStore.putObject(effect); + await systemStore.appendEvent({ + occurredAt: Date.now(), + kind: 'effect', + key: effectHash, + hash: effectHash, + meta: JSON.stringify({ + type: effect.type || effect.kind || 'unknown', + }), + codeRev, + }); + } + } + ticking = false; + // GC tick โ€” runs actual GC every N ticks + gcTick(); + // Adaptive tick frequency logic + if (pendingWake) { + // Immediate next tick if wake was requested + tickMs = 0; + } + else { + // Determine if there's active work + const hasActivity = effects.length > 0 || (hasActiveWork && hasActiveWork(curr)); + if (hasActivity) { + // Active work detected โ†’ reset to base frequency + tickMs = baseTickMs; + } + else { + // No activity โ†’ exponential backoff + tickMs = Math.min(tickMs * backoffFactor, maxTickMs); + } + // Apply rule-suggested tickMs if provided + if (nextTickMs !== defaultTickMs) { + tickMs = nextTickMs; + } + } + // Record tick event with the actual tickMs that will be used for next iteration + await systemStore.appendEvent({ + occurredAt: Date.now(), + kind: 'tick', + meta: JSON.stringify({ + tick_ms: tickMs, + duration_ms: Date.now() - tickStart, + effect_count: effects.length, + }), + codeRev, + }); + prev = curr; + } +} +// โ”€โ”€ Runtime V2 (Projection-based) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +/** + * Run the Pulse loop with RuleDefs and projection-based snapshots (V2). + * + * Uses RuleDef + projections to drive the loop instead of senseKeys. + * Automatically folds projections and builds snapshots from declared dependencies. + */ +export async function runPulseV2(options) { + const { scopedStore, execute, ruleDefs, defaultTickMs = 15000, codeRev, watchers, } = options; + // Collect all projection paths from rule definitions (deduplicated) + const allProjectionPaths = new Set(); + for (const ruleDef of ruleDefs) { + for (const projectionPath of ruleDef.projections) { + allProjectionPaths.add(projectionPath); + } + } + const projectionPaths = Array.from(allProjectionPaths); + // Extract unique scope names + const scopeNames = new Set(); + for (const projectionPath of projectionPaths) { + const [scopeName] = projectionPath.split('/'); + if (scopeName) { + scopeNames.add(scopeName); + } + } + // Compose rules from RuleDefs + const rules = ruleDefs.map((def) => def.rule); + const pulse = composeRules(rules, defaultTickMs); + // Build initial snapshot + let prev = await buildSnapshotFromProjections(scopedStore, projectionPaths); + let tickMs = defaultTickMs; + // โ”€โ”€ Wake mechanism โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + let wakeResolve = null; + let pendingWake = false; + let ticking = false; + function wakeTick() { + if (ticking) { + pendingWake = true; + } + else if (wakeResolve) { + const resolve = wakeResolve; + wakeResolve = null; + resolve(); + } + } + function interruptibleSleep(ms) { + return new Promise((resolve) => { + const timer = setTimeout(() => { + wakeResolve = null; + resolve(); + }, ms); + wakeResolve = () => { + clearTimeout(timer); + resolve(); + }; + }); + } + // Start watchers โ€” they write to vitalsStore + if (watchers) { + const vitalsStore = scopedStore.scope('_vitals'); + for (const def of watchers) { + startWatcher(def, vitalsStore, wakeTick); + } + } + // Start executor loop โ€” picks up effect events and executes them asynchronously + const executorAbort = new AbortController(); + startExecutorLoop({ + store: scopedStore.scope('_system'), + execute, + scanIntervalMs: options.executorScanIntervalMs ?? 1000, + signal: executorAbort.signal, + }); + // Main loop + while (true) { + await interruptibleSleep(tickMs); + ticking = true; + pendingWake = false; + // Fold all projections for involved scopes + for (const scopeName of scopeNames) { + try { + const scopeDb = scopedStore.scopeDatabase(scopeName); + await foldAllProjections(scopeDb, scopeName, codeRev); + } + catch (error) { + console.warn(`[runPulseV2] Failed to fold projections for scope "${scopeName}":`, error); + } + } + // Build current snapshot from projections + const curr = await buildSnapshotFromProjections(scopedStore, projectionPaths); + const tickStart = Date.now(); + const [effects, nextTickMs] = await pulse(prev, curr); + // Write effect events to store (fire-and-forget โ€” executorLoop picks them up) + if (effects.length > 0) { + const sysStore = scopedStore.scope('_system'); + for (const effect of effects) { + const effectHash = await sysStore.putObject(effect); + await sysStore.appendEvent({ + occurredAt: Date.now(), + kind: 'effect', + key: effectHash, + hash: effectHash, + meta: JSON.stringify({ + type: effect.type || effect.kind || 'unknown', + }), + codeRev, + }); + } + } + // Record tick event in system store + const systemStore = scopedStore.scope('_system'); + await systemStore.appendEvent({ + occurredAt: Date.now(), + kind: 'tick', + meta: JSON.stringify({ + tick_ms: nextTickMs, + duration_ms: Date.now() - tickStart, + effect_count: effects.length, + }), + codeRev, + }); + ticking = false; + tickMs = pendingWake ? 0 : nextTickMs; + prev = curr; + } +} +// โ”€โ”€ Executor Loop (fire-and-forget) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +/** + * Independent executor loop that scans for pending effect events + * and executes them asynchronously (fire-and-forget). + * + * Decouples effect execution from the senseยทthink tick loop: + * ticks write effect events, executorLoop picks them up and runs them. + */ +export async function executorLoop(options) { + const { store, execute, scanIntervalMs = 5000, signal } = options; + /** Set of effect event ids currently being executed (prevents double-exec). */ + const inflight = new Set(); + while (!signal?.aborted) { + try { + // Find all pending effect events + const effectEvents = await store.queryByKind('effect'); + for (const effectEvent of effectEvents) { + if (signal?.aborted) + break; + if (inflight.has(effectEvent.id)) + continue; + const idStr = String(effectEvent.id); + // Check if already acked or failed + const acked = await store.getLatest('effect-acked', idStr); + if (acked) + continue; + const failed = await store.getLatest('effect-failed', idStr); + if (failed) + continue; + const executing = await store.getLatest('effect-executing', idStr); + if (executing) + continue; + // Retrieve the effect object from CAS + if (!effectEvent.hash) + continue; + const effectObj = (await store.getObject(effectEvent.hash)); + if (effectObj === null) + continue; + // Mark as inflight + inflight.add(effectEvent.id); + // Write effect-executing event + await store.appendEvent({ + occurredAt: Date.now(), + kind: 'effect-executing', + key: idStr, + hash: effectEvent.hash, + meta: effectEvent.meta, + codeRev: effectEvent.codeRev, + }); + // Fire-and-forget: execute asynchronously + execute([effectObj]) + .then(() => { + // Write effect-acked event + return store.appendEvent({ + occurredAt: Date.now(), + kind: 'effect-acked', + key: idStr, + hash: effectEvent.hash, + codeRev: effectEvent.codeRev, + }); + }) + .catch((err) => { + // Write effect-failed event + const errorMessage = err instanceof Error ? err.message : String(err); + return store.appendEvent({ + occurredAt: Date.now(), + kind: 'effect-failed', + key: idStr, + hash: effectEvent.hash, + meta: JSON.stringify({ error: errorMessage }), + codeRev: effectEvent.codeRev, + }); + }) + .finally(() => { + inflight.delete(effectEvent.id); + }); + } + } + catch (err) { + // Database may have been closed (e.g. during test teardown) โ€” exit gracefully + if (err instanceof RangeError && + String(err.message).includes('closed database')) { + return; + } + throw err; + } + // Sleep until next scan (or abort) + await new Promise((resolve) => { + if (signal?.aborted) { + resolve(); + return; + } + const timer = setTimeout(resolve, scanIntervalMs); + signal?.addEventListener('abort', () => { + clearTimeout(timer); + resolve(); + }, { once: true }); + }); + } +} +/** + * Start the executor loop in the background. + * Returns the AbortController so the caller can stop it. + */ +export function startExecutorLoop(options) { + // Fire-and-forget โ€” the loop runs until signal is aborted + executorLoop(options).catch((err) => { + console.error('[pulse] executorLoop crashed:', err); + }); +} +// โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +/** + * Create a rule from an accessor + pure logic. + * Adaptation happens at construction time โ€” no need for contramap. + */ +export function createRule(accessor, logic) { + return (prev, curr, inner) => logic(accessor(prev), accessor(curr), inner); +} +// โ”€โ”€ Storage โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +export { createScopedStore, createStore, } from './store.js'; +// โ”€โ”€ Built-in Rules โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +export { adaptiveInterval, clampTick, dedup, errorBackoff, } from './rules/builtin.js'; +// โ”€โ”€ Watcher โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +export { startWatcher, } from './watcher.js'; +// โ”€โ”€ P0 Watchers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +export * from './watchers/index.js'; +// โ”€โ”€ Survival Layer โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +export * from './rules/index.js'; +// โ”€โ”€ LLM Client โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +export { createOpenAiLlmClient, } from './llm-client.js'; +// โ”€โ”€ Agent Loop Rule โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +export { createAgentLoopRule, } from './rules/agent-loop.js'; +// โ”€โ”€ Persona Registry โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +export { buildPersonasFromEvents } from './persona.js'; +// โ”€โ”€ Council v2: WorkflowType โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +export { createWorkflowTicker } from './workflows/index.js'; +export { createWorkflowRule } from './workflows/workflow-rule-adapter.js'; +export { END, START, } from './workflows/workflow-type.js'; +export { createAgentExecutorRole, createCursorRunner, } from './workflows/roles/agent-executor.js'; +export { createLlmRole, createToolRole, } from './workflows/roles/llm-role-factory.js'; +export { scaffoldWorkflow } from './workflows/scaffold.js'; +// โ”€โ”€ Executors โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// โ”€โ”€ Definition Layer โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +export * from './defs.js'; +export * from './executors/index.js'; +// โ”€โ”€ GC โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +export { createGcTrigger, DEFAULT_GC_CONFIG, gcOrphanObjects, gcVitals, runGc, } from './gc.js'; +// โ”€โ”€ Projection Engine โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +export * from './projection-engine.js'; diff --git a/packages/pulse/src/index.ts b/packages/pulse/src/index.ts index de4172c..ca9cb49 100644 --- a/packages/pulse/src/index.ts +++ b/packages/pulse/src/index.ts @@ -963,18 +963,7 @@ export { buildPersonasFromEvents } from './persona.js'; // โ”€โ”€ Council v2: WorkflowType โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -export { - type ArchitectMeta, - type CloserMeta, - type CoderMeta, - type CodingRoles, - createCodingWorkflow, - type ReviewerMeta, -} from './workflows/coding.js'; export { createWorkflowTicker } from './workflows/index.js'; -export { createArchitectRole } from './workflows/roles/architect-llm.js'; -export { createCoderRole } from './workflows/roles/coder-cursor.js'; -export { createReviewerRole } from './workflows/roles/reviewer-cursor.js'; export type { WorkflowRule, WorkflowTickResult, @@ -993,6 +982,20 @@ export { type WorkflowMessage, type WorkflowType, } from './workflows/workflow-type.js'; +export { + type AgentExecutorConfig, + type AgentResult, + type AgentRunner, + createAgentExecutorRole, + createCursorRunner, +} from './workflows/roles/agent-executor.js'; +export { + type LlmRoleConfig, + type ToolRoleConfig, + createLlmRole, + createToolRole, +} from './workflows/roles/llm-role-factory.js'; +export { type ScaffoldOptions, scaffoldWorkflow } from './workflows/scaffold.js'; // โ”€โ”€ Executors โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ diff --git a/packages/pulse/src/llm-client.d.ts b/packages/pulse/src/llm-client.d.ts new file mode 100644 index 0000000..8cf406a --- /dev/null +++ b/packages/pulse/src/llm-client.d.ts @@ -0,0 +1,51 @@ +export interface LlmMessage { + role: 'system' | 'user' | 'assistant' | 'tool'; + content?: string; + tool_calls?: Array<{ + id: string; + function: { + name: string; + arguments: string; + }; + }>; + tool_call_id?: string; +} +export interface LlmTool { + type: 'function'; + function: { + name: string; + description: string; + parameters: Record; + }; +} +export interface LlmResponse { + content?: string; + tool_calls?: Array<{ + id: string; + function: { + name: string; + arguments: string; + }; + }>; + usage?: { + prompt_tokens: number; + completion_tokens: number; + }; +} +export interface LlmClient { + chat(opts: { + messages: LlmMessage[]; + tools?: LlmTool[]; + tool_choice?: 'auto' | 'required'; + }): Promise; +} +/** + * Create an OpenAI-compatible LLM client. + * Works with DashScope, LiteLLM proxy, OpenAI, etc. + */ +export declare function createOpenAiLlmClient(opts: { + baseUrl: string; + apiKey: string; + model: string; + timeoutMs?: number; +}): LlmClient; diff --git a/packages/pulse/src/llm-client.js b/packages/pulse/src/llm-client.js new file mode 100644 index 0000000..d5dbd54 --- /dev/null +++ b/packages/pulse/src/llm-client.js @@ -0,0 +1,50 @@ +/** + * Create an OpenAI-compatible LLM client. + * Works with DashScope, LiteLLM proxy, OpenAI, etc. + */ +export function createOpenAiLlmClient(opts) { + const { baseUrl, apiKey, model, timeoutMs = 30_000 } = opts; + return { + async chat({ messages, tools, tool_choice }) { + const body = { + model, + messages, + }; + if (tools && tools.length > 0) + body.tools = tools; + if (tool_choice) + body.tool_choice = tool_choice; + const url = `${baseUrl.replace(/\/+$/, '')}/chat/completions`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify(body), + signal: controller.signal, + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`LLM API error ${res.status}: ${text}`); + } + const json = (await res.json()); + const choice = json.choices?.[0]?.message; + // copilot-api ๋“ฑ ์ผ๋ถ€ ๊ตฌํ˜„์€ choices๋ฅผ 2๊ฐœ ๋ฐ˜ํ™˜ํ•˜๊ณ  + // tool_calls๊ฐ€ ๋‘ ๋ฒˆ์งธ choice์— ์žˆ์„ ์ˆ˜ ์žˆ์Œ + const toolChoice = json.choices?.find((c) => c.message?.tool_calls)?.message; + return { + content: choice?.content ?? undefined, + tool_calls: toolChoice?.tool_calls ?? choice?.tool_calls, + usage: json.usage, + }; + } + finally { + clearTimeout(timer); + } + }, + }; +} diff --git a/packages/pulse/src/persona.d.ts b/packages/pulse/src/persona.d.ts new file mode 100644 index 0000000..6a1f5ad --- /dev/null +++ b/packages/pulse/src/persona.d.ts @@ -0,0 +1,10 @@ +import type { PulseStore } from './store.js'; +import type { PersonaState } from './task-events.js'; +/** + * Build a Map of PersonaState from persona-registered and persona-updated events. + * + * - Same personaId registered multiple times โ†’ idempotent overwrite. + * - persona-updated only patches provided fields. + * - Events are merged in occurredAt order. + */ +export declare function buildPersonasFromEvents(store: PulseStore): Promise>; diff --git a/packages/pulse/src/persona.js b/packages/pulse/src/persona.js new file mode 100644 index 0000000..96d0066 --- /dev/null +++ b/packages/pulse/src/persona.js @@ -0,0 +1,51 @@ +/** + * Build a Map of PersonaState from persona-registered and persona-updated events. + * + * - Same personaId registered multiple times โ†’ idempotent overwrite. + * - persona-updated only patches provided fields. + * - Events are merged in occurredAt order. + */ +export async function buildPersonasFromEvents(store) { + const registered = await store.queryByKind('persona-registered'); + const updated = await store.queryByKind('persona-updated'); + const allEvents = [...registered, ...updated]; + allEvents.sort((a, b) => a.occurredAt - b.occurredAt); + const personas = new Map(); + for (const ev of allEvents) { + if (!ev.meta) + continue; + let meta; + try { + meta = JSON.parse(ev.meta); + } + catch { + continue; + } + const personaId = meta.personaId; + if (!personaId) + continue; + if (ev.kind === 'persona-registered') { + const m = meta; + personas.set(personaId, { + personaId: m.personaId, + name: m.name, + container: m.container, + capabilities: m.capabilities, + registeredAt: ev.occurredAt, + updatedAt: ev.occurredAt, + }); + } + else if (ev.kind === 'persona-updated') { + const existing = personas.get(personaId); + if (!existing) + continue; + const m = meta; + if (m.container !== undefined) + existing.container = m.container; + if (m.capabilities !== undefined) + existing.capabilities = m.capabilities; + existing.updatedAt = ev.occurredAt; + } + } + return personas; +} diff --git a/packages/pulse/src/projection-engine.d.ts b/packages/pulse/src/projection-engine.d.ts new file mode 100644 index 0000000..2c411a4 --- /dev/null +++ b/packages/pulse/src/projection-engine.d.ts @@ -0,0 +1,35 @@ +/** + * @uncaged/pulse โ€” Projection Engine (Phase 2) + * + * Incremental fold engine for projections with JSONata expressions. + * Projections are first-class citizens with their own state table. + */ +import type { Database } from 'bun:sqlite'; +/** Clear the compiled expression cache (useful for testing). */ +export declare function clearExpressionCache(): void; +export interface ProjectionState { + name: string; + value: any; + lastEventId: number; + codeRev: string; + updatedAt: number; +} +export declare const PROJECTIONS_SCHEMA = "\nCREATE TABLE IF NOT EXISTS projections (\n name TEXT PRIMARY KEY,\n value TEXT NOT NULL, -- JSON, current state after fold\n last_event_id INTEGER NOT NULL DEFAULT 0,\n code_rev TEXT NOT NULL,\n updated_at INTEGER NOT NULL\n);\n"; +/** + * Get current projection state from the database. + */ +export declare function getProjectionState(scopeDb: Database, projectionName: string): Promise; +/** + * Incremental fold for a single projection. + * Reads definition, current state, processes new events since last_event_id. + */ +export declare function foldProjection(scopeDb: Database, _scopeName: string, projectionName: string, codeRev: string): Promise; +/** + * Fold all projections in a scope with the given code revision. + */ +export declare function foldAllProjections(scopeDb: Database, scopeName: string, codeRev: string): Promise>; +/** + * Reset all projections and replay with new code revision. + * This is the only function that DELETES from projections table. + */ +export declare function resetProjections(scopeDb: Database, codeRev: string): Promise; diff --git a/packages/pulse/src/projection-engine.js b/packages/pulse/src/projection-engine.js new file mode 100644 index 0000000..360a0c1 --- /dev/null +++ b/packages/pulse/src/projection-engine.js @@ -0,0 +1,310 @@ +/** + * @uncaged/pulse โ€” Projection Engine (Phase 2) + * + * Incremental fold engine for projections with JSONata expressions. + * Projections are first-class citizens with their own state table. + */ +import jsonata from 'jsonata'; +import { getProjectionDef } from './defs.js'; +// โ”€โ”€ Expression Cache โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +const expressionCache = new Map(); +function getOrCompileExpression(expressionStr) { + let expr = expressionCache.get(expressionStr); + if (!expr) { + expr = jsonata(expressionStr); + expressionCache.set(expressionStr, expr); + } + return expr; +} +/** Clear the compiled expression cache (useful for testing). */ +export function clearExpressionCache() { + expressionCache.clear(); +} +// โ”€โ”€ Database Schema โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +export const PROJECTIONS_SCHEMA = ` +CREATE TABLE IF NOT EXISTS projections ( + name TEXT PRIMARY KEY, + value TEXT NOT NULL, -- JSON, current state after fold + last_event_id INTEGER NOT NULL DEFAULT 0, + code_rev TEXT NOT NULL, + updated_at INTEGER NOT NULL +); +`; +// โ”€โ”€ Prepared Statements โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function getProjectionStateStmt(db) { + return db.prepare(` + SELECT name, value, last_event_id, code_rev, updated_at + FROM projections + WHERE name = ? + `); +} +function upsertProjectionStateStmt(db) { + return db.prepare(` + INSERT OR REPLACE INTO projections (name, value, last_event_id, code_rev, updated_at) + VALUES (?, ?, ?, ?, ?) + `); +} +function selectEventsAfterStmt(db) { + return db.prepare(` + SELECT id, occurred_at, kind, key, hash, code_rev, meta + FROM events + WHERE id > ? + ORDER BY id ASC + `); +} +function selectEventsAfterWithKindFilterStmt(db) { + return db.prepare(` + SELECT id, occurred_at, kind, key, hash, code_rev, meta + FROM events + WHERE id > ? AND kind = ? + ORDER BY id ASC + `); +} +function insertEventStmt(db) { + return db.prepare(` + INSERT INTO events (occurred_at, kind, key, hash, code_rev, meta) + VALUES (?, ?, ?, ?, ?, ?) + `); +} +function deleteAllProjectionsStmt(db) { + return db.prepare(`DELETE FROM projections`); +} +function selectAllEventsStmt(db) { + return db.prepare(` + SELECT id, occurred_at, kind, key, hash, code_rev, meta + FROM events + ORDER BY id ASC + `); +} +// โ”€โ”€ Helper Functions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function rowToEventRecord(row) { + const rec = { + id: Number(row.id), + occurredAt: row.occurred_at, + kind: row.kind, + }; + if (row.key != null) + rec.key = row.key; + if (row.hash != null) + rec.hash = row.hash; + if (row.code_rev != null) + rec.codeRev = row.code_rev; + if (row.meta != null) + rec.meta = row.meta; + return rec; +} +function writeErrorEvent(db, projectionName, error, eventId) { + try { + const now = Date.now(); + const payload = { + error: error.message || String(error), + eventId, + projectionName, + }; + insertEventStmt(db).run(now, 'projection.fold.error', projectionName, null, // no hash + null, // no code_rev + JSON.stringify(payload)); + } + catch (writeError) { + // If we can't even write the error event, log it but don't crash + console.error('Failed to write projection error event:', writeError); + } +} +// โ”€โ”€ Core API โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +/** + * Get current projection state from the database. + */ +export async function getProjectionState(scopeDb, projectionName) { + const stmt = getProjectionStateStmt(scopeDb); + const row = stmt.get(projectionName); + if (!row) + return null; + return { + name: row.name, + value: JSON.parse(row.value), + lastEventId: Number(row.last_event_id), + codeRev: row.code_rev, + updatedAt: row.updated_at, + }; +} +/** + * Incremental fold for a single projection. + * Reads definition, current state, processes new events since last_event_id. + */ +export async function foldProjection(scopeDb, _scopeName, projectionName, codeRev) { + // 1. Get projection definition + const def = await getProjectionDef(scopeDb, projectionName, codeRev); + if (!def) { + throw new Error(`Projection definition not found: ${projectionName}@${codeRev}`); + } + // 2. Get current state from projections table + const currentState = await getProjectionState(scopeDb, projectionName); + const currentValue = currentState?.value ?? def.initialValue; + const lastEventId = currentState?.lastEventId ?? 0; + // If code_rev changed, we need full replay (handled by resetProjections) + if (currentState && currentState.codeRev !== codeRev) { + throw new Error(`Projection ${projectionName} has different code_rev (${currentState.codeRev} vs ${codeRev}). Call resetProjections first.`); + } + // 3. Get events after last_event_id + const eventKinds = new Set(def.sources.map((s) => s.eventKind)); + let newEvents = []; + if (eventKinds.size === 1) { + // Optimize for single event kind + const kind = Array.from(eventKinds)[0]; + const stmt = selectEventsAfterWithKindFilterStmt(scopeDb); + const rows = stmt.all(lastEventId, kind); + newEvents = rows.map(rowToEventRecord); + } + else { + // Multiple event kinds - get all and filter + const stmt = selectEventsAfterStmt(scopeDb); + const rows = stmt.all(lastEventId); + newEvents = rows + .map(rowToEventRecord) + .filter((event) => eventKinds.has(event.kind)); + } + // 4. Process each event through JSONata expressions + let updatedValue = currentValue; + let lastProcessedEventId = lastEventId; + for (const event of newEvents) { + // Find matching sources for this event + const matchingSources = def.sources.filter((source) => { + const kindMatch = source.eventKind === event.kind; + const keyMatch = !source.eventKey || source.eventKey === event.key; + return kindMatch && keyMatch; + }); + // Process each matching source + for (const source of matchingSources) { + try { + const expr = getOrCompileExpression(source.expression); + // JSONata needs variables as bindings, not in data context + const data = {}; // Empty data object + const bindings = { + state: updatedValue, + event: { + id: event.id, + occurred_at: event.occurredAt, + kind: event.kind, + key: event.key, + payload: event.meta ? JSON.parse(event.meta) : {}, + }, + params: def.params || {}, + }; + const result = await expr.evaluate(data, bindings); + updatedValue = result; + lastProcessedEventId = event.id; + } + catch (error) { + // Write error event but continue processing + writeErrorEvent(scopeDb, projectionName, error, event.id); + console.warn(`Projection ${projectionName} fold error for event ${event.id}:`, error); + // Skip this event, don't update state + } + } + } + // 5. Update projections table + const now = Date.now(); + // Ensure we have a valid value to store + if (updatedValue === undefined || updatedValue === null) { + console.warn(`Projection ${projectionName} ended up with undefined/null value, using initial value`); + updatedValue = def.initialValue; + } + const newState = { + name: projectionName, + value: updatedValue, + lastEventId: lastProcessedEventId, + codeRev, + updatedAt: now, + }; + const stmt = upsertProjectionStateStmt(scopeDb); + const valueJson = JSON.stringify(updatedValue); + stmt.run(projectionName, valueJson, lastProcessedEventId, codeRev, now); + return newState; +} +/** + * Fold all projections in a scope with the given code revision. + */ +export async function foldAllProjections(scopeDb, scopeName, codeRev) { + const hasTable = scopeDb + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='projection_defs'") + .get(); + if (!hasTable) { + return new Map(); + } + const { listProjectionDefs } = await import('./defs.js'); + const projectionDefs = await listProjectionDefs(scopeDb, { codeRev }); + const results = new Map(); + for (const def of projectionDefs) { + try { + const state = await foldProjection(scopeDb, scopeName, def.name, codeRev); + results.set(def.name, state); + } + catch (error) { + console.error(`Failed to fold projection ${def.name}:`, error); + // Continue with other projections + } + } + return results; +} +/** + * Reset all projections and replay with new code revision. + * This is the only function that DELETES from projections table. + */ +export async function resetProjections(scopeDb, codeRev) { + // 1. Clear projections table + const deleteStmt = deleteAllProjectionsStmt(scopeDb); + deleteStmt.run(); + // 2. Get all projection definitions for new code_rev + const { listProjectionDefs } = await import('./defs.js'); + const projectionDefs = await listProjectionDefs(scopeDb, { codeRev }); + // 3. Replay all events for each projection + const allEventsStmt = selectAllEventsStmt(scopeDb); + const allEvents = allEventsStmt.all().map(rowToEventRecord); + for (const def of projectionDefs) { + let currentValue = def.initialValue; + let lastEventId = 0; + // Process all events + for (const event of allEvents) { + // Find matching sources for this event + const matchingSources = def.sources.filter((source) => { + if (source.eventKind !== event.kind) + return false; + if (source.eventKey && source.eventKey !== event.key) + return false; + return true; + }); + // Process each matching source + for (const source of matchingSources) { + try { + const expr = getOrCompileExpression(source.expression); + // JSONata needs variables as bindings, not in data context + const data = {}; // Empty data object + const bindings = { + state: currentValue, + event: { + id: event.id, + occurred_at: event.occurredAt, + kind: event.kind, + key: event.key, + payload: event.meta ? JSON.parse(event.meta) : {}, + }, + params: def.params || {}, + }; + const result = await expr.evaluate(data, bindings); + currentValue = result; + lastEventId = event.id; + } + catch (error) { + // Write error event but continue processing + writeErrorEvent(scopeDb, def.name, error, event.id); + console.warn(`Projection ${def.name} replay error for event ${event.id}:`, error); + // Skip this event, don't update state + } + } + } + // Insert final state + const now = Date.now(); + const stmt = upsertProjectionStateStmt(scopeDb); + stmt.run(def.name, JSON.stringify(currentValue), lastEventId, codeRev, now); + } +} diff --git a/packages/pulse/src/rules/agent-loop.d.ts b/packages/pulse/src/rules/agent-loop.d.ts new file mode 100644 index 0000000..b2b69b4 --- /dev/null +++ b/packages/pulse/src/rules/agent-loop.d.ts @@ -0,0 +1,16 @@ +import type { Rule } from '../index.js'; +import type { LlmClient, LlmTool } from '../llm-client.js'; +import type { PulseStore } from '../store.js'; +export interface AgentLoopRuleOptions { + llmClient: LlmClient; + workflowStore: PulseStore; + systemPrompt?: string; + llmTimeoutMs?: number; +} +type Snapshot = Record & { + timestamp: number; +}; +type Effect = Record; +export declare const EFFECT_TOOLS: LlmTool[]; +export declare function createAgentLoopRule(opts: AgentLoopRuleOptions): Rule; +export {}; diff --git a/packages/pulse/src/rules/agent-loop.js b/packages/pulse/src/rules/agent-loop.js new file mode 100644 index 0000000..ec76a07 --- /dev/null +++ b/packages/pulse/src/rules/agent-loop.js @@ -0,0 +1,253 @@ +// โ”€โ”€ EFFECT_TOOLS (function calling schema) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +export const EFFECT_TOOLS = [ + { + type: 'function', + function: { + name: 'assign_task', + description: 'ๅˆ†้…ไปปๅŠก็ป™ cursor agent ๆ‰ง่กŒ', + parameters: { + type: 'object', + properties: { + taskId: { type: 'string' }, + prompt: { type: 'string' }, + scenario: { + type: 'string', + enum: ['bug-fix', 'feature', 'refactor', 'test', 'docs', 'review'], + }, + repoDir: { type: 'string' }, + timeoutMs: { type: 'number' }, + }, + required: ['taskId', 'prompt', 'scenario', 'repoDir'], + }, + }, + }, + { + type: 'function', + function: { + name: 'close_task', + description: '็กฎ่ฎคไปปๅŠกๅทฒๅฎŒๆˆ๏ผŒๅ…ณ้—ญไปปๅŠก', + parameters: { + type: 'object', + properties: { + taskId: { type: 'string' }, + summary: { type: 'string' }, + }, + required: ['taskId'], + }, + }, + }, + { + type: 'function', + function: { + name: 'give_up_task', + description: 'ๆ”พๅผƒไปปๅŠก๏ผŒ่ฎฐๅฝ•ๆ— ๆณ•ๅฎŒๆˆ็š„ๅŽŸๅ› ', + parameters: { + type: 'object', + properties: { + taskId: { type: 'string' }, + reason: { type: 'string' }, + }, + required: ['taskId', 'reason'], + }, + }, + }, + { + type: 'function', + function: { + name: 'set_next_check', + description: '่ฎพ็ฝฎไธ‹ๆฌกๆฃ€ๆŸฅ็š„็ญ‰ๅพ…ๆ—ถ้—ด๏ผˆๆฏซ็ง’๏ผ‰ใ€‚ๆฒกๆœ‰ๅ…ถไป– task ๆ“ไฝœๆ—ถๅฟ…้กป่ฐƒ็”จใ€‚', + parameters: { + type: 'object', + properties: { + tickMs: { + type: 'number', + description: '็ญ‰ๅพ…ๆฏซ็ง’ๆ•ฐ๏ผŒไพ‹ๅฆ‚ 60000 = 1ๅˆ†้’Ÿ', + }, + reason: { type: 'string' }, + }, + required: ['tickMs'], + }, + }, + }, +]; +// โ”€โ”€ Default System Prompt โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +const DEFAULT_SYSTEM_PROMPT = `ไฝ ๆ˜ฏไธ€ไธชไปปๅŠก่ฐƒๅบฆ Agent๏ผŒ่ดŸ่ดฃ็ฎก็†้กน็›ฎ็š„็ผ–็ ไปปๅŠก้˜Ÿๅˆ—ใ€‚ + +ไฝ ็š„่Œ่ดฃ๏ผš +1. ๆŸฅ็œ‹ๅพ…ๅค„็†ไปปๅŠกๅˆ—่กจ๏ผŒๅ†ณๅฎšๅ“ชไบ›ไปปๅŠกๅฏไปฅๆดพ็ป™ cursor agent ๆ‰ง่กŒ +2. ๆŸฅ็œ‹ๅทฒๆ‰ง่กŒๅฎŒๆˆ๏ผˆacked๏ผ‰็š„ไปปๅŠก๏ผŒๅ†ณๅฎšๆ˜ฏๅฆๅ…ณ้—ญๆˆ–้‡่ฏ• +3. ๅฏนๆ— ๆณ•ๅฎŒๆˆ็š„ไปปๅŠก๏ผŒ็ป™ๅ‡บๆ˜Ž็กฎๅŽŸๅ› ๅนถๆ”พๅผƒ + +่ฐƒๅบฆๅŽŸๅˆ™๏ผš +- ๆฏๆฌกๅชๆดพๅ‘ไธ€ไธชไปปๅŠก๏ผˆ้ฟๅ…ๅนถๅ‘ๅ†ฒ็ช๏ผ‰ +- ไปปๅŠก acked ๅŽๅ…ˆๆฃ€ๆŸฅๆ‰ง่กŒ็ป“ๆžœ๏ผŒๆˆๅŠŸๅˆ™ close๏ผŒๅคฑ่ดฅๅˆ™่€ƒ่™‘ retry ๆˆ– give_up +- ๆ— ไปปๅŠกๅฏๅค„็†ๆ—ถ๏ผŒ่ฐƒ็”จ set_next_check ่ฎพ็ฝฎ็ญ‰ๅพ…ๆ—ถ้—ด๏ผˆ้€šๅธธ 60000-300000 ms๏ผ‰ + +ไฝ ็š„ๆฏๆฌกๅ†ณ็ญ–้ƒฝไผš่ขซ่ฎฐๅฝ•ไฝœไธบ่ƒฝๅŠ›ๆŒ‡ๆ ‡๏ผŒ่ฏท่ฎค็œŸๅˆคๆ–ญใ€‚`; +function buildTaskSummary(projectId, tasks) { + const lines = [`้กน็›ฎ ${projectId} ็š„ไปปๅŠกๅˆ—่กจ (${tasks.length} ไธช):\n`]; + for (const task of tasks) { + lines.push(`- [${task.status}] ${task.taskId}: ${task.title} (priority=${task.priority})`); + if (task.lastRespondedResult) { + lines.push(` ๆœ€่ฟ‘ๆ‰ง่กŒ็ป“ๆžœ: ${task.lastRespondedResult}`); + } + } + return lines.join('\n'); +} +function traceMessageToLlmMessage(m) { + if (m.role === 'assistant') { + const parts = []; + if (m.content) + parts.push(m.content); + if (m.toolCalls) { + for (const tc of m.toolCalls) { + parts.push(`[tool_call] ${tc.name}(${JSON.stringify(tc.arguments)})`); + } + } + return { role: 'assistant', content: parts.join('\n') || undefined }; + } + return { + role: 'tool', + content: `[${m.toolName}] ${m.result ?? 'ok'}`, + }; +} +function parseToolCallsToEffects(toolCalls, projectId) { + const effects = []; + let tickMs = 60_000; // default 1min + for (const tc of toolCalls) { + let args; + try { + args = JSON.parse(tc.function.arguments); + } + catch { + continue; + } + switch (tc.function.name) { + case 'assign_task': + if (args.taskId && args.prompt && args.scenario && args.repoDir) { + effects.push({ + kind: 'coding-task', + taskId: args.taskId, + prompt: args.prompt, + scenario: args.scenario, + repoDir: args.repoDir, + timeoutMs: args.timeoutMs, + projectId, + }); + } + break; + case 'close_task': + if (args.taskId) { + effects.push({ + kind: 'task-closed', + taskId: args.taskId, + agentId: 'agent-loop', + summary: args.summary, + projectId, + }); + } + break; + case 'give_up_task': + if (args.taskId && args.reason) { + effects.push({ + kind: 'task-given-up', + taskId: args.taskId, + agentId: 'agent-loop', + reason: args.reason, + projectId, + }); + } + break; + case 'set_next_check': + if (typeof args.tickMs === 'number' && args.tickMs > 0) { + tickMs = args.tickMs; + } + break; + } + } + return { effects, tickMs }; +} +// โ”€โ”€ Per-project loop โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +async function runProjectLoop(projectId, curr, opts) { + // 1. cooldown check + const traceData = curr[`agent-loop-trace:${projectId}`]?.data; + if (traceData?.nextCheckAt && Date.now() < traceData.nextCheckAt) { + return { effects: [], tickMs: traceData.nextCheckAt - Date.now() }; + } + // 2. pending tasks for this project + const pendingData = curr['pending-tasks']?.data; + const allTasks = pendingData?.byProject?.[projectId] ?? []; + if (allTasks.length === 0) { + return { effects: [], tickMs: 60_000 }; + } + // 3. build user message + const taskSummary = buildTaskSummary(projectId, allTasks); + // 4. history from trace + const history = (traceData?.messages ?? []).map(traceMessageToLlmMessage); + // 5. LLM call with timeout + const systemPrompt = opts.systemPrompt ?? DEFAULT_SYSTEM_PROMPT; + const llmCall = opts.llmClient.chat({ + messages: [ + { role: 'system', content: systemPrompt }, + ...history, + { role: 'user', content: taskSummary }, + ], + tools: EFFECT_TOOLS, + tool_choice: 'required', + }); + await opts.workflowStore.appendEvent({ + kind: 'llm-call-started', + meta: JSON.stringify({ projectId, taskCount: allTasks.length }), + occurredAt: Date.now(), + }); + let response; + const startTime = Date.now(); + try { + response = await Promise.race([ + llmCall, + new Promise((_, reject) => setTimeout(() => reject(new Error('LLM timeout')), opts.llmTimeoutMs ?? 30_000)), + ]); + } + catch (err) { + await opts.workflowStore.appendEvent({ + kind: 'llm-call-failed', + meta: JSON.stringify({ projectId, error: String(err) }), + occurredAt: Date.now(), + }); + return { effects: [], tickMs: 60_000 }; + } + // 6. write trace event + const durationMs = Date.now() - startTime; + await opts.workflowStore.appendEvent({ + kind: 'llm-call-completed', + meta: JSON.stringify({ + projectId, + toolCalls: response.tool_calls?.map((tc) => ({ + name: tc.function.name, + arguments: JSON.parse(tc.function.arguments), + })), + usage: response.usage, + durationMs, + }), + occurredAt: Date.now(), + }); + // 7. parse effects + tickMs from tool_calls + return parseToolCallsToEffects(response.tool_calls ?? [], projectId); +} +export function createAgentLoopRule(opts) { + return async (prev, curr, inner) => { + const [effects, tickMs] = await inner(prev, curr); + const activeProjects = curr['active-projects']?.data + ?.projectIds ?? []; + if (activeProjects.length === 0) + return [effects, tickMs]; + const results = await Promise.all(activeProjects.map((projectId) => runProjectLoop(projectId, curr, opts))); + const allEffects = [ + ...effects, + ...results.flatMap((r) => r.effects), + ]; + const candidateTicks = results.map((r) => r.tickMs).filter((t) => t > 0); + const nextTick = Math.min(tickMs, ...candidateTicks); + return [allEffects, nextTick]; + }; +} diff --git a/packages/pulse/src/rules/builtin.d.ts b/packages/pulse/src/rules/builtin.d.ts new file mode 100644 index 0000000..501d167 --- /dev/null +++ b/packages/pulse/src/rules/builtin.d.ts @@ -0,0 +1,36 @@ +/** + * @uncaged/pulse โ€” Built-in Rules + * + * Common rule primitives: tick clamping, error backoff, + * adaptive intervals, effect deduplication. + */ +import type { Rule } from '../index.js'; +/** + * Clamp tickMs within [min, max]. + * Pure frequency guard โ€” doesn't inspect snapshots. + */ +export declare function clampTick(min?: number, max?: number): Rule; +/** + * Exponential backoff on errors. + * + * Uses an accessor to extract error count from snapshot. + * tickMs *= 2^errors, capped at maxMs. + * Zero errors โ†’ pass through unchanged. + */ +export declare function errorBackoff(getErrors: (s: S) => number, maxMs?: number): Rule; +/** + * Adaptive interval: speed up when state changes, slow down when idle. + * + * - Change detected โ†’ fastMs + * - No change โ†’ tickMs * slowFactor, capped at slowMs + */ +export declare function adaptiveInterval(hasChanged: (prev: S, curr: S) => boolean, fastMs?: number, slowMs?: number, slowFactor?: number): Rule; +/** + * Deduplicate effects by kind (or custom key). + * + * Keeps only the last occurrence of each key. + * Requires E extends { kind: string }. + */ +export declare function dedup(key?: (e: E) => string): Rule; diff --git a/packages/pulse/src/rules/builtin.js b/packages/pulse/src/rules/builtin.js new file mode 100644 index 0000000..e9b7048 --- /dev/null +++ b/packages/pulse/src/rules/builtin.js @@ -0,0 +1,65 @@ +/** + * @uncaged/pulse โ€” Built-in Rules + * + * Common rule primitives: tick clamping, error backoff, + * adaptive intervals, effect deduplication. + */ +/** + * Clamp tickMs within [min, max]. + * Pure frequency guard โ€” doesn't inspect snapshots. + */ +export function clampTick(min = 1000, max = 300_000) { + return async (_prev, _curr, inner) => { + const [effects, tickMs] = await inner(_prev, _curr); + return [effects, Math.max(min, Math.min(max, tickMs))]; + }; +} +/** + * Exponential backoff on errors. + * + * Uses an accessor to extract error count from snapshot. + * tickMs *= 2^errors, capped at maxMs. + * Zero errors โ†’ pass through unchanged. + */ +export function errorBackoff(getErrors, maxMs = 300_000) { + return async (_prev, curr, inner) => { + const [effects, tickMs] = await inner(_prev, curr); + const errors = getErrors(curr); + if (errors <= 0) + return [effects, tickMs]; + const backed = Math.min(tickMs * 2 ** errors, maxMs); + return [effects, backed]; + }; +} +/** + * Adaptive interval: speed up when state changes, slow down when idle. + * + * - Change detected โ†’ fastMs + * - No change โ†’ tickMs * slowFactor, capped at slowMs + */ +export function adaptiveInterval(hasChanged, fastMs = 5000, slowMs = 120_000, slowFactor = 1.5) { + return async (prev, curr, inner) => { + const [effects, tickMs] = await inner(prev, curr); + if (hasChanged(prev, curr)) { + return [effects, fastMs]; + } + return [effects, Math.min(tickMs * slowFactor, slowMs)]; + }; +} +/** + * Deduplicate effects by kind (or custom key). + * + * Keeps only the last occurrence of each key. + * Requires E extends { kind: string }. + */ +export function dedup(key) { + const getKey = key ?? ((e) => e.kind); + return async (_prev, _curr, inner) => { + const [effects, tickMs] = await inner(_prev, _curr); + const seen = new Map(); + for (const e of effects) { + seen.set(getKey(e), e); + } + return [Array.from(seen.values()), tickMs]; + }; +} diff --git a/packages/pulse/src/rules/constants.d.ts b/packages/pulse/src/rules/constants.d.ts new file mode 100644 index 0000000..bb40908 --- /dev/null +++ b/packages/pulse/src/rules/constants.d.ts @@ -0,0 +1,15 @@ +/** + * Survival layer constants + * + * These values are hardcoded in the core package - agents cannot modify them. + */ +/** Critical process whitelist โ€” hardcoded, agent cannot change */ +export declare const ESSENTIAL_PROCESSES: Set; +/** Idempotent window: don't repeat same action within 5 minutes */ +export declare const IDEMPOTENT_WINDOW_MS: number; +/** Auto-rollback observation window after promote: 5 minutes */ +export declare const ROLLBACK_WINDOW_MS: number; +/** Error count threshold to trigger rollback */ +export declare const ROLLBACK_ERROR_THRESHOLD = 5; +/** Max consecutive restart count, escalate to panic if exceeded */ +export declare const MAX_RESTART_COUNT = 3; diff --git a/packages/pulse/src/rules/constants.js b/packages/pulse/src/rules/constants.js new file mode 100644 index 0000000..31d68e3 --- /dev/null +++ b/packages/pulse/src/rules/constants.js @@ -0,0 +1,19 @@ +/** + * Survival layer constants + * + * These values are hardcoded in the core package - agents cannot modify them. + */ +/** Critical process whitelist โ€” hardcoded, agent cannot change */ +export const ESSENTIAL_PROCESSES = new Set([ + 'upulse', // Pulse itself + 'sshd', // SSH rescue channel + 'systemd', // System +]); +/** Idempotent window: don't repeat same action within 5 minutes */ +export const IDEMPOTENT_WINDOW_MS = 5 * 60 * 1000; +/** Auto-rollback observation window after promote: 5 minutes */ +export const ROLLBACK_WINDOW_MS = 5 * 60 * 1000; +/** Error count threshold to trigger rollback */ +export const ROLLBACK_ERROR_THRESHOLD = 5; +/** Max consecutive restart count, escalate to panic if exceeded */ +export const MAX_RESTART_COUNT = 3; diff --git a/packages/pulse/src/rules/health.d.ts b/packages/pulse/src/rules/health.d.ts new file mode 100644 index 0000000..70b6a47 --- /dev/null +++ b/packages/pulse/src/rules/health.d.ts @@ -0,0 +1,30 @@ +/** + * Health snapshot management + * + * Rebuilds health state from events table for use by survival rules. + */ +import type { PulseStore } from '../store.js'; +export interface HealthSnapshot { + lastRestart: Record; + lastGc: { + ts: number; + } | null; + lastNotify: { + ts: number; + } | null; + panicCount: number; + lastPromote?: { + ts: number; + codeRev: string; + prevCodeRev: string; + }; + recentErrorCount: number; +} +/** + * Rebuild health field from events table. + * This function is in core package, agent cannot change. + */ +export declare function rebuildHealth(store: PulseStore): Promise; diff --git a/packages/pulse/src/rules/health.js b/packages/pulse/src/rules/health.js new file mode 100644 index 0000000..778a3a0 --- /dev/null +++ b/packages/pulse/src/rules/health.js @@ -0,0 +1,71 @@ +/** + * Health snapshot management + * + * Rebuilds health state from events table for use by survival rules. + */ +/** + * Rebuild health field from events table. + * This function is in core package, agent cannot change. + */ +export async function rebuildHealth(store) { + const now = Date.now(); + const windowStart = now - 5 * 60 * 1000; // 5 minute window + // Query recent events from events table + const recentEvents = await store.queryByKind('effect', { + since: windowStart, + }); + // Count restarts + const lastRestart = {}; + for (const ev of recentEvents) { + if (ev.meta) { + try { + const meta = JSON.parse(ev.meta); + if (meta.type === 'restart-service' && meta.service) { + const existing = lastRestart[meta.service]; + if (existing) { + existing.count++; + if (ev.occurredAt > existing.ts) + existing.ts = ev.occurredAt; + } + else { + lastRestart[meta.service] = { ts: ev.occurredAt, count: 1 }; + } + } + } + catch { } + } + } + // Count error events + const errorEvents = await store.queryByKind('error', { since: windowStart }); + // Find latest promote within window + const latestPromote = await store.getLatest('promote'); + let lastPromote; + if (latestPromote && latestPromote.occurredAt > windowStart) { + const meta = latestPromote.meta ? JSON.parse(latestPromote.meta) : {}; + lastPromote = { + ts: latestPromote.occurredAt, + codeRev: latestPromote.codeRev ?? 'unknown', + prevCodeRev: meta.prevCodeRev ?? 'unknown', + }; + } + // Count rollback-config events as panic events + const panicCount = recentEvents.filter((ev) => { + if (!ev.meta) + return false; + try { + const meta = JSON.parse(ev.meta); + return meta.type === 'rollback-config'; + } + catch { + return false; + } + }).length; + return { + lastRestart, + lastGc: null, // TODO: rebuild from events + lastNotify: null, // TODO: rebuild from events + panicCount, + lastPromote, + recentErrorCount: errorEvents.length, + }; +} diff --git a/packages/pulse/src/rules/index.d.ts b/packages/pulse/src/rules/index.d.ts new file mode 100644 index 0000000..7a6d3f9 --- /dev/null +++ b/packages/pulse/src/rules/index.d.ts @@ -0,0 +1,4 @@ +export { adaptiveInterval, clampTick, dedup, errorBackoff } from './builtin.js'; +export { ESSENTIAL_PROCESSES, IDEMPOTENT_WINDOW_MS, MAX_RESTART_COUNT, ROLLBACK_ERROR_THRESHOLD, ROLLBACK_WINDOW_MS, } from './constants.js'; +export { type HealthSnapshot, rebuildHealth } from './health.js'; +export { type SurvivalSnapshot, survivalRules } from './survival.js'; diff --git a/packages/pulse/src/rules/index.js b/packages/pulse/src/rules/index.js new file mode 100644 index 0000000..ce26043 --- /dev/null +++ b/packages/pulse/src/rules/index.js @@ -0,0 +1,4 @@ +export { adaptiveInterval, clampTick, dedup, errorBackoff } from './builtin.js'; +export { ESSENTIAL_PROCESSES, IDEMPOTENT_WINDOW_MS, MAX_RESTART_COUNT, ROLLBACK_ERROR_THRESHOLD, ROLLBACK_WINDOW_MS, } from './constants.js'; +export { rebuildHealth } from './health.js'; +export { survivalRules } from './survival.js'; diff --git a/packages/pulse/src/rules/survival.d.ts b/packages/pulse/src/rules/survival.d.ts new file mode 100644 index 0000000..9bf9797 --- /dev/null +++ b/packages/pulse/src/rules/survival.d.ts @@ -0,0 +1,61 @@ +/** + * Survival rules - P0 protection layer + * + * These rules are hardcoded in the outermost onion layer. Agents cannot modify them. + * Rules are applied in array order (first element is outermost layer). + */ +import type { SurvivalEffect } from '../executors/survival.js'; +import type { Rule, Sensed } from '../index.js'; +import type { ErrorLogData } from '../watchers/error-log.js'; +import type { NetworkData } from '../watchers/network.js'; +import type { ProcessAliveData } from '../watchers/process-alive.js'; +import type { SystemResourceData } from '../watchers/system-resource.js'; +import type { HealthSnapshot } from './health.js'; +/** Minimal LLM health shape used by llmWatchdog (full type lives in @uncaged/pulse-openclaw) */ +interface LlmHealthLike { + processOk: boolean; + completionOk?: boolean; +} +export interface SurvivalSnapshot { + timestamp: number; + system?: Sensed; + processes?: Sensed; + network?: Sensed; + errorLog?: Sensed; + llm?: Sensed; + health?: HealthSnapshot; +} +/** + * Panic rollback - outermost fallback + * If panicCount >= 3, rollback all configs and bypass inner layers + */ +export declare const panicRollback: Rule; +/** + * Auto rollback after recent promote if too many errors + */ +export declare const autoRollback: Rule; +/** + * Process watchdog - restart dead essential processes + */ +export declare const processWatchdog: Rule; +/** + * Resource guard - handle disk/memory pressure + */ +export declare const resourceGuard: Rule; +/** + * LLM watchdog - monitor LLM service health + */ +export declare const llmWatchdog: Rule; +/** + * Network watchdog - monitor connectivity (notify-only stub) + */ +export declare const networkWatchdog: Rule; +/** + * Error escalation - accelerate patrol on errors (notify-only stub) + */ +export declare const errorEscalate: Rule; +/** + * Survival rules in onion order (first element is outermost layer) + */ +export declare const survivalRules: Rule[]; +export {}; diff --git a/packages/pulse/src/rules/survival.js b/packages/pulse/src/rules/survival.js new file mode 100644 index 0000000..e06d912 --- /dev/null +++ b/packages/pulse/src/rules/survival.js @@ -0,0 +1,190 @@ +/** + * Survival rules - P0 protection layer + * + * These rules are hardcoded in the outermost onion layer. Agents cannot modify them. + * Rules are applied in array order (first element is outermost layer). + */ +import { ESSENTIAL_PROCESSES, MAX_RESTART_COUNT, ROLLBACK_ERROR_THRESHOLD, ROLLBACK_WINDOW_MS, } from './constants.js'; +/** + * Panic rollback - outermost fallback + * If panicCount >= 3, rollback all configs and bypass inner layers + */ +export const panicRollback = async (prev, curr, inner) => { + const panicCount = curr.health?.panicCount ?? 0; + if (panicCount >= 3) { + return [ + [ + { + type: 'rollback-config', + }, + ], + 30000, + ]; // Emergency interval + } + // Pass through to inner layers + return await inner(prev, curr); +}; +/** + * Auto rollback after recent promote if too many errors + */ +export const autoRollback = async (prev, curr, inner) => { + const now = Date.now(); + const lastPromote = curr.health?.lastPromote; + if (lastPromote && + now - lastPromote.ts <= ROLLBACK_WINDOW_MS && + (curr.health?.recentErrorCount ?? 0) >= ROLLBACK_ERROR_THRESHOLD) { + return [ + [ + { + type: 'rollback-code', + to: lastPromote.prevCodeRev, + }, + ], + 60000, + ]; // Check every minute during rollback + } + // Pass through to inner layers + return await inner(prev, curr); +}; +/** + * Process watchdog - restart dead essential processes + */ +export const processWatchdog = async (prev, curr, inner) => { + // processes.data.processes is Record (name โ†’ alive) + const processes = curr.processes?.data?.processes; + if (!processes) { + return await inner(prev, curr); + } + const effects = []; + let hasDeadProcess = false; + // Check each essential process + for (const serviceName of ESSENTIAL_PROCESSES) { + // Check if Record contains matching key and value is false + const isAlive = Object.entries(processes).some(([name, alive]) => name.toLowerCase().includes(serviceName.toLowerCase()) && alive); + const isMonitored = Object.keys(processes).some((k) => k.toLowerCase().includes(serviceName.toLowerCase())); + if (isMonitored && !isAlive) { + // Process is being monitored but is dead + hasDeadProcess = true; + const restartCount = curr.health?.lastRestart?.[serviceName]?.count ?? 0; + if (restartCount < MAX_RESTART_COUNT) { + effects.push({ + type: 'restart-service', + service: serviceName, + }); + } + else { + effects.push({ + type: 'notify-owner', + message: `[CRITICAL] Process ${serviceName} failed ${restartCount} times, needs manual intervention`, + }); + } + } + } + if (hasDeadProcess) { + // Bypass inner layers if critical processes are down + return [effects, 10000]; // Fast retry for process recovery + } + // All processes healthy, pass through + return await inner(prev, curr); +}; +/** + * Resource guard - handle disk/memory pressure + */ +export const resourceGuard = async (prev, curr, inner) => { + const system = curr.system?.data; + if (!system) { + return await inner(prev, curr); + } + // system-resource watcher returns flat { diskPct, memoryPct, cpuPct, swapPct } + const diskUsage = system.diskPct ?? 0; + const memUsage = system.memoryPct ?? 0; + const effects = []; + if (diskUsage > 95) { + effects.push({ type: 'gc-vitals' }, { type: 'clear-cache', target: 'journal' }, { type: 'clear-cache', target: 'tmp' }); + // Bypass inner layers during disk crisis + return [effects, 30000]; + } + if (memUsage > 90) { + effects.push({ type: 'archive-sessions' }, { type: 'restart-service', service: 'openclaw' }); + // Bypass inner layers during memory crisis + return [effects, 30000]; + } + // Resources healthy, pass through + return await inner(prev, curr); +}; +/** + * LLM watchdog - monitor LLM service health + */ +export const llmWatchdog = async (prev, curr, inner) => { + const llm = curr.llm?.data; + if (!llm) { + return await inner(prev, curr); + } + const effects = []; + if (llm.processOk === false) { + effects.push({ type: 'restart-service', service: 'litellm' }, { type: 'notify-owner', message: '[WARN] LiteLLM process restarted' }); + // Don't bypass - let inner layers see degraded state and adapt + } + if (llm.completionOk === false) { + effects.push({ + type: 'notify-owner', + message: '[WARN] LLM upstream unavailable', + }); + // Don't bypass - upstream issues we can't fix locally + } + // Continue to inner layers with any effects + const [innerEffects, tickMs] = await inner(prev, curr); + return [effects.concat(innerEffects), tickMs]; +}; +/** + * Network watchdog - monitor connectivity (notify-only stub) + */ +export const networkWatchdog = async (prev, curr, inner) => { + const network = curr.network?.data; + // network watcher returns { dnsOk, httpOk, latencyMs }; connectivity is lost when both fail + if (network && !network.dnsOk && !network.httpOk) { + const effects = [ + { + type: 'notify-owner', + message: '[CRITICAL] Network connectivity lost', + }, + ]; + // Continue to inner layers + const [innerEffects, tickMs] = await inner(prev, curr); + return [effects.concat(innerEffects), tickMs]; + } + // Pass through to inner layers + return await inner(prev, curr); +}; +/** + * Error escalation - accelerate patrol on errors (notify-only stub) + */ +export const errorEscalate = async (prev, curr, inner) => { + const recentErrors = curr.health?.recentErrorCount ?? 0; + if (recentErrors > 0) { + const effects = [ + { + type: 'notify-owner', + message: `[WARN] ${recentErrors} recent errors detected, accelerating patrol`, + }, + ]; + // Continue to inner layers with accelerated timing + const [innerEffects, tickMs] = await inner(prev, curr); + const acceleratedTick = Math.max(tickMs * 0.5, 5000); // 50% faster, min 5s + return [effects.concat(innerEffects), acceleratedTick]; + } + // Pass through to inner layers + return await inner(prev, curr); +}; +/** + * Survival rules in onion order (first element is outermost layer) + */ +export const survivalRules = [ + panicRollback, + autoRollback, + processWatchdog, + resourceGuard, + llmWatchdog, + networkWatchdog, + errorEscalate, +]; diff --git a/packages/pulse/src/store.d.ts b/packages/pulse/src/store.d.ts new file mode 100644 index 0000000..43e33d5 --- /dev/null +++ b/packages/pulse/src/store.d.ts @@ -0,0 +1,97 @@ +/** + * @uncaged/pulse โ€” Storage Layer (v4: events table + CAS) + * + * Single `events` table with INTEGER AUTOINCREMENT primary keys + + * content-addressed object store (CAS) on disk via SHA-256 hashes. + */ +import { Database } from 'bun:sqlite'; +export interface EventRecord { + id: number; + occurredAt: number; + kind: string; + key?: string; + hash?: string; + codeRev?: string; + meta?: string; + objectId?: number; +} +/** An immutable entity instance tracked in the objects table. */ +export interface ObjectInstance { + id: number; + objectType: string; + externalId: string | null; + createdAt: number; + codeRev: string; +} +export interface PulseStore { + /** Append one event (id is auto-incremented) */ + appendEvent(event: Omit): Promise; + /** Append multiple events in a transaction */ + appendEvents(events: Omit[]): Promise; + /** Create an immutable object instance. Returns the integer id. Idempotent on (objectType, externalId). */ + createObject(opts: { + objectType: string; + externalId?: string; + codeRev: string; + }): Promise; + /** Get an object instance by id. Returns null if not found. */ + getObjectInstance(id: number): Promise; + /** Query object instances by type. */ + queryObjectsByType(objectType: string): Promise; + /** Get the latest event by kind + optional key */ + getLatest(kind: string, key?: string): Promise; + /** Get latest event with additional filters */ + getLatestWhere(opts: { + kind: string; + key?: string; + codeRev?: string; + }): Promise; + /** Get recent events (newest first) */ + getRecent(limit?: number): Promise; + /** Query events by kind with optional filters */ + queryByKind(kind: string, opts?: { + key?: string; + since?: number; + codeRev?: string; + limit?: number; + }): Promise; + /** Get all events after a specific event id */ + getAfter(afterId: number, opts?: { + kind?: string; + key?: string; + codeRev?: string; + }): Promise; + /** Check if any events exist */ + hasEvents(): Promise; + /** Write data to CAS store. Returns hash. No-op if already exists. */ + putObject(data: unknown): Promise; + /** Read data from CAS store by hash. Returns null if not found. */ + getObject(hash: string): Promise; + /** Close the database */ + close(): Promise; + /** Delete events older than the given timestamp. Returns count of deleted rows. */ + archiveEvents(olderThan: number): Promise; + /** Downsample events of a specific kind+key: keep one per interval window. Returns count of deleted rows. */ + downsampleEvents(kind: string, key: string, intervalMs: number, olderThan: number): Promise; +} +export interface CreateStoreOptions { + eventsDbPath: string; + /** @deprecated Vitals now use events table via scoped store. This field is accepted but ignored. */ + vitalsDbPath?: string; + objectsDir: string; +} +export declare function createStore(options: CreateStoreOptions): PulseStore; +export interface CreateScopedStoreOptions { + basePath: string; + objectsDir: string; +} +export interface ScopedStore { + scope(name: string): PulseStore; + listScopes(): string[]; + /** Get underlying Database for scope (used by projection engine) */ + scopeDatabase(name: string): Database; + putObject(data: unknown): Promise; + getObject(hash: string): Promise; + close(): Promise; +} +export declare function createScopedStore(options: CreateScopedStoreOptions): ScopedStore; diff --git a/packages/pulse/src/store.js b/packages/pulse/src/store.js new file mode 100644 index 0000000..f7d95f1 --- /dev/null +++ b/packages/pulse/src/store.js @@ -0,0 +1,525 @@ +/** + * @uncaged/pulse โ€” Storage Layer (v4: events table + CAS) + * + * Single `events` table with INTEGER AUTOINCREMENT primary keys + + * content-addressed object store (CAS) on disk via SHA-256 hashes. + */ +import { Database } from 'bun:sqlite'; +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { initDefsSchema } from './defs.js'; +import { PROJECTIONS_SCHEMA } from './projection-engine.js'; +// โ”€โ”€ CAS Hashing โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function hashObject(data) { + return createHash('sha256') + .update(JSON.stringify(data)) + .digest('hex') + .slice(0, 32); // 32 hex chars = 128 bits, safe against birthday collisions +} +// โ”€โ”€ Schema โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +const EVENTS_SCHEMA = ` +CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + occurred_at INTEGER NOT NULL, + kind TEXT NOT NULL, + key TEXT, + hash TEXT, + code_rev TEXT, + meta TEXT +); + +CREATE INDEX IF NOT EXISTS idx_occurred ON events(occurred_at); +CREATE INDEX IF NOT EXISTS idx_kind_key ON events(kind, key, occurred_at); +CREATE INDEX IF NOT EXISTS idx_code_rev ON events(code_rev, occurred_at); +`; +const OBJECTS_SCHEMA = ` +CREATE TABLE IF NOT EXISTS objects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + object_type TEXT NOT NULL, + external_id TEXT, + created_at INTEGER NOT NULL, + code_rev TEXT NOT NULL, + UNIQUE(object_type, external_id) +); +`; +/** Safe migration: add object_id column to events if it doesn't exist yet. */ +function migrateEventsObjectId(db) { + try { + db.run('ALTER TABLE events ADD COLUMN object_id INTEGER REFERENCES objects(id)'); + } + catch (_e) { + // Column already exists โ€” ignore + } +} +function rowToRecord(row) { + const rec = { + id: Number(row.id), + occurredAt: row.occurred_at, + kind: row.kind, + }; + if (row.key != null) + rec.key = row.key; + if (row.hash != null) + rec.hash = row.hash; + if (row.code_rev != null) + rec.codeRev = row.code_rev; + if (row.meta != null) + rec.meta = row.meta; + if (row.object_id != null) + rec.objectId = row.object_id; + return rec; +} +function rowToObjectInstance(row) { + return { + id: row.id, + objectType: row.object_type, + externalId: row.external_id, + createdAt: row.created_at, + codeRev: row.code_rev, + }; +} +export function createStore(options) { + const { eventsDbPath, objectsDir } = options; + mkdirSync(objectsDir, { recursive: true }); + const eventsDb = new Database(eventsDbPath, { create: true }); + eventsDb.exec('PRAGMA journal_mode = WAL'); + eventsDb.exec('PRAGMA busy_timeout = 5000'); + eventsDb.exec(EVENTS_SCHEMA); + eventsDb.exec(OBJECTS_SCHEMA); + migrateEventsObjectId(eventsDb); + const insertEvent = eventsDb.prepare(` + INSERT INTO events (occurred_at, kind, key, hash, code_rev, meta, object_id) + VALUES (?, ?, ?, ?, ?, ?, ?) + `); + const selectLatest = eventsDb.prepare(` + SELECT * FROM events + WHERE kind = ? AND (key = ? OR ? IS NULL) + ORDER BY occurred_at DESC, id DESC + LIMIT 1 + `); + const selectHasEvents = eventsDb.prepare(` + SELECT 1 FROM events LIMIT 1 + `); + function doAppendEvent(event) { + const result = insertEvent.run(event.occurredAt, event.kind, event.key ?? null, event.hash ?? null, event.codeRev ?? null, event.meta ?? null, event.objectId ?? null); + const id = Number(result.lastInsertRowid); + return { id, ...event }; + } + const appendManyTx = eventsDb.transaction((events) => { + const results = []; + for (const event of events) { + const result = insertEvent.run(event.occurredAt, event.kind, event.key ?? null, event.hash ?? null, event.codeRev ?? null, event.meta ?? null, event.objectId ?? null); + const id = Number(result.lastInsertRowid); + results.push({ id, ...event }); + } + return results; + }); + return { + async appendEvent(event) { + return doAppendEvent(event); + }, + async appendEvents(events) { + return appendManyTx(events); + }, + async getLatest(kind, key) { + const row = selectLatest.get(kind, key ?? null, key ?? null); + return row ? rowToRecord(row) : null; + }, + async getLatestWhere(opts) { + const conditions = ['kind = ?']; + const params = [opts.kind]; + if (opts.key !== undefined) { + conditions.push('key = ?'); + params.push(opts.key); + } + if (opts.codeRev !== undefined) { + conditions.push('code_rev = ?'); + params.push(opts.codeRev); + } + const sql = `SELECT * FROM events WHERE ${conditions.join(' AND ')} ORDER BY occurred_at DESC, id DESC LIMIT 1`; + const row = eventsDb.prepare(sql).get(...params); + return row ? rowToRecord(row) : null; + }, + async getRecent(limit = 20) { + const sql = `SELECT * FROM events ORDER BY occurred_at DESC, id DESC LIMIT ?`; + return eventsDb.prepare(sql).all(limit).map(rowToRecord); + }, + async queryByKind(kind, opts) { + const conditions = ['kind = ?']; + const params = [kind]; + if (opts?.key !== undefined) { + conditions.push('key = ?'); + params.push(opts.key); + } + if (opts?.since !== undefined) { + conditions.push('occurred_at >= ?'); + params.push(opts.since); + } + if (opts?.codeRev !== undefined) { + conditions.push('code_rev = ?'); + params.push(opts.codeRev); + } + let sql = `SELECT * FROM events WHERE ${conditions.join(' AND ')} ORDER BY occurred_at DESC, id DESC`; + if (opts?.limit !== undefined) { + sql += ' LIMIT ?'; + params.push(opts.limit); + } + return eventsDb.prepare(sql).all(...params).map(rowToRecord); + }, + async getAfter(afterId, opts) { + const conditions = ['id > ?']; + const params = [afterId]; + if (opts?.kind !== undefined) { + conditions.push('kind = ?'); + params.push(opts.kind); + } + if (opts?.key !== undefined) { + conditions.push('key = ?'); + params.push(opts.key); + } + if (opts?.codeRev !== undefined) { + conditions.push('code_rev = ?'); + params.push(opts.codeRev); + } + const sql = `SELECT * FROM events WHERE ${conditions.join(' AND ')} ORDER BY id ASC`; + return eventsDb.prepare(sql).all(...params).map(rowToRecord); + }, + async hasEvents() { + return selectHasEvents.get() !== null; + }, + async createObject(opts) { + const now = Date.now(); + const extId = opts.externalId ?? null; + // Idempotent: if (objectType, externalId) already exists, return existing id + if (extId !== null) { + const existing = eventsDb + .prepare('SELECT id FROM objects WHERE object_type = ? AND external_id = ?') + .get(opts.objectType, extId); + if (existing) + return existing.id; + } + const result = eventsDb + .prepare('INSERT INTO objects (object_type, external_id, created_at, code_rev) VALUES (?, ?, ?, ?)') + .run(opts.objectType, extId, now, opts.codeRev); + return Number(result.lastInsertRowid); + }, + async getObjectInstance(id) { + const row = eventsDb + .prepare('SELECT * FROM objects WHERE id = ?') + .get(id); + return row ? rowToObjectInstance(row) : null; + }, + async queryObjectsByType(objectType) { + return eventsDb + .prepare('SELECT * FROM objects WHERE object_type = ?') + .all(objectType).map(rowToObjectInstance); + }, + async putObject(data) { + const hash = hashObject(data); + const filePath = join(objectsDir, `${hash}.json`); + if (!existsSync(filePath)) { + mkdirSync(objectsDir, { recursive: true }); + writeFileSync(filePath, JSON.stringify(data), 'utf-8'); + } + return hash; + }, + async getObject(hash) { + const filePath = join(objectsDir, `${hash}.json`); + if (!existsSync(filePath)) + return null; + return JSON.parse(readFileSync(filePath, 'utf-8')); + }, + async close() { + eventsDb.close(); + }, + async archiveEvents(olderThan) { + const result = eventsDb + .prepare('DELETE FROM events WHERE occurred_at < ?') + .run(olderThan); + return result.changes; + }, + async downsampleEvents(kind, key, intervalMs, olderThan) { + const safeInterval = Math.floor(Math.abs(intervalMs)); + if (safeInterval <= 0) + return 0; + const stmt = eventsDb.prepare(` + DELETE FROM events WHERE kind = ? AND key = ? AND occurred_at < ? AND id NOT IN ( + SELECT id FROM ( + SELECT id, ROW_NUMBER() OVER ( + PARTITION BY (occurred_at / ${safeInterval}) ORDER BY occurred_at DESC + ) as rn FROM events WHERE kind = ? AND key = ? AND occurred_at < ? + ) WHERE rn = 1 + ) + `); + const result = stmt.run(kind, key, olderThan, kind, key, olderThan); + return result.changes; + }, + }; +} +// โ”€โ”€ Scoped Store โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +const SCOPE_NAME_RE = /^[a-z0-9_-]{1,64}$/; +function validateScopeName(name) { + if (!SCOPE_NAME_RE.test(name)) { + throw new Error(`Invalid scope name "${name}": must match [a-z0-9_-] and be 1-64 chars`); + } +} +/** + * Open (or create) a scope database at the given path. + * Sets WAL mode and creates the events table and projections table. + * initDefsSchema is async in interface but synchronous in bun:sqlite โ€” safe to call with void. + */ +function openScopeDb(path) { + mkdirSync(dirname(path), { recursive: true }); + const db = new Database(path, { create: true }); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA busy_timeout = 5000'); + db.exec(EVENTS_SCHEMA); + db.exec(OBJECTS_SCHEMA); + migrateEventsObjectId(db); + // Use canonical PROJECTIONS_SCHEMA (INTEGER last_event_id) + db.exec(PROJECTIONS_SCHEMA); + // Each scope carries its own def tables (bun:sqlite is sync under async wrapper) + void initDefsSchema(db); + return db; +} +function createScopeStore(db, objectsDir) { + const insertEvent = db.prepare(` + INSERT INTO events (occurred_at, kind, key, hash, code_rev, meta, object_id) + VALUES (?, ?, ?, ?, ?, ?, ?) + `); + const selectLatest = db.prepare(` + SELECT * FROM events + WHERE kind = ? AND (key = ? OR ? IS NULL) + ORDER BY occurred_at DESC, id DESC + LIMIT 1 + `); + const selectHasEvents = db.prepare(` + SELECT 1 FROM events LIMIT 1 + `); + function doAppendEvent(event) { + const result = insertEvent.run(event.occurredAt, event.kind, event.key ?? null, event.hash ?? null, event.codeRev ?? null, event.meta ?? null, event.objectId ?? null); + const id = Number(result.lastInsertRowid); + return { id, ...event }; + } + const appendManyTx = db.transaction((events) => { + const results = []; + for (const event of events) { + const result = insertEvent.run(event.occurredAt, event.kind, event.key ?? null, event.hash ?? null, event.codeRev ?? null, event.meta ?? null, event.objectId ?? null); + const id = Number(result.lastInsertRowid); + results.push({ id, ...event }); + } + return results; + }); + return { + async appendEvent(event) { + return doAppendEvent(event); + }, + async appendEvents(events) { + return appendManyTx(events); + }, + async getLatest(kind, key) { + const row = selectLatest.get(kind, key ?? null, key ?? null); + return row ? rowToRecord(row) : null; + }, + async getLatestWhere(opts) { + const conditions = ['kind = ?']; + const params = [opts.kind]; + if (opts.key !== undefined) { + conditions.push('key = ?'); + params.push(opts.key); + } + if (opts.codeRev !== undefined) { + conditions.push('code_rev = ?'); + params.push(opts.codeRev); + } + const sql = `SELECT * FROM events WHERE ${conditions.join(' AND ')} ORDER BY occurred_at DESC, id DESC LIMIT 1`; + const row = db.prepare(sql).get(...params); + return row ? rowToRecord(row) : null; + }, + async getRecent(limit = 20) { + const sql = `SELECT * FROM events ORDER BY occurred_at DESC, id DESC LIMIT ?`; + return db.prepare(sql).all(limit).map(rowToRecord); + }, + async queryByKind(kind, opts) { + const conditions = ['kind = ?']; + const params = [kind]; + if (opts?.key !== undefined) { + conditions.push('key = ?'); + params.push(opts.key); + } + if (opts?.since !== undefined) { + conditions.push('occurred_at >= ?'); + params.push(opts.since); + } + if (opts?.codeRev !== undefined) { + conditions.push('code_rev = ?'); + params.push(opts.codeRev); + } + let sql = `SELECT * FROM events WHERE ${conditions.join(' AND ')} ORDER BY occurred_at DESC, id DESC`; + if (opts?.limit !== undefined) { + sql += ' LIMIT ?'; + params.push(opts.limit); + } + return db.prepare(sql).all(...params).map(rowToRecord); + }, + async getAfter(afterId, opts) { + const conditions = ['id > ?']; + const params = [afterId]; + if (opts?.kind !== undefined) { + conditions.push('kind = ?'); + params.push(opts.kind); + } + if (opts?.key !== undefined) { + conditions.push('key = ?'); + params.push(opts.key); + } + if (opts?.codeRev !== undefined) { + conditions.push('code_rev = ?'); + params.push(opts.codeRev); + } + const sql = `SELECT * FROM events WHERE ${conditions.join(' AND ')} ORDER BY id ASC`; + return db.prepare(sql).all(...params).map(rowToRecord); + }, + async hasEvents() { + return selectHasEvents.get() !== null; + }, + async createObject(opts) { + const now = Date.now(); + const extId = opts.externalId ?? null; + if (extId !== null) { + const existing = db + .prepare('SELECT id FROM objects WHERE object_type = ? AND external_id = ?') + .get(opts.objectType, extId); + if (existing) + return existing.id; + } + const result = db + .prepare('INSERT INTO objects (object_type, external_id, created_at, code_rev) VALUES (?, ?, ?, ?)') + .run(opts.objectType, extId, now, opts.codeRev); + return Number(result.lastInsertRowid); + }, + async getObjectInstance(id) { + const row = db + .prepare('SELECT * FROM objects WHERE id = ?') + .get(id); + return row ? rowToObjectInstance(row) : null; + }, + async queryObjectsByType(objectType) { + return db + .prepare('SELECT * FROM objects WHERE object_type = ?') + .all(objectType).map(rowToObjectInstance); + }, + async putObject(data) { + const hash = hashObject(data); + const filePath = join(objectsDir, `${hash}.json`); + if (!existsSync(filePath)) { + mkdirSync(objectsDir, { recursive: true }); + writeFileSync(filePath, JSON.stringify(data), 'utf-8'); + } + return hash; + }, + async getObject(hash) { + const filePath = join(objectsDir, `${hash}.json`); + if (!existsSync(filePath)) + return null; + return JSON.parse(readFileSync(filePath, 'utf-8')); + }, + async close() { + db.close(); + }, + async archiveEvents(olderThan) { + const result = db + .prepare('DELETE FROM events WHERE occurred_at < ?') + .run(olderThan); + return result.changes; + }, + async downsampleEvents(kind, key, intervalMs, olderThan) { + const safeInterval = Math.floor(Math.abs(intervalMs)); + if (safeInterval <= 0) + return 0; + const stmt = db.prepare(` + DELETE FROM events WHERE kind = ? AND key = ? AND occurred_at < ? AND id NOT IN ( + SELECT id FROM ( + SELECT id, ROW_NUMBER() OVER ( + PARTITION BY (occurred_at / ${safeInterval}) ORDER BY occurred_at DESC + ) as rn FROM events WHERE kind = ? AND key = ? AND occurred_at < ? + ) WHERE rn = 1 + ) + `); + const result = stmt.run(kind, key, olderThan, kind, key, olderThan); + return result.changes; + }, + }; +} +export function createScopedStore(options) { + const { basePath, objectsDir } = options; + mkdirSync(basePath, { recursive: true }); + mkdirSync(objectsDir, { recursive: true }); + const openStores = new Map(); + const openDatabases = new Map(); + return { + scope(name) { + validateScopeName(name); + const existing = openStores.get(name); + if (existing) + return existing; + const dbPath = join(basePath, `${name}.db`); + const db = openScopeDb(dbPath); + const store = createScopeStore(db, objectsDir); + openStores.set(name, store); + openDatabases.set(name, db); + return store; + }, + scopeDatabase(name) { + validateScopeName(name); + // Try to get existing database + const existing = openDatabases.get(name); + if (existing) + return existing; + // Open database if not already open + const dbPath = join(basePath, `${name}.db`); + const db = openScopeDb(dbPath); + openDatabases.set(name, db); + // Also create store if not already created + if (!openStores.has(name)) { + const store = createScopeStore(db, objectsDir); + openStores.set(name, store); + } + return db; + }, + listScopes() { + if (!existsSync(basePath)) + return []; + return readdirSync(basePath) + .filter((f) => f.endsWith('.db')) + .map((f) => f.slice(0, -3)) + .sort(); + }, + async putObject(data) { + const hash = hashObject(data); + const filePath = join(objectsDir, `${hash}.json`); + if (!existsSync(filePath)) { + mkdirSync(objectsDir, { recursive: true }); + writeFileSync(filePath, JSON.stringify(data), 'utf-8'); + } + return hash; + }, + async getObject(hash) { + const filePath = join(objectsDir, `${hash}.json`); + if (!existsSync(filePath)) + return null; + return JSON.parse(readFileSync(filePath, 'utf-8')); + }, + async close() { + for (const store of openStores.values()) { + await store.close(); + } + for (const db of openDatabases.values()) { + db.close(); + } + openStores.clear(); + openDatabases.clear(); + }, + }; +} diff --git a/packages/pulse/src/task-events.d.ts b/packages/pulse/src/task-events.d.ts new file mode 100644 index 0000000..501570a --- /dev/null +++ b/packages/pulse/src/task-events.d.ts @@ -0,0 +1,121 @@ +export type TaskStatus = 'pending' | 'routing' | 'assigned' | 'closed'; +export type TaskType = 'bug' | 'rfc' | 'action' | 'review'; +export interface TaskCreatedMeta { + taskId: string; + projectId: string; + title: string; + description: string; + type: TaskType; + priority: number; + creatorId: string; +} +export interface TaskRoutingMeta { + taskId: string; + brokerSessionId: string; +} +export interface TaskAssignedMeta { + taskId: string; + assigneeId: string; + assignedBy: string; +} +export interface TaskRespondedMeta { + taskId: string; + assigneeId: string; + result: string; +} +export interface TaskClosedMeta { + taskId: string; + creatorId: string; +} +export interface ProjectCreatedMeta { + projectId: string; + name: string; + repoDir: string; +} +export interface TaskState { + taskId: string; + projectId: string; + title: string; + description: string; + type: TaskType; + priority: number; + creatorId: string; + status: TaskStatus; + assigneeId?: string; + lastRespondedResult?: string; + createdAt: number; + updatedAt: number; +} +export interface PendingTasksData { + pendingCount: number; + tasks: TaskState[]; + byProject: Record; + checkedAt: number; +} +export interface ProjectState { + projectId: string; + name: string; + repoDir: string; +} +export interface InflightBrokerData { + active: boolean; +} +export type ContainerType = 'openclaw' | 'cursor' | 'claude-code' | 'hermes'; +export type ContainerStatus = 'online' | 'offline' | 'busy'; +export interface PersonaRegisteredMeta { + personaId: string; + name: string; + container: ContainerType; + capabilities: string[]; +} +export interface PersonaUpdatedMeta { + personaId: string; + container?: ContainerType; + capabilities?: string[]; +} +export interface PersonaState { + personaId: string; + name: string; + container: ContainerType; + capabilities: string[]; + registeredAt: number; + updatedAt: number; +} +export interface LlmCallStartedMeta { + projectId: string; + model?: string; +} +export interface LlmCallCompletedMeta { + projectId: string; + model?: string; + usage?: { + inputTokens: number; + outputTokens: number; + }; + toolCalls?: Array<{ + name: string; + arguments: Record; + }>; + durationMs: number; +} +export interface ToolResponseMeta { + projectId: string; + toolCallIndex: number; + toolName: string; + result: string; +} +export interface TraceMessage { + role: 'assistant' | 'tool'; + content?: string; + toolCalls?: LlmCallCompletedMeta['toolCalls']; + toolName?: string; + result?: string; + ts: number; +} +export interface AgentLoopTraceData { + messages: TraceMessage[]; + nextCheckAt: number; +} +export interface ActiveProjectsData { + projectIds: string[]; +} diff --git a/packages/pulse/src/task-events.js b/packages/pulse/src/task-events.js new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/packages/pulse/src/task-events.js @@ -0,0 +1 @@ +export {}; diff --git a/packages/pulse/src/watcher.d.ts b/packages/pulse/src/watcher.d.ts new file mode 100644 index 0000000..c0f35a8 --- /dev/null +++ b/packages/pulse/src/watcher.d.ts @@ -0,0 +1,73 @@ +/** + * @uncaged/pulse โ€” Watcher Framework + * + * Periodic data collection loops that store events (kind='vital') and trigger + * wake callbacks when a configurable condition is met over a sliding window. + */ +import type { EventRecord, PulseStore } from './store.js'; +/** + * An event record with the resolved data payload attached. + * Passed to `shouldWake` so conditions can operate on typed data + * without re-fetching from the object store. + */ +export interface VitalWithData extends EventRecord { + data: T; +} +/** + * Predicate evaluated against a recent window of vital records + * with their resolved data payloads. + * Return `true` to fire the wake callback. + */ +export type WakeCondition = (window: VitalWithData[]) => boolean; +/** + * Definition of a watcher: what to collect, how often, and when to wake. + */ +export interface WatcherDef { + /** Human-readable name, used in logs and the returned handle. */ + name: string; + /** Vital key under which collected data is stored. */ + key: string; + /** + * Async function that collects a snapshot of data. + * The returned value is persisted via CAS and referenced in the vital record. + */ + collect: () => Promise; + /** + * Evaluated after each collection against the last 12 vital records + * with their resolved data payloads. + * When it returns `true`, `wakeTick` is invoked. + */ + shouldWake: WakeCondition; + /** + * Collection interval in milliseconds. + * @default 5000 + */ + intervalMs?: number; +} +/** + * Returned by `startWatcher`. Allows the caller to identify and stop the loop. + */ +export interface WatcherHandle { + /** The watcher's name, as provided in {@link WatcherDef}. */ + name: string; + /** Stop the collection loop. The current in-flight tick completes first. */ + stop: () => void; +} +/** + * Start a periodic collection loop for the given watcher definition. + * + * The loop runs in the background (fire-and-forget). Each tick: + * 1. Calls `def.collect()` to obtain a snapshot. + * 2. Persists the snapshot via `store.putObject()`. + * 3. Appends an event with kind='vital' via `store.appendEvent()`. + * 4. Fetches the last 12 vital events and evaluates `def.shouldWake()`. + * 5. If the condition is met, calls `wakeTick()`. + * + * Errors in any step are caught, logged, and do not stop the loop. + * + * @param def Watcher configuration. + * @param store Pulse store instance for persistence (typically the _vitals scope). + * @param wakeTick Callback invoked when `def.shouldWake` returns `true`. + * @returns A handle that exposes the watcher name and a `stop` function. + */ +export declare function startWatcher(def: WatcherDef, store: PulseStore, wakeTick: () => void): WatcherHandle; diff --git a/packages/pulse/src/watcher.js b/packages/pulse/src/watcher.js new file mode 100644 index 0000000..718fc0f --- /dev/null +++ b/packages/pulse/src/watcher.js @@ -0,0 +1,90 @@ +/** + * @uncaged/pulse โ€” Watcher Framework + * + * Periodic data collection loops that store events (kind='vital') and trigger + * wake callbacks when a configurable condition is met over a sliding window. + */ +// โ”€โ”€ Implementation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +/** + * Start a periodic collection loop for the given watcher definition. + * + * The loop runs in the background (fire-and-forget). Each tick: + * 1. Calls `def.collect()` to obtain a snapshot. + * 2. Persists the snapshot via `store.putObject()`. + * 3. Appends an event with kind='vital' via `store.appendEvent()`. + * 4. Fetches the last 12 vital events and evaluates `def.shouldWake()`. + * 5. If the condition is met, calls `wakeTick()`. + * + * Errors in any step are caught, logged, and do not stop the loop. + * + * @param def Watcher configuration. + * @param store Pulse store instance for persistence (typically the _vitals scope). + * @param wakeTick Callback invoked when `def.shouldWake` returns `true`. + * @returns A handle that exposes the watcher name and a `stop` function. + */ +export function startWatcher(def, store, wakeTick) { + const intervalMs = def.intervalMs ?? 5000; + let running = true; + async function loop() { + while (running) { + await sleep(intervalMs); + if (!running) + break; + try { + const data = await def.collect(); + const hash = await store.putObject(data); + await store.appendEvent({ + occurredAt: Date.now(), + kind: 'vital', + key: def.key, + hash, + }); + const window = await store.queryByKind('vital', { + key: def.key, + limit: 12, + }); + const resolvedWindow = await Promise.all(window.map(async (v) => ({ + ...v, + data: v.hash ? await store.getObject(v.hash) : null, + }))); + if (def.shouldWake(resolvedWindow)) { + wakeTick(); + } + } + catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes('Database has closed')) { + running = false; + break; + } + console.error(`[watcher:${def.name}] error during tick:`, err); + try { + await store.appendEvent({ + occurredAt: Date.now(), + kind: 'vital', + key: `_error:${def.key}`, + hash: await store.putObject({ + error: msg, + watcher: def.name, + }), + }); + } + catch { + // Best-effort: if even error recording fails, just log + } + } + } + } + // Start loop without awaiting so the caller is not blocked. + void loop(); + return { + name: def.name, + stop() { + running = false; + }, + }; +} +// โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/pulse/src/watchers/error-log.d.ts b/packages/pulse/src/watchers/error-log.d.ts new file mode 100644 index 0000000..cd3b7ea --- /dev/null +++ b/packages/pulse/src/watchers/error-log.d.ts @@ -0,0 +1,12 @@ +import type { WatcherDef } from '../watcher.js'; +export interface ErrorLogData { + matches: string[]; + source: string; +} +export interface ErrorLogOptions { + /** ่ฆ็›‘ๆŽง็š„ๆ—ฅๅฟ—ๆ–‡ไปถ่ทฏๅพ„ๅˆ—่กจ */ + logFiles: string[]; + /** ่งฆๅ‘ๅ”ค้†’็š„ๅ…ณ้”ฎ่ฏ */ + keywords?: string[]; +} +export declare function errorLogWatcher(opts: ErrorLogOptions): WatcherDef; diff --git a/packages/pulse/src/watchers/error-log.js b/packages/pulse/src/watchers/error-log.js new file mode 100644 index 0000000..4ff24e6 --- /dev/null +++ b/packages/pulse/src/watchers/error-log.js @@ -0,0 +1,87 @@ +import * as fs from 'node:fs'; +export function errorLogWatcher(opts) { + const keywords = opts.keywords ?? [ + 'panic', + 'fatal', + 'OOM', + 'SIGKILL', + 'unhandled rejection', + ]; + // ไธบๆฏไธชๆ–‡ไปถไฟๅญ˜ๆœ€ๅŽ่ฏปๅ–็š„ไฝ็ฝฎ + const filePositions = new Map(); + return { + name: 'error-log', + key: 'errorlog', + intervalMs: 5000, + collect: async () => { + const allMatches = []; + let sourceFile = ''; + for (const logFile of opts.logFiles) { + try { + // ๆฃ€ๆŸฅๆ–‡ไปถๆ˜ฏๅฆๅญ˜ๅœจ + if (!fs.existsSync(logFile)) { + continue; + } + const stats = fs.statSync(logFile); + const currentSize = stats.size; + let lastPosition = filePositions.get(logFile) ?? 0; + // ๅฆ‚ๆžœๆ–‡ไปถ่ขซ่ฝฎ่ฝฌๆˆ–ๆˆชๆ–ญ๏ผŒ้‡็ฝฎไฝ็ฝฎ + if (currentSize < lastPosition) { + lastPosition = 0; + filePositions.set(logFile, 0); + } + // ๅช่ฏปๅ–ๆ–ฐๅขž็š„้ƒจๅˆ† + if (currentSize > lastPosition) { + const fd = fs.openSync(logFile, 'r'); + try { + const bufferSize = currentSize - lastPosition; + const buffer = Buffer.alloc(bufferSize); + const bytesRead = fs.readSync(fd, buffer, 0, bufferSize, lastPosition); + const newContent = buffer.subarray(0, bytesRead).toString('utf8'); + const lines = newContent + .split('\n') + .filter((line) => line.trim()); + // ๅœจๆ–ฐ่กŒไธญๆœ็ดขๅ…ณ้”ฎ่ฏ + for (const line of lines) { + const lowerLine = line.toLowerCase(); + for (const keyword of keywords) { + if (lowerLine.includes(keyword.toLowerCase())) { + allMatches.push(line); + sourceFile = logFile; + break; // ๆฏ่กŒๅชๅŒน้…ไธ€ๆฌก + } + } + } + // ๆ›ดๆ–ฐๆ–‡ไปถไฝ็ฝฎ + filePositions.set(logFile, currentSize); + } + finally { + fs.closeSync(fd); + } + } + } + catch (error) { + console.error(`[error-log] Failed to read ${logFile}:`, error); + // ่ฏปๅ–ๅคฑ่ดฅๆ—ถไธๆ›ดๆ–ฐไฝ็ฝฎ๏ผŒไธ‹ๆฌก้‡่ฏ• + } + } + return { + matches: allMatches, + source: sourceFile || opts.logFiles[0] || '', + }; + }, + shouldWake: (window) => { + if (window.length === 0) + return false; + const latest = window[0]; + if (!latest?.data) + return false; + // ๅฆ‚ๆžœๆœ‰ไปปไฝ•ๅŒน้…็š„้”™่ฏฏๅ…ณ้”ฎ่ฏ๏ผŒ็ซ‹ๅณๅ”ค้†’ + if (latest.data.matches.length > 0) { + console.warn(`[error-log] Found ${latest.data.matches.length} error patterns:`, latest.data.matches.slice(0, 3)); // ๅชๆ˜พ็คบๅ‰3ไธชๅŒน้… + return true; + } + return false; + }, + }; +} diff --git a/packages/pulse/src/watchers/index.d.ts b/packages/pulse/src/watchers/index.d.ts new file mode 100644 index 0000000..fdb513f --- /dev/null +++ b/packages/pulse/src/watchers/index.d.ts @@ -0,0 +1,4 @@ +export { type ErrorLogData, type ErrorLogOptions, errorLogWatcher, } from './error-log.js'; +export { type NetworkData, type NetworkOptions, networkWatcher, } from './network.js'; +export { type ProcessAliveData, type ProcessAliveOptions, processAliveWatcher, } from './process-alive.js'; +export { type SystemResourceData, type SystemResourceOptions, systemResourceWatcher, } from './system-resource.js'; diff --git a/packages/pulse/src/watchers/index.js b/packages/pulse/src/watchers/index.js new file mode 100644 index 0000000..f1593f3 --- /dev/null +++ b/packages/pulse/src/watchers/index.js @@ -0,0 +1,4 @@ +export { errorLogWatcher, } from './error-log.js'; +export { networkWatcher, } from './network.js'; +export { processAliveWatcher, } from './process-alive.js'; +export { systemResourceWatcher, } from './system-resource.js'; diff --git a/packages/pulse/src/watchers/network.d.ts b/packages/pulse/src/watchers/network.d.ts new file mode 100644 index 0000000..6092324 --- /dev/null +++ b/packages/pulse/src/watchers/network.d.ts @@ -0,0 +1,15 @@ +import * as dns from 'node:dns'; +import type { WatcherDef } from '../watcher.js'; +export interface NetworkData { + dnsOk: boolean; + httpOk: boolean; + latencyMs: number; +} +export interface NetworkOptions { + dnsHost?: string; + httpUrl?: string; + timeoutMs?: number; + /** Inject DNS resolve function for testing */ + dnsResolveFn?: typeof dns.promises.resolve; +} +export declare function networkWatcher(opts?: NetworkOptions): WatcherDef; diff --git a/packages/pulse/src/watchers/network.js b/packages/pulse/src/watchers/network.js new file mode 100644 index 0000000..0e110e9 --- /dev/null +++ b/packages/pulse/src/watchers/network.js @@ -0,0 +1,55 @@ +import * as dns from 'node:dns'; +export function networkWatcher(opts) { + const dnsHost = opts?.dnsHost ?? 'cloudflare.com'; + const httpUrl = opts?.httpUrl ?? 'https://1.1.1.1/cdn-cgi/trace'; + const timeoutMs = opts?.timeoutMs ?? 5000; + const dnsResolveFn = opts?.dnsResolveFn ?? dns.promises.resolve; + return { + name: 'network-connectivity', + key: 'network', + intervalMs: 5000, + collect: async () => { + const startTime = Date.now(); + let dnsOk = false; + let httpOk = false; + // DNS ่งฃๆžๆต‹่ฏ• + try { + await Promise.race([ + dnsResolveFn(dnsHost), + new Promise((_, reject) => setTimeout(() => reject(new Error('DNS timeout')), timeoutMs)), + ]); + dnsOk = true; + } + catch { + dnsOk = false; + } + // HTTP ่ฟžๆŽฅๆต‹่ฏ• + try { + const controller = new AbortController(); + const tid = setTimeout(() => controller.abort(), timeoutMs); + const response = await fetch(httpUrl, { + method: 'GET', + signal: controller.signal, + redirect: 'manual', + }); + clearTimeout(tid); + httpOk = response.status >= 200 && response.status < 400; + } + catch { + httpOk = false; + } + return { dnsOk, httpOk, latencyMs: Date.now() - startTime }; + }, + shouldWake: (window) => { + if (window.length < 2) + return false; + // ่ฟž็ปญ 2 ๆฌก DNS ๅ’Œ HTTP ๅ…จ้ƒจๅคฑ่ดฅ โ†’ ๅ”ค้†’ + const recentFailures = window.slice(0, 2).filter((v) => { + if (!v?.data) + return true; + return !v.data.dnsOk && !v.data.httpOk; + }); + return recentFailures.length >= 2; + }, + }; +} diff --git a/packages/pulse/src/watchers/process-alive.d.ts b/packages/pulse/src/watchers/process-alive.d.ts new file mode 100644 index 0000000..18854a8 --- /dev/null +++ b/packages/pulse/src/watchers/process-alive.d.ts @@ -0,0 +1,12 @@ +import { execSync } from 'node:child_process'; +import type { WatcherDef } from '../watcher.js'; +export interface ProcessAliveData { + processes: Record; +} +export interface ProcessAliveOptions { + /** ่ฆ็›‘ๆŽง็š„่ฟ›็จ‹ๅˆ—่กจ๏ผšname โ†’ ๅŒน้…ๅ‘ฝไปค่กŒ็š„ๅ…ณ้”ฎ่ฏ */ + processes: Record; + /** Inject execSync for testing */ + execSyncFn?: typeof execSync; +} +export declare function processAliveWatcher(opts: ProcessAliveOptions): WatcherDef; diff --git a/packages/pulse/src/watchers/process-alive.js b/packages/pulse/src/watchers/process-alive.js new file mode 100644 index 0000000..3cd97c6 --- /dev/null +++ b/packages/pulse/src/watchers/process-alive.js @@ -0,0 +1,64 @@ +import { execSync } from 'node:child_process'; +export function processAliveWatcher(opts) { + const execSyncFn = opts.execSyncFn ?? execSync; + return { + name: 'process-alive', + key: 'processes', + intervalMs: 5000, + collect: async () => { + const processes = {}; + try { + // ่Žทๅ–ๆ‰€ๆœ‰่ฟ›็จ‹ไฟกๆฏ + const psOutput = execSyncFn('ps aux --no-headers', { + encoding: 'utf8', + }); + // ๆฃ€ๆŸฅๆฏไธช้…็ฝฎ็š„่ฟ›็จ‹ + for (const [name, keyword] of Object.entries(opts.processes)) { + // ๅœจ่ฟ›็จ‹ๅˆ—่กจไธญๆœ็ดขๅŒ…ๅซๅ…ณ้”ฎ่ฏ็š„่ฟ›็จ‹ + const isRunning = psOutput + .split('\n') + .some((line) => line.toLowerCase().includes(keyword.toLowerCase())); + processes[name] = isRunning; + } + } + catch (error) { + // ps ๅ‘ฝไปคๅคฑ่ดฅๆ—ถ๏ผŒๆ ‡่ฎฐๆ‰€ๆœ‰่ฟ›็จ‹ไธบๆœช็Ÿฅ็Šถๆ€๏ผˆfalse๏ผ‰ + console.error('[process-alive] Failed to get process list:', error); + for (const name of Object.keys(opts.processes)) { + processes[name] = false; + } + } + return { processes }; + }, + shouldWake: (window) => { + if (window.length === 0) + return false; + const latest = window[0]; + if (!latest?.data) + return false; + const currentProcesses = latest.data.processes; + // ๆฃ€ๆŸฅๆ˜ฏๅฆๆœ‰ไปปไฝ•่ฟ›็จ‹ๆถˆๅคฑ + for (const [name, isAlive] of Object.entries(currentProcesses)) { + if (!isAlive) { + console.warn(`[process-alive] Process '${name}' is not running`); + return true; + } + } + // ๅฆ‚ๆžœๆœ‰ๅކๅฒๆ•ฐๆฎ๏ผŒๆฃ€ๆŸฅๆ˜ฏๅฆๆœ‰่ฟ›็จ‹ไปŽ่ฟ่กŒๅ˜ไธบๅœๆญข + if (window.length > 1) { + const previous = window[1]; + if (previous?.data) { + const prevProcesses = previous.data.processes; + for (const [name, isAlive] of Object.entries(currentProcesses)) { + const wasAlive = prevProcesses[name]; + if (wasAlive && !isAlive) { + console.warn(`[process-alive] Process '${name}' stopped running`); + return true; + } + } + } + } + return false; + }, + }; +} diff --git a/packages/pulse/src/watchers/system-resource.d.ts b/packages/pulse/src/watchers/system-resource.d.ts new file mode 100644 index 0000000..891cda5 --- /dev/null +++ b/packages/pulse/src/watchers/system-resource.d.ts @@ -0,0 +1,22 @@ +import { execSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import type { WatcherDef } from '../watcher.js'; +export interface SystemResourceData { + cpuPct: number; + memoryPct: number; + diskPct: number; + swapPct: number; +} +export interface SystemResourceOptions { + memoryThreshold?: number; + diskThreshold?: number; + sustainedSeconds?: number; + /** Inject execSync for testing */ + execSyncFn?: typeof execSync; + /** Inject fs module for testing */ + fsFn?: typeof fs; + /** Inject os module for testing */ + osFn?: typeof os; +} +export declare function systemResourceWatcher(opts?: SystemResourceOptions): WatcherDef; diff --git a/packages/pulse/src/watchers/system-resource.js b/packages/pulse/src/watchers/system-resource.js new file mode 100644 index 0000000..4542ed4 --- /dev/null +++ b/packages/pulse/src/watchers/system-resource.js @@ -0,0 +1,124 @@ +import { execSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +/** + * Get accurate memory usage percentage. + * + * On macOS, os.freemem() only returns truly "free" pages, excluding + * inactive and purgeable memory that macOS can reclaim instantly. + * This causes memoryPct to report ~95%+ on healthy machines. + * + * On Linux, /proc/meminfo MemAvailable is preferred over MemFree + * for the same reason (buffers/cache are reclaimable). + */ +function getMemoryPct(totalMem, osFn, execSyncFn, fsFn) { + const platform = osFn.platform(); + if (platform === 'darwin') { + try { + const output = execSyncFn('vm_stat', { encoding: 'utf8' }); + const pageSize = parseInt(output.match(/page size of (\d+) bytes/)?.[1] ?? '0', 10) || + 16384; + const parse = (label) => { + const match = output.match(new RegExp(`${label}:\\s+(\\d+)`)); + return parseInt(match?.[1] ?? '0', 10) * pageSize; + }; + const free = parse('Pages free'); + const inactive = parse('Pages inactive'); + const purgeable = parse('Pages purgeable'); + const available = free + inactive + purgeable; + return ((totalMem - available) / totalMem) * 100; + } + catch { + // fallback to os.freemem() + } + } + if (platform === 'linux') { + try { + const meminfo = fsFn.readFileSync('/proc/meminfo', 'utf8'); + const memAvailable = parseInt(meminfo.match(/MemAvailable:\s+(\d+)/)?.[1] ?? '0', 10); + if (memAvailable > 0) { + return ((totalMem - memAvailable * 1024) / totalMem) * 100; + } + } + catch { + // fallback to os.freemem() + } + } + // Fallback for other platforms + return ((totalMem - osFn.freemem()) / totalMem) * 100; +} +export function systemResourceWatcher(opts) { + const memThreshold = opts?.memoryThreshold ?? 90; + const diskThreshold = opts?.diskThreshold ?? 95; + const sustained = opts?.sustainedSeconds ?? 30; + const execSyncFn = opts?.execSyncFn ?? execSync; + const fsFn = opts?.fsFn ?? fs; + const osFn = opts?.osFn ?? os; + return { + name: 'system-resource', + key: 'system', + intervalMs: 5000, + collect: async () => { + const totalMem = osFn.totalmem(); + const memoryPct = getMemoryPct(totalMem, osFn, execSyncFn, fsFn); + // CPU๏ผš็”จ os.cpus() ็ฎ— idle ็™พๅˆ†ๆฏ”๏ผˆ็ฎ€ๅŒ–็‰ˆ๏ผŒไฝฟ็”จ็žฌๆ—ถๅ€ผ๏ผ‰ + const cpus = osFn.cpus(); + const totalIdle = cpus.reduce((sum, cpu) => sum + cpu.times.idle, 0); + const totalTick = cpus.reduce((sum, cpu) => sum + Object.values(cpu.times).reduce((a, b) => a + b, 0), 0); + const cpuPct = totalTick > 0 ? (1 - totalIdle / totalTick) * 100 : 0; + // ็ฃ็›˜๏ผšไฝฟ็”จ df ๅ‘ฝไปค่งฃๆžๆ นๅˆ†ๅŒบไฝฟ็”จ็އ + let diskPct = 0; + try { + const output = execSyncFn('df / --output=pcent | tail -1', { + encoding: 'utf8', + }); + diskPct = parseFloat(output.trim().replace('%', '')) || 0; + } + catch { + diskPct = 0; + } + // Swap๏ผšไปŽ /proc/meminfo ่ฏปๅ– + let swapPct = 0; + try { + const meminfo = fsFn.readFileSync('/proc/meminfo', 'utf8'); + const swapTotal = parseInt(meminfo.match(/SwapTotal:\s+(\d+)/)?.[1] ?? '0', 10); + const swapFree = parseInt(meminfo.match(/SwapFree:\s+(\d+)/)?.[1] ?? '0', 10); + swapPct = + swapTotal > 0 ? ((swapTotal - swapFree) / swapTotal) * 100 : 0; + } + catch { + swapPct = 0; + } + return { + cpuPct: Math.round(cpuPct * 10) / 10, + memoryPct: Math.round(memoryPct * 10) / 10, + diskPct, + swapPct: Math.round(swapPct * 10) / 10, + }; + }, + shouldWake: (window) => { + if (window.length === 0) + return false; + const latest = window[0]; + if (!latest?.data) + return false; + const data = latest.data; + // ็ฃ็›˜ > 95% ็ซ‹ๅณๅ”ค้†’๏ผˆๅ•็‚นๅฐฑๅคŸๅฑ้™ฉ๏ผ‰ + if (data.diskPct >= diskThreshold) { + return true; + } + // ๅ†…ๅญ˜ๆŒ็ปญ่ถ…้˜ˆๅ€ผๆฃ€ๆŸฅ + if (data.memoryPct >= memThreshold) { + // ๆฃ€ๆŸฅๆŒ็ปญๆ—ถ้—ด๏ผšๆœ€่ฟ‘ N ๆก่ฎฐๅฝ•้ƒฝ่ถ…้˜ˆๅ€ผ + const sustainedCount = Math.ceil((sustained * 1000) / 5000); // 5s interval + const recentOverThreshold = window + .slice(0, sustainedCount) + .filter((v) => v.data && v.data.memoryPct >= memThreshold); + if (recentOverThreshold.length >= sustainedCount) { + return true; + } + } + return false; + }, + }; +} diff --git a/packages/pulse/src/workflows/cursor-health.test.ts b/packages/pulse/src/workflows/cursor-health.test.ts deleted file mode 100644 index 5402b13..0000000 --- a/packages/pulse/src/workflows/cursor-health.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -/** - * Cursor Health Rule Tests - * - * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) - */ - -import { Database } from 'bun:sqlite'; -import { describe, expect, test } from 'bun:test'; -import { unlink } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { checkCursorHealth } from './cursor-health.js'; - -describe('cursor-health', () => { - function createTestDb(records: number[], baseTime = Date.now()): string { - const tempDbPath = join( - tmpdir(), - `cursor-health-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`, - ); - const db = new Database(tempDbPath); - - // ๅˆ›ๅปบ่กจ็ป“ๆž„๏ผˆๆจกไปฟ Cursor ็š„ schema๏ผ‰ - db.exec(` - CREATE TABLE IF NOT EXISTS ai_code_hashes ( - timestamp INTEGER - ) - `); - - // ๆ’ๅ…ฅๆต‹่ฏ•ๆ•ฐๆฎ - const stmt = db.prepare( - 'INSERT INTO ai_code_hashes (timestamp) VALUES (?)', - ); - for (const offset of records) { - stmt.run(baseTime + offset); - } - - db.close(); - return tempDbPath; - } - - async function cleanupDb(dbPath: string) { - try { - await unlink(dbPath); - } catch { - // Ignore cleanup errors - } - } - - test('ๅฅๅบท็Šถๆ€ - ่ฐƒ็”จๆฌกๆ•ฐไฝŽไบŽ้˜ˆๅ€ผ', async () => { - const now = Date.now(); - // ๆ’ๅ…ฅ 50 ๆก่ฎฐๅฝ•๏ผŒ้ƒฝๅœจๆœ€่ฟ‘ 15 ๅˆ†้’Ÿๅ†… - const records = Array.from({ length: 50 }, (_, i) => -i * 10000); // ๆฏ 10 ็ง’ไธ€ๆก - const tempDbPath = createTestDb(records, now); - - const result = checkCursorHealth({ - dbPath: tempDbPath, - windowMs: 15 * 60 * 1000, - threshold: 100, - }); - - expect(result.recentCount).toBe(50); - expect(result.threshold).toBe(100); - expect(result.isHealthy).toBe(true); - expect(result.windowMs).toBe(15 * 60 * 1000); - expect(result.checkedAt).toBeGreaterThan(now - 1000); - - await cleanupDb(tempDbPath); - }); - - test('ไธๅฅๅบท็Šถๆ€ - ่ฐƒ็”จๆฌกๆ•ฐ่ถ…่ฟ‡้˜ˆๅ€ผ', async () => { - const now = Date.now(); - // ๆ’ๅ…ฅ 150 ๆก่ฎฐๅฝ•๏ผŒ้ƒฝๅœจๆœ€่ฟ‘ 15 ๅˆ†้’Ÿๅ†… - const records = Array.from({ length: 150 }, (_, i) => -i * 5000); // ๆฏ 5 ็ง’ไธ€ๆก - const tempDbPath = createTestDb(records, now); - - const result = checkCursorHealth({ - dbPath: tempDbPath, - windowMs: 15 * 60 * 1000, - threshold: 100, - }); - - expect(result.recentCount).toBe(150); - expect(result.threshold).toBe(100); - expect(result.isHealthy).toBe(false); - expect(result.windowMs).toBe(15 * 60 * 1000); - - await cleanupDb(tempDbPath); - }); - - test('ๆ—ถ้—ด็ช—ๅฃ่ฟ‡ๆปค - ๅช็ปŸ่ฎกๆ—ถ้—ด็ช—ๅฃๅ†…็š„่ฎฐๅฝ•', async () => { - const now = Date.now(); - const windowMs = 10 * 60 * 1000; // 10 ๅˆ†้’Ÿ็ช—ๅฃ - - const records = [ - // ็ช—ๅฃๅ†…๏ผˆๆœ€่ฟ‘ 10 ๅˆ†้’Ÿ๏ผ‰- ๅบ”่ฏฅ่ขซ็ปŸ่ฎก - -5 * 60 * 1000, // 5 ๅˆ†้’Ÿๅ‰ - -8 * 60 * 1000, // 8 ๅˆ†้’Ÿๅ‰ - -9 * 60 * 1000, // 9 ๅˆ†้’Ÿๅ‰ - - // ็ช—ๅฃๅค–๏ผˆ่ถ…่ฟ‡ 10 ๅˆ†้’Ÿ๏ผ‰- ไธๅบ”่ฏฅ่ขซ็ปŸ่ฎก - -12 * 60 * 1000, // 12 ๅˆ†้’Ÿๅ‰ - -20 * 60 * 1000, // 20 ๅˆ†้’Ÿๅ‰ - ]; - const tempDbPath = createTestDb(records, now); - - const result = checkCursorHealth({ - dbPath: tempDbPath, - windowMs, - threshold: 50, - }); - - expect(result.recentCount).toBe(3); // ๅชๆœ‰็ช—ๅฃๅ†…็š„ 3 ๆก - expect(result.isHealthy).toBe(true); - - await cleanupDb(tempDbPath); - }); - - test('่‡ชๅฎšไน‰ๅ‚ๆ•ฐ', async () => { - const now = Date.now(); - const records = Array.from({ length: 30 }, (_, i) => -i * 1000); - const tempDbPath = createTestDb(records, now); - - const result = checkCursorHealth({ - dbPath: tempDbPath, - windowMs: 5 * 60 * 1000, // 5 ๅˆ†้’Ÿ็ช—ๅฃ - threshold: 20, // ้˜ˆๅ€ผ 20 - }); - - expect(result.windowMs).toBe(5 * 60 * 1000); - expect(result.threshold).toBe(20); - // 5 ๅˆ†้’Ÿๅ†…็š„่ฎฐๅฝ•ๆ•ฐ้‡ - expect(result.recentCount).toBeGreaterThan(0); - - await cleanupDb(tempDbPath); - }); - - test('ๆ•ฐๆฎๅบ“ไธๅญ˜ๅœจๆ—ถ่ฟ”ๅ›žๅฅๅบท็Šถๆ€', () => { - const result = checkCursorHealth({ - dbPath: '/nonexistent/path/to/db.sqlite', - }); - - expect(result.recentCount).toBe(0); - expect(result.isHealthy).toBe(true); - expect(result.threshold).toBe(100); - expect(result.windowMs).toBe(15 * 60 * 1000); - }); - - test('็ฉบๆ•ฐๆฎๅบ“่ฟ”ๅ›žๅฅๅบท็Šถๆ€', async () => { - // ๅˆ›ๅปบ็ฉบๆ•ฐๆฎๅบ“ - const tempDbPath = createTestDb([], Date.now()); - - const result = checkCursorHealth({ - dbPath: tempDbPath, - }); - - expect(result.recentCount).toBe(0); - expect(result.isHealthy).toBe(true); - - await cleanupDb(tempDbPath); - }); - - test('้ป˜่ฎคๅ‚ๆ•ฐๆญฃ็กฎ', async () => { - const tempDbPath = createTestDb([], Date.now()); - - const result = checkCursorHealth({ - dbPath: tempDbPath, - }); - - expect(result.threshold).toBe(100); - expect(result.windowMs).toBe(15 * 60 * 1000); - expect(result.recentCount).toBe(0); - expect(result.isHealthy).toBe(true); - - await cleanupDb(tempDbPath); - }); -}); diff --git a/packages/pulse/src/workflows/index.d.ts b/packages/pulse/src/workflows/index.d.ts new file mode 100644 index 0000000..ec06bdd --- /dev/null +++ b/packages/pulse/src/workflows/index.d.ts @@ -0,0 +1,22 @@ +/** + * Workflows module โ€” re-exports + createWorkflowTicker utility. + * + * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) + */ +export type { MetaCoderMeta, MetaTesterMeta, } from './meta.js'; +export { createMetaWorkflow } from './meta.js'; +export { type AgentExecutorConfig, type AgentResult, type AgentRunner, createAgentExecutorRole, createCursorRunner, } from './roles/agent-executor.js'; +export type { LlmRoleConfig, ToolRoleConfig, } from './roles/llm-role-factory.js'; +export { createLlmRole, createToolRole } from './roles/llm-role-factory.js'; +export { createMetaCoderRole } from './roles/meta-coder-cursor.js'; +export { createMetaTesterRole } from './roles/meta-tester.js'; +export { createMetaCheckerRole } from './roles/meta-checker.js'; +export { type ScaffoldOptions, scaffoldWorkflow } from './scaffold.js'; +export { createWorkflowRule, type WorkflowRule, type WorkflowTickResult, } from './workflow-rule-adapter.js'; +export { END, type MetaOf, type ModeratorInput, type Role, type RoleOutput, type RoleResult, START, type StartSignal, type WorkflowAction, type WorkflowMessage, type WorkflowType, } from './workflow-type.js'; +import type { WorkflowRule } from './workflow-rule-adapter.js'; +/** + * createWorkflowTicker โ€” wraps multiple WorkflowRules into a single async function + * suitable for calling at the end of a runPulse tick cycle. + */ +export declare function createWorkflowTicker(rules: WorkflowRule[]): () => Promise; diff --git a/packages/pulse/src/workflows/index.js b/packages/pulse/src/workflows/index.js new file mode 100644 index 0000000..775eb69 --- /dev/null +++ b/packages/pulse/src/workflows/index.js @@ -0,0 +1,30 @@ +/** + * Workflows module โ€” re-exports + createWorkflowTicker utility. + * + * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) + */ +export { createMetaWorkflow } from './meta.js'; +export { createAgentExecutorRole, createCursorRunner, } from './roles/agent-executor.js'; +export { createLlmRole, createToolRole } from './roles/llm-role-factory.js'; +export { createMetaCoderRole } from './roles/meta-coder-cursor.js'; +export { createMetaTesterRole } from './roles/meta-tester.js'; +export { createMetaCheckerRole } from './roles/meta-checker.js'; +export { scaffoldWorkflow } from './scaffold.js'; +export { createWorkflowRule, } from './workflow-rule-adapter.js'; +export { END, START, } from './workflow-type.js'; +/** + * createWorkflowTicker โ€” wraps multiple WorkflowRules into a single async function + * suitable for calling at the end of a runPulse tick cycle. + */ +export function createWorkflowTicker(rules) { + let pending = null; + return () => { + const run = async () => { + for (const rule of rules) { + await rule.tick(); + } + }; + pending = (pending ?? Promise.resolve()).then(run, run); + return pending; + }; +} diff --git a/packages/pulse/src/workflows/index.ts b/packages/pulse/src/workflows/index.ts index 44c6bd4..e7f7fa3 100644 --- a/packages/pulse/src/workflows/index.ts +++ b/packages/pulse/src/workflows/index.ts @@ -4,20 +4,11 @@ * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) */ -export { - type ArchitectMeta, - type CloserMeta, - type CoderMeta, - type CodingRoles, - createCodingWorkflow, - type ReviewerMeta, -} from './coding.js'; export type { MetaCoderMeta, MetaTesterMeta, } from './meta.js'; export { createMetaWorkflow } from './meta.js'; -export { createReportWorkflow } from './report.js'; export { type AgentExecutorConfig, type AgentResult, @@ -25,10 +16,6 @@ export { createAgentExecutorRole, createCursorRunner, } from './roles/agent-executor.js'; -export type { AnalysisResult } from './roles/analyst-llm.js'; -export { createAnalystRole } from './roles/analyst-llm.js'; -export { createArchitectRole } from './roles/architect-llm.js'; -export { createCoderRole } from './roles/coder-cursor.js'; export type { LlmRoleConfig, ToolRoleConfig, @@ -36,8 +23,7 @@ export type { export { createLlmRole, createToolRole } from './roles/llm-role-factory.js'; export { createMetaCoderRole } from './roles/meta-coder-cursor.js'; export { createMetaTesterRole } from './roles/meta-tester.js'; -export { createRendererRole } from './roles/renderer-template.js'; -export { createReviewerRole } from './roles/reviewer-cursor.js'; +export { createMetaCheckerRole } from './roles/meta-checker.js'; export { type ScaffoldOptions, scaffoldWorkflow } from './scaffold.js'; export { createWorkflowRule, @@ -64,20 +50,6 @@ import type { WorkflowRule } from './workflow-rule-adapter.js'; * createWorkflowTicker โ€” wraps multiple WorkflowRules into a single async function * suitable for calling at the end of a runPulse tick cycle. */ -export { - type AutoTesterMeta, - type CreateTddCodingWorkflowOpts, - createTddCodingWorkflow, - type ManualTesterMeta, - type TddCloserMeta, - type TddCoderMeta, - type TddCodingRoles, - type TddReviewerMeta, - type TestCoderMeta, - type TestPlannerMeta, - type TestReviewerMeta, -} from './coding-tdd.js'; - export function createWorkflowTicker( rules: WorkflowRule[], ): () => Promise { diff --git a/packages/pulse/src/workflows/meta.d.ts b/packages/pulse/src/workflows/meta.d.ts new file mode 100644 index 0000000..2be7f62 --- /dev/null +++ b/packages/pulse/src/workflows/meta.d.ts @@ -0,0 +1,43 @@ +/** + * Meta Workflow โ€” workflow for developing workflows. + * + * Roles: + * coder (Agent) โ†’ implement code + * checker (code) โ†’ validate file scope + build + unit test + * tester (code) โ†’ e2e lifecycle verification + commit/push on pass + * + * Flow: + * START โ†’ coder โ†’ checker + * โ†’ fail โ†’ coder (file scope violation or build/test fail) + * checker pass โ†’ tester + * โ†’ pass โ†’ END (with commit + push) + * โ†’ fail โ†’ coder (e2e fail, with diagnostic) + * + * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) + */ +import { type Role, type WorkflowType } from './workflow-type.js'; +export interface MetaCoderMeta { + [key: string]: unknown; + filesChanged: string[]; + testsPassed: boolean; +} +export interface MetaCheckerMeta { + [key: string]: unknown; + pass: boolean; + reason: string; + violations?: string[]; +} +export interface MetaTesterMeta { + [key: string]: unknown; + pass: boolean; + reason: string; + /** Only present when pass=true */ + commitHash?: string; + pushed?: boolean; +} +export type MetaWorkflowRoles = { + coder: Role; + checker: Role; + tester: Role; +}; +export declare function createMetaWorkflow(roles: MetaWorkflowRoles): WorkflowType; diff --git a/packages/pulse/src/workflows/meta.js b/packages/pulse/src/workflows/meta.js new file mode 100644 index 0000000..2e7a0a9 --- /dev/null +++ b/packages/pulse/src/workflows/meta.js @@ -0,0 +1,45 @@ +/** + * Meta Workflow โ€” workflow for developing workflows. + * + * Roles: + * coder (Agent) โ†’ implement code + * checker (code) โ†’ validate file scope + build + unit test + * tester (code) โ†’ e2e lifecycle verification + commit/push on pass + * + * Flow: + * START โ†’ coder โ†’ checker + * โ†’ fail โ†’ coder (file scope violation or build/test fail) + * checker pass โ†’ tester + * โ†’ pass โ†’ END (with commit + push) + * โ†’ fail โ†’ coder (e2e fail, with diagnostic) + * + * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) + */ +import { END, START, } from './workflow-type.js'; +// โ”€โ”€ Moderator โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function metaModerator(input, _topicId) { + switch (input.role) { + case START: + return 'coder'; + case 'coder': + return 'checker'; + case 'checker': { + const meta = input.meta; + return meta?.pass ? 'tester' : 'coder'; + } + case 'tester': { + const meta = input.meta; + return meta?.pass ? END : 'coder'; + } + default: + return END; + } +} +// โ”€โ”€ Factory โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +export function createMetaWorkflow(roles) { + return { + name: 'meta', + roles, + moderator: metaModerator, + }; +} diff --git a/packages/pulse/src/workflows/roles/agent-executor.d.ts b/packages/pulse/src/workflows/roles/agent-executor.d.ts new file mode 100644 index 0000000..f8c62d3 --- /dev/null +++ b/packages/pulse/src/workflows/roles/agent-executor.d.ts @@ -0,0 +1,49 @@ +/** + * Agent Executor โ€” LLM-Agent-LLM sandwich pattern. + * + * 1. LLMโ‚ (prep): prepPrompt builds the agent's prompt + * 2. Agent (exec): runs CLI agent, gets free-text report + * 3. LLMโ‚‚ (parse): extracts structured meta via tool_choice + * + * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) + */ +import type { LlmClient, LlmTool } from '../../llm-client.js'; +import type { Role, WorkflowMessage } from '../workflow-type.js'; +export interface AgentResult { + success: boolean; + output: string; + durationMs: number; +} +export interface AgentRunner { + run(prompt: string, cwd: string): Promise; +} +/** + * Default agent runner โ€” Cursor CLI. + */ +export declare function createCursorRunner(opts: { + agentBin: string; + timeoutMs?: number; +}): AgentRunner; +export interface AgentExecutorConfig { + /** Build prompt + cwd for the agent. */ + prepPrompt: (chain: WorkflowMessage[], topicId: string) => { + prompt: string; + cwd: string; + }; + /** LLMโ‚‚ structured output: tool definition for meta extraction. */ + parseMeta: { + /** System prompt for the meta-extraction LLM call. */ + system: string; + /** Tool definition โ€” parameters schema defines Meta shape. */ + tool: LlmTool; + /** Parse tool_call arguments into Meta. Falls back to defaultMeta on failure. */ + parse: (args: string) => Meta; + /** Fallback when LLMโ‚‚ fails or returns no tool_call. */ + defaultMeta: (output: string) => Meta; + }; +} +/** + * Create a pure Role from an agent executor config. + * The Role runs: prepPrompt โ†’ agent โ†’ LLMโ‚‚ parse โ†’ { content, meta }. + */ +export declare function createAgentExecutorRole(agent: AgentRunner, llm: LlmClient, config: AgentExecutorConfig): Role; diff --git a/packages/pulse/src/workflows/roles/agent-executor.js b/packages/pulse/src/workflows/roles/agent-executor.js new file mode 100644 index 0000000..e6bbb65 --- /dev/null +++ b/packages/pulse/src/workflows/roles/agent-executor.js @@ -0,0 +1,89 @@ +/** + * Agent Executor โ€” LLM-Agent-LLM sandwich pattern. + * + * 1. LLMโ‚ (prep): prepPrompt builds the agent's prompt + * 2. Agent (exec): runs CLI agent, gets free-text report + * 3. LLMโ‚‚ (parse): extracts structured meta via tool_choice + * + * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) + */ +import { mkdirSync, unlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +/** + * Default agent runner โ€” Cursor CLI. + */ +export function createCursorRunner(opts) { + const { agentBin, timeoutMs = 300_000 } = opts; + return { + async run(prompt, cwd) { + const promptDir = join(tmpdir(), 'pulse-v2-prompts'); + mkdirSync(promptDir, { recursive: true }); + const promptFile = join(promptDir, `task-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.md`); + writeFileSync(promptFile, prompt, 'utf-8'); + const cursorApiKey = await (async () => { + const p = Bun.spawn(['sh', '-c', 'secret get CURSOR_API_KEY | head -1'], { + stdout: 'pipe', + }); + await p.exited; + return (await new Response(p.stdout).text()).trim(); + })(); + const start = Date.now(); + const proc = Bun.spawn([agentBin, '--yolo', '-p', '--output-format', 'text', '-f', promptFile], { + cwd, + env: { ...process.env, CURSOR_API_KEY: cursorApiKey }, + stdout: 'pipe', + stderr: 'pipe', + }); + const timer = setTimeout(() => proc.kill(), timeoutMs); + const exitCode = await proc.exited; + clearTimeout(timer); + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + try { + unlinkSync(promptFile); + } + catch { } + return { + success: exitCode === 0, + output: stdout.trim() || stderr.trim(), + durationMs: Date.now() - start, + }; + }, + }; +} +/** + * Create a pure Role from an agent executor config. + * The Role runs: prepPrompt โ†’ agent โ†’ LLMโ‚‚ parse โ†’ { content, meta }. + */ +export function createAgentExecutorRole(agent, llm, config) { + return async (chain, topicId) => { + // 1. LLMโ‚ prep (built into config.prepPrompt โ€” no LLM call needed for prompt templates) + const { prompt, cwd } = config.prepPrompt(chain, topicId); + // 2. Agent exec + const result = await agent.run(prompt, cwd); + // 3. LLMโ‚‚ parse meta + let meta; + try { + const resp = await llm.chat({ + messages: [ + { role: 'system', content: config.parseMeta.system }, + { role: 'user', content: result.output }, + ], + tools: [config.parseMeta.tool], + tool_choice: 'required', + }); + const toolCall = resp.tool_calls?.[0]; + if (toolCall) { + meta = config.parseMeta.parse(toolCall.function.arguments); + } + else { + meta = config.parseMeta.defaultMeta(result.output); + } + } + catch { + meta = config.parseMeta.defaultMeta(result.output); + } + return { content: result.output, meta }; + }; +} diff --git a/packages/pulse/src/workflows/roles/llm-role-factory.d.ts b/packages/pulse/src/workflows/roles/llm-role-factory.d.ts new file mode 100644 index 0000000..63db1c2 --- /dev/null +++ b/packages/pulse/src/workflows/roles/llm-role-factory.d.ts @@ -0,0 +1,63 @@ +/** + * LLM Role Factory โ€” shared "sandwich" pattern for all LLM-based roles. + * + * The "bread" is always the same: + * 1. Extract context from message chain + * 2. Build messages array + * 3. llm.chat({ messages, tools?, tool_choice? }) + * 4. Parse response โ†’ { content, meta } + * + * The "filling" is what differs per role: + * - buildMessages: chain โ†’ LLM messages + * - parseResponse: LLM response โ†’ { content, meta } + * - tools/tool_choice (optional) + * + * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) + */ +import type { LlmClient, LlmResponse } from '../../llm-client.js'; +import type { Role, RoleResult, WorkflowMessage } from '../workflow-type.js'; +export interface LlmRoleConfig> { + /** System prompt */ + systemPrompt: string; + /** Build user messages from the workflow chain. Default: last message content. */ + buildUserMessage?: (chain: WorkflowMessage[]) => string; + /** Tool definitions for structured output */ + tools?: Array<{ + type: 'function'; + function: { + name: string; + description: string; + parameters: Record; + }; + }>; + /** Tool choice strategy */ + toolChoice?: 'auto' | 'required'; + /** Parse the LLM response into role result. */ + parseResponse: (resp: LlmResponse, chain: WorkflowMessage[]) => RoleResult; +} +/** + * Create a reusable LLM role from config. + * All LLM roles share the same call skeleton โ€” only the filling differs. + */ +export declare function createLlmRole>(llm: LlmClient, config: LlmRoleConfig): Role; +export interface ToolRoleConfig, TToolResult = unknown> { + systemPrompt: string; + buildUserMessage?: (chain: WorkflowMessage[]) => string; + tool: { + type: 'function'; + function: { + name: string; + description: string; + parameters: Record; + }; + }; + /** Default result if tool call parsing fails */ + defaultResult: TToolResult; + /** Convert parsed tool result to role result */ + toRoleResult: (parsed: TToolResult, chain: WorkflowMessage[]) => RoleResult; +} +/** + * Create an LLM role that uses tool_choice: required for structured output. + * Handles tool_call parsing and fallback automatically. + */ +export declare function createToolRole, TToolResult = unknown>(llm: LlmClient, config: ToolRoleConfig): Role; diff --git a/packages/pulse/src/workflows/roles/llm-role-factory.js b/packages/pulse/src/workflows/roles/llm-role-factory.js new file mode 100644 index 0000000..533b098 --- /dev/null +++ b/packages/pulse/src/workflows/roles/llm-role-factory.js @@ -0,0 +1,59 @@ +/** + * LLM Role Factory โ€” shared "sandwich" pattern for all LLM-based roles. + * + * The "bread" is always the same: + * 1. Extract context from message chain + * 2. Build messages array + * 3. llm.chat({ messages, tools?, tool_choice? }) + * 4. Parse response โ†’ { content, meta } + * + * The "filling" is what differs per role: + * - buildMessages: chain โ†’ LLM messages + * - parseResponse: LLM response โ†’ { content, meta } + * - tools/tool_choice (optional) + * + * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) + */ +/** + * Create a reusable LLM role from config. + * All LLM roles share the same call skeleton โ€” only the filling differs. + */ +export function createLlmRole(llm, config) { + return async (chain, _topicId, _store) => { + const userMessage = config.buildUserMessage + ? config.buildUserMessage(chain) + : (chain[chain.length - 1]?.content ?? ''); + const resp = await llm.chat({ + messages: [ + { role: 'system', content: config.systemPrompt }, + { role: 'user', content: userMessage }, + ], + tools: config.tools, + tool_choice: config.toolChoice, + }); + return config.parseResponse(resp, chain); + }; +} +/** + * Create an LLM role that uses tool_choice: required for structured output. + * Handles tool_call parsing and fallback automatically. + */ +export function createToolRole(llm, config) { + return createLlmRole(llm, { + systemPrompt: config.systemPrompt, + buildUserMessage: config.buildUserMessage, + tools: [config.tool], + toolChoice: 'required', + parseResponse: (resp, chain) => { + const toolCall = resp.tool_calls?.find((tc) => tc.function.name === config.tool.function.name); + let parsed = config.defaultResult; + if (toolCall) { + try { + parsed = JSON.parse(toolCall.function.arguments); + } + catch { } + } + return config.toRoleResult(parsed, chain); + }, + }); +} diff --git a/packages/pulse/src/workflows/roles/meta-checker.d.ts b/packages/pulse/src/workflows/roles/meta-checker.d.ts new file mode 100644 index 0000000..3c9dcf7 --- /dev/null +++ b/packages/pulse/src/workflows/roles/meta-checker.d.ts @@ -0,0 +1,26 @@ +/** + * Meta Checker role โ€” validates coder output before e2e testing. + * + * Checks: + * 1. All changed files are within the allowed directory (engine dir) + * 2. No modifications to pulse core files + * 3. Build succeeds + * 4. Unit tests pass + * + * Pure code, no LLM. + * + * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) + */ +import type { Role } from '../workflow-type.js'; +export interface MetaCheckerMeta { + [key: string]: unknown; + pass: boolean; + reason: string; + violations?: string[]; +} +export declare function createMetaCheckerRole(opts: { + /** Engine repo directory (allowed file scope) */ + engineDir: string; + /** Extra allowed path prefixes (optional) */ + allowedPrefixes?: string[]; +}): Role; diff --git a/packages/pulse/src/workflows/roles/meta-checker.js b/packages/pulse/src/workflows/roles/meta-checker.js new file mode 100644 index 0000000..1c81079 --- /dev/null +++ b/packages/pulse/src/workflows/roles/meta-checker.js @@ -0,0 +1,107 @@ +/** + * Meta Checker role โ€” validates coder output before e2e testing. + * + * Checks: + * 1. All changed files are within the allowed directory (engine dir) + * 2. No modifications to pulse core files + * 3. Build succeeds + * 4. Unit tests pass + * + * Pure code, no LLM. + * + * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) + */ +import { execSync } from 'node:child_process'; +export function createMetaCheckerRole(opts) { + return async (_chain) => { + const cwd = opts.engineDir; + const exec = (cmd) => execSync(cmd, { cwd, encoding: 'utf-8', timeout: 30_000 }).trim(); + const violations = []; + // 1. Check git diff โ€” what files did coder change? + let changedFiles = []; + try { + // Get all uncommitted changes + last commit changes + const diffOutput = exec('git diff --name-only HEAD~1 HEAD 2>/dev/null || git diff --name-only'); + changedFiles = diffOutput.split('\n').filter(Boolean); + } + catch { + // No git history โ€” check working tree + try { + const statusOutput = exec('git status --porcelain'); + changedFiles = statusOutput + .split('\n') + .filter(Boolean) + .map((line) => line.slice(3).trim()); + } + catch { + // Can't check โ€” skip file validation + } + } + if (changedFiles.length > 0) { + // Allowed: src/workflows/*, docs/*, tests under src/ + const allowedPrefixes = [ + 'src/', + 'docs/', + 'test/', + 'tests/', + ...(opts.allowedPrefixes ?? []), + ]; + for (const file of changedFiles) { + const allowed = allowedPrefixes.some((prefix) => file.startsWith(prefix)); + if (!allowed) { + violations.push(`่ถŠ็•Œๆ–‡ไปถ: ${file}๏ผˆๅชๅ…่ฎธไฟฎๆ”น ${allowedPrefixes.join(', ')} ไธ‹็š„ๆ–‡ไปถ๏ผ‰`); + } + } + // Blacklist: never touch these even if under src/ + const blacklist = [ + 'package.json', + 'tsconfig.json', + 'bun.lockb', + '.gitignore', + ]; + for (const file of changedFiles) { + const basename = file.split('/').pop() ?? file; + if (blacklist.includes(basename) && !file.startsWith('src/workflows/')) { + violations.push(`็ฆๆญขไฟฎๆ”น: ${file}`); + } + } + } + if (violations.length > 0) { + return { + content: `็บฆๆŸๆฃ€ๆŸฅๅคฑ่ดฅ\n\n${violations.map((v) => `โŒ ${v}`).join('\n')}\n\nไฟฎๆ”น็š„ๆ–‡ไปถ:\n${changedFiles.map((f) => ` ${f}`).join('\n')}`, + meta: { pass: false, reason: 'ๆ–‡ไปถ่Œƒๅ›ด่ถŠ็•Œ', violations }, + }; + } + // 2. Build check + try { + exec('bun run build 2>&1'); + } + catch (err) { + return { + content: `็ผ–่ฏ‘ๅคฑ่ดฅ\n\n---\n${(err.stdout ?? err.message).slice(0, 2000)}`, + meta: { pass: false, reason: '็ผ–่ฏ‘ๅคฑ่ดฅ' }, + }; + } + // 3. Unit test check + try { + const testOutput = exec('bun test src/workflows/ 2>&1'); + // Check for failures + if (testOutput.includes('fail') && !testOutput.includes('0 fail')) { + return { + content: `ๅ•ๅ…ƒๆต‹่ฏ•ๅคฑ่ดฅ\n\n---\n${testOutput.slice(-2000)}`, + meta: { pass: false, reason: 'ๅ•ๅ…ƒๆต‹่ฏ•ๅคฑ่ดฅ' }, + }; + } + } + catch (err) { + return { + content: `ๅ•ๅ…ƒๆต‹่ฏ•ๅคฑ่ดฅ\n\n---\n${(err.stdout ?? err.message).slice(0, 2000)}`, + meta: { pass: false, reason: 'ๅ•ๅ…ƒๆต‹่ฏ•ๅคฑ่ดฅ' }, + }; + } + return { + content: `็บฆๆŸๆฃ€ๆŸฅ้€š่ฟ‡\n\nไฟฎๆ”นๆ–‡ไปถ: ${changedFiles.length} ไธช\n็ผ–่ฏ‘: โœ…\nๅ•ๅ…ƒๆต‹่ฏ•: โœ…`, + meta: { pass: true, reason: '็บฆๆŸๆฃ€ๆŸฅ้€š่ฟ‡' }, + }; + }; +} diff --git a/packages/pulse/src/workflows/roles/meta-coder-cursor.d.ts b/packages/pulse/src/workflows/roles/meta-coder-cursor.d.ts new file mode 100644 index 0000000..aba0ae4 --- /dev/null +++ b/packages/pulse/src/workflows/roles/meta-coder-cursor.d.ts @@ -0,0 +1,11 @@ +/** + * Meta Coder role โ€” uses Cursor Agent to implement workflow code. + * Uses createAgentExecutorRole with LLMโ‚‚ meta parsing. + * + * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) + */ +import type { LlmClient } from '../../llm-client.js'; +import type { MetaCoderMeta } from '../meta.js'; +import type { Role } from '../workflow-type.js'; +import { type AgentRunner } from './agent-executor.js'; +export declare function createMetaCoderRole(runner: AgentRunner, llm: LlmClient, repoDir: string): Role; diff --git a/packages/pulse/src/workflows/roles/meta-coder-cursor.js b/packages/pulse/src/workflows/roles/meta-coder-cursor.js new file mode 100644 index 0000000..5f0d73a --- /dev/null +++ b/packages/pulse/src/workflows/roles/meta-coder-cursor.js @@ -0,0 +1,71 @@ +/** + * Meta Coder role โ€” uses Cursor Agent to implement workflow code. + * Uses createAgentExecutorRole with LLMโ‚‚ meta parsing. + * + * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) + */ +import { createAgentExecutorRole } from './agent-executor.js'; +const PARSE_META_TOOL = { + type: 'function', + function: { + name: 'extract_coder_meta', + description: 'Extract coder execution metadata', + parameters: { + type: 'object', + properties: { + filesChanged: { + type: 'array', + items: { type: 'string' }, + description: 'Files created or modified', + }, + testsPassed: { + type: 'boolean', + description: 'Whether all tests passed', + }, + }, + required: ['filesChanged', 'testsPassed'], + }, + }, +}; +export function createMetaCoderRole(runner, llm, repoDir) { + return createAgentExecutorRole(runner, llm, { + prepPrompt: (chain, _topicId) => { + const startMsg = chain.find((m) => m.role === '__start__'); + const taskDescription = startMsg?.content ?? ''; + // ๅฆ‚ๆžœๆœ‰ tester ๅคฑ่ดฅๅ้ฆˆ๏ผŒ้™„ๅŠ  + const testerMsg = [...chain].reverse().find((m) => m.role === 'tester'); + const testerFeedback = testerMsg ? `\n\n## ไธŠๆฌก้ชŒ่ฏๅคฑ่ดฅ\n${testerMsg.content}` : ''; + const prompt = `# ไปปๅŠก +${taskDescription} +${testerFeedback} + +## ๅ‚่€ƒ +- ๅ…ˆ้˜…่ฏป้กน็›ฎ็ป“ๆž„ไบ†่งฃไธŠไธ‹ๆ–‡ +- ๅ‚่€ƒๅทฒๆœ‰ไปฃ็ ้ฃŽๆ ผ + +## ๆญฅ้ชค +1. ็†่งฃไปปๅŠก้œ€ๆฑ‚ +2. ๅ†™ไปฃ็ ๅฎž็Žฐ +3. ่ฟ่กŒ \`bun run build\` ็กฎ่ฎค็ผ–่ฏ‘้€š่ฟ‡ +4. ่ฟ่กŒๆต‹่ฏ•็กฎ่ฎค้€š่ฟ‡ +5. ไธ่ฆ commit๏ผŒ่ฎฉ workflow ๅค„็† + +## ็บฆๆŸ +- commit author: ๅฐๆฉ˜ +- ไธไฟฎๆ”น workflow-rule-adapter.ts ๅ’Œ workflow-type.ts`; + return { prompt, cwd: repoDir }; + }, + parseMeta: { + system: 'ไปŽ Cursor Agent ็š„่พ“ๅ‡บไธญๆๅ– coder ๆ‰ง่กŒ็ป“ๆžœใ€‚', + tool: PARSE_META_TOOL, + parse: (args) => { + const parsed = JSON.parse(args); + return { + filesChanged: parsed.filesChanged ?? [], + testsPassed: parsed.testsPassed ?? false, + }; + }, + defaultMeta: (_output) => ({ filesChanged: [], testsPassed: false }), + }, + }); +} diff --git a/packages/pulse/src/workflows/roles/meta-tester.d.ts b/packages/pulse/src/workflows/roles/meta-tester.d.ts new file mode 100644 index 0000000..f0bfb54 --- /dev/null +++ b/packages/pulse/src/workflows/roles/meta-tester.d.ts @@ -0,0 +1,20 @@ +/** + * Meta Tester role โ€” e2e verification. + * + * Dynamic-imports the workflow coder just wrote, spins up a temp store, + * ticks through a full lifecycle, and checks it reaches __end__. + * On pass: git commit + push. On fail: back to coder. + * + * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) + */ +import type { MetaTesterMeta } from '../meta.js'; +import type { Role } from '../workflow-type.js'; +export declare function createMetaTesterRole(opts: { + repoDir: string; + /** git remote (auto-detect if omitted) */ + remote?: string; + /** git branch (default: main) */ + branch?: string; + /** max ticks for e2e run (default: 100๏ผ›ไธ‹้™ 20 ไปฅ่ฆ†็›– ping-pong ้™้ป˜ + ่พƒ้•ฟ workflow) */ + maxTicks?: number; +}): Role; diff --git a/packages/pulse/src/workflows/roles/meta-tester.js b/packages/pulse/src/workflows/roles/meta-tester.js new file mode 100644 index 0000000..7b60559 --- /dev/null +++ b/packages/pulse/src/workflows/roles/meta-tester.js @@ -0,0 +1,211 @@ +/** + * Meta Tester role โ€” e2e verification. + * + * Dynamic-imports the workflow coder just wrote, spins up a temp store, + * ticks through a full lifecycle, and checks it reaches __end__. + * On pass: git commit + push. On fail: back to coder. + * + * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) + */ +import { execSync } from 'node:child_process'; +import { existsSync, mkdtempSync, readdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createStore } from '../../store.js'; +import { createWorkflowRule } from '../workflow-rule-adapter.js'; +export function createMetaTesterRole(opts) { + const branch = opts.branch ?? 'main'; + const maxTicks = Math.max(opts.maxTicks ?? 100, 20); + return async (chain) => { + const cwd = opts.repoDir; + // Step 0: Skip build โ€” checker already verified build + unit tests + // Go straight to e2e verification + // Step 1: Discover workflow files in engine src/workflows/ + const workflowDir = join(cwd, 'src', 'workflows'); + if (!existsSync(workflowDir)) { + return { + content: 'src/workflows/ ็›ฎๅฝ•ไธๅญ˜ๅœจ', + meta: { pass: false, reason: 'src/workflows/ not found' }, + }; + } + const files = readdirSync(workflowDir).filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts')); + // Step 2: Dynamic import each workflow, find WorkflowType exports + const workflows = []; + const importErrors = []; + for (const file of files) { + const fullPath = join(workflowDir, file); + try { + const mod = await import(fullPath); + for (const [key, val] of Object.entries(mod)) { + // Direct WorkflowType export (e.g. `export const pingPong: WorkflowType`) + if (val && typeof val === 'object' && 'name' in val && 'roles' in val && 'moderator' in val) { + workflows.push(val); + } + // Factory function (e.g. `export function createWerewolfWorkflow()`) + if (typeof val === 'function' && key.startsWith('create') && key.endsWith('Workflow')) { + try { + // Call with no args first (mock mode) + const wf = val(); + if (wf && typeof wf === 'object' && 'name' in wf && 'roles' in wf && 'moderator' in wf) { + workflows.push(wf); + } + } + catch { + // Factory needs args โ€” skip (can't auto-test) + } + } + } + } + catch (err) { + importErrors.push(`${file}: ${err.message}`); + } + } + if (workflows.length === 0) { + return { + content: `ๆฒกๆœ‰ๆ‰พๅˆฐๅฏๆต‹่ฏ•็š„ WorkflowType\n\nimport errors:\n${importErrors.join('\n')}`, + meta: { pass: false, reason: 'no testable workflows found' }, + }; + } + // Step 3: E2E โ€” for each workflow, create temp store, tick through lifecycle + const results = []; + for (const wf of workflows) { + const tmpDir = mkdtempSync(join(tmpdir(), `pulse-tester-${wf.name}-`)); + const testStore = createStore({ + eventsDbPath: join(tmpDir, 'events.db'), + objectsDir: join(tmpDir, 'objects'), + }); + try { + const rule = createWorkflowRule(wf, testStore); + // Seed __start__ + const taskContent = `e2e test for ${wf.name}`; + const hash = await testStore.putObject(taskContent); + await testStore.appendEvent({ + occurredAt: Date.now(), + kind: `${wf.name}.__start__`, + key: `e2e-test-${Date.now()}`, + hash, + }); + // Tick until workflow quiesces (executed=[]) or maxTicks + let completed = false; + let tickCount = 0; + let lastError = null; + let didExecute = false; + for (let i = 0; i < maxTicks; i++) { + tickCount++; + try { + const r = await rule.tick(); + if (r.executed.length === 0) { + // No more work โ€” if we executed at least once, workflow is done + if (didExecute) { + completed = true; + } + break; + } + didExecute = true; + } + catch (err) { + lastError = err.message; + break; + } + } + // Gather diagnostic info on failure + let diagnostic = ''; + if (!completed) { + try { + const allEvents = await testStore.getAfter(0); + const wfEvents = allEvents.filter((ev) => ev.kind.startsWith(`${wf.name}.`)); + const lastEvent = wfEvents[wfEvents.length - 1]; + const roles = wfEvents.map((ev) => ev.kind.replace(`${wf.name}.`, '')).join(' โ†’ '); + diagnostic = `\n ไบ‹ไปถ้“พ: ${roles}`; + if (lastEvent) { + diagnostic += `\n ๆœ€ๅŽไบ‹ไปถ: ${lastEvent.kind} (id=${lastEvent.id})`; + if (lastEvent.meta) { + try { + const meta = JSON.parse(lastEvent.meta); + diagnostic += `\n ๆœ€ๅŽ meta: ${JSON.stringify(meta)}`; + } + catch { } + } + } + diagnostic += `\n ๆ€ปไบ‹ไปถๆ•ฐ: ${wfEvents.length}`; + } + catch { } + } + if (completed) { + results.push({ name: wf.name, pass: true, detail: `completed in ${tickCount} ticks` }); + } + else if (lastError) { + results.push({ name: wf.name, pass: false, detail: `error: ${lastError}${diagnostic}` }); + } + else { + results.push({ name: wf.name, pass: false, detail: `did not complete in ${tickCount} ticks${diagnostic}` }); + } + } + finally { + await testStore.close(); + } + } + const allPass = results.every((r) => r.pass); + const summary = results + .map((r) => `${r.pass ? 'โœ…' : 'โŒ'} ${r.name}: ${r.detail}`) + .join('\n'); + if (!allPass) { + return { + content: `e2e ้ชŒ่ฏๅคฑ่ดฅ\n\n${summary}`, + meta: { pass: false, reason: 'e2e verification failed' }, + }; + } + // Step 4: All pass โ€” commit + push + let commitHash; + let pushed; + const exec = (cmd) => execSync(cmd, { cwd, encoding: 'utf-8', timeout: 30_000 }).trim(); + try { + const startMsg = chain.find((m) => m.role === '__start__'); + const firstLine = (startMsg?.content ?? '').split('\n')[0].slice(0, 60); + const commitMsg = firstLine || 'meta workflow auto-commit'; + exec('git add -A'); + // Check if there's anything to commit + try { + exec('git diff --cached --quiet'); + // No changes โ€” still pass, just no commit needed + commitHash = exec('git rev-parse --short HEAD'); + pushed = false; + } + catch { + // There are staged changes + exec(`git commit -m "${commitMsg}" --author="ๅฐๆฉ˜ "`); + commitHash = exec('git rev-parse --short HEAD'); + // Auto-detect remote + const remote = opts.remote ?? (() => { + try { + const remotes = exec('git remote').split('\n').filter(Boolean); + return remotes[0] || null; + } + catch { + return null; + } + })(); + if (remote) { + try { + exec(`git push ${remote} ${branch} --no-verify`); + pushed = true; + } + catch { + pushed = false; + } + } + else { + pushed = false; + } + } + } + catch (err) { + commitHash = undefined; + pushed = false; + } + return { + content: `e2e ้ชŒ่ฏ้€š่ฟ‡\n\n${summary}\n\nCommit: ${commitHash ?? 'none'}\nPushed: ${pushed ? 'yes' : 'no'}`, + meta: { pass: true, reason: 'e2e verification passed', commitHash, pushed }, + }; + }; +} diff --git a/packages/pulse/src/workflows/scaffold.d.ts b/packages/pulse/src/workflows/scaffold.d.ts new file mode 100644 index 0000000..ceafde0 --- /dev/null +++ b/packages/pulse/src/workflows/scaffold.d.ts @@ -0,0 +1,18 @@ +/** + * Workflow scaffold generator โ€” creates default workflow skeleton files. + * + * Usage: `upulse workflow init ` + * Generates: + * workflows/{name}.ts โ€” moderator + meta types (STARTโ†’END) + * workflows/{name}.test.ts โ€” test shell + * workflows/roles/{name}-*.ts โ€” empty role stubs (if roles specified) + * updates workflows/index.ts โ€” barrel exports + * + * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) + */ +export interface ScaffoldOptions { + name: string; + roles?: string[]; + workflowsDir: string; +} +export declare function scaffoldWorkflow(opts: ScaffoldOptions): string[]; diff --git a/packages/pulse/src/workflows/scaffold.js b/packages/pulse/src/workflows/scaffold.js new file mode 100644 index 0000000..2f0a2b8 --- /dev/null +++ b/packages/pulse/src/workflows/scaffold.js @@ -0,0 +1,179 @@ +/** + * Workflow scaffold generator โ€” creates default workflow skeleton files. + * + * Usage: `upulse workflow init ` + * Generates: + * workflows/{name}.ts โ€” moderator + meta types (STARTโ†’END) + * workflows/{name}.test.ts โ€” test shell + * workflows/roles/{name}-*.ts โ€” empty role stubs (if roles specified) + * updates workflows/index.ts โ€” barrel exports + * + * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) + */ +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +export function scaffoldWorkflow(opts) { + const { name, roles = ['processor'], workflowsDir } = opts; + const created = []; + const rolesDir = join(workflowsDir, 'roles'); + mkdirSync(rolesDir, { recursive: true }); + const pascal = name.charAt(0).toUpperCase() + name.slice(1); + // โ”€โ”€ workflow definition โ”€โ”€ + const metaTypes = roles + .map((r) => `export interface ${pascal}${r.charAt(0).toUpperCase() + r.slice(1)}Meta { + [key: string]: unknown; + // TODO: define meta fields +}`) + .join('\n\n'); + const rolesType = roles + .map((r) => ` ${r}: Role<${pascal}${r.charAt(0).toUpperCase() + r.slice(1)}Meta>;`) + .join('\n'); + const moderatorCases = roles + .map((r, i) => { + const next = i < roles.length - 1 ? `'${roles[i + 1]}'` : 'END'; + if (i === 0) + return ` case START:\n return '${r}';`; + return ` case '${roles[i - 1]}':\n return '${r}';`; + }) + .join('\n'); + const lastRole = roles[roles.length - 1]; + const workflowFile = join(workflowsDir, `${name}.ts`); + if (!existsSync(workflowFile)) { + writeFileSync(workflowFile, `/** + * ${pascal} Workflow โ€” auto-generated scaffold. + * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) + */ + +import { + END, + type ModeratorInput, + type Role, + START, + type WorkflowType, +} from './workflow-type.js'; + +// โ”€โ”€ Meta Types โ”€โ”€ + +${metaTypes} + +// โ”€โ”€ Roles Type โ”€โ”€ + +export type ${pascal}Roles = { +${rolesType} +}; + +// โ”€โ”€ Moderator โ”€โ”€ + +function ${name}Moderator( + input: ModeratorInput<${pascal}Roles>, + _topicId: string, +): (keyof ${pascal}Roles & string) | typeof END { + switch (input.role) { +${moderatorCases} + case '${lastRole}': + return END; + default: + return END; + } +} + +// โ”€โ”€ Factory โ”€โ”€ + +export function create${pascal}Workflow( + roles: ${pascal}Roles, +): WorkflowType<${pascal}Roles> { + return { + name: '${name}', + roles, + moderator: ${name}Moderator, + }; +} +`); + created.push(workflowFile); + } + // โ”€โ”€ test file โ”€โ”€ + const testFile = join(workflowsDir, `${name}.test.ts`); + if (!existsSync(testFile)) { + writeFileSync(testFile, `/** + * ${pascal} Workflow โ€” tests. + * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) + */ + +import { describe, expect, test } from 'bun:test'; +import { createStore } from '../store.js'; +import { create${pascal}Workflow } from './${name}.js'; +import { createWorkflowRule } from './workflow-rule-adapter.js'; +import { END, START } from './workflow-type.js'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +function mockStore() { + const dir = mkdtempSync(join(tmpdir(), '${name}-test-')); + return createStore({ eventsDbPath: join(dir, 'events.db'), objectsDir: join(dir, 'objects') }); +} + +describe('${pascal} Workflow', () => { + test('moderator routes happy path', () => { + const wf = create${pascal}Workflow({ +${roles.map((r) => ` ${r}: async () => ({ content: 'ok', meta: {} }),`).join('\n')} + }); + + expect(wf.moderator({ role: START, meta: null }, 'x')).toBe('${roles[0]}'); + expect(wf.moderator({ role: '${lastRole}', meta: {} }, 'x')).toBe(END); + }); + + test('full run via adapter', async () => { + const store = mockStore(); + const wf = create${pascal}Workflow({ +${roles.map((r) => ` ${r}: async () => ({ content: '${r} done', meta: {} }),`).join('\n')} + }); + + const rule = createWorkflowRule(wf, store); + const hash = store.putObject('test input'); + store.appendEvent({ occurredAt: Date.now(), kind: '${name}.__start__', key: 'test-1', hash }); + + const executed: string[] = []; + for (let i = 0; i < ${roles.length + 2}; i++) { + const r = await rule.tick(); + if (r.executed.length === 0) break; + executed.push(...r.executed.map((a) => a.role)); + } + + expect(executed).toEqual([${roles.map((r) => `'${r}'`).join(', ')}]); + store.close(); + }); +}); +`); + created.push(testFile); + } + // โ”€โ”€ role stubs โ”€โ”€ + for (const role of roles) { + const roleFile = join(rolesDir, `${name}-${role}.ts`); + if (!existsSync(roleFile)) { + const metaName = `${pascal}${role.charAt(0).toUpperCase() + role.slice(1)}Meta`; + writeFileSync(roleFile, `/** + * ${pascal} ${role} role โ€” TODO: implement. + * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) + */ + +import type { ${metaName} } from '../${name}.js'; +import type { Role, RoleResult, WorkflowMessage } from '../workflow-type.js'; + +export function create${pascal}${role.charAt(0).toUpperCase() + role.slice(1)}Role( + // TODO: inject dependencies (llm, runner, etc.) +): Role<${metaName}> { + return async (chain: WorkflowMessage[]): Promise> => { + // TODO: implement + return { + content: 'not implemented', + meta: {} as ${metaName}, + }; + }; +} +`); + created.push(roleFile); + } + } + return created; +} diff --git a/packages/pulse/src/workflows/werewolf.test.ts b/packages/pulse/src/workflows/werewolf.test.ts deleted file mode 100644 index c484783..0000000 --- a/packages/pulse/src/workflows/werewolf.test.ts +++ /dev/null @@ -1,280 +0,0 @@ -/** - * Werewolf (็‹ผไบบๆ€) WorkflowType tests. - * - * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) - */ - -import { describe, expect, it } from 'bun:test'; -import { mkdtempSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { createStore, type PulseStore } from '../store.js'; -import { - createWerewolfWorkflow, - createPlayers, - parseGameState, - filterChainForPlayer, - checkGameOver, - type GameState, - type WolfNightMeta, - type SeerCheckMeta, - type WitchActionMeta, - type DaySpeechMeta, - type VoteMeta, - type HunterShotMeta, - type GameEndMeta, -} from './werewolf.js'; -import { createWorkflowRule } from './workflow-rule-adapter.js'; -import { END, START, type WorkflowMessage, type Role } from './workflow-type.js'; - -describe('werewolf WorkflowType', () => { - let store: PulseStore; - let tmpDir: string; - - function setup() { - tmpDir = mkdtempSync(join(tmpdir(), 'werewolf-')); - store = createStore({ - eventsDbPath: join(tmpDir, 'test.db'), - objectsDir: join(tmpDir, 'objects'), - }); - } - - function cleanup() { - try { - store?.close(); - } catch {} - if (tmpDir) rmSync(tmpDir, { recursive: true, force: true }); - } - - async function trigger(topicId: string) { - const hash = await store.putObject('werewolf game start'); - await store.appendEvent({ - occurredAt: Date.now(), - kind: 'werewolf.__start__', - key: topicId, - hash, - meta: JSON.stringify({}), - }); - } - - it('mock ๆจกๅผ่ƒฝ่ท‘ๅฎŒๆ•ดๅฑ€', async () => { - setup(); - try { - const wf = createWerewolfWorkflow(); - const rule = createWorkflowRule(wf, store); - await trigger('g1'); - - const order: string[] = []; - // Max 200 ticks to allow the game to finish - for (let i = 0; i < 200; i++) { - const r = await rule.tick(); - if (r.executed.length === 0) break; - order.push(...r.executed.map((x) => x.role)); - } - - // Game must have started and ended - expect(order[0]).toBe('wolf-night'); - expect(order[order.length - 1]).toBe('game-end'); - expect(order.length).toBeGreaterThanOrEqual(6); // at least one full cycle + game-end - } finally { - cleanup(); - } - }); - - it('้˜ถๆฎตๆญฃ็กฎ่ฝฎ่ฝฌ', () => { - const wf = createWerewolfWorkflow(); - - // START โ†’ wolf-night - expect(wf.moderator({ role: START, meta: null }, 'x')).toBe('wolf-night'); - - // wolf-night โ†’ seer-check - expect(wf.moderator( - { role: 'wolf-night', meta: { phase: 'wolf-night', targetId: 'p7' } }, - 'x', - )).toBe('seer-check'); - - // seer-check โ†’ witch-action - expect(wf.moderator( - { role: 'seer-check', meta: { phase: 'seer-check', targetId: 'p1', isWolf: true, visibleTo: ['p4'] } }, - 'x', - )).toBe('witch-action'); - - // witch-action (no game over) โ†’ day-speech - expect(wf.moderator( - { role: 'witch-action', meta: { phase: 'witch-action', saved: false, poisonTarget: null, visibleTo: ['p5'], witchPotion: true, witchPoison: true, gameOver: false } as any }, - 'x', - )).toBe('day-speech'); - - // day-speech โ†’ vote - expect(wf.moderator( - { role: 'day-speech', meta: { phase: 'day-speech', speeches: [] } }, - 'x', - )).toBe('vote'); - - // vote (no game over, no hunter) โ†’ wolf-night (next cycle) - expect(wf.moderator( - { role: 'vote', meta: { phase: 'vote', votes: {}, eliminatedId: 'p7', gameOver: false, hunterTriggered: false } as any }, - 'x', - )).toBe('wolf-night'); - }); - - it('็‹ผไบบๅ…จๆญป โ†’ ๅฅฝไบบ่ƒœ', () => { - const players = createPlayers(); - // 3 wolves are p1, p2, p3 - const wolves = players.filter(p => p.identity.team === 'wolf'); - expect(wolves.length).toBe(3); - - // All wolves dead - const aliveWithoutWolves = players.filter(p => p.identity.team === 'good'); - expect(checkGameOver(aliveWithoutWolves)).toBe(true); - - // Verify via moderator: vote with gameOver=true โ†’ game-end - const wf = createWerewolfWorkflow(); - expect(wf.moderator( - { role: 'vote', meta: { phase: 'vote', votes: {}, eliminatedId: 'p3', gameOver: true } as any }, - 'x', - )).toBe('game-end'); - - // game-end โ†’ END - expect(wf.moderator( - { role: 'game-end', meta: { phase: 'game-end', winner: 'good', summary: '' } }, - 'x', - )).toBe(END); - }); - - it('ๅฅฝไบบๆ•ฐ <= ็‹ผไบบๆ•ฐ โ†’ ็‹ผไบบ่ƒœ', () => { - const players = createPlayers(); - // 3 wolves + 1 good alive โ†’ wolves >= good โ†’ game over - const alive = [ - players[0], // wolf - players[1], // wolf - players[2], // wolf - players[6], // villager - ]; - expect(checkGameOver(alive)).toBe(true); - - // 3 wolves + 2 good โ†’ wolves >= good-wolves โ†’ still over - const alive2 = [ - players[0], players[1], players[2], // 3 wolves - players[6], players[7], // 2 good - ]; - // 3 >= 2 โ†’ true - expect(checkGameOver(alive2)).toBe(true); - - // 3 wolves + 4 good โ†’ not over - const alive3 = [ - players[0], players[1], players[2], // 3 wolves - players[3], players[4], players[5], players[6], // 4 good - ]; - expect(checkGameOver(alive3)).toBe(false); - }); - - it('ไฟกๆฏๅฏ่งๆ€งๆญฃ็กฎ', () => { - const players = createPlayers(); - const wolf = players[0]; // p1, wolf - const seer = players[3]; // p4, ้ข„่จ€ๅฎถ - const villager = players[6]; // p7, ๆ‘ๆฐ‘ - - const chain: WorkflowMessage[] = [ - { - role: 'wolf-night', - content: '็‹ผไบบๆ€ไบบ', - meta: { phase: 'wolf-night', targetId: 'p7' }, - timestamp: 1, - }, - { - role: 'seer-check', - content: '้ข„่จ€ๅฎถ้ชŒไบบ', - meta: { phase: 'seer-check', targetId: 'p1', isWolf: true, visibleTo: ['p4'] }, - timestamp: 2, - }, - { - role: 'witch-action', - content: 'ๅฅณๅทซ่กŒๅŠจ', - meta: { phase: 'witch-action', saved: false, poisonTarget: null, visibleTo: ['p5'] }, - timestamp: 3, - }, - { - role: 'day-speech', - content: '็™ฝๅคฉๅ‘่จ€', - meta: { phase: 'day-speech', speeches: [] }, - timestamp: 4, - }, - { - role: 'vote', - content: 'ๆŠ•็ฅจ', - meta: { phase: 'vote', votes: {}, eliminatedId: 'p8' }, - timestamp: 5, - }, - ]; - - // Wolf can see wolf-night, day-speech, vote - const wolfView = filterChainForPlayer(chain, wolf.id, wolf.identity); - expect(wolfView.some(m => (m.meta as any)?.phase === 'wolf-night')).toBe(true); - expect(wolfView.some(m => (m.meta as any)?.phase === 'seer-check')).toBe(false); - expect(wolfView.some(m => (m.meta as any)?.phase === 'witch-action')).toBe(false); - expect(wolfView.some(m => (m.meta as any)?.phase === 'day-speech')).toBe(true); - expect(wolfView.some(m => (m.meta as any)?.phase === 'vote')).toBe(true); - - // Seer can see seer-check but not wolf-night or witch-action - const seerView = filterChainForPlayer(chain, seer.id, seer.identity); - expect(seerView.some(m => (m.meta as any)?.phase === 'seer-check')).toBe(true); - expect(seerView.some(m => (m.meta as any)?.phase === 'wolf-night')).toBe(false); - expect(seerView.some(m => (m.meta as any)?.phase === 'witch-action')).toBe(false); - - // Villager can only see day-speech and vote - const villagerView = filterChainForPlayer(chain, villager.id, villager.identity); - expect(villagerView.some(m => (m.meta as any)?.phase === 'wolf-night')).toBe(false); - expect(villagerView.some(m => (m.meta as any)?.phase === 'seer-check')).toBe(false); - expect(villagerView.some(m => (m.meta as any)?.phase === 'witch-action')).toBe(false); - expect(villagerView.some(m => (m.meta as any)?.phase === 'day-speech')).toBe(true); - expect(villagerView.some(m => (m.meta as any)?.phase === 'vote')).toBe(true); - }); - - it('็ŒŽไบบ่ขซๆŠ•็ฅจๅ‡บๅฑ€่งฆๅ‘ๅผ€ๆžช', () => { - const wf = createWerewolfWorkflow(); - // Vote eliminates hunter โ†’ hunterTriggered - expect(wf.moderator( - { role: 'vote', meta: { phase: 'vote', votes: {}, eliminatedId: 'p6', gameOver: false, hunterTriggered: true } as any }, - 'x', - )).toBe('hunter-shot'); - }); - - it('ๅฅณๅทซ็”จๅฎŒ่ฏๅŽไธ่ƒฝๅ†็”จ', () => { - const chain: WorkflowMessage[] = [ - { - role: 'wolf-night', - content: '็‹ผไบบๆ€ p7', - meta: { phase: 'wolf-night', targetId: 'p7' }, - timestamp: 1, - }, - { - role: 'witch-action', - content: 'ๅฅณๅทซๆ•‘ไบบ', - meta: { phase: 'witch-action', saved: true, poisonTarget: null, visibleTo: ['p5'], witchPotion: false, witchPoison: true }, - timestamp: 2, - }, - ]; - - const state = parseGameState(chain); - // After using potion, witchPotion should be false - expect(state.witchPotion).toBe(false); - expect(state.witchPoison).toBe(true); - - // Second night, add another wolf kill - chain.push({ - role: 'wolf-night', - content: '็‹ผไบบๆ€ p8', - meta: { phase: 'wolf-night', targetId: 'p8' }, - timestamp: 3, - }); - - // Now create a mock witch role that respects potion state - // The mock witch checks state.witchPotion โ€” it should be false, so no save - const state2 = parseGameState(chain); - expect(state2.witchPotion).toBe(false); - expect(state2.lastKill).toBe('p8'); - // If witchPotion is false, the mock witch cannot save - // (The mock uses state.witchPotion in the condition) - }); -}); diff --git a/packages/pulse/tsconfig.json b/packages/pulse/tsconfig.json index 5f102d1..5047509 100644 --- a/packages/pulse/tsconfig.json +++ b/packages/pulse/tsconfig.json @@ -13,5 +13,5 @@ "types": ["bun-types"] }, "include": ["src"], - "exclude": ["src/**/*.test.ts"] + "exclude": ["src/**/*.test.ts", "src/e2e/**", "src/bin/**"] }