diff --git a/packages/pulse/src/adaptive-tick.test.ts b/packages/pulse/src/adaptive-tick.test.ts index 5ae5442..95af22e 100644 --- a/packages/pulse/src/adaptive-tick.test.ts +++ b/packages/pulse/src/adaptive-tick.test.ts @@ -6,7 +6,6 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { - composeRules, createStore, type PulseStore, type Rule, diff --git a/packages/pulse/src/bin/workflow-daemon.ts b/packages/pulse/src/bin/workflow-daemon.ts index 1eaf5dc..3adfb04 100644 --- a/packages/pulse/src/bin/workflow-daemon.ts +++ b/packages/pulse/src/bin/workflow-daemon.ts @@ -12,14 +12,14 @@ import { existsSync, mkdirSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; +import { + createAnalystRole, + createCodingWorkflow, + createRendererRole, + createReportWorkflow, +} from '@upulse/workflows'; import { createOpenAiLlmClient } from '../llm-client.js'; import { createStore } from '../store.js'; -import { - createCodingWorkflow, - createReportWorkflow, - createAnalystRole, - createRendererRole, -} from '@upulse/workflows'; import { createWorkflowTicker } from '../workflows/index.js'; import { createCursorRunner } from '../workflows/roles/agent-executor.js'; import { createWorkflowRule } from '../workflows/workflow-rule-adapter.js'; @@ -79,19 +79,19 @@ const codingRule = createWorkflowRule(codingWf, store, logStore); // 2. Meta workflow โ€” engine override > core fallback const metaMod = await tryLoadFromEngine( 'src/workflows/meta.ts', - () => import('../workflows/meta.js') + () => import('../workflows/meta.js'), ); const metaCoderMod = await tryLoadFromEngine( 'src/workflows/roles/meta-coder-cursor.ts', - () => import('../workflows/roles/meta-coder-cursor.js') + () => import('../workflows/roles/meta-coder-cursor.js'), ); const metaCheckerMod = await tryLoadFromEngine( 'src/workflows/roles/meta-checker.ts', - () => import('../workflows/roles/meta-checker.js') + () => import('../workflows/roles/meta-checker.js'), ); const metaTesterMod = await tryLoadFromEngine( 'src/workflows/roles/meta-tester.ts', - () => import('../workflows/roles/meta-tester.js') + () => import('../workflows/roles/meta-tester.js'), ); // Load gate role if available from engine @@ -129,7 +129,9 @@ console.log(` Store: ${DATA_DIR}/workflows.db`); console.log( ` Tick: adaptive ${BASE_TICK_MS / 1000}s โ†’ ${MAX_TICK_MS / 1000}s (ร—${BACKOFF_FACTOR})`, ); -console.log(` Engine override: ${existsSync(join(ENGINE_DIR, 'src/workflows/meta.ts')) ? 'YES' : 'no'}`); +console.log( + ` Engine override: ${existsSync(join(ENGINE_DIR, 'src/workflows/meta.ts')) ? 'YES' : 'no'}`, +); console.log(` Workflows: coding, meta, report`); console.log(''); @@ -180,7 +182,10 @@ scheduleNext(); /** * Try loading a workflow module from ENGINE_DIR, fallback to core package. */ -async function tryLoadFromEngine(engineRelPath: string, fallbackImport: () => Promise): Promise { +async function tryLoadFromEngine( + engineRelPath: string, + fallbackImport: () => Promise, +): Promise { const enginePath = join(ENGINE_DIR, engineRelPath); if (existsSync(enginePath)) { console.log(` ๐Ÿ“ฆ Loading from engine: ${engineRelPath}`); diff --git a/packages/pulse/src/defs.d.ts b/packages/pulse/src/defs.d.ts index 5574946..694095c 100644 --- a/packages/pulse/src/defs.d.ts +++ b/packages/pulse/src/defs.d.ts @@ -6,77 +6,104 @@ */ import type { Database } from 'bun:sqlite'; export interface ObjectDef { - name: string; - codeRev: string; - createdAt: number; + name: string; + codeRev: string; + createdAt: number; } export interface EventDef { - hash: string; - name: string; - parentHash?: string; - schema?: any; - codeRev: string; - createdAt: number; + 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; - }>; + 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; + 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: { +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: { + }, +): 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: { + }, +): 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: { + }, +): Promise; +export declare function registerProjectionDef( + db: Database, + opts: { name: string; params?: any; valueSchema?: any; initialValue: any; sources: Array<{ - eventKind: string; - eventKey?: string; - expression: string; + 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: { + }, +): Promise; +export declare function getProjectionDef( + db: Database, + name: string, + codeRev: string, +): Promise; +export declare function listProjectionDefs( + db: Database, + opts: { codeRev: string; -}): Promise; + }, +): Promise; export declare function validateExpression(opts: { - expression: string; - initialValue: any; - mockEvent: any; + expression: string; + initialValue: any; + mockEvent: any; }): Promise; diff --git a/packages/pulse/src/defs.js b/packages/pulse/src/defs.js deleted file mode 100644 index c91fdb7..0000000 --- a/packages/pulse/src/defs.js +++ /dev/null @@ -1,322 +0,0 @@ -/** - * @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 af6bfc9..8d78678 100644 --- a/packages/pulse/src/e2e/council-demo.ts +++ b/packages/pulse/src/e2e/council-demo.ts @@ -10,10 +10,9 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { createArchitectRole, createCodingWorkflow } from '@upulse/workflows'; import { createOpenAiLlmClient } from '../llm-client.js'; import { createStore } from '../store.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 a33ef85..11d1a8a 100644 --- a/packages/pulse/src/e2e/council-v2-live.ts +++ b/packages/pulse/src/e2e/council-v2-live.ts @@ -12,11 +12,13 @@ import { mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; +import { + createCoderRole, + createCodingWorkflow, + createReviewerRole, +} from '@upulse/workflows'; import { createStore } from '../index.js'; import { createOpenAiLlmClient } from '../llm-client.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 7bb47d0..3d6e1a7 100644 --- a/packages/pulse/src/e2e/report-live.ts +++ b/packages/pulse/src/e2e/report-live.ts @@ -13,11 +13,13 @@ import { mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { + createAnalystRole, + createRendererRole, + createReportWorkflow, +} from '@upulse/workflows'; import { createStore } from '../index.js'; import { createOpenAiLlmClient } from '../llm-client.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 a4339c0..e8be65a 100644 --- a/packages/pulse/src/e2e/t11-council-v2.test.ts +++ b/packages/pulse/src/e2e/t11-council-v2.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 { createCodingWorkflow } from '@upulse/workflows'; +import { createStore, type PulseStore } from '../store.js'; 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 0796a25..7a26f31 100644 --- a/packages/pulse/src/e2e/werewolf-live.ts +++ b/packages/pulse/src/e2e/werewolf-live.ts @@ -2,27 +2,25 @@ * ็‹ผไบบๆ€ LLM ๅฏนๆˆ˜ โ€” ๆŽฅ็œŸ LLM ่ท‘ไธ€ๅฑ€ * * bun run packages/pulse/src/e2e/werewolf-live.ts - * + * * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) */ -import { createScopedStore, createWorkflowTicker } from '../index.js'; import { - createWerewolfWorkflow, createPlayers, - parseGameState, - filterChainForPlayer, - type WolfNightMeta, - type SeerCheckMeta, - type WitchActionMeta, + createWerewolfWorkflow, type DaySpeechMeta, - type VoteMeta, - type HunterShotMeta, + filterChainForPlayer, type GameEndMeta, + type HunterShotMeta, type Player, - type Identity, - type GameState, + parseGameState, + type SeerCheckMeta, + type VoteMeta, + type WitchActionMeta, + type WolfNightMeta, } from '@upulse/workflows'; +import { createWorkflowTicker } from '../index.js'; import type { Role, WorkflowMessage } from '../workflows/workflow-type.js'; // โ”€โ”€ LLM Client โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -35,21 +33,27 @@ interface LlmMessage { content: string; } -async function callLlm(messages: LlmMessage[], temperature = 0.8): Promise { - const res = await fetch('https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${DASHSCOPE_API_KEY}`, - 'Content-Type': 'application/json', +async function callLlm( + messages: LlmMessage[], + temperature = 0.8, +): Promise { + const res = await fetch( + 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions', + { + method: 'POST', + headers: { + Authorization: `Bearer ${DASHSCOPE_API_KEY}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: 'qwen-plus', + messages, + temperature, + max_tokens: 800, + }), }, - body: JSON.stringify({ - model: 'qwen-plus', - messages, - temperature, - max_tokens: 800, - }), - }); - const data = await res.json() as any; + ); + const data = (await res.json()) as any; return data.choices?.[0]?.message?.content ?? ''; } @@ -68,9 +72,10 @@ const PERSONALITIES = [ ]; function buildSystemPrompt(player: Player, personality: string): string { - const identityInfo = player.identity.team === 'wolf' - ? `ไฝ ็š„่บซไปฝๆ˜ฏใ€็‹ผไบบใ€‘ใ€‚ไฝ ็š„ๅŒไผดๆ˜ฏๅ…ถไป–็‹ผไบบ๏ผˆ็Žฉๅฎถ1ใ€็Žฉๅฎถ2ใ€็Žฉๅฎถ3๏ผ‰ใ€‚\n่ƒœๅˆฉๆกไปถ๏ผšๆท˜ๆฑฐๆ‰€ๆœ‰ๅฅฝไบบใ€‚\nๅคœๆ™šไฝ ๅ’Œ็‹ผไบบๅŒไผดๅ•†้‡ๆ€่ฐใ€‚็™ฝๅคฉไฝ ้œ€่ฆไผช่ฃ…ๆˆๅฅฝไบบ๏ผŒไธ่ขซๆŠ•็ฅจๅ‡บๅฑ€ใ€‚` - : `ไฝ ็š„่บซไปฝๆ˜ฏใ€${player.identity.name}ใ€‘ใ€‚\n่ƒœๅˆฉๆกไปถ๏ผšๆ‰พๅ‡บๅนถๆท˜ๆฑฐๆ‰€ๆœ‰็‹ผไบบใ€‚\n${player.identity.abilities ? `็‰นๆฎŠ่ƒฝๅŠ›๏ผš${player.identity.abilities}` : 'ไฝ ๆฒกๆœ‰็‰นๆฎŠ่ƒฝๅŠ›๏ผŒ้ ๅ‘่จ€ๅ’ŒๆŠ•็ฅจๅธฎๅŠฉๅฅฝไบบ้˜ต่ฅใ€‚'}`; + const identityInfo = + player.identity.team === 'wolf' + ? `ไฝ ็š„่บซไปฝๆ˜ฏใ€็‹ผไบบใ€‘ใ€‚ไฝ ็š„ๅŒไผดๆ˜ฏๅ…ถไป–็‹ผไบบ๏ผˆ็Žฉๅฎถ1ใ€็Žฉๅฎถ2ใ€็Žฉๅฎถ3๏ผ‰ใ€‚\n่ƒœๅˆฉๆกไปถ๏ผšๆท˜ๆฑฐๆ‰€ๆœ‰ๅฅฝไบบใ€‚\nๅคœๆ™šไฝ ๅ’Œ็‹ผไบบๅŒไผดๅ•†้‡ๆ€่ฐใ€‚็™ฝๅคฉไฝ ้œ€่ฆไผช่ฃ…ๆˆๅฅฝไบบ๏ผŒไธ่ขซๆŠ•็ฅจๅ‡บๅฑ€ใ€‚` + : `ไฝ ็š„่บซไปฝๆ˜ฏใ€${player.identity.name}ใ€‘ใ€‚\n่ƒœๅˆฉๆกไปถ๏ผšๆ‰พๅ‡บๅนถๆท˜ๆฑฐๆ‰€ๆœ‰็‹ผไบบใ€‚\n${player.identity.abilities ? `็‰นๆฎŠ่ƒฝๅŠ›๏ผš${player.identity.abilities}` : 'ไฝ ๆฒกๆœ‰็‰นๆฎŠ่ƒฝๅŠ›๏ผŒ้ ๅ‘่จ€ๅ’ŒๆŠ•็ฅจๅธฎๅŠฉๅฅฝไบบ้˜ต่ฅใ€‚'}`; return `ไฝ ๆญฃๅœจ็Žฉไธ€ๅฑ€9ไบบ็‹ผไบบๆ€ๆธธๆˆใ€‚ไฝ ๆ˜ฏ ${player.name}ใ€‚ ๆ€งๆ ผ็‰นๅพ๏ผš${personality} @@ -87,37 +92,49 @@ ${identityInfo} // โ”€โ”€ LLM Roles โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -const players = createPlayers(); +const _players = createPlayers(); function getVisibleHistory(chain: WorkflowMessage[], player: Player): string { const visible = filterChainForPlayer(chain, player.id, player.identity); if (visible.length === 0) return '๏ผˆๆš‚ๆ— ๅކๅฒไฟกๆฏ๏ผ‰'; - return visible.map(m => m.content).join('\n'); + return visible.map((m) => m.content).join('\n'); } const llmWolfNight: Role = async (chain) => { const state = parseGameState(chain); - const wolves = state.alive.filter(p => p.identity.team === 'wolf'); - const goodAlive = state.alive.filter(p => p.identity.team === 'good'); - + const wolves = state.alive.filter((p) => p.identity.team === 'wolf'); + const goodAlive = state.alive.filter((p) => p.identity.team === 'good'); + if (goodAlive.length === 0) { - return { content: '[็‹ผไบบๅคœๆ™š] ๆ— ๅฅฝไบบๅฏๆ€', meta: { phase: 'wolf-night', targetId: '' } }; + return { + content: '[็‹ผไบบๅคœๆ™š] ๆ— ๅฅฝไบบๅฏๆ€', + meta: { phase: 'wolf-night', targetId: '' }, + }; } // ่ฎฉ็ฌฌไธ€ไธชๅญ˜ๆดป็‹ผไบบไปฃ่กจๅ†ณ็ญ– const leadWolf = wolves[0]; const history = getVisibleHistory(chain, leadWolf); - - const targetList = goodAlive.map(p => `${p.id}(${p.name})`).join('ใ€'); - const response = await callLlm([ - { role: 'system', content: buildSystemPrompt(leadWolf, PERSONALITIES[0]) }, - { role: 'user', content: `${history}\n\n็Žฐๅœจๆ˜ฏๅคœๆ™š๏ผŒไฝ ไปฌ็‹ผไบบ่ฆๅ•†้‡ๆ€่ฐใ€‚\nๅญ˜ๆดป็š„้ž็‹ผไบบ็Žฉๅฎถ๏ผš${targetList}\n\n่ฏท็›ดๆŽฅๅ›žๅคไฝ ่ฆๆ€็š„็ŽฉๅฎถID๏ผˆๅฆ‚ p4๏ผ‰๏ผŒไธ€ไธชๅญ—้ƒฝไธ่ฆๅคš่ฏดใ€‚` }, - ], 0.3); + + const targetList = goodAlive.map((p) => `${p.id}(${p.name})`).join('ใ€'); + const response = await callLlm( + [ + { + role: 'system', + content: buildSystemPrompt(leadWolf, PERSONALITIES[0]), + }, + { + role: 'user', + content: `${history}\n\n็Žฐๅœจๆ˜ฏๅคœๆ™š๏ผŒไฝ ไปฌ็‹ผไบบ่ฆๅ•†้‡ๆ€่ฐใ€‚\nๅญ˜ๆดป็š„้ž็‹ผไบบ็Žฉๅฎถ๏ผš${targetList}\n\n่ฏท็›ดๆŽฅๅ›žๅคไฝ ่ฆๆ€็š„็ŽฉๅฎถID๏ผˆๅฆ‚ p4๏ผ‰๏ผŒไธ€ไธชๅญ—้ƒฝไธ่ฆๅคš่ฏดใ€‚`, + }, + ], + 0.3, + ); // ่งฃๆž็›ฎๆ ‡ const match = response.match(/p\d+/); const targetId = match ? match[0] : goodAlive[0].id; - const target = goodAlive.find(p => p.id === targetId) ?? goodAlive[0]; + const target = goodAlive.find((p) => p.id === targetId) ?? goodAlive[0]; console.log(` ๐Ÿบ ็‹ผไบบๅ†ณๅฎšๅ‡ปๆ€ ${target.name}`); @@ -129,59 +146,92 @@ const llmWolfNight: Role = async (chain) => { const llmSeerCheck: Role = async (chain) => { const state = parseGameState(chain); - const seer = state.alive.find(p => p.identity.name === '้ข„่จ€ๅฎถ'); + const seer = state.alive.find((p) => p.identity.name === '้ข„่จ€ๅฎถ'); if (!seer) { - return { content: '[้ข„่จ€ๅฎถๅทฒๆญป๏ผŒ่ทณ่ฟ‡]', meta: { phase: 'seer-check', targetId: '', isWolf: false, visibleTo: [] } }; + return { + content: '[้ข„่จ€ๅฎถๅทฒๆญป๏ผŒ่ทณ่ฟ‡]', + meta: { phase: 'seer-check', targetId: '', isWolf: false, visibleTo: [] }, + }; } - const others = state.alive.filter(p => p.id !== seer.id); + const others = state.alive.filter((p) => p.id !== seer.id); const history = getVisibleHistory(chain, seer); - const targetList = others.map(p => `${p.id}(${p.name})`).join('ใ€'); + const targetList = others.map((p) => `${p.id}(${p.name})`).join('ใ€'); - const response = await callLlm([ - { role: 'system', content: buildSystemPrompt(seer, PERSONALITIES[3]) }, - { role: 'user', content: `${history}\n\n็Žฐๅœจ่ฝฎๅˆฐไฝ ๆŸฅ้ชŒ่บซไปฝใ€‚ๅฏๆŸฅ้ชŒ็š„็Žฉๅฎถ๏ผš${targetList}\n\n่ฏท็›ดๆŽฅๅ›žๅคไฝ ่ฆๆŸฅ้ชŒ็š„็ŽฉๅฎถID๏ผˆๅฆ‚ p1๏ผ‰๏ผŒไธ€ไธชๅญ—้ƒฝไธ่ฆๅคš่ฏดใ€‚` }, - ], 0.3); + const response = await callLlm( + [ + { role: 'system', content: buildSystemPrompt(seer, PERSONALITIES[3]) }, + { + role: 'user', + content: `${history}\n\n็Žฐๅœจ่ฝฎๅˆฐไฝ ๆŸฅ้ชŒ่บซไปฝใ€‚ๅฏๆŸฅ้ชŒ็š„็Žฉๅฎถ๏ผš${targetList}\n\n่ฏท็›ดๆŽฅๅ›žๅคไฝ ่ฆๆŸฅ้ชŒ็š„็ŽฉๅฎถID๏ผˆๅฆ‚ p1๏ผ‰๏ผŒไธ€ไธชๅญ—้ƒฝไธ่ฆๅคš่ฏดใ€‚`, + }, + ], + 0.3, + ); const match = response.match(/p\d+/); const targetId = match ? match[0] : others[0].id; - const target = others.find(p => p.id === targetId) ?? others[0]; + const target = others.find((p) => p.id === targetId) ?? others[0]; const isWolf = target.identity.team === 'wolf'; - console.log(` ๐Ÿ”ฎ ้ข„่จ€ๅฎถๆŸฅ้ชŒ ${target.name} โ†’ ${isWolf ? '๐Ÿบ็‹ผไบบ' : 'โœ…ๅฅฝไบบ'}`); + console.log( + ` ๐Ÿ”ฎ ้ข„่จ€ๅฎถๆŸฅ้ชŒ ${target.name} โ†’ ${isWolf ? '๐Ÿบ็‹ผไบบ' : 'โœ…ๅฅฝไบบ'}`, + ); return { content: `[้ข„่จ€ๅฎถๆŸฅ้ชŒ] ${target.name} ็š„่บซไปฝๆ˜ฏ${isWolf ? '็‹ผไบบ' : 'ๅฅฝไบบ'}`, - meta: { phase: 'seer-check', targetId: target.id, isWolf, visibleTo: [seer.id] }, + meta: { + phase: 'seer-check', + targetId: target.id, + isWolf, + visibleTo: [seer.id], + }, }; }; -const llmWitchAction: Role = async (chain) => { +const llmWitchAction: Role = async ( + chain, +) => { const state = parseGameState(chain); - const witch = state.alive.find(p => p.identity.name === 'ๅฅณๅทซ'); - + const witch = state.alive.find((p) => p.identity.name === 'ๅฅณๅทซ'); + if (!witch) { const nightDead: string[] = []; if (state.lastKill) nightDead.push(state.lastKill); - const deadIds = new Set([...state.dead.map(d => d.id), ...nightDead]); - const aliveAfter = state.players.filter(p => !deadIds.has(p.id)); - const wolves = aliveAfter.filter(p => p.identity.team === 'wolf'); - const gameOver = wolves.length === 0 || wolves.length >= aliveAfter.length - wolves.length; + const deadIds = new Set([...state.dead.map((d) => d.id), ...nightDead]); + const aliveAfter = state.players.filter((p) => !deadIds.has(p.id)); + const wolves = aliveAfter.filter((p) => p.identity.team === 'wolf'); + const gameOver = + wolves.length === 0 || wolves.length >= aliveAfter.length - wolves.length; return { content: '[ๅฅณๅทซๅทฒๆญป๏ผŒ่ทณ่ฟ‡]', - meta: { phase: 'witch-action', saved: false, poisonTarget: null, visibleTo: [], witchPotion: state.witchPotion, witchPoison: state.witchPoison, gameOver } as any, + meta: { + phase: 'witch-action', + saved: false, + poisonTarget: null, + visibleTo: [], + witchPotion: state.witchPotion, + witchPoison: state.witchPoison, + gameOver, + } as any, }; } let saved = false; - let poisonTarget: string | null = null; + const poisonTarget: string | null = null; if (state.lastKill && state.witchPotion) { - const killed = state.players.find(p => p.id === state.lastKill); - const response = await callLlm([ - { role: 'system', content: buildSystemPrompt(witch, PERSONALITIES[7]) }, - { role: 'user', content: `ไปŠๆ™š ${killed?.name ?? 'ๆŸไบบ'} ่ขซ็‹ผไบบๆ€ๅฎณใ€‚ไฝ ๆœ‰่งฃ่ฏ๏ผŒ่ฆๆ•‘ๅ—๏ผŸๅ›žๅค"ๆ•‘"ๆˆ–"ไธๆ•‘"ใ€‚` }, - ], 0.5); + const killed = state.players.find((p) => p.id === state.lastKill); + const response = await callLlm( + [ + { role: 'system', content: buildSystemPrompt(witch, PERSONALITIES[7]) }, + { + role: 'user', + content: `ไปŠๆ™š ${killed?.name ?? 'ๆŸไบบ'} ่ขซ็‹ผไบบๆ€ๅฎณใ€‚ไฝ ๆœ‰่งฃ่ฏ๏ผŒ่ฆๆ•‘ๅ—๏ผŸๅ›žๅค"ๆ•‘"ๆˆ–"ไธๆ•‘"ใ€‚`, + }, + ], + 0.5, + ); saved = response.includes('ๆ•‘') && !response.includes('ไธๆ•‘'); if (saved) console.log(` ๐Ÿงช ๅฅณๅทซๆ•‘ไบ† ${killed?.name}`); } @@ -189,17 +239,21 @@ const llmWitchAction: Role = async (ch // ็ฎ€ๅŒ–๏ผšmock ไธ็”จๆฏ’่ฏ const nightDead: string[] = []; if (state.lastKill && !saved) nightDead.push(state.lastKill); - const deadIds = new Set([...state.dead.map(d => d.id), ...nightDead]); - const aliveAfter = state.players.filter(p => !deadIds.has(p.id)); - const wolves = aliveAfter.filter(p => p.identity.team === 'wolf'); - const gameOver = wolves.length === 0 || wolves.length >= aliveAfter.length - wolves.length; + const deadIds = new Set([...state.dead.map((d) => d.id), ...nightDead]); + const aliveAfter = state.players.filter((p) => !deadIds.has(p.id)); + const wolves = aliveAfter.filter((p) => p.identity.team === 'wolf'); + const gameOver = + wolves.length === 0 || wolves.length >= aliveAfter.length - wolves.length; console.log(` ๐Ÿง™ ๅฅณๅทซ${saved ? 'ไฝฟ็”จไบ†่งฃ่ฏ' : 'ๆœชไฝฟ็”จ่งฃ่ฏ'}`); return { content: `[ๅฅณๅทซ่กŒๅŠจ] ${saved ? 'ไฝฟ็”จไบ†่งฃ่ฏ' : 'ๆœชไฝฟ็”จ่งฃ่ฏ'}`, meta: { - phase: 'witch-action', saved, poisonTarget, visibleTo: [witch.id], + phase: 'witch-action', + saved, + poisonTarget, + visibleTo: [witch.id], witchPotion: saved ? false : state.witchPotion, witchPoison: poisonTarget ? false : state.witchPoison, gameOver, @@ -216,14 +270,22 @@ const llmDaySpeech: Role = async (chain) => { for (let i = 0; i < state.alive.length; i++) { const p = state.alive[i]; const history = getVisibleHistory(chain, p); - const prevSpeeches = speeches.map(s => { - const sp = state.alive.find(pp => pp.id === s.playerId); - return `${sp?.name ?? s.playerId}๏ผš${s.speech}`; - }).join('\n'); + const prevSpeeches = speeches + .map((s) => { + const sp = state.alive.find((pp) => pp.id === s.playerId); + return `${sp?.name ?? s.playerId}๏ผš${s.speech}`; + }) + .join('\n'); const response = await callLlm([ - { role: 'system', content: buildSystemPrompt(p, PERSONALITIES[i % PERSONALITIES.length]) }, - { role: 'user', content: `${history}\n\n${prevSpeeches ? `ๅ‰้ข็Žฉๅฎถ็š„ๅ‘่จ€๏ผš\n${prevSpeeches}\n\n` : ''}็Žฐๅœจ่ฝฎๅˆฐไฝ ๅ‘่จ€๏ผˆ50-150ๅญ—๏ผ‰๏ผŒๅˆ†ๆžๅฑ€ๅŠฟ่กจ่พพ่ง‚็‚น๏ผš` }, + { + role: 'system', + content: buildSystemPrompt(p, PERSONALITIES[i % PERSONALITIES.length]), + }, + { + role: 'user', + content: `${history}\n\n${prevSpeeches ? `ๅ‰้ข็Žฉๅฎถ็š„ๅ‘่จ€๏ผš\n${prevSpeeches}\n\n` : ''}็Žฐๅœจ่ฝฎๅˆฐไฝ ๅ‘่จ€๏ผˆ50-150ๅญ—๏ผ‰๏ผŒๅˆ†ๆžๅฑ€ๅŠฟ่กจ่พพ่ง‚็‚น๏ผš`, + }, ]); speeches.push({ playerId: p.id, speech: response.trim() }); @@ -231,35 +293,51 @@ const llmDaySpeech: Role = async (chain) => { } return { - content: speeches.map(s => `ใ€${s.playerId}ใ€‘${s.speech}`).join('\n\n'), + content: speeches.map((s) => `ใ€${s.playerId}ใ€‘${s.speech}`).join('\n\n'), meta: { phase: 'day-speech', speeches }, }; }; -const llmVote: Role = async (chain) => { +const llmVote: Role< + VoteMeta & { hunterTriggered?: boolean; gameOver?: boolean } +> = async (chain) => { const state = parseGameState(chain); // Resolve night deaths for effective alive const nightDead: string[] = []; if (state.lastKill) { - const witchMsg = [...chain].reverse().find(m => (m.meta as any)?.phase === 'witch-action'); + const witchMsg = [...chain] + .reverse() + .find((m) => (m.meta as any)?.phase === 'witch-action'); if (!(witchMsg?.meta as any)?.saved) nightDead.push(state.lastKill); } - const deadSet = new Set([...state.dead.map(d => d.id), ...nightDead]); - const effectiveAlive = state.players.filter(p => !deadSet.has(p.id)); + const deadSet = new Set([...state.dead.map((d) => d.id), ...nightDead]); + const effectiveAlive = state.players.filter((p) => !deadSet.has(p.id)); console.log(` ๐Ÿ—ณ๏ธ === ๆŠ•็ฅจ้˜ถๆฎต ===`); const votes: Record = {}; for (const p of effectiveAlive) { - const others = effectiveAlive.filter(o => o.id !== p.id); + const others = effectiveAlive.filter((o) => o.id !== p.id); const history = getVisibleHistory(chain, p); - const targetList = others.map(o => `${o.id}(${o.name})`).join('ใ€'); + const targetList = others.map((o) => `${o.id}(${o.name})`).join('ใ€'); - const response = await callLlm([ - { role: 'system', content: buildSystemPrompt(p, PERSONALITIES[effectiveAlive.indexOf(p) % PERSONALITIES.length]) }, - { role: 'user', content: `${history}\n\n็ŽฐๅœจๆŠ•็ฅจ็Žฏ่Š‚ใ€‚่ฏทๆŠ•ๅ‡บไฝ ่ฎคไธบๆœ€ๅฏ็–‘็š„็Žฉๅฎถใ€‚ๅฏๆŠ•๏ผš${targetList}\n\n็›ดๆŽฅๅ›žๅค็ŽฉๅฎถID๏ผˆๅฆ‚ p5๏ผ‰๏ผŒไธ€ไธชๅญ—้ƒฝไธ่ฆๅคš่ฏดใ€‚` }, - ], 0.3); + const response = await callLlm( + [ + { + role: 'system', + content: buildSystemPrompt( + p, + PERSONALITIES[effectiveAlive.indexOf(p) % PERSONALITIES.length], + ), + }, + { + role: 'user', + content: `${history}\n\n็ŽฐๅœจๆŠ•็ฅจ็Žฏ่Š‚ใ€‚่ฏทๆŠ•ๅ‡บไฝ ่ฎคไธบๆœ€ๅฏ็–‘็š„็Žฉๅฎถใ€‚ๅฏๆŠ•๏ผš${targetList}\n\n็›ดๆŽฅๅ›žๅค็ŽฉๅฎถID๏ผˆๅฆ‚ p5๏ผ‰๏ผŒไธ€ไธชๅญ—้ƒฝไธ่ฆๅคš่ฏดใ€‚`, + }, + ], + 0.3, + ); const match = response.match(/p\d+/); votes[p.id] = match ? match[0] : others[0].id; @@ -271,48 +349,71 @@ const llmVote: Role v === maxVotes).map(([k]) => k); + const topIds = Object.entries(tally) + .filter(([, v]) => v === maxVotes) + .map(([k]) => k); const eliminatedId = topIds[0]; // ๅนณ็ฅจๅ–็ฌฌไธ€ไธช - const eliminated = effectiveAlive.find(p => p.id === eliminatedId); - const aliveAfterVote = effectiveAlive.filter(p => p.id !== eliminatedId); - const wolves = aliveAfterVote.filter(p => p.identity.team === 'wolf'); - const gameOver = wolves.length === 0 || wolves.length >= aliveAfterVote.length - wolves.length; + const eliminated = effectiveAlive.find((p) => p.id === eliminatedId); + const aliveAfterVote = effectiveAlive.filter((p) => p.id !== eliminatedId); + const wolves = aliveAfterVote.filter((p) => p.identity.team === 'wolf'); + const gameOver = + wolves.length === 0 || + wolves.length >= aliveAfterVote.length - wolves.length; const hunterTriggered = eliminated?.identity.name === '็ŒŽไบบ'; // Print votes for (const [voter, target] of Object.entries(votes)) { - const vName = effectiveAlive.find(p => p.id === voter)?.name ?? voter; - const tName = effectiveAlive.find(p => p.id === target)?.name ?? target; + const vName = effectiveAlive.find((p) => p.id === voter)?.name ?? voter; + const tName = effectiveAlive.find((p) => p.id === target)?.name ?? target; console.log(` ๐Ÿ—ณ๏ธ ${vName} โ†’ ${tName}`); } - console.log(` โŒ ${eliminated?.name}๏ผˆ${eliminated?.identity.name}๏ผ‰่ขซๆ”พ้€ๅ‡บๅฑ€๏ผ\n`); + console.log( + ` โŒ ${eliminated?.name}๏ผˆ${eliminated?.identity.name}๏ผ‰่ขซๆ”พ้€ๅ‡บๅฑ€๏ผ\n`, + ); return { content: `[ๆŠ•็ฅจ็ป“ๆžœ] ${eliminated?.name ?? eliminatedId} ่ขซๆ”พ้€ๅ‡บๅฑ€`, - meta: { phase: 'vote', votes, eliminatedId, hunterTriggered, gameOver } as any, + meta: { + phase: 'vote', + votes, + eliminatedId, + hunterTriggered, + gameOver, + } as any, }; }; -const llmHunterShot: Role = async (chain) => { +const llmHunterShot: Role = async ( + chain, +) => { const state = parseGameState(chain); // ็ŒŽไบบๅทฒๆญป๏ผŒไปŽๆœ€ๅŽ็š„ chain ๆ‰พๅˆฐ็ŒŽไบบ - const hunter = state.players.find(p => p.identity.name === '็ŒŽไบบ')!; - const targetList = state.alive.map(p => `${p.id}(${p.name})`).join('ใ€'); + const hunter = state.players.find((p) => p.identity.name === '็ŒŽไบบ')!; + const targetList = state.alive.map((p) => `${p.id}(${p.name})`).join('ใ€'); - const response = await callLlm([ - { role: 'system', content: buildSystemPrompt(hunter, PERSONALITIES[2]) }, - { role: 'user', content: `ไฝ ่ขซๆŠ•็ฅจๅ‡บๅฑ€ไบ†๏ผไฝœไธบ็ŒŽไบบไฝ ๅฏไปฅๅผ€ๆžชๅธฆ่ตฐไธ€ไบบใ€‚ๅญ˜ๆดป็Žฉๅฎถ๏ผš${targetList}\n\n็›ดๆŽฅๅ›žๅค็ŽฉๅฎถID๏ผˆๅฆ‚ p1๏ผ‰๏ผš` }, - ], 0.3); + const response = await callLlm( + [ + { role: 'system', content: buildSystemPrompt(hunter, PERSONALITIES[2]) }, + { + role: 'user', + content: `ไฝ ่ขซๆŠ•็ฅจๅ‡บๅฑ€ไบ†๏ผไฝœไธบ็ŒŽไบบไฝ ๅฏไปฅๅผ€ๆžชๅธฆ่ตฐไธ€ไบบใ€‚ๅญ˜ๆดป็Žฉๅฎถ๏ผš${targetList}\n\n็›ดๆŽฅๅ›žๅค็ŽฉๅฎถID๏ผˆๅฆ‚ p1๏ผ‰๏ผš`, + }, + ], + 0.3, + ); const match = response.match(/p\d+/); const targetId = match ? match[0] : state.alive[0].id; - const target = state.alive.find(p => p.id === targetId) ?? state.alive[0]; - const aliveAfter = state.alive.filter(p => p.id !== target.id); - const wolves = aliveAfter.filter(p => p.identity.team === 'wolf'); - const gameOver = wolves.length === 0 || wolves.length >= aliveAfter.length - wolves.length; + const target = state.alive.find((p) => p.id === targetId) ?? state.alive[0]; + const aliveAfter = state.alive.filter((p) => p.id !== target.id); + const wolves = aliveAfter.filter((p) => p.identity.team === 'wolf'); + const gameOver = + wolves.length === 0 || wolves.length >= aliveAfter.length - wolves.length; - console.log(` ๐Ÿ”ซ ็ŒŽไบบๅผ€ๆžชๅธฆ่ตฐไบ† ${target.name}๏ผˆ${target.identity.name}๏ผ‰๏ผ\n`); + console.log( + ` ๐Ÿ”ซ ็ŒŽไบบๅผ€ๆžชๅธฆ่ตฐไบ† ${target.name}๏ผˆ${target.identity.name}๏ผ‰๏ผ\n`, + ); return { content: `[็ŒŽไบบๅผ€ๆžช] ็ŒŽไบบๅธฆ่ตฐไบ† ${target.name}`, @@ -322,17 +423,17 @@ const llmHunterShot: Role = async (chai const llmGameEnd: Role = async (chain) => { const state = parseGameState(chain); - const wolves = state.alive.filter(p => p.identity.team === 'wolf'); + const wolves = state.alive.filter((p) => p.identity.team === 'wolf'); const winner = wolves.length === 0 ? 'good' : 'wolf'; const summary = ` ๐Ÿ† ${winner === 'good' ? 'ๅฅฝไบบ้˜ต่ฅ' : '็‹ผไบบ้˜ต่ฅ'}่Žท่ƒœ๏ผ -ๅญ˜ๆดป็Žฉๅฎถ๏ผš${state.alive.map(p => `${p.name}(${p.identity.name})`).join('ใ€') || 'ๆ— '} -ๆญปไบก็Žฉๅฎถ๏ผš${state.dead.map(d => `${d.name}(${d.identity.name},${d.cause})`).join('ใ€')} +ๅญ˜ๆดป็Žฉๅฎถ๏ผš${state.alive.map((p) => `${p.name}(${p.identity.name})`).join('ใ€') || 'ๆ— '} +ๆญปไบก็Žฉๅฎถ๏ผš${state.dead.map((d) => `${d.name}(${d.identity.name},${d.cause})`).join('ใ€')} ็Žฉๅฎถ่บซไปฝๆญๆ™“๏ผš -${state.players.map(p => ` ${p.name} โ†’ ${p.identity.name}๏ผˆ${p.identity.team === 'wolf' ? '๐Ÿบ' : 'โœ…'}๏ผ‰`).join('\n')} +${state.players.map((p) => ` ${p.name} โ†’ ${p.identity.name}๏ผˆ${p.identity.team === 'wolf' ? '๐Ÿบ' : 'โœ…'}๏ผ‰`).join('\n')} `; console.log(summary); @@ -349,7 +450,11 @@ async function main() { console.log('๐Ÿบ๐ŸŒ™ ============ ็‹ผไบบๆ€ LLM ๅฏนๆˆ˜ ============\n'); console.log('็Žฉๅฎถ่บซไปฝ๏ผˆไธŠๅธ่ง†่ง’๏ผ‰๏ผš'); const allPlayers = createPlayers(); - allPlayers.forEach(p => console.log(` ${p.name} โ†’ ${p.identity.name}๏ผˆ${p.identity.team === 'wolf' ? '๐Ÿบ' : 'โœ…'}๏ผ‰`)); + allPlayers.forEach((p) => + console.log( + ` ${p.name} โ†’ ${p.identity.name}๏ผˆ${p.identity.team === 'wolf' ? '๐Ÿบ' : 'โœ…'}๏ผ‰`, + ), + ); console.log('\nๆธธๆˆๅผ€ๅง‹๏ผ\n'); const wf = createWerewolfWorkflow({ @@ -369,15 +474,23 @@ async function main() { mkdirSync(`${tmpDir}/scopes`, { recursive: true }); mkdirSync(`${tmpDir}/objects`, { recursive: true }); - const ss = css({ basePath: `${tmpDir}/scopes`, objectsDir: `${tmpDir}/objects` }); + const ss = css({ + basePath: `${tmpDir}/scopes`, + objectsDir: `${tmpDir}/objects`, + }); const store = ss.scope('werewolf-game'); - const { createWorkflowRule } = await import('../workflows/workflow-rule-adapter.js'); + const { createWorkflowRule } = await import( + '../workflows/workflow-rule-adapter.js' + ); const rule = createWorkflowRule(wf, store); const ticker = createWorkflowTicker([rule]); // Submit start event - const startPayload = JSON.stringify({ title: 'AI ็‹ผไบบๆ€', players: allPlayers.map(p => p.name) }); + const startPayload = JSON.stringify({ + title: 'AI ็‹ผไบบๆ€', + players: allPlayers.map((p) => p.name), + }); const hash = await store.putObject(startPayload); await store.appendEvent({ occurredAt: Date.now(), @@ -392,7 +505,9 @@ async function main() { while (maxTicks-- > 0) { await ticker(); // Check if game ended - const endEvents = await store.queryByKind('werewolf.game-end', { limit: 1 }); + const endEvents = await store.queryByKind('werewolf.game-end', { + limit: 1, + }); if (endEvents.length > 0) { console.log('\n๐ŸŽฎ ๆธธๆˆ็ป“ๆŸ๏ผ'); break; diff --git a/packages/pulse/src/e2e/werewolf-report.ts b/packages/pulse/src/e2e/werewolf-report.ts index 0bb9caf..5e91d80 100644 --- a/packages/pulse/src/e2e/werewolf-report.ts +++ b/packages/pulse/src/e2e/werewolf-report.ts @@ -1,29 +1,34 @@ #!/usr/bin/env bun /** - * ็‹ผไบบๆ€ๆˆ˜ๆŠฅ็”Ÿๆˆ โ€” ็”จ Report Workflow (analyst โ†’ renderer) + * ็‹ผไบบๆ€ๆˆ˜ๆŠฅ็”Ÿๆˆ โ€” ็”จ Report Workflow (analyst โ†’ renderer) * ่ฏปๅ–็‹ผไบบๆ€ game ๆ•ฐๆฎ๏ผŒ็”Ÿๆˆ HTML ๆˆ˜ๆŠฅ * * bun packages/pulse/src/e2e/werewolf-report.ts [--db /tmp/werewolf-xxx] - * + * * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) */ -import { mkdtempSync, writeFileSync, readdirSync } from 'node:fs'; +import { mkdtempSync, readdirSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { + createAnalystRole, + createRendererRole, + createReportWorkflow, +} from '@upulse/workflows'; import { createScopedStore, createStore } from '../index.js'; import { createOpenAiLlmClient } from '../llm-client.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 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -let dbDir = process.argv.find(a => a.startsWith('--db='))?.slice(5); +let dbDir = process.argv.find((a) => a.startsWith('--db='))?.slice(5); if (!dbDir) { // Auto-detect latest /tmp/werewolf-* - const dirs = readdirSync('/tmp').filter(d => d.startsWith('werewolf-')).sort().reverse(); + const dirs = readdirSync('/tmp') + .filter((d) => d.startsWith('werewolf-')) + .sort() + .reverse(); if (dirs.length > 0) { dbDir = join('/tmp', dirs[0]); console.log(`๐Ÿ” Auto-detected: ${dbDir}`); @@ -33,7 +38,9 @@ if (!dbDir) { } } -const baseUrl = process.env.PULSE_LLM_BASE_URL ?? 'https://dashscope.aliyuncs.com/compatible-mode/v1'; +const baseUrl = + process.env.PULSE_LLM_BASE_URL ?? + 'https://dashscope.aliyuncs.com/compatible-mode/v1'; const apiKey = process.env.PULSE_LLM_API_KEY ?? process.env.DASHSCOPE_API_KEY; const model = process.env.PULSE_LLM_MODEL ?? 'qwen-plus'; @@ -46,7 +53,10 @@ const t0 = Date.now(); const ts = () => `[+${((Date.now() - t0) / 1000).toFixed(1)}s]`; // โ”€โ”€ Read werewolf game events โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -const ss = createScopedStore({ basePath: join(dbDir, 'scopes'), objectsDir: join(dbDir, 'objects') }); +const ss = createScopedStore({ + basePath: join(dbDir, 'scopes'), + objectsDir: join(dbDir, 'objects'), +}); const store = ss.scope('werewolf-game'); const allEvents = await store.getAfter(0); @@ -60,34 +70,43 @@ if (allEvents.length === 0) { // Build timeline JSON (adapted for werewolf) const tStart = allEvents[0].occurredAt; -const eventItems = await Promise.all(allEvents.map(async (e, i) => { - const role = e.kind.replace('werewolf.', ''); - const meta = e.meta ? JSON.parse(e.meta) : null; - let content: string | null = null; - if (e.hash) { - try { - const obj = await store.getObject(e.hash); - content = typeof obj === 'string' ? obj : JSON.stringify(obj); - } catch {} - } - return { - id: e.id, - role, - offsetMs: e.occurredAt - tStart, - durationMs: i > 0 ? e.occurredAt - allEvents[i - 1].occurredAt : 0, - meta, - content, - }; -})); +const eventItems = await Promise.all( + allEvents.map(async (e, i) => { + const role = e.kind.replace('werewolf.', ''); + const meta = e.meta ? JSON.parse(e.meta) : null; + let content: string | null = null; + if (e.hash) { + try { + const obj = await store.getObject(e.hash); + content = typeof obj === 'string' ? obj : JSON.stringify(obj); + } catch {} + } + return { + id: e.id, + role, + offsetMs: e.occurredAt - tStart, + durationMs: i > 0 ? e.occurredAt - allEvents[i - 1].occurredAt : 0, + meta, + content, + }; + }), +); -const timelineJson = JSON.stringify({ - key: 'werewolf-game-1', - totalMs: allEvents[allEvents.length - 1].occurredAt - tStart, - events: eventItems, -}, null, 2); +const timelineJson = JSON.stringify( + { + key: 'werewolf-game-1', + totalMs: allEvents[allEvents.length - 1].occurredAt - tStart, + events: eventItems, + }, + null, + 2, +); ss.close(); -console.log(ts(), `Timeline: ${eventItems.length} phases, ${((JSON.parse(timelineJson).totalMs) / 1000).toFixed(1)}s total`); +console.log( + ts(), + `Timeline: ${eventItems.length} phases, ${(JSON.parse(timelineJson).totalMs / 1000).toFixed(1)}s total`, +); // โ”€โ”€ Run report workflow โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const tmpDir = mkdtempSync(join(tmpdir(), 'werewolf-report-')); @@ -139,12 +158,12 @@ while (tickNum < 5) { // Extract HTML report const reportEvents = await reportStore.getAfter(0); -const rendererEvt = reportEvents.find(e => e.kind === 'report.renderer'); +const rendererEvt = reportEvents.find((e) => e.kind === 'report.renderer'); if (rendererEvt?.hash) { - const html = await reportStore.getObject(rendererEvt.hash) as string; + const html = (await reportStore.getObject(rendererEvt.hash)) as string; const outPath = join(tmpDir, 'werewolf-report.html'); writeFileSync(outPath, html, 'utf-8'); - + // Also copy to workspace const wsPath = '/home/azureuser/.openclaw/workspace/werewolf-report-v2.html'; writeFileSync(wsPath, html, 'utf-8'); @@ -152,12 +171,14 @@ if (rendererEvt?.hash) { } // Print analyst findings -const analystEvt = reportEvents.find(e => e.kind === 'report.analyst'); +const analystEvt = reportEvents.find((e) => e.kind === 'report.analyst'); if (analystEvt?.meta) { const meta = JSON.parse(analystEvt.meta); console.log(`\n๐Ÿ“Š Score: ${meta.score}/10`); console.log(`โœ… Highlights: ${meta.highlights?.join(', ')}`); - console.log(`โš ๏ธ Issues: ${meta.issues?.length ? meta.issues.join(', ') : 'None'}`); + console.log( + `โš ๏ธ Issues: ${meta.issues?.length ? meta.issues.join(', ') : 'None'}`, + ); } await reportStore.close(); diff --git a/packages/pulse/src/executors/index.d.ts b/packages/pulse/src/executors/index.d.ts index d0eb898..2df0816 100644 --- a/packages/pulse/src/executors/index.d.ts +++ b/packages/pulse/src/executors/index.d.ts @@ -1 +1,5 @@ -export { executeSurvivalEffect, type SurvivalEffect, type SurvivalExecDeps, } from './survival.js'; +export { + executeSurvivalEffect, + type SurvivalEffect, + type SurvivalExecDeps, +} from './survival.js'; diff --git a/packages/pulse/src/executors/survival.d.ts b/packages/pulse/src/executors/survival.d.ts index f85c6b6..533509d 100644 --- a/packages/pulse/src/executors/survival.d.ts +++ b/packages/pulse/src/executors/survival.d.ts @@ -3,19 +3,25 @@ * * Execute survival effects - all deterministic local commands. */ -import { execFileSync as defaultExecFileSync, type execSync } from 'node:child_process'; +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; + type: string; + [key: string]: unknown; } /** Dependencies that can be injected for testing */ export interface SurvivalExecDeps { - fs?: typeof fs; - execSyncFn?: typeof execSync; - execFileSyncFn?: typeof defaultExecFileSync; + 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; +export declare function executeSurvivalEffect( + effect: SurvivalEffect, + deps?: SurvivalExecDeps, +): Promise; diff --git a/packages/pulse/src/gc.d.ts b/packages/pulse/src/gc.d.ts index 5fd2d8d..b22664c 100644 --- a/packages/pulse/src/gc.d.ts +++ b/packages/pulse/src/gc.d.ts @@ -12,33 +12,36 @@ */ 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; + /** 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[]; + /** 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; + 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; +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. @@ -46,26 +49,29 @@ export declare function gcVitals(vitalsStore: PulseStore, config?: GcConfig): Pr * 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; +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; + 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; + 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 deleted file mode 100644 index b780f27..0000000 --- a/packages/pulse/src/gc.js +++ /dev/null @@ -1,181 +0,0 @@ -/** - * 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/guard-core.ts b/packages/pulse/src/guard-core.ts new file mode 100644 index 0000000..07c4dc9 --- /dev/null +++ b/packages/pulse/src/guard-core.ts @@ -0,0 +1,228 @@ +/** + * Guard core logic โ€” pure functions without database dependencies. + * Can be used in both bun:sqlite (Pulse) and D1 (Pulseflare) environments. + */ + +import jsonata, { type Expression } from 'jsonata'; + +// โ”€โ”€ Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export interface GuardSource { + kind: string; + key_prefix?: string; + check: string; + transition: string; +} + +export interface GuardProjectionDef { + name: string; + initial_value: any; + sources: GuardSource[]; +} + +export interface GuardContext { + currentState: any; + event: { kind: string; key?: string; meta?: string; occurredAt: number }; + sources: GuardSource[]; + initialValue: any; +} + +export interface GuardEvalResult { + ok: boolean; + newState: any; + matchedAny: boolean; + errorMessage?: string; +} + +export class GuardViolationError extends Error { + constructor( + public guardName: string, + public eventKind: string, + public eventKey: string | undefined, + public reason: string, + ) { + super(`Guard "${guardName}" rejected event ${eventKind}: ${reason}`); + this.name = 'GuardViolationError'; + } +} + +export interface GuardUpdate { + guardName: string; + key: string; + newState: any; + lastEventId: number; +} + +// โ”€โ”€ Expression cache โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const exprCache = new Map(); + +function getExpr(expressionStr: string): Expression { + let e = exprCache.get(expressionStr); + if (!e) { + e = jsonata(expressionStr); + exprCache.set(expressionStr, e); + } + return e; +} + +export function clearGuardExpressionCache(): void { + exprCache.clear(); +} + +// โ”€โ”€ Kind pattern matching (* = one dot segment) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export function matchEventKindPattern(pattern: string, kind: string): boolean { + const ps = pattern.split('.'); + const ks = kind.split('.'); + if (ps.length !== ks.length) return false; + for (let i = 0; i < ps.length; i++) { + if (ps[i] === '*') continue; + if (ps[i] !== ks[i]) return false; + } + return true; +} + +// โ”€โ”€ Event bindings helper โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function eventBindings( + event: { kind: string; key?: string; meta?: string; occurredAt: number }, + eventId: number, +) { + return { + id: eventId, + occurred_at: event.occurredAt, + kind: event.kind, + key: event.key, + payload: event.meta ? JSON.parse(event.meta) : {}, + }; +} + +async function evalBool( + expressionStr: string, + context: Record, +): Promise { + const e = getExpr(expressionStr); + const out = await e.evaluate(context, {}); + if (typeof out === 'boolean') return out; + if (out === null || out === undefined) return false; + throw new Error(`check must return boolean, got ${typeof out}`); +} + +// โ”€โ”€ Core evaluation logic (pure, no DB) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Evaluate a single guard against an event. + * Returns ok=true + newState if all checks pass, ok=false + error if any fail. + * Check runs BEFORE transition (fail fast). + */ +export async function evaluateGuard( + ctx: GuardContext, +): Promise { + const { currentState, event, sources, initialValue } = ctx; + const baseState = currentState !== undefined ? currentState : initialValue; + + let working = baseState; + let matchedAny = false; + + for (const source of sources) { + if (!matchEventKindPattern(source.kind, event.kind)) continue; + + if (source.key_prefix !== undefined) { + const k = event.key; + if (k === undefined || !k.startsWith(source.key_prefix)) continue; + } + + matchedAny = true; + + const ev = eventBindings(event, 0); + const evalCtx = { state: working, event: ev }; + + // Check FIRST (fail fast) + let ok: boolean; + try { + ok = await evalBool(source.check, evalCtx); + } catch (err: any) { + return { + ok: false, + newState: working, + matchedAny, + errorMessage: `check failed: ${err?.message ?? String(err)}`, + }; + } + + if (!ok) { + return { + ok: false, + newState: working, + matchedAny, + errorMessage: 'check returned false', + }; + } + + // Transition AFTER check passes + let newState: any; + try { + newState = await getExpr(source.transition).evaluate(evalCtx, {}); + } catch (err: any) { + return { + ok: false, + newState: working, + matchedAny, + errorMessage: `transition failed: ${err?.message ?? String(err)}`, + }; + } + + working = newState; + } + + return { ok: true, newState: working, matchedAny }; +} + +/** + * Evaluate all matching guards for an event. + * Returns updates to apply, or throws GuardViolationError on first failure. + */ +export async function checkGuardsCore( + defs: GuardProjectionDef[], + event: { kind: string; key?: string; meta?: string; occurredAt: number }, + loadState: ( + guardName: string, + key: string, + ) => { value: any; lastEventId: number } | null, +): Promise<{ updates: GuardUpdate[] }> { + const updates: GuardUpdate[] = []; + + for (const def of defs) { + const stateKey = event.key ?? ''; + const row = loadState(def.name, stateKey); + const currentState = row ? row.value : undefined; + + const result = await evaluateGuard({ + currentState, + event, + sources: def.sources, + initialValue: def.initial_value, + }); + + if (!result.ok) { + throw new GuardViolationError( + def.name, + event.kind, + event.key, + result.errorMessage || 'unknown error', + ); + } + + if (result.matchedAny) { + updates.push({ + guardName: def.name, + key: stateKey, + newState: result.newState, + lastEventId: 0, + }); + } + } + + return { updates }; +} diff --git a/packages/pulse/src/guard-projection.ts b/packages/pulse/src/guard-projection.ts index b96a8c0..2fc4b72 100644 --- a/packages/pulse/src/guard-projection.ts +++ b/packages/pulse/src/guard-projection.ts @@ -1,9 +1,25 @@ /** - * Guard projections: synchronous fold + check on append (JSONata). + * Guard projections: bun:sqlite storage layer. + * Core logic delegated to guard-core.ts (pure, portable). */ import type { Database } from 'bun:sqlite'; -import jsonata, { type Expression } from 'jsonata'; +import { + checkGuardsCore, + clearGuardExpressionCache as clearCoreCache, + type GuardProjectionDef, + type GuardSource, + type GuardUpdate, +} from './guard-core.js'; + +// Re-export for backward compat +export { + type GuardProjectionDef, + type GuardSource, + type GuardUpdate, + GuardViolationError, + matchEventKindPattern, +} from './guard-core.js'; // โ”€โ”€ Schema โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -29,72 +45,16 @@ export function initGuardSchema(db: Database): void { db.exec(GUARD_SCHEMA); } -// โ”€โ”€ Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// โ”€โ”€ Memory cache โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -export interface GuardSource { - kind: string; - key_prefix?: string; - check: string; - transition: string; -} +const guardDefMemory = new Map(); -export interface GuardProjectionDef { - name: string; - initial_value: any; - sources: GuardSource[]; -} - -export class GuardViolationError extends Error { - constructor( - public guardName: string, - public eventKind: string, - public eventKey: string | undefined, - public reason: string, - ) { - super(`Guard "${guardName}" rejected event ${eventKind}: ${reason}`); - this.name = 'GuardViolationError'; - } -} - -export interface GuardUpdate { - guardName: string; - key: string; - newState: any; - lastEventId: number; -} - -// โ”€โ”€ Expression cache โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -const exprCache = new Map(); - -function expr(expressionStr: string): Expression { - let e = exprCache.get(expressionStr); - if (!e) { - e = jsonata(expressionStr); - exprCache.set(expressionStr, e); - } - return e; -} - -/** @internal */ +/** @internal Clear expression cache and memory cache (for testing) */ export function clearGuardExpressionCache(): void { - exprCache.clear(); + clearCoreCache(); guardDefMemory.clear(); } -// โ”€โ”€ Kind pattern (* = one dot segment) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -export function matchEventKindPattern(pattern: string, kind: string): boolean { - const ps = pattern.split('.'); - const ks = kind.split('.'); - if (ps.length !== ks.length) return false; - for (let i = 0; i < ps.length; i++) { - if (ps[i] === '*') continue; - if (ps[i] !== ks[i]) return false; - } - return true; -} - // โ”€โ”€ DB helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const insertGuardDefStmt = (db: Database) => @@ -158,41 +118,11 @@ function loadGuardRow( }; } -function eventBindings( - event: { - kind: string; - key?: string; - meta?: string; - occurredAt: number; - }, - eventId: number, -) { - return { - id: eventId, - occurred_at: event.occurredAt, - kind: event.kind, - key: event.key, - payload: event.meta ? JSON.parse(event.meta) : {}, - }; -} - -async function evalBool( - label: string, - expressionStr: string, - context: Record, -): Promise { - const e = expr(expressionStr); - const out = await e.evaluate(context, {}); - if (typeof out === 'boolean') return out; - if (out === null || out === undefined) return false; - throw new Error(`${label} must return boolean, got ${typeof out}`); -} +// โ”€โ”€ Public API โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ /** - * Register guard (persist + memory cache of compiled defs for this process). + * Register guard (persist + memory cache). */ -const guardDefMemory = new Map(); - export function registerGuard(db: Database, def: GuardProjectionDef): void { const now = Date.now(); insertGuardDefStmt(db).run( @@ -205,9 +135,13 @@ export function registerGuard(db: Database, def: GuardProjectionDef): void { } /** - * Read guard's current state (same idea as lazy projection read). + * Read guard's current state. */ -export function getGuardState(db: Database, guardName: string, key: string): any { +export function getGuardState( + db: Database, + guardName: string, + key: string, +): any { const row = loadGuardRow(db, guardName, key); if (!row) { const defs = listGuardDefs(db); @@ -218,87 +152,17 @@ export function getGuardState(db: Database, guardName: string, key: string): any } /** - * Before append: match guards, fold, check. Returns pending state updates - * (lastEventId is a placeholder; caller sets to written event id). + * Before append: match guards, fold, check. Returns pending state updates. + * Delegates core logic to guard-core (pure, portable). */ export async function checkGuards( db: Database, event: { kind: string; key?: string; meta?: string; occurredAt: number }, ): Promise<{ updates: GuardUpdate[] }> { const defs = listGuardDefs(db); - const updates: GuardUpdate[] = []; - const pendingEventId = 0; - - for (const def of defs) { - const stateKey = event.key ?? ''; - - const row = loadGuardRow(db, def.name, stateKey); - const baseState = row ? row.value : def.initial_value; - - let working = baseState; - let matchedAny = false; - - for (const source of def.sources) { - if (!matchEventKindPattern(source.kind, event.kind)) continue; - - if (source.key_prefix !== undefined) { - const k = event.key; - if (k === undefined || !k.startsWith(source.key_prefix)) continue; - } - - matchedAny = true; - - const ev = eventBindings(event, pendingEventId); - - const ctx = { state: working, event: ev }; - - let newState: any; - try { - newState = await expr(source.transition).evaluate(ctx, {}); - } catch (err: any) { - throw new GuardViolationError( - def.name, - event.kind, - event.key, - `transition failed: ${err?.message ?? String(err)}`, - ); - } - - let ok: boolean; - try { - ok = await evalBool('check', source.check, ctx); - } catch (err: any) { - throw new GuardViolationError( - def.name, - event.kind, - event.key, - `check failed: ${err?.message ?? String(err)}`, - ); - } - - if (!ok) { - throw new GuardViolationError( - def.name, - event.kind, - event.key, - 'check returned false', - ); - } - - working = newState; - } - - if (matchedAny) { - updates.push({ - guardName: def.name, - key: stateKey, - newState: working, - lastEventId: 0, - }); - } - } - - return { updates }; + return checkGuardsCore(defs, event, (guardName, key) => + loadGuardRow(db, guardName, key), + ); } /** diff --git a/packages/pulse/src/index.d.ts b/packages/pulse/src/index.d.ts index 9365b68..e9dd589 100644 --- a/packages/pulse/src/index.d.ts +++ b/packages/pulse/src/index.d.ts @@ -19,8 +19,8 @@ import { type WatcherDef } from './watcher.js'; * refreshedAt: when this data was last collected (= collect event's occurred_at) */ export interface Sensed { - data: T; - refreshedAt: number; + data: T; + refreshedAt: number; } /** * Rule: the universal composition primitive. @@ -38,7 +38,11 @@ export interface Sensed { * - 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]>; +export type Rule = ( + prev: S, + curr: S, + inner: (prev: S, curr: S) => Promise<[E[], number]>, +) => Promise<[E[], number]>; /** * Rule definition with projection dependencies. * @@ -46,10 +50,10 @@ export type Rule = (prev: S, curr: S, inner: (prev: S, curr: S) => Promise * 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; + name: string; + /** Projection dependencies, format "scope/name" like ["_vitals/cpu_usage", "neko/session_count"] */ + projections: string[]; + rule: Rule; } /** * Executor: executes a batch of effects. @@ -79,7 +83,9 @@ export type ChainableExecutor = (effects: E[]) => Promise; * 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; +export declare function chainExecutors( + executors: ChainableExecutor[], +): Executor; /** * Compose rules via onion middleware (reduceRight). * @@ -89,14 +95,19 @@ export declare function chainExecutors(executors: ChainableExecutor[]): Ex * 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]>; +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; +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. @@ -110,23 +121,34 @@ export declare function findEffectiveEpoch(store: PulseStore): Promise(storeOrStores: PulseStore | { - system: PulseStore; - vitals: PulseStore; -}, senseKeys: string[], epoch?: EventRecord | null, options?: { + }, +>( + storeOrStores: + | PulseStore + | { + system: PulseStore; + vitals: PulseStore; + }, + senseKeys: string[], + epoch?: EventRecord | null, + options?: { systemStore?: PulseStore; workflowStore?: PulseStore; -}): Promise; + }, +): 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; + }, +>(scopedStore: ScopedStore, projectionPaths: string[]): Promise; /** * Run the Pulse loop. * @@ -137,35 +159,38 @@ export declare function buildSnapshotFromProjections(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; - }; + }, + E, +>(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). @@ -173,17 +198,20 @@ export declare function runPulse(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; + }, + E, +>(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 @@ -193,44 +221,139 @@ export declare function runPulseV2(options: { - store: PulseStore; - execute: Executor; - scanIntervalMs?: number; - signal?: AbortSignal; + 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; + 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 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 { + 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 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 { + 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 { + 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'; +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 deleted file mode 100644 index 7394fd3..0000000 --- a/packages/pulse/src/index.js +++ /dev/null @@ -1,672 +0,0 @@ -/** - * @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 6ff8ee7..8176a15 100644 --- a/packages/pulse/src/index.ts +++ b/packages/pulse/src/index.ts @@ -204,7 +204,7 @@ export async function rebuildSnapshot( storeOrStores: PulseStore | { system: PulseStore; vitals: PulseStore }, senseKeys: string[], epoch?: EventRecord | null, - options?: { systemStore?: PulseStore; workflowStore?: PulseStore }, + _options?: { systemStore?: PulseStore; workflowStore?: PulseStore }, ): Promise { const isMultiStore = typeof storeOrStores === 'object' && @@ -552,8 +552,7 @@ export async function runPulse(options: { tickMs = 0; } else { // Determine if there's active work - const hasActivity = - effects.length > 0 || (hasActiveWork && hasActiveWork(curr)); + const hasActivity = effects.length > 0 || hasActiveWork?.(curr); if (hasActivity) { // Active work detected โ†’ reset to base frequency @@ -964,15 +963,32 @@ export { buildPersonasFromEvents } from './persona.js'; // โ”€โ”€ Council v2: WorkflowType โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ export { createWorkflowTicker } from './workflows/index.js'; +export { + type AgentExecutorConfig, + type AgentResult, + type AgentRunner, + createAgentExecutorRole, + createCursorRunner, +} from './workflows/roles/agent-executor.js'; +export { + createLlmRole, + createToolRole, + type LlmRoleConfig, + type ToolRoleConfig, +} from './workflows/roles/llm-role-factory.js'; +export { + type ScaffoldOptions, + scaffoldWorkflow, +} from './workflows/scaffold.js'; +export { + createSubprocessRole, + type SubprocessRoleConfig, +} from './workflows/subprocess-role.js'; export type { WorkflowRule, WorkflowTickResult, } from './workflows/workflow-rule-adapter.js'; export { createWorkflowRule } from './workflows/workflow-rule-adapter.js'; -export { - type SubprocessRoleConfig, - createSubprocessRole, -} from './workflows/subprocess-role.js'; export { END, type MetaOf, @@ -986,20 +1002,6 @@ 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 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -1015,9 +1017,21 @@ export { gcVitals, runGc, } from './gc.js'; -// โ”€โ”€ Projection Engine โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -export * from './projection-engine.js'; - +// โ”€โ”€ Guard core (pure logic, no DB dependency โ€” for Pulseflare) โ”€โ”€ +// โ”€โ”€ Guard core (pure logic, portable โ€” for Pulseflare / D1) โ”€โ”€โ”€โ”€โ”€โ”€ +export { + checkGuardsCore, + checkGuardsCore, + evaluateGuard, + evaluateGuard, + type GuardContext, + type GuardContext, + type GuardEvalResult, + type GuardEvalResult, + type GuardUpdate, + matchEventKindPattern as matchEventKindPatternCore, + matchEventKindPattern, +} from './guard-core.js'; // โ”€โ”€ Guard projections โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ export { type GuardProjectionDef, @@ -1026,6 +1040,8 @@ export { getGuardState, registerGuard, } from './guard-projection.js'; +// โ”€โ”€ Projection Engine โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +export * from './projection-engine.js'; // โ”€โ”€ Task Event Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ export type { diff --git a/packages/pulse/src/llm-client.d.ts b/packages/pulse/src/llm-client.d.ts index 8cf406a..e035f0d 100644 --- a/packages/pulse/src/llm-client.d.ts +++ b/packages/pulse/src/llm-client.d.ts @@ -1,51 +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; + 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; - }; + 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; + 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; + 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; + 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 deleted file mode 100644 index d5dbd54..0000000 --- a/packages/pulse/src/llm-client.js +++ /dev/null @@ -1,50 +0,0 @@ -/** - * 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 index 6a1f5ad..488b409 100644 --- a/packages/pulse/src/persona.d.ts +++ b/packages/pulse/src/persona.d.ts @@ -7,4 +7,6 @@ import type { PersonaState } from './task-events.js'; * - persona-updated only patches provided fields. * - Events are merged in occurredAt order. */ -export declare function buildPersonasFromEvents(store: PulseStore): Promise>; +export declare function buildPersonasFromEvents( + store: PulseStore, +): Promise>; diff --git a/packages/pulse/src/persona.js b/packages/pulse/src/persona.js deleted file mode 100644 index 96d0066..0000000 --- a/packages/pulse/src/persona.js +++ /dev/null @@ -1,51 +0,0 @@ -/** - * 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 index 2c411a4..101b6b2 100644 --- a/packages/pulse/src/projection-engine.d.ts +++ b/packages/pulse/src/projection-engine.d.ts @@ -8,28 +8,44 @@ 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; + 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"; +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; +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; +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>; +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; +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 deleted file mode 100644 index 360a0c1..0000000 --- a/packages/pulse/src/projection-engine.js +++ /dev/null @@ -1,310 +0,0 @@ -/** - * @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 index b2b69b4..ee43037 100644 --- a/packages/pulse/src/rules/agent-loop.d.ts +++ b/packages/pulse/src/rules/agent-loop.d.ts @@ -2,15 +2,17 @@ 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; + llmClient: LlmClient; + workflowStore: PulseStore; + systemPrompt?: string; + llmTimeoutMs?: number; } type Snapshot = Record & { - timestamp: number; + timestamp: number; }; type Effect = Record; export declare const EFFECT_TOOLS: LlmTool[]; -export declare function createAgentLoopRule(opts: AgentLoopRuleOptions): Rule; -export {}; +export declare function createAgentLoopRule< + S extends Snapshot, + E extends Effect, +>(opts: AgentLoopRuleOptions): Rule; diff --git a/packages/pulse/src/rules/builtin.d.ts b/packages/pulse/src/rules/builtin.d.ts index 501d167..4474b33 100644 --- a/packages/pulse/src/rules/builtin.d.ts +++ b/packages/pulse/src/rules/builtin.d.ts @@ -17,20 +17,31 @@ export declare function clampTick(min?: number, max?: number): Rule; * tickMs *= 2^errors, capped at maxMs. * Zero errors โ†’ pass through unchanged. */ -export declare function errorBackoff(getErrors: (s: S) => number, maxMs?: number): Rule; +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; +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; + }, +>(key?: (e: E) => string): Rule; diff --git a/packages/pulse/src/rules/health.d.ts b/packages/pulse/src/rules/health.d.ts index 70b6a47..b64ffed 100644 --- a/packages/pulse/src/rules/health.d.ts +++ b/packages/pulse/src/rules/health.d.ts @@ -5,26 +5,31 @@ */ 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; + lastRestart: Record< + string, + { + ts: number; + count: number; + } + >; + 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; +export declare function rebuildHealth( + store: PulseStore, +): Promise; diff --git a/packages/pulse/src/rules/index.d.ts b/packages/pulse/src/rules/index.d.ts index 7a6d3f9..bcadfe1 100644 --- a/packages/pulse/src/rules/index.d.ts +++ b/packages/pulse/src/rules/index.d.ts @@ -1,4 +1,10 @@ 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 { + 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/survival.d.ts b/packages/pulse/src/rules/survival.d.ts index 9bf9797..6aa177c 100644 --- a/packages/pulse/src/rules/survival.d.ts +++ b/packages/pulse/src/rules/survival.d.ts @@ -13,17 +13,17 @@ 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; + processOk: boolean; + completionOk?: boolean; } export interface SurvivalSnapshot { - timestamp: number; - system?: Sensed; - processes?: Sensed; - network?: Sensed; - errorLog?: Sensed; - llm?: Sensed; - health?: HealthSnapshot; + timestamp: number; + system?: Sensed; + processes?: Sensed; + network?: Sensed; + errorLog?: Sensed; + llm?: Sensed; + health?: HealthSnapshot; } /** * Panic rollback - outermost fallback @@ -58,4 +58,3 @@ 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/store.d.ts b/packages/pulse/src/store.d.ts index 43e33d5..d71509d 100644 --- a/packages/pulse/src/store.d.ts +++ b/packages/pulse/src/store.d.ts @@ -6,92 +6,105 @@ */ import { Database } from 'bun:sqlite'; export interface EventRecord { - id: number; - occurredAt: number; - kind: string; - key?: string; - hash?: string; - codeRev?: string; - meta?: string; - objectId?: number; + 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; + 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; + /** 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; + 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; + 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; + 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; +export declare function createScopedStore( + options: CreateScopedStoreOptions, +): ScopedStore; diff --git a/packages/pulse/src/store.js b/packages/pulse/src/store.js deleted file mode 100644 index f7d95f1..0000000 --- a/packages/pulse/src/store.js +++ /dev/null @@ -1,525 +0,0 @@ -/** - * @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 index 501570a..80ec44f 100644 --- a/packages/pulse/src/task-events.d.ts +++ b/packages/pulse/src/task-events.d.ts @@ -1,121 +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; + taskId: string; + projectId: string; + title: string; + description: string; + type: TaskType; + priority: number; + creatorId: string; } export interface TaskRoutingMeta { - taskId: string; - brokerSessionId: string; + taskId: string; + brokerSessionId: string; } export interface TaskAssignedMeta { - taskId: string; - assigneeId: string; - assignedBy: string; + taskId: string; + assigneeId: string; + assignedBy: string; } export interface TaskRespondedMeta { - taskId: string; - assigneeId: string; - result: string; + taskId: string; + assigneeId: string; + result: string; } export interface TaskClosedMeta { - taskId: string; - creatorId: string; + taskId: string; + creatorId: string; } export interface ProjectCreatedMeta { - projectId: string; - name: string; - repoDir: string; + 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; + 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; + pendingCount: number; + tasks: TaskState[]; + byProject: Record; + checkedAt: number; } export interface ProjectState { - projectId: string; - name: string; - repoDir: string; + projectId: string; + name: string; + repoDir: string; } export interface InflightBrokerData { - active: boolean; + 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[]; + personaId: string; + name: string; + container: ContainerType; + capabilities: string[]; } export interface PersonaUpdatedMeta { - personaId: string; - container?: ContainerType; - capabilities?: string[]; + personaId: string; + container?: ContainerType; + capabilities?: string[]; } export interface PersonaState { - personaId: string; - name: string; - container: ContainerType; - capabilities: string[]; - registeredAt: number; - updatedAt: number; + personaId: string; + name: string; + container: ContainerType; + capabilities: string[]; + registeredAt: number; + updatedAt: number; } export interface LlmCallStartedMeta { - projectId: string; - model?: string; + projectId: string; + model?: string; } export interface LlmCallCompletedMeta { - projectId: string; - model?: string; - usage?: { - inputTokens: number; - outputTokens: number; - }; - toolCalls?: Array<{ - name: string; - arguments: Record; - }>; - durationMs: number; + 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; + 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; + role: 'assistant' | 'tool'; + content?: string; + toolCalls?: LlmCallCompletedMeta['toolCalls']; + toolName?: string; + result?: string; + ts: number; } export interface AgentLoopTraceData { - messages: TraceMessage[]; - nextCheckAt: number; + messages: TraceMessage[]; + nextCheckAt: number; } export interface ActiveProjectsData { - projectIds: string[]; + projectIds: string[]; } diff --git a/packages/pulse/src/task-events.js b/packages/pulse/src/task-events.js deleted file mode 100644 index cb0ff5c..0000000 --- a/packages/pulse/src/task-events.js +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/packages/pulse/src/watcher.d.ts b/packages/pulse/src/watcher.d.ts index c0f35a8..2e09da2 100644 --- a/packages/pulse/src/watcher.d.ts +++ b/packages/pulse/src/watcher.d.ts @@ -11,47 +11,49 @@ import type { EventRecord, PulseStore } from './store.js'; * without re-fetching from the object store. */ export interface VitalWithData extends EventRecord { - data: T; + 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; +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; + /** 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; + /** 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. @@ -70,4 +72,8 @@ export interface WatcherHandle { * @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; +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 deleted file mode 100644 index 718fc0f..0000000 --- a/packages/pulse/src/watcher.js +++ /dev/null @@ -1,90 +0,0 @@ -/** - * @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 index cd3b7ea..2a65ddd 100644 --- a/packages/pulse/src/watchers/error-log.d.ts +++ b/packages/pulse/src/watchers/error-log.d.ts @@ -1,12 +1,14 @@ import type { WatcherDef } from '../watcher.js'; export interface ErrorLogData { - matches: string[]; - source: string; + matches: string[]; + source: string; } export interface ErrorLogOptions { - /** ่ฆ็›‘ๆŽง็š„ๆ—ฅๅฟ—ๆ–‡ไปถ่ทฏๅพ„ๅˆ—่กจ */ - logFiles: string[]; - /** ่งฆๅ‘ๅ”ค้†’็š„ๅ…ณ้”ฎ่ฏ */ - keywords?: string[]; + /** ่ฆ็›‘ๆŽง็š„ๆ—ฅๅฟ—ๆ–‡ไปถ่ทฏๅพ„ๅˆ—่กจ */ + logFiles: string[]; + /** ่งฆๅ‘ๅ”ค้†’็š„ๅ…ณ้”ฎ่ฏ */ + keywords?: string[]; } -export declare function errorLogWatcher(opts: ErrorLogOptions): WatcherDef; +export declare function errorLogWatcher( + opts: ErrorLogOptions, +): WatcherDef; diff --git a/packages/pulse/src/watchers/index.d.ts b/packages/pulse/src/watchers/index.d.ts index fdb513f..99c6284 100644 --- a/packages/pulse/src/watchers/index.d.ts +++ b/packages/pulse/src/watchers/index.d.ts @@ -1,4 +1,20 @@ -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'; +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/network.d.ts b/packages/pulse/src/watchers/network.d.ts index 6092324..d7a8b88 100644 --- a/packages/pulse/src/watchers/network.d.ts +++ b/packages/pulse/src/watchers/network.d.ts @@ -1,15 +1,17 @@ import * as dns from 'node:dns'; import type { WatcherDef } from '../watcher.js'; export interface NetworkData { - dnsOk: boolean; - httpOk: boolean; - latencyMs: number; + 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; + dnsHost?: string; + httpUrl?: string; + timeoutMs?: number; + /** Inject DNS resolve function for testing */ + dnsResolveFn?: typeof dns.promises.resolve; } -export declare function networkWatcher(opts?: NetworkOptions): WatcherDef; +export declare function networkWatcher( + opts?: NetworkOptions, +): WatcherDef; diff --git a/packages/pulse/src/watchers/process-alive.d.ts b/packages/pulse/src/watchers/process-alive.d.ts index 18854a8..fcd4d9d 100644 --- a/packages/pulse/src/watchers/process-alive.d.ts +++ b/packages/pulse/src/watchers/process-alive.d.ts @@ -1,12 +1,14 @@ import { execSync } from 'node:child_process'; import type { WatcherDef } from '../watcher.js'; export interface ProcessAliveData { - processes: Record; + processes: Record; } export interface ProcessAliveOptions { - /** ่ฆ็›‘ๆŽง็š„่ฟ›็จ‹ๅˆ—่กจ๏ผšname โ†’ ๅŒน้…ๅ‘ฝไปค่กŒ็š„ๅ…ณ้”ฎ่ฏ */ - processes: Record; - /** Inject execSync for testing */ - execSyncFn?: typeof execSync; + /** ่ฆ็›‘ๆŽง็š„่ฟ›็จ‹ๅˆ—่กจ๏ผšname โ†’ ๅŒน้…ๅ‘ฝไปค่กŒ็š„ๅ…ณ้”ฎ่ฏ */ + processes: Record; + /** Inject execSync for testing */ + execSyncFn?: typeof execSync; } -export declare function processAliveWatcher(opts: ProcessAliveOptions): WatcherDef; +export declare function processAliveWatcher( + opts: ProcessAliveOptions, +): WatcherDef; diff --git a/packages/pulse/src/watchers/system-resource.d.ts b/packages/pulse/src/watchers/system-resource.d.ts index 891cda5..c8b2efb 100644 --- a/packages/pulse/src/watchers/system-resource.d.ts +++ b/packages/pulse/src/watchers/system-resource.d.ts @@ -3,20 +3,22 @@ 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; + 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; + 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; +export declare function systemResourceWatcher( + opts?: SystemResourceOptions, +): WatcherDef; diff --git a/packages/pulse/src/workflows/index.d.ts b/packages/pulse/src/workflows/index.d.ts index ec06bdd..2deba16 100644 --- a/packages/pulse/src/workflows/index.d.ts +++ b/packages/pulse/src/workflows/index.d.ts @@ -3,20 +3,47 @@ * * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) */ -export type { MetaCoderMeta, MetaTesterMeta, } from './meta.js'; +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 { + 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'; +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; +export declare function createWorkflowTicker( + rules: WorkflowRule[], +): () => Promise; diff --git a/packages/pulse/src/workflows/index.ts b/packages/pulse/src/workflows/index.ts index 4879976..4bea0ca 100644 --- a/packages/pulse/src/workflows/index.ts +++ b/packages/pulse/src/workflows/index.ts @@ -21,14 +21,14 @@ export type { ToolRoleConfig, } from './roles/llm-role-factory.js'; export { createLlmRole, createToolRole } from './roles/llm-role-factory.js'; +export { createMetaCheckerRole } from './roles/meta-checker.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 SubprocessRoleConfig, - createSubprocessRole, -} from './subprocess-role.js'; export { type ScaffoldOptions, scaffoldWorkflow } from './scaffold.js'; +export { + createSubprocessRole, + type SubprocessRoleConfig, +} from './subprocess-role.js'; export { createWorkflowRule, type WorkflowRule, diff --git a/packages/pulse/src/workflows/meta.d.ts b/packages/pulse/src/workflows/meta.d.ts index 2be7f62..494cc67 100644 --- a/packages/pulse/src/workflows/meta.d.ts +++ b/packages/pulse/src/workflows/meta.d.ts @@ -17,27 +17,29 @@ */ import { type Role, type WorkflowType } from './workflow-type.js'; export interface MetaCoderMeta { - [key: string]: unknown; - filesChanged: string[]; - testsPassed: boolean; + [key: string]: unknown; + filesChanged: string[]; + testsPassed: boolean; } export interface MetaCheckerMeta { - [key: string]: unknown; - pass: boolean; - reason: string; - violations?: string[]; + [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; + [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; + coder: Role; + checker: Role; + tester: Role; }; -export declare function createMetaWorkflow(roles: MetaWorkflowRoles): WorkflowType; +export declare function createMetaWorkflow( + roles: MetaWorkflowRoles, +): WorkflowType; diff --git a/packages/pulse/src/workflows/meta.test.ts b/packages/pulse/src/workflows/meta.test.ts index 459ea8d..b6f0df9 100644 --- a/packages/pulse/src/workflows/meta.test.ts +++ b/packages/pulse/src/workflows/meta.test.ts @@ -8,12 +8,7 @@ import { mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createStore } from '../store.js'; -import { - createMetaWorkflow, - type MetaCheckerMeta, - type MetaCoderMeta, - type MetaTesterMeta, -} from './meta.js'; +import { createMetaWorkflow } from './meta.js'; import { createWorkflowRule } from './workflow-rule-adapter.js'; import { END, START } from './workflow-type.js'; @@ -37,7 +32,11 @@ const mockCheckerPass = async () => ({ const mockCheckerFail = async () => ({ content: 'fail', - meta: { pass: false, reason: 'ๆ–‡ไปถ่Œƒๅ›ด่ถŠ็•Œ', violations: ['่ถŠ็•Œๆ–‡ไปถ: package.json'] }, + meta: { + pass: false, + reason: 'ๆ–‡ไปถ่Œƒๅ›ด่ถŠ็•Œ', + violations: ['่ถŠ็•Œๆ–‡ไปถ: package.json'], + }, }); const mockTesterPass = async () => ({ @@ -72,10 +71,7 @@ describe('Meta Workflow', () => { ), ).toBe('tester'); expect( - wf.moderator( - { role: 'tester', meta: { pass: true, reason: 'ok' } }, - 'x', - ), + wf.moderator({ role: 'tester', meta: { pass: true, reason: 'ok' } }, 'x'), ).toBe(END); }); @@ -200,7 +196,14 @@ describe('Meta Workflow', () => { } // coderโ†’checkerโ†’tester(fail)โ†’coderโ†’checkerโ†’tester(pass) - expect(roles).toEqual(['coder', 'checker', 'tester', 'coder', 'checker', 'tester']); + expect(roles).toEqual([ + 'coder', + 'checker', + 'tester', + 'coder', + 'checker', + 'tester', + ]); await store.close(); }); }); diff --git a/packages/pulse/src/workflows/roles/agent-executor.d.ts b/packages/pulse/src/workflows/roles/agent-executor.d.ts index f8c62d3..58e3065 100644 --- a/packages/pulse/src/workflows/roles/agent-executor.d.ts +++ b/packages/pulse/src/workflows/roles/agent-executor.d.ts @@ -10,40 +10,47 @@ 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; + success: boolean; + output: string; + durationMs: number; } export interface AgentRunner { - run(prompt: string, cwd: string): Promise; + run(prompt: string, cwd: string): Promise; } /** * Default agent runner โ€” Cursor CLI. */ export declare function createCursorRunner(opts: { - agentBin: string; - timeoutMs?: number; + 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; - }; + /** 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; +export declare function createAgentExecutorRole( + agent: AgentRunner, + llm: LlmClient, + config: AgentExecutorConfig, +): Role; diff --git a/packages/pulse/src/workflows/roles/llm-role-factory.d.ts b/packages/pulse/src/workflows/roles/llm-role-factory.d.ts index 63db1c2..18c1a76 100644 --- a/packages/pulse/src/workflows/roles/llm-role-factory.d.ts +++ b/packages/pulse/src/workflows/roles/llm-role-factory.d.ts @@ -17,47 +17,62 @@ 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; + /** 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; - }; +export declare function createLlmRole>( + llm: LlmClient, + config: LlmRoleConfig, +): Role; +export interface ToolRoleConfig< + TMeta extends Record, + 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; + }; + /** 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; +export declare function createToolRole< + TMeta extends Record, + TToolResult = unknown, +>(llm: LlmClient, config: ToolRoleConfig): Role; diff --git a/packages/pulse/src/workflows/roles/meta-checker.d.ts b/packages/pulse/src/workflows/roles/meta-checker.d.ts index 3c9dcf7..3a73554 100644 --- a/packages/pulse/src/workflows/roles/meta-checker.d.ts +++ b/packages/pulse/src/workflows/roles/meta-checker.d.ts @@ -13,14 +13,14 @@ */ import type { Role } from '../workflow-type.js'; export interface MetaCheckerMeta { - [key: string]: unknown; - pass: boolean; - reason: string; - violations?: string[]; + [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[]; + /** 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.ts b/packages/pulse/src/workflows/roles/meta-checker.ts index a459508..63c3e58 100644 --- a/packages/pulse/src/workflows/roles/meta-checker.ts +++ b/packages/pulse/src/workflows/roles/meta-checker.ts @@ -41,7 +41,9 @@ export function createMetaCheckerRole(opts: { let changedFiles: string[] = []; 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'); + 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 @@ -68,7 +70,9 @@ export function createMetaCheckerRole(opts: { file.startsWith(prefix), ); if (!allowed) { - violations.push(`่ถŠ็•Œๆ–‡ไปถ: ${file}๏ผˆๅชๅ…่ฎธไฟฎๆ”น ${allowedPrefixes.join(', ')} ไธ‹็š„ๆ–‡ไปถ๏ผ‰`); + violations.push( + `่ถŠ็•Œๆ–‡ไปถ: ${file}๏ผˆๅชๅ…่ฎธไฟฎๆ”น ${allowedPrefixes.join(', ')} ไธ‹็š„ๆ–‡ไปถ๏ผ‰`, + ); } } diff --git a/packages/pulse/src/workflows/roles/meta-coder-cursor.d.ts b/packages/pulse/src/workflows/roles/meta-coder-cursor.d.ts index aba0ae4..5c1a944 100644 --- a/packages/pulse/src/workflows/roles/meta-coder-cursor.d.ts +++ b/packages/pulse/src/workflows/roles/meta-coder-cursor.d.ts @@ -8,4 +8,8 @@ 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; +export declare function createMetaCoderRole( + runner: AgentRunner, + llm: LlmClient, + repoDir: string, +): Role; diff --git a/packages/pulse/src/workflows/roles/meta-coder-cursor.ts b/packages/pulse/src/workflows/roles/meta-coder-cursor.ts index 2101c31..cd223c8 100644 --- a/packages/pulse/src/workflows/roles/meta-coder-cursor.ts +++ b/packages/pulse/src/workflows/roles/meta-coder-cursor.ts @@ -42,11 +42,13 @@ export function createMetaCoderRole( 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 testerFeedback = testerMsg + ? `\n\n## ไธŠๆฌก้ชŒ่ฏๅคฑ่ดฅ\n${testerMsg.content}` + : ''; + const prompt = `# ไปปๅŠก ${taskDescription} ${testerFeedback} diff --git a/packages/pulse/src/workflows/roles/meta-tester.d.ts b/packages/pulse/src/workflows/roles/meta-tester.d.ts index f0bfb54..3350e2d 100644 --- a/packages/pulse/src/workflows/roles/meta-tester.d.ts +++ b/packages/pulse/src/workflows/roles/meta-tester.d.ts @@ -10,11 +10,11 @@ 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; + 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.ts b/packages/pulse/src/workflows/roles/meta-tester.ts index 827b222..97bd609 100644 --- a/packages/pulse/src/workflows/roles/meta-tester.ts +++ b/packages/pulse/src/workflows/roles/meta-tester.ts @@ -12,10 +12,15 @@ import { execSync } from 'node:child_process'; import { existsSync, mkdtempSync, readdirSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import type { MetaTesterMeta } from '../meta.js'; -import type { Role, RoleResult, WorkflowMessage, WorkflowType } from '../workflow-type.js'; import { createStore } from '../../store.js'; +import type { MetaTesterMeta } from '../meta.js'; import { createWorkflowRule } from '../workflow-rule-adapter.js'; +import type { + Role, + RoleResult, + WorkflowMessage, + WorkflowType, +} from '../workflow-type.js'; export function createMetaTesterRole(opts: { repoDir: string; @@ -60,15 +65,31 @@ export function createMetaTesterRole(opts: { 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 as any) && 'roles' in (val as any) && 'moderator' in (val as any)) { + if ( + val && + typeof val === 'object' && + 'name' in (val as any) && + 'roles' in (val as any) && + 'moderator' in (val as any) + ) { workflows.push(val as WorkflowType); } // Factory function (e.g. `export function createWerewolfWorkflow()`) - if (typeof val === 'function' && key.startsWith('create') && key.endsWith('Workflow')) { + 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) { + if ( + wf && + typeof wf === 'object' && + 'name' in wf && + 'roles' in wf && + 'moderator' in wf + ) { workflows.push(wf as WorkflowType); } } catch { @@ -140,9 +161,13 @@ export function createMetaTesterRole(opts: { if (!completed) { try { const allEvents = await testStore.getAfter(0); - const wfEvents = allEvents.filter((ev: any) => ev.kind.startsWith(`${wf.name}.`)); + const wfEvents = allEvents.filter((ev: any) => + ev.kind.startsWith(`${wf.name}.`), + ); const lastEvent = wfEvents[wfEvents.length - 1]; - const roles = wfEvents.map((ev: any) => ev.kind.replace(`${wf.name}.`, '')).join(' โ†’ '); + const roles = wfEvents + .map((ev: any) => ev.kind.replace(`${wf.name}.`, '')) + .join(' โ†’ '); diagnostic = `\n ไบ‹ไปถ้“พ: ${roles}`; if (lastEvent) { diagnostic += `\n ๆœ€ๅŽไบ‹ไปถ: ${lastEvent.kind} (id=${lastEvent.id})`; @@ -158,11 +183,23 @@ export function createMetaTesterRole(opts: { } if (completed) { - results.push({ name: wf.name, pass: true, detail: `completed in ${tickCount} ticks` }); + 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}` }); + 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}` }); + results.push({ + name: wf.name, + pass: false, + detail: `did not complete in ${tickCount} ticks${diagnostic}`, + }); } } finally { await testStore.close(); @@ -209,12 +246,16 @@ export function createMetaTesterRole(opts: { 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; } - })(); + const remote = + opts.remote ?? + (() => { + try { + const remotes = exec('git remote').split('\n').filter(Boolean); + return remotes[0] || null; + } catch { + return null; + } + })(); if (remote) { try { @@ -227,14 +268,19 @@ export function createMetaTesterRole(opts: { pushed = false; } } - } catch (err: any) { + } catch (_err: any) { 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 }, + 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 index ceafde0..b742e47 100644 --- a/packages/pulse/src/workflows/scaffold.d.ts +++ b/packages/pulse/src/workflows/scaffold.d.ts @@ -11,8 +11,8 @@ * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) */ export interface ScaffoldOptions { - name: string; - roles?: string[]; - workflowsDir: string; + name: string; + roles?: string[]; + workflowsDir: string; } export declare function scaffoldWorkflow(opts: ScaffoldOptions): string[]; diff --git a/packages/pulse/src/workflows/scaffold.ts b/packages/pulse/src/workflows/scaffold.ts index 756a1a6..4ae4b30 100644 --- a/packages/pulse/src/workflows/scaffold.ts +++ b/packages/pulse/src/workflows/scaffold.ts @@ -11,7 +11,7 @@ * ๅฐๆฉ˜ ๐ŸŠ (NEKO Team) */ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; export interface ScaffoldOptions { @@ -49,7 +49,7 @@ export function scaffoldWorkflow(opts: ScaffoldOptions): string[] { const moderatorCases = roles .map((r, i) => { - const next = i < roles.length - 1 ? `'${roles[i + 1]}'` : 'END'; + 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}';`; }) diff --git a/packages/pulse/src/workflows/subprocess-role.test.ts b/packages/pulse/src/workflows/subprocess-role.test.ts index 787030f..99fdda8 100644 --- a/packages/pulse/src/workflows/subprocess-role.test.ts +++ b/packages/pulse/src/workflows/subprocess-role.test.ts @@ -17,7 +17,12 @@ describe('createSubprocessRole', () => { }); const chain = [ - { role: '__start__', content: 'hello', meta: null, timestamp: Date.now() }, + { + role: '__start__', + content: 'hello', + meta: null, + timestamp: Date.now(), + }, ]; const result = await role(chain, 'topic-1', null as any); diff --git a/packages/pulse/src/workflows/subprocess-role.ts b/packages/pulse/src/workflows/subprocess-role.ts index a451e64..fb3749d 100644 --- a/packages/pulse/src/workflows/subprocess-role.ts +++ b/packages/pulse/src/workflows/subprocess-role.ts @@ -32,13 +32,10 @@ const RUNNER_PATH = join(import.meta.dir, 'subprocess-runner.ts'); * Wrap a Role as a subprocess execution. * Returns a function matching the Role signature. */ -export function createSubprocessRole(config: SubprocessRoleConfig): Role { - const { - rolePath, - roleExport, - timeoutMs = 300_000, - storeConfig, - } = config; +export function createSubprocessRole( + config: SubprocessRoleConfig, +): Role { + const { rolePath, roleExport, timeoutMs = 300_000, storeConfig } = config; return async ( chain: WorkflowMessage[],