refactor: extract guard-core pure logic + fix check-before-transition (refs #9)
CI / test (push) Has been cancelled

This commit is contained in:
2026-04-18 15:14:29 +00:00
parent d6b607d610
commit ff1efdf3d2
57 changed files with 1603 additions and 3192 deletions
-1
View File
@@ -6,7 +6,6 @@ import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { import {
composeRules,
createStore, createStore,
type PulseStore, type PulseStore,
type Rule, type Rule,
+17 -12
View File
@@ -12,14 +12,14 @@
import { existsSync, mkdirSync } from 'node:fs'; import { existsSync, mkdirSync } from 'node:fs';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import {
createAnalystRole,
createCodingWorkflow,
createRendererRole,
createReportWorkflow,
} from '@upulse/workflows';
import { createOpenAiLlmClient } from '../llm-client.js'; import { createOpenAiLlmClient } from '../llm-client.js';
import { createStore } from '../store.js'; import { createStore } from '../store.js';
import {
createCodingWorkflow,
createReportWorkflow,
createAnalystRole,
createRendererRole,
} from '@upulse/workflows';
import { createWorkflowTicker } from '../workflows/index.js'; import { createWorkflowTicker } from '../workflows/index.js';
import { createCursorRunner } from '../workflows/roles/agent-executor.js'; import { createCursorRunner } from '../workflows/roles/agent-executor.js';
import { createWorkflowRule } from '../workflows/workflow-rule-adapter.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 // 2. Meta workflow — engine override > core fallback
const metaMod = await tryLoadFromEngine( const metaMod = await tryLoadFromEngine(
'src/workflows/meta.ts', 'src/workflows/meta.ts',
() => import('../workflows/meta.js') () => import('../workflows/meta.js'),
); );
const metaCoderMod = await tryLoadFromEngine( const metaCoderMod = await tryLoadFromEngine(
'src/workflows/roles/meta-coder-cursor.ts', '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( const metaCheckerMod = await tryLoadFromEngine(
'src/workflows/roles/meta-checker.ts', 'src/workflows/roles/meta-checker.ts',
() => import('../workflows/roles/meta-checker.js') () => import('../workflows/roles/meta-checker.js'),
); );
const metaTesterMod = await tryLoadFromEngine( const metaTesterMod = await tryLoadFromEngine(
'src/workflows/roles/meta-tester.ts', '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 // Load gate role if available from engine
@@ -129,7 +129,9 @@ console.log(` Store: ${DATA_DIR}/workflows.db`);
console.log( console.log(
` Tick: adaptive ${BASE_TICK_MS / 1000}s → ${MAX_TICK_MS / 1000}s (×${BACKOFF_FACTOR})`, ` 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(` Workflows: coding, meta, report`);
console.log(''); console.log('');
@@ -180,7 +182,10 @@ scheduleNext();
/** /**
* Try loading a workflow module from ENGINE_DIR, fallback to core package. * Try loading a workflow module from ENGINE_DIR, fallback to core package.
*/ */
async function tryLoadFromEngine<T>(engineRelPath: string, fallbackImport: () => Promise<T>): Promise<T> { async function tryLoadFromEngine<T>(
engineRelPath: string,
fallbackImport: () => Promise<T>,
): Promise<T> {
const enginePath = join(ENGINE_DIR, engineRelPath); const enginePath = join(ENGINE_DIR, engineRelPath);
if (existsSync(enginePath)) { if (existsSync(enginePath)) {
console.log(` 📦 Loading from engine: ${engineRelPath}`); console.log(` 📦 Loading from engine: ${engineRelPath}`);
+71 -44
View File
@@ -6,77 +6,104 @@
*/ */
import type { Database } from 'bun:sqlite'; import type { Database } from 'bun:sqlite';
export interface ObjectDef { export interface ObjectDef {
name: string; name: string;
codeRev: string; codeRev: string;
createdAt: number; createdAt: number;
} }
export interface EventDef { export interface EventDef {
hash: string; hash: string;
name: string; name: string;
parentHash?: string; parentHash?: string;
schema?: any; schema?: any;
codeRev: string; codeRev: string;
createdAt: number; createdAt: number;
} }
export interface ProjectionDef { export interface ProjectionDef {
hash: string; hash: string;
name: string; name: string;
parentHash?: string; parentHash?: string;
params?: any; params?: any;
valueSchema?: any; valueSchema?: any;
initialValue: any; initialValue: any;
codeRev: string; codeRev: string;
createdAt: number; createdAt: number;
sources: Array<{ sources: Array<{
eventKind: string; eventKind: string;
eventKey?: string; eventKey?: string;
expression: string; expression: string;
}>; }>;
} }
export interface ValidationResult { export interface ValidationResult {
valid: boolean; valid: boolean;
result?: any; result?: any;
error?: string; error?: string;
} }
/** /**
* Initialize the definition schema on an existing database connection. * Initialize the definition schema on an existing database connection.
* Use this when you manage the database lifecycle externally. * Use this when you manage the database lifecycle externally.
*/ */
export declare function initDefsSchema(db: Database): Promise<void>; export declare function initDefsSchema(db: Database): Promise<void>;
export declare function registerObjectDef(db: Database, opts: { export declare function registerObjectDef(
db: Database,
opts: {
name: string; name: string;
codeRev: string; codeRev: string;
}): Promise<ObjectDef>; },
export declare function getObjectDef(db: Database, name: string, codeRev: string): Promise<ObjectDef | null>; ): Promise<ObjectDef>;
export declare function registerEventDef(db: Database, opts: { export declare function getObjectDef(
db: Database,
name: string,
codeRev: string,
): Promise<ObjectDef | null>;
export declare function registerEventDef(
db: Database,
opts: {
name: string; name: string;
schema?: any; schema?: any;
parentHash?: string; parentHash?: string;
codeRev: string; codeRev: string;
}): Promise<EventDef>; },
export declare function getEventDef(db: Database, name: string, codeRev: string): Promise<EventDef | null>; ): Promise<EventDef>;
export declare function listEventDefs(db: Database, opts: { export declare function getEventDef(
db: Database,
name: string,
codeRev: string,
): Promise<EventDef | null>;
export declare function listEventDefs(
db: Database,
opts: {
codeRev: string; codeRev: string;
}): Promise<EventDef[]>; },
export declare function registerProjectionDef(db: Database, opts: { ): Promise<EventDef[]>;
export declare function registerProjectionDef(
db: Database,
opts: {
name: string; name: string;
params?: any; params?: any;
valueSchema?: any; valueSchema?: any;
initialValue: any; initialValue: any;
sources: Array<{ sources: Array<{
eventKind: string; eventKind: string;
eventKey?: string; eventKey?: string;
expression: string; expression: string;
}>; }>;
parentHash?: string; parentHash?: string;
codeRev: string; codeRev: string;
}): Promise<ProjectionDef>; },
export declare function getProjectionDef(db: Database, name: string, codeRev: string): Promise<ProjectionDef | null>; ): Promise<ProjectionDef>;
export declare function listProjectionDefs(db: Database, opts: { export declare function getProjectionDef(
db: Database,
name: string,
codeRev: string,
): Promise<ProjectionDef | null>;
export declare function listProjectionDefs(
db: Database,
opts: {
codeRev: string; codeRev: string;
}): Promise<ProjectionDef[]>; },
): Promise<ProjectionDef[]>;
export declare function validateExpression(opts: { export declare function validateExpression(opts: {
expression: string; expression: string;
initialValue: any; initialValue: any;
mockEvent: any; mockEvent: any;
}): Promise<ValidationResult>; }): Promise<ValidationResult>;
-322
View File
@@ -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,
};
}
}
+1 -2
View File
@@ -10,10 +10,9 @@
import { mkdtempSync, rmSync } from 'node:fs'; import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { createArchitectRole, createCodingWorkflow } from '@upulse/workflows';
import { createOpenAiLlmClient } from '../llm-client.js'; import { createOpenAiLlmClient } from '../llm-client.js';
import { createStore } from '../store.js'; import { createStore } from '../store.js';
import { createCodingWorkflow } from '@upulse/workflows';
import { createArchitectRole } from '@upulse/workflows';
import { createWorkflowRule } from '../workflows/workflow-rule-adapter.js'; import { createWorkflowRule } from '../workflows/workflow-rule-adapter.js';
const SEP = '─'.repeat(50); const SEP = '─'.repeat(50);
+5 -3
View File
@@ -12,11 +12,13 @@
import { mkdtempSync } from 'node:fs'; import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import {
createCoderRole,
createCodingWorkflow,
createReviewerRole,
} from '@upulse/workflows';
import { createStore } from '../index.js'; import { createStore } from '../index.js';
import { createOpenAiLlmClient } from '../llm-client.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 { createWorkflowRule } from '../workflows/workflow-rule-adapter.js';
import type { WorkflowMessage } from '../workflows/workflow-type.js'; import type { WorkflowMessage } from '../workflows/workflow-type.js';
+5 -3
View File
@@ -13,11 +13,13 @@
import { mkdtempSync, writeFileSync } from 'node:fs'; import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import {
createAnalystRole,
createRendererRole,
createReportWorkflow,
} from '@upulse/workflows';
import { createStore } from '../index.js'; import { createStore } from '../index.js';
import { createOpenAiLlmClient } from '../llm-client.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'; import { createWorkflowRule } from '../workflows/workflow-rule-adapter.js';
// ── Args ─────────────────────────────────────────────────────── // ── Args ───────────────────────────────────────────────────────
@@ -8,8 +8,8 @@ import { afterEach, describe, expect, it } from 'bun:test';
import { mkdtempSync, rmSync } from 'node:fs'; import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { createStore, type PulseStore } from '../store.js';
import { createCodingWorkflow } from '@upulse/workflows'; import { createCodingWorkflow } from '@upulse/workflows';
import { createStore, type PulseStore } from '../store.js';
import { createWorkflowRule } from '../workflows/workflow-rule-adapter.js'; import { createWorkflowRule } from '../workflows/workflow-rule-adapter.js';
describe('Council v2 E2E', () => { describe('Council v2 E2E', () => {
+234 -119
View File
@@ -2,27 +2,25 @@
* 狼人杀 LLM 对战 — 接真 LLM 跑一局 * 狼人杀 LLM 对战 — 接真 LLM 跑一局
* *
* bun run packages/pulse/src/e2e/werewolf-live.ts * bun run packages/pulse/src/e2e/werewolf-live.ts
* *
* 小橘 🍊 (NEKO Team) * 小橘 🍊 (NEKO Team)
*/ */
import { createScopedStore, createWorkflowTicker } from '../index.js';
import { import {
createWerewolfWorkflow,
createPlayers, createPlayers,
parseGameState, createWerewolfWorkflow,
filterChainForPlayer,
type WolfNightMeta,
type SeerCheckMeta,
type WitchActionMeta,
type DaySpeechMeta, type DaySpeechMeta,
type VoteMeta, filterChainForPlayer,
type HunterShotMeta,
type GameEndMeta, type GameEndMeta,
type HunterShotMeta,
type Player, type Player,
type Identity, parseGameState,
type GameState, type SeerCheckMeta,
type VoteMeta,
type WitchActionMeta,
type WolfNightMeta,
} from '@upulse/workflows'; } from '@upulse/workflows';
import { createWorkflowTicker } from '../index.js';
import type { Role, WorkflowMessage } from '../workflows/workflow-type.js'; import type { Role, WorkflowMessage } from '../workflows/workflow-type.js';
// ── LLM Client ───────────────────────────────────────────────── // ── LLM Client ─────────────────────────────────────────────────
@@ -35,21 +33,27 @@ interface LlmMessage {
content: string; content: string;
} }
async function callLlm(messages: LlmMessage[], temperature = 0.8): Promise<string> { async function callLlm(
const res = await fetch('https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions', { messages: LlmMessage[],
method: 'POST', temperature = 0.8,
headers: { ): Promise<string> {
'Authorization': `Bearer ${DASHSCOPE_API_KEY}`, const res = await fetch(
'Content-Type': 'application/json', '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', const data = (await res.json()) as any;
messages,
temperature,
max_tokens: 800,
}),
});
const data = await res.json() as any;
return data.choices?.[0]?.message?.content ?? ''; return data.choices?.[0]?.message?.content ?? '';
} }
@@ -68,9 +72,10 @@ const PERSONALITIES = [
]; ];
function buildSystemPrompt(player: Player, personality: string): string { function buildSystemPrompt(player: Player, personality: string): string {
const identityInfo = player.identity.team === 'wolf' const identityInfo =
? `你的身份是【狼人】。你的同伴是其他狼人(玩家1、玩家2、玩家3)。\n胜利条件:淘汰所有好人。\n夜晚你和狼人同伴商量杀谁。白天你需要伪装成好人,不被投票出局。` player.identity.team === 'wolf'
: `你的身份是【${player.identity.name}\n胜利条件:找出并淘汰所有人。\n${player.identity.abilities ? `特殊能力:${player.identity.abilities}` : '你没有特殊能力,靠发言和投票帮助好人阵营。'}`; ? `你的身份是【狼人】。你的同伴是其他狼人(玩家1、玩家2、玩家3)\n胜利条件:淘汰所有人。\n夜晚你和狼人同伴商量杀谁。白天你需要伪装成好人,不被投票出局。`
: `你的身份是【${player.identity.name}】。\n胜利条件:找出并淘汰所有狼人。\n${player.identity.abilities ? `特殊能力:${player.identity.abilities}` : '你没有特殊能力,靠发言和投票帮助好人阵营。'}`;
return `你正在玩一局9人狼人杀游戏。你是 ${player.name} return `你正在玩一局9人狼人杀游戏。你是 ${player.name}
性格特征:${personality} 性格特征:${personality}
@@ -87,37 +92,49 @@ ${identityInfo}
// ── LLM Roles ────────────────────────────────────────────────── // ── LLM Roles ──────────────────────────────────────────────────
const players = createPlayers(); const _players = createPlayers();
function getVisibleHistory(chain: WorkflowMessage[], player: Player): string { function getVisibleHistory(chain: WorkflowMessage[], player: Player): string {
const visible = filterChainForPlayer(chain, player.id, player.identity); const visible = filterChainForPlayer(chain, player.id, player.identity);
if (visible.length === 0) return '(暂无历史信息)'; if (visible.length === 0) return '(暂无历史信息)';
return visible.map(m => m.content).join('\n'); return visible.map((m) => m.content).join('\n');
} }
const llmWolfNight: Role<WolfNightMeta> = async (chain) => { const llmWolfNight: Role<WolfNightMeta> = async (chain) => {
const state = parseGameState(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 goodAlive = state.alive.filter(p => p.identity.team === 'good'); const goodAlive = state.alive.filter((p) => p.identity.team === 'good');
if (goodAlive.length === 0) { if (goodAlive.length === 0) {
return { content: '[狼人夜晚] 无好人可杀', meta: { phase: 'wolf-night', targetId: '' } }; return {
content: '[狼人夜晚] 无好人可杀',
meta: { phase: 'wolf-night', targetId: '' },
};
} }
// 让第一个存活狼人代表决策 // 让第一个存活狼人代表决策
const leadWolf = wolves[0]; const leadWolf = wolves[0];
const history = getVisibleHistory(chain, leadWolf); const history = getVisibleHistory(chain, leadWolf);
const targetList = goodAlive.map(p => `${p.id}(${p.name})`).join('、'); const targetList = goodAlive.map((p) => `${p.id}(${p.name})`).join('、');
const response = await callLlm([ 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); 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 match = response.match(/p\d+/);
const targetId = match ? match[0] : goodAlive[0].id; 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}`); console.log(` 🐺 狼人决定击杀 ${target.name}`);
@@ -129,59 +146,92 @@ const llmWolfNight: Role<WolfNightMeta> = async (chain) => {
const llmSeerCheck: Role<SeerCheckMeta> = async (chain) => { const llmSeerCheck: Role<SeerCheckMeta> = async (chain) => {
const state = parseGameState(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) { 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 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([ const response = await callLlm(
{ role: 'system', content: buildSystemPrompt(seer, PERSONALITIES[3]) }, [
{ role: 'user', content: `${history}\n\n现在轮到你查验身份。可查验的玩家:${targetList}\n\n请直接回复你要查验的玩家ID(如 p1),一个字都不要多说。` }, { role: 'system', content: buildSystemPrompt(seer, PERSONALITIES[3]) },
], 0.3); {
role: 'user',
content: `${history}\n\n现在轮到你查验身份。可查验的玩家:${targetList}\n\n请直接回复你要查验的玩家ID(如 p1),一个字都不要多说。`,
},
],
0.3,
);
const match = response.match(/p\d+/); const match = response.match(/p\d+/);
const targetId = match ? match[0] : others[0].id; 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'; const isWolf = target.identity.team === 'wolf';
console.log(` 🔮 预言家查验 ${target.name}${isWolf ? '🐺狼人' : '✅好人'}`); console.log(
` 🔮 预言家查验 ${target.name}${isWolf ? '🐺狼人' : '✅好人'}`,
);
return { return {
content: `[预言家查验] ${target.name} 的身份是${isWolf ? '狼人' : '好人'}`, 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<WitchActionMeta & { gameOver?: boolean }> = async (chain) => { const llmWitchAction: Role<WitchActionMeta & { gameOver?: boolean }> = async (
chain,
) => {
const state = parseGameState(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) { if (!witch) {
const nightDead: string[] = []; const nightDead: string[] = [];
if (state.lastKill) nightDead.push(state.lastKill); if (state.lastKill) nightDead.push(state.lastKill);
const deadIds = new Set([...state.dead.map(d => d.id), ...nightDead]); const deadIds = new Set([...state.dead.map((d) => d.id), ...nightDead]);
const aliveAfter = state.players.filter(p => !deadIds.has(p.id)); const aliveAfter = state.players.filter((p) => !deadIds.has(p.id));
const wolves = aliveAfter.filter(p => p.identity.team === 'wolf'); const wolves = aliveAfter.filter((p) => p.identity.team === 'wolf');
const gameOver = wolves.length === 0 || wolves.length >= aliveAfter.length - wolves.length; const gameOver =
wolves.length === 0 || wolves.length >= aliveAfter.length - wolves.length;
return { return {
content: '[女巫已死,跳过]', 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 saved = false;
let poisonTarget: string | null = null; const poisonTarget: string | null = null;
if (state.lastKill && state.witchPotion) { if (state.lastKill && state.witchPotion) {
const killed = state.players.find(p => p.id === state.lastKill); const killed = state.players.find((p) => p.id === state.lastKill);
const response = await callLlm([ const response = await callLlm(
{ role: 'system', content: buildSystemPrompt(witch, PERSONALITIES[7]) }, [
{ role: 'user', content: `今晚 ${killed?.name ?? '某人'} 被狼人杀害。你有解药,要救吗?回复"救"或"不救"。` }, { role: 'system', content: buildSystemPrompt(witch, PERSONALITIES[7]) },
], 0.5); {
role: 'user',
content: `今晚 ${killed?.name ?? '某人'} 被狼人杀害。你有解药,要救吗?回复"救"或"不救"。`,
},
],
0.5,
);
saved = response.includes('救') && !response.includes('不救'); saved = response.includes('救') && !response.includes('不救');
if (saved) console.log(` 🧪 女巫救了 ${killed?.name}`); if (saved) console.log(` 🧪 女巫救了 ${killed?.name}`);
} }
@@ -189,17 +239,21 @@ const llmWitchAction: Role<WitchActionMeta & { gameOver?: boolean }> = async (ch
// 简化:mock 不用毒药 // 简化:mock 不用毒药
const nightDead: string[] = []; const nightDead: string[] = [];
if (state.lastKill && !saved) nightDead.push(state.lastKill); if (state.lastKill && !saved) nightDead.push(state.lastKill);
const deadIds = new Set([...state.dead.map(d => d.id), ...nightDead]); const deadIds = new Set([...state.dead.map((d) => d.id), ...nightDead]);
const aliveAfter = state.players.filter(p => !deadIds.has(p.id)); const aliveAfter = state.players.filter((p) => !deadIds.has(p.id));
const wolves = aliveAfter.filter(p => p.identity.team === 'wolf'); const wolves = aliveAfter.filter((p) => p.identity.team === 'wolf');
const gameOver = wolves.length === 0 || wolves.length >= aliveAfter.length - wolves.length; const gameOver =
wolves.length === 0 || wolves.length >= aliveAfter.length - wolves.length;
console.log(` 🧙 女巫${saved ? '使用了解药' : '未使用解药'}`); console.log(` 🧙 女巫${saved ? '使用了解药' : '未使用解药'}`);
return { return {
content: `[女巫行动] ${saved ? '使用了解药' : '未使用解药'}`, content: `[女巫行动] ${saved ? '使用了解药' : '未使用解药'}`,
meta: { meta: {
phase: 'witch-action', saved, poisonTarget, visibleTo: [witch.id], phase: 'witch-action',
saved,
poisonTarget,
visibleTo: [witch.id],
witchPotion: saved ? false : state.witchPotion, witchPotion: saved ? false : state.witchPotion,
witchPoison: poisonTarget ? false : state.witchPoison, witchPoison: poisonTarget ? false : state.witchPoison,
gameOver, gameOver,
@@ -216,14 +270,22 @@ const llmDaySpeech: Role<DaySpeechMeta> = async (chain) => {
for (let i = 0; i < state.alive.length; i++) { for (let i = 0; i < state.alive.length; i++) {
const p = state.alive[i]; const p = state.alive[i];
const history = getVisibleHistory(chain, p); const history = getVisibleHistory(chain, p);
const prevSpeeches = speeches.map(s => { const prevSpeeches = speeches
const sp = state.alive.find(pp => pp.id === s.playerId); .map((s) => {
return `${sp?.name ?? s.playerId}${s.speech}`; const sp = state.alive.find((pp) => pp.id === s.playerId);
}).join('\n'); return `${sp?.name ?? s.playerId}${s.speech}`;
})
.join('\n');
const response = await callLlm([ 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() }); speeches.push({ playerId: p.id, speech: response.trim() });
@@ -231,35 +293,51 @@ const llmDaySpeech: Role<DaySpeechMeta> = async (chain) => {
} }
return { 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 }, meta: { phase: 'day-speech', speeches },
}; };
}; };
const llmVote: Role<VoteMeta & { hunterTriggered?: boolean; gameOver?: boolean }> = async (chain) => { const llmVote: Role<
VoteMeta & { hunterTriggered?: boolean; gameOver?: boolean }
> = async (chain) => {
const state = parseGameState(chain); const state = parseGameState(chain);
// Resolve night deaths for effective alive // Resolve night deaths for effective alive
const nightDead: string[] = []; const nightDead: string[] = [];
if (state.lastKill) { 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); if (!(witchMsg?.meta as any)?.saved) nightDead.push(state.lastKill);
} }
const deadSet = new Set([...state.dead.map(d => d.id), ...nightDead]); const deadSet = new Set([...state.dead.map((d) => d.id), ...nightDead]);
const effectiveAlive = state.players.filter(p => !deadSet.has(p.id)); const effectiveAlive = state.players.filter((p) => !deadSet.has(p.id));
console.log(` 🗳️ === 投票阶段 ===`); console.log(` 🗳️ === 投票阶段 ===`);
const votes: Record<string, string> = {}; const votes: Record<string, string> = {};
for (const p of effectiveAlive) { 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 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([ 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); 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+/); const match = response.match(/p\d+/);
votes[p.id] = match ? match[0] : others[0].id; votes[p.id] = match ? match[0] : others[0].id;
@@ -271,48 +349,71 @@ const llmVote: Role<VoteMeta & { hunterTriggered?: boolean; gameOver?: boolean }
tally[target] = (tally[target] || 0) + 1; tally[target] = (tally[target] || 0) + 1;
} }
const maxVotes = Math.max(...Object.values(tally)); const maxVotes = Math.max(...Object.values(tally));
const topIds = Object.entries(tally).filter(([, v]) => v === maxVotes).map(([k]) => k); const topIds = Object.entries(tally)
.filter(([, v]) => v === maxVotes)
.map(([k]) => k);
const eliminatedId = topIds[0]; // 平票取第一个 const eliminatedId = topIds[0]; // 平票取第一个
const eliminated = effectiveAlive.find(p => p.id === eliminatedId); const eliminated = effectiveAlive.find((p) => p.id === eliminatedId);
const aliveAfterVote = effectiveAlive.filter(p => p.id !== eliminatedId); const aliveAfterVote = effectiveAlive.filter((p) => p.id !== eliminatedId);
const wolves = aliveAfterVote.filter(p => p.identity.team === 'wolf'); const wolves = aliveAfterVote.filter((p) => p.identity.team === 'wolf');
const gameOver = wolves.length === 0 || wolves.length >= aliveAfterVote.length - wolves.length; const gameOver =
wolves.length === 0 ||
wolves.length >= aliveAfterVote.length - wolves.length;
const hunterTriggered = eliminated?.identity.name === '猎人'; const hunterTriggered = eliminated?.identity.name === '猎人';
// Print votes // Print votes
for (const [voter, target] of Object.entries(votes)) { for (const [voter, target] of Object.entries(votes)) {
const vName = effectiveAlive.find(p => p.id === voter)?.name ?? voter; const vName = effectiveAlive.find((p) => p.id === voter)?.name ?? voter;
const tName = effectiveAlive.find(p => p.id === target)?.name ?? target; const tName = effectiveAlive.find((p) => p.id === target)?.name ?? target;
console.log(` 🗳️ ${vName}${tName}`); console.log(` 🗳️ ${vName}${tName}`);
} }
console.log(`${eliminated?.name}${eliminated?.identity.name})被放逐出局!\n`); console.log(
`${eliminated?.name}${eliminated?.identity.name})被放逐出局!\n`,
);
return { return {
content: `[投票结果] ${eliminated?.name ?? eliminatedId} 被放逐出局`, 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<HunterShotMeta & { gameOver?: boolean }> = async (chain) => { const llmHunterShot: Role<HunterShotMeta & { gameOver?: boolean }> = async (
chain,
) => {
const state = parseGameState(chain); const state = parseGameState(chain);
// 猎人已死,从最后的 chain 找到猎人 // 猎人已死,从最后的 chain 找到猎人
const hunter = state.players.find(p => p.identity.name === '猎人')!; const hunter = state.players.find((p) => p.identity.name === '猎人')!;
const targetList = state.alive.map(p => `${p.id}(${p.name})`).join('、'); const targetList = state.alive.map((p) => `${p.id}(${p.name})`).join('、');
const response = await callLlm([ const response = await callLlm(
{ role: 'system', content: buildSystemPrompt(hunter, PERSONALITIES[2]) }, [
{ role: 'user', content: `你被投票出局了!作为猎人你可以开枪带走一人。存活玩家:${targetList}\n\n直接回复玩家ID(如 p1):` }, { role: 'system', content: buildSystemPrompt(hunter, PERSONALITIES[2]) },
], 0.3); {
role: 'user',
content: `你被投票出局了!作为猎人你可以开枪带走一人。存活玩家:${targetList}\n\n直接回复玩家ID(如 p1):`,
},
],
0.3,
);
const match = response.match(/p\d+/); const match = response.match(/p\d+/);
const targetId = match ? match[0] : state.alive[0].id; const targetId = match ? match[0] : state.alive[0].id;
const target = state.alive.find(p => p.id === targetId) ?? state.alive[0]; const target = state.alive.find((p) => p.id === targetId) ?? state.alive[0];
const aliveAfter = state.alive.filter(p => p.id !== target.id); const aliveAfter = state.alive.filter((p) => p.id !== target.id);
const wolves = aliveAfter.filter(p => p.identity.team === 'wolf'); const wolves = aliveAfter.filter((p) => p.identity.team === 'wolf');
const gameOver = wolves.length === 0 || wolves.length >= aliveAfter.length - wolves.length; 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 { return {
content: `[猎人开枪] 猎人带走了 ${target.name}`, content: `[猎人开枪] 猎人带走了 ${target.name}`,
@@ -322,17 +423,17 @@ const llmHunterShot: Role<HunterShotMeta & { gameOver?: boolean }> = async (chai
const llmGameEnd: Role<GameEndMeta> = async (chain) => { const llmGameEnd: Role<GameEndMeta> = async (chain) => {
const state = parseGameState(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 winner = wolves.length === 0 ? 'good' : 'wolf';
const summary = ` const summary = `
🏆 ${winner === 'good' ? '好人阵营' : '狼人阵营'}获胜! 🏆 ${winner === 'good' ? '好人阵营' : '狼人阵营'}获胜!
存活玩家:${state.alive.map(p => `${p.name}(${p.identity.name})`).join('、') || '无'} 存活玩家:${state.alive.map((p) => `${p.name}(${p.identity.name})`).join('、') || '无'}
死亡玩家:${state.dead.map(d => `${d.name}(${d.identity.name},${d.cause})`).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); console.log(summary);
@@ -349,7 +450,11 @@ async function main() {
console.log('🐺🌙 ============ 狼人杀 LLM 对战 ============\n'); console.log('🐺🌙 ============ 狼人杀 LLM 对战 ============\n');
console.log('玩家身份(上帝视角):'); console.log('玩家身份(上帝视角):');
const allPlayers = createPlayers(); 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'); console.log('\n游戏开始!\n');
const wf = createWerewolfWorkflow({ const wf = createWerewolfWorkflow({
@@ -369,15 +474,23 @@ async function main() {
mkdirSync(`${tmpDir}/scopes`, { recursive: true }); mkdirSync(`${tmpDir}/scopes`, { recursive: true });
mkdirSync(`${tmpDir}/objects`, { 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 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 rule = createWorkflowRule(wf, store);
const ticker = createWorkflowTicker([rule]); const ticker = createWorkflowTicker([rule]);
// Submit start event // 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); const hash = await store.putObject(startPayload);
await store.appendEvent({ await store.appendEvent({
occurredAt: Date.now(), occurredAt: Date.now(),
@@ -392,7 +505,9 @@ async function main() {
while (maxTicks-- > 0) { while (maxTicks-- > 0) {
await ticker(); await ticker();
// Check if game ended // 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) { if (endEvents.length > 0) {
console.log('\n🎮 游戏结束!'); console.log('\n🎮 游戏结束!');
break; break;
+61 -40
View File
@@ -1,29 +1,34 @@
#!/usr/bin/env bun #!/usr/bin/env bun
/** /**
* 狼人杀战报生成 — 用 Report Workflow (analyst → renderer) * 狼人杀战报生成 — 用 Report Workflow (analyst → renderer)
* 读取狼人杀 game 数据,生成 HTML 战报 * 读取狼人杀 game 数据,生成 HTML 战报
* *
* bun packages/pulse/src/e2e/werewolf-report.ts [--db /tmp/werewolf-xxx] * bun packages/pulse/src/e2e/werewolf-report.ts [--db /tmp/werewolf-xxx]
* *
* 小橘 🍊 (NEKO Team) * 小橘 🍊 (NEKO Team)
*/ */
import { mkdtempSync, writeFileSync, readdirSync } from 'node:fs'; import { mkdtempSync, readdirSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import {
createAnalystRole,
createRendererRole,
createReportWorkflow,
} from '@upulse/workflows';
import { createScopedStore, createStore } from '../index.js'; import { createScopedStore, createStore } from '../index.js';
import { createOpenAiLlmClient } from '../llm-client.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'; import { createWorkflowRule } from '../workflows/workflow-rule-adapter.js';
// ── Args ─────────────────────────────────────────────────────── // ── 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) { if (!dbDir) {
// Auto-detect latest /tmp/werewolf-* // 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) { if (dirs.length > 0) {
dbDir = join('/tmp', dirs[0]); dbDir = join('/tmp', dirs[0]);
console.log(`🔍 Auto-detected: ${dbDir}`); 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 apiKey = process.env.PULSE_LLM_API_KEY ?? process.env.DASHSCOPE_API_KEY;
const model = process.env.PULSE_LLM_MODEL ?? 'qwen-plus'; 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]`; const ts = () => `[+${((Date.now() - t0) / 1000).toFixed(1)}s]`;
// ── Read werewolf game events ────────────────────────────────── // ── 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 store = ss.scope('werewolf-game');
const allEvents = await store.getAfter(0); const allEvents = await store.getAfter(0);
@@ -60,34 +70,43 @@ if (allEvents.length === 0) {
// Build timeline JSON (adapted for werewolf) // Build timeline JSON (adapted for werewolf)
const tStart = allEvents[0].occurredAt; const tStart = allEvents[0].occurredAt;
const eventItems = await Promise.all(allEvents.map(async (e, i) => { const eventItems = await Promise.all(
const role = e.kind.replace('werewolf.', ''); allEvents.map(async (e, i) => {
const meta = e.meta ? JSON.parse(e.meta) : null; const role = e.kind.replace('werewolf.', '');
let content: string | null = null; const meta = e.meta ? JSON.parse(e.meta) : null;
if (e.hash) { let content: string | null = null;
try { if (e.hash) {
const obj = await store.getObject(e.hash); try {
content = typeof obj === 'string' ? obj : JSON.stringify(obj); const obj = await store.getObject(e.hash);
} catch {} content = typeof obj === 'string' ? obj : JSON.stringify(obj);
} } catch {}
return { }
id: e.id, return {
role, id: e.id,
offsetMs: e.occurredAt - tStart, role,
durationMs: i > 0 ? e.occurredAt - allEvents[i - 1].occurredAt : 0, offsetMs: e.occurredAt - tStart,
meta, durationMs: i > 0 ? e.occurredAt - allEvents[i - 1].occurredAt : 0,
content, meta,
}; content,
})); };
}),
);
const timelineJson = JSON.stringify({ const timelineJson = JSON.stringify(
key: 'werewolf-game-1', {
totalMs: allEvents[allEvents.length - 1].occurredAt - tStart, key: 'werewolf-game-1',
events: eventItems, totalMs: allEvents[allEvents.length - 1].occurredAt - tStart,
}, null, 2); events: eventItems,
},
null,
2,
);
ss.close(); 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 ──────────────────────────────────────── // ── Run report workflow ────────────────────────────────────────
const tmpDir = mkdtempSync(join(tmpdir(), 'werewolf-report-')); const tmpDir = mkdtempSync(join(tmpdir(), 'werewolf-report-'));
@@ -139,12 +158,12 @@ while (tickNum < 5) {
// Extract HTML report // Extract HTML report
const reportEvents = await reportStore.getAfter(0); 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) { 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'); const outPath = join(tmpDir, 'werewolf-report.html');
writeFileSync(outPath, html, 'utf-8'); writeFileSync(outPath, html, 'utf-8');
// Also copy to workspace // Also copy to workspace
const wsPath = '/home/azureuser/.openclaw/workspace/werewolf-report-v2.html'; const wsPath = '/home/azureuser/.openclaw/workspace/werewolf-report-v2.html';
writeFileSync(wsPath, html, 'utf-8'); writeFileSync(wsPath, html, 'utf-8');
@@ -152,12 +171,14 @@ if (rendererEvt?.hash) {
} }
// Print analyst findings // Print analyst findings
const analystEvt = reportEvents.find(e => e.kind === 'report.analyst'); const analystEvt = reportEvents.find((e) => e.kind === 'report.analyst');
if (analystEvt?.meta) { if (analystEvt?.meta) {
const meta = JSON.parse(analystEvt.meta); const meta = JSON.parse(analystEvt.meta);
console.log(`\n📊 Score: ${meta.score}/10`); console.log(`\n📊 Score: ${meta.score}/10`);
console.log(`✅ Highlights: ${meta.highlights?.join(', ')}`); 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(); await reportStore.close();
+5 -1
View File
@@ -1 +1,5 @@
export { executeSurvivalEffect, type SurvivalEffect, type SurvivalExecDeps, } from './survival.js'; export {
executeSurvivalEffect,
type SurvivalEffect,
type SurvivalExecDeps,
} from './survival.js';
+13 -7
View File
@@ -3,19 +3,25 @@
* *
* Execute survival effects - all deterministic local commands. * 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'; import type * as fs from 'node:fs';
export interface SurvivalEffect { export interface SurvivalEffect {
type: string; type: string;
[key: string]: unknown; [key: string]: unknown;
} }
/** Dependencies that can be injected for testing */ /** Dependencies that can be injected for testing */
export interface SurvivalExecDeps { export interface SurvivalExecDeps {
fs?: typeof fs; fs?: typeof fs;
execSyncFn?: typeof execSync; execSyncFn?: typeof execSync;
execFileSyncFn?: typeof defaultExecFileSync; execFileSyncFn?: typeof defaultExecFileSync;
} }
/** /**
* Execute survival effects — all deterministic local commands * Execute survival effects — all deterministic local commands
*/ */
export declare function executeSurvivalEffect(effect: SurvivalEffect, deps?: SurvivalExecDeps): Promise<void>; export declare function executeSurvivalEffect(
effect: SurvivalEffect,
deps?: SurvivalExecDeps,
): Promise<void>;
+34 -28
View File
@@ -12,33 +12,36 @@
*/ */
import type { PulseStore } from './store.js'; import type { PulseStore } from './store.js';
export interface GcTier { export interface GcTier {
/** Events older than this (ms) are candidates for this tier. */ /** Events older than this (ms) are candidates for this tier. */
olderThanMs: number; olderThanMs: number;
/** Keep one event per this interval (ms). null = hard delete. */ /** Keep one event per this interval (ms). null = hard delete. */
intervalMs: number | null; intervalMs: number | null;
} }
export interface GcConfig { export interface GcConfig {
/** Enable automatic GC. Default: true. */ /** Enable automatic GC. Default: true. */
enabled: boolean; enabled: boolean;
/** Run GC every N ticks. Default: 240 (~1h at 15s tick). */ /** Run GC every N ticks. Default: 240 (~1h at 15s tick). */
tickInterval: number; tickInterval: number;
/** Retention tiers, ordered by olderThanMs ascending. */ /** Retention tiers, ordered by olderThanMs ascending. */
tiers: GcTier[]; tiers: GcTier[];
} }
export declare const DEFAULT_GC_CONFIG: GcConfig; export declare const DEFAULT_GC_CONFIG: GcConfig;
export interface GcResult { export interface GcResult {
downsampledCount: number; downsampledCount: number;
archivedCount: number; archivedCount: number;
orphanObjectsCount: number; orphanObjectsCount: number;
durationMs: number; durationMs: number;
} }
/** /**
* Run GC on vitals store: downsample + archive. * Run GC on vitals store: downsample + archive.
* Returns stats about what was cleaned up. * Returns stats about what was cleaned up.
*/ */
export declare function gcVitals(vitalsStore: PulseStore, config?: GcConfig): Promise<{ export declare function gcVitals(
downsampledCount: number; vitalsStore: PulseStore,
archivedCount: number; config?: GcConfig,
): Promise<{
downsampledCount: number;
archivedCount: number;
}>; }>;
/** /**
* CAS mark-and-sweep: delete orphaned objects not referenced by any event. * 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, * Scans all events in the given stores for hash references,
* then compares against files in objectsDir. * then compares against files in objectsDir.
*/ */
export declare function gcOrphanObjects(stores: PulseStore[], objectsDir: string): Promise<number>; export declare function gcOrphanObjects(
stores: PulseStore[],
objectsDir: string,
): Promise<number>;
/** /**
* Full GC cycle: vitals downsample/archive + CAS orphan sweep. * Full GC cycle: vitals downsample/archive + CAS orphan sweep.
* Writes a gc event to systemStore for observability. * Writes a gc event to systemStore for observability.
*/ */
export declare function runGc(options: { export declare function runGc(options: {
vitalsStore: PulseStore; vitalsStore: PulseStore;
systemStore: PulseStore; systemStore: PulseStore;
allStores: PulseStore[]; allStores: PulseStore[];
objectsDir: string; objectsDir: string;
config?: GcConfig; config?: GcConfig;
}): Promise<GcResult>; }): Promise<GcResult>;
/** /**
* Create a GC trigger that fires every N ticks. * Create a GC trigger that fires every N ticks.
* Returns a function to call after each tick. * Returns a function to call after each tick.
*/ */
export declare function createGcTrigger(options: { export declare function createGcTrigger(options: {
vitalsStore: PulseStore; vitalsStore: PulseStore;
systemStore: PulseStore; systemStore: PulseStore;
allStores: PulseStore[]; allStores: PulseStore[];
objectsDir: string; objectsDir: string;
config?: GcConfig; config?: GcConfig;
}): () => void; }): () => void;
-181
View File
@@ -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;
}
+228
View File
@@ -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<string, Expression>();
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<string, unknown>,
): Promise<boolean> {
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<GuardEvalResult> {
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 };
}
+35 -171
View File
@@ -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 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 ──────────────────────────────────────────────────── // ── Schema ────────────────────────────────────────────────────
@@ -29,72 +45,16 @@ export function initGuardSchema(db: Database): void {
db.exec(GUARD_SCHEMA); db.exec(GUARD_SCHEMA);
} }
// ── Types ───────────────────────────────────────────────────── // ── Memory cache ───────────────────────────────────────────────
export interface GuardSource { const guardDefMemory = new Map<string, GuardProjectionDef>();
kind: string;
key_prefix?: string;
check: string;
transition: string;
}
export interface GuardProjectionDef { /** @internal Clear expression cache and memory cache (for testing) */
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<string, Expression>();
function expr(expressionStr: string): Expression {
let e = exprCache.get(expressionStr);
if (!e) {
e = jsonata(expressionStr);
exprCache.set(expressionStr, e);
}
return e;
}
/** @internal */
export function clearGuardExpressionCache(): void { export function clearGuardExpressionCache(): void {
exprCache.clear(); clearCoreCache();
guardDefMemory.clear(); 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 ───────────────────────────────────────────────── // ── DB helpers ─────────────────────────────────────────────────
const insertGuardDefStmt = (db: Database) => const insertGuardDefStmt = (db: Database) =>
@@ -158,41 +118,11 @@ function loadGuardRow(
}; };
} }
function eventBindings( // ── Public API ─────────────────────────────────────────────────
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<string, unknown>,
): Promise<boolean> {
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}`);
}
/** /**
* Register guard (persist + memory cache of compiled defs for this process). * Register guard (persist + memory cache).
*/ */
const guardDefMemory = new Map<string, GuardProjectionDef>();
export function registerGuard(db: Database, def: GuardProjectionDef): void { export function registerGuard(db: Database, def: GuardProjectionDef): void {
const now = Date.now(); const now = Date.now();
insertGuardDefStmt(db).run( 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); const row = loadGuardRow(db, guardName, key);
if (!row) { if (!row) {
const defs = listGuardDefs(db); 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 * Before append: match guards, fold, check. Returns pending state updates.
* (lastEventId is a placeholder; caller sets to written event id). * Delegates core logic to guard-core (pure, portable).
*/ */
export async function checkGuards( export async function checkGuards(
db: Database, db: Database,
event: { kind: string; key?: string; meta?: string; occurredAt: number }, event: { kind: string; key?: string; meta?: string; occurredAt: number },
): Promise<{ updates: GuardUpdate[] }> { ): Promise<{ updates: GuardUpdate[] }> {
const defs = listGuardDefs(db); const defs = listGuardDefs(db);
const updates: GuardUpdate[] = []; return checkGuardsCore(defs, event, (guardName, key) =>
const pendingEventId = 0; loadGuardRow(db, guardName, key),
);
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 };
} }
/** /**
+200 -77
View File
@@ -19,8 +19,8 @@ import { type WatcherDef } from './watcher.js';
* refreshedAt: when this data was last collected (= collect event's occurred_at) * refreshedAt: when this data was last collected (= collect event's occurred_at)
*/ */
export interface Sensed<T> { export interface Sensed<T> {
data: T; data: T;
refreshedAt: number; refreshedAt: number;
} }
/** /**
* Rule: the universal composition primitive. * Rule: the universal composition primitive.
@@ -38,7 +38,11 @@ export interface Sensed<T> {
* - Short-circuit by not calling inner (bypass inner layers) * - Short-circuit by not calling inner (bypass inner layers)
* - Pass through by returning inner's result unchanged * - Pass through by returning inner's result unchanged
*/ */
export type Rule<S, E> = (prev: S, curr: S, inner: (prev: S, curr: S) => Promise<[E[], number]>) => Promise<[E[], number]>; export type Rule<S, E> = (
prev: S,
curr: S,
inner: (prev: S, curr: S) => Promise<[E[], number]>,
) => Promise<[E[], number]>;
/** /**
* Rule definition with projection dependencies. * Rule definition with projection dependencies.
* *
@@ -46,10 +50,10 @@ export type Rule<S, E> = (prev: S, curr: S, inner: (prev: S, curr: S) => Promise
* The engine will automatically read these projections and build the snapshot. * The engine will automatically read these projections and build the snapshot.
*/ */
export interface RuleDef<S, E> { export interface RuleDef<S, E> {
name: string; name: string;
/** Projection dependencies, format "scope/name" like ["_vitals/cpu_usage", "neko/session_count"] */ /** Projection dependencies, format "scope/name" like ["_vitals/cpu_usage", "neko/session_count"] */
projections: string[]; projections: string[];
rule: Rule<S, E>; rule: Rule<S, E>;
} }
/** /**
* Executor: executes a batch of effects. * Executor: executes a batch of effects.
@@ -79,7 +83,9 @@ export type ChainableExecutor<E> = (effects: E[]) => Promise<E[]>;
* a console.warn is emitted. If executors array is empty and effects is * a console.warn is emitted. If executors array is empty and effects is
* non-empty, a warning is emitted immediately. * non-empty, a warning is emitted immediately.
*/ */
export declare function chainExecutors<E>(executors: ChainableExecutor<E>[]): Executor<E>; export declare function chainExecutors<E>(
executors: ChainableExecutor<E>[],
): Executor<E>;
/** /**
* Compose rules via onion middleware (reduceRight). * Compose rules via onion middleware (reduceRight).
* *
@@ -89,14 +95,19 @@ export declare function chainExecutors<E>(executors: ChainableExecutor<E>[]): Ex
* r1 is outermost (executes first), r3 is innermost (closest to base). * r1 is outermost (executes first), r3 is innermost (closest to base).
* Each rule calls inner(prev, curr) to delegate to the next layer. * Each rule calls inner(prev, curr) to delegate to the next layer.
*/ */
export declare function composeRules<S, E>(rules: Rule<S, E>[], defaultTickMs?: number): (prev: S, curr: S) => Promise<[E[], number]>; export declare function composeRules<S, E>(
rules: Rule<S, E>[],
defaultTickMs?: number,
): (prev: S, curr: S) => Promise<[E[], number]>;
/** /**
* Find the effective version epoch — the promote event that current runtime should use. * 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 there's a rollback, use the promote event of the rolled-back-to version.
* If no rollback, use the latest promote event. * If no rollback, use the latest promote event.
* If no promote at all, return null (cold start). * If no promote at all, return null (cold start).
*/ */
export declare function findEffectiveEpoch(store: PulseStore): Promise<EventRecord | null>; export declare function findEffectiveEpoch(
store: PulseStore,
): Promise<EventRecord | null>;
/** /**
* Rebuild a Snapshot from the events table, respecting version epoch. * 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 provided, only reads events after the epoch with matching code_rev.
@@ -110,23 +121,34 @@ export declare function findEffectiveEpoch(store: PulseStore): Promise<EventReco
* task projections (pending-tasks, agent-capability-stats) are folded from workflowStore. * task projections (pending-tasks, agent-capability-stats) are folded from workflowStore.
* Falls back to options.systemStore for backward compatibility. * Falls back to options.systemStore for backward compatibility.
*/ */
export declare function rebuildSnapshot<S extends { export declare function rebuildSnapshot<
S extends {
timestamp: number; timestamp: number;
}>(storeOrStores: PulseStore | { },
system: PulseStore; >(
vitals: PulseStore; storeOrStores:
}, senseKeys: string[], epoch?: EventRecord | null, options?: { | PulseStore
| {
system: PulseStore;
vitals: PulseStore;
},
senseKeys: string[],
epoch?: EventRecord | null,
options?: {
systemStore?: PulseStore; systemStore?: PulseStore;
workflowStore?: PulseStore; workflowStore?: PulseStore;
}): Promise<S>; },
): Promise<S>;
/** /**
* Build snapshot from projections. * Build snapshot from projections.
* Read each declared projection's current value, using "scope/name" as key. * Read each declared projection's current value, using "scope/name" as key.
* If projection doesn't exist, value is null (graceful degradation). * If projection doesn't exist, value is null (graceful degradation).
*/ */
export declare function buildSnapshotFromProjections<S extends { export declare function buildSnapshotFromProjections<
S extends {
timestamp: number; timestamp: number;
}>(scopedStore: ScopedStore, projectionPaths: string[]): Promise<S>; },
>(scopedStore: ScopedStore, projectionPaths: string[]): Promise<S>;
/** /**
* Run the Pulse loop. * Run the Pulse loop.
* *
@@ -137,35 +159,38 @@ export declare function buildSnapshotFromProjections<S extends {
* Cold-start is handled by rules: they see undefined senses and * Cold-start is handled by rules: they see undefined senses and
* produce collect effects on the first tick. * produce collect effects on the first tick.
*/ */
export declare function runPulse<S extends { export declare function runPulse<
S extends {
timestamp: number; timestamp: number;
}, E>(options: { },
scopedStore?: ScopedStore; E,
/** @deprecated Use {@link scopedStore} instead. */ >(options: {
store?: PulseStore; scopedStore?: ScopedStore;
execute: Executor<E>; /** @deprecated Use {@link scopedStore} instead. */
rules: Rule<S, E>[]; store?: PulseStore;
senseKeys: string[]; execute: Executor<E>;
defaultTickMs?: number; rules: Rule<S, E>[];
codeRev?: string; senseKeys: string[];
watchers?: WatcherDef[]; defaultTickMs?: number;
/** Scan interval for the background executor loop (ms). Default 1000. */ codeRev?: string;
executorScanIntervalMs?: number; watchers?: WatcherDef[];
/** GC configuration. Set `{ enabled: false }` to disable. Uses DEFAULT_GC_CONFIG if omitted. */ /** Scan interval for the background executor loop (ms). Default 1000. */
gc?: Partial<GcConfig>; executorScanIntervalMs?: number;
/** Objects directory path for CAS orphan cleanup. Required for CAS GC. */ /** GC configuration. Set `{ enabled: false }` to disable. Uses DEFAULT_GC_CONFIG if omitted. */
objectsDir?: string; gc?: Partial<GcConfig>;
/** Adaptive tick frequency configuration */ /** Objects directory path for CAS orphan cleanup. Required for CAS GC. */
adaptiveTick?: { objectsDir?: string;
/** Base tick interval in ms when active (default: 5000) */ /** Adaptive tick frequency configuration */
baseTickMs?: number; adaptiveTick?: {
/** Maximum tick interval in ms when idle (default: 300000) */ /** Base tick interval in ms when active (default: 5000) */
maxTickMs?: number; baseTickMs?: number;
/** Backoff factor when idle (default: 2) */ /** Maximum tick interval in ms when idle (default: 300000) */
backoffFactor?: number; maxTickMs?: number;
/** Function to determine if there are active topics/work (optional) */ /** Backoff factor when idle (default: 2) */
hasActiveWork?: (snapshot: S) => boolean; backoffFactor?: number;
}; /** Function to determine if there are active topics/work (optional) */
hasActiveWork?: (snapshot: S) => boolean;
};
}): Promise<never>; }): Promise<never>;
/** /**
* Run the Pulse loop with RuleDefs and projection-based snapshots (V2). * Run the Pulse loop with RuleDefs and projection-based snapshots (V2).
@@ -173,17 +198,20 @@ export declare function runPulse<S extends {
* Uses RuleDef + projections to drive the loop instead of senseKeys. * Uses RuleDef + projections to drive the loop instead of senseKeys.
* Automatically folds projections and builds snapshots from declared dependencies. * Automatically folds projections and builds snapshots from declared dependencies.
*/ */
export declare function runPulseV2<S extends { export declare function runPulseV2<
S extends {
timestamp: number; timestamp: number;
}, E>(options: { },
scopedStore: ScopedStore; E,
execute: Executor<E>; >(options: {
ruleDefs: RuleDef<S, E>[]; scopedStore: ScopedStore;
defaultTickMs?: number; execute: Executor<E>;
codeRev: string; ruleDefs: RuleDef<S, E>[];
watchers?: WatcherDef[]; defaultTickMs?: number;
/** Scan interval for the background executor loop (ms). Default 1000. */ codeRev: string;
executorScanIntervalMs?: number; watchers?: WatcherDef[];
/** Scan interval for the background executor loop (ms). Default 1000. */
executorScanIntervalMs?: number;
}): Promise<never>; }): Promise<never>;
/** /**
* Independent executor loop that scans for pending effect events * Independent executor loop that scans for pending effect events
@@ -193,44 +221,139 @@ export declare function runPulseV2<S extends {
* ticks write effect events, executorLoop picks them up and runs them. * ticks write effect events, executorLoop picks them up and runs them.
*/ */
export declare function executorLoop<E>(options: { export declare function executorLoop<E>(options: {
store: PulseStore; store: PulseStore;
execute: Executor<E>; execute: Executor<E>;
scanIntervalMs?: number; scanIntervalMs?: number;
signal?: AbortSignal; signal?: AbortSignal;
}): Promise<void>; }): Promise<void>;
/** /**
* Start the executor loop in the background. * Start the executor loop in the background.
* Returns the AbortController so the caller can stop it. * Returns the AbortController so the caller can stop it.
*/ */
export declare function startExecutorLoop<E>(options: { export declare function startExecutorLoop<E>(options: {
store: PulseStore; store: PulseStore;
execute: Executor<E>; execute: Executor<E>;
scanIntervalMs?: number; scanIntervalMs?: number;
signal?: AbortSignal; signal?: AbortSignal;
}): void; }): void;
/** /**
* Create a rule from an accessor + pure logic. * Create a rule from an accessor + pure logic.
* Adaptation happens at construction time — no need for contramap. * Adaptation happens at construction time — no need for contramap.
*/ */
export declare function createRule<S, E, T>(accessor: (s: S) => T, logic: (prev: T, curr: T, inner: (prev: S, curr: S) => Promise<[E[], number]>) => Promise<[E[], number]>): Rule<S, E>; export declare function createRule<S, E, T>(
export { type CreateScopedStoreOptions, type CreateStoreOptions, createScopedStore, createStore, type EventRecord, type ObjectInstance, type PulseStore, type ScopedStore, } from './store.js'; accessor: (s: S) => T,
export { adaptiveInterval, clampTick, dedup, errorBackoff, } from './rules/builtin.js'; logic: (
export { startWatcher, type VitalWithData, type WakeCondition, type WatcherDef, type WatcherHandle, } from './watcher.js'; prev: T,
curr: T,
inner: (prev: S, curr: S) => Promise<[E[], number]>,
) => Promise<[E[], number]>,
): Rule<S, E>;
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 './watchers/index.js';
export * from './rules/index.js'; export * from './rules/index.js';
export { createOpenAiLlmClient, type LlmClient, type LlmMessage, type LlmResponse, type LlmTool, } from './llm-client.js'; export {
export { type AgentLoopRuleOptions, createAgentLoopRule, } from './rules/agent-loop.js'; 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 { buildPersonasFromEvents } from './persona.js';
export { createWorkflowTicker } from './workflows/index.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 { 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 {
export { type AgentExecutorConfig, type AgentResult, type AgentRunner, createAgentExecutorRole, createCursorRunner, } from './workflows/roles/agent-executor.js'; END,
export { type LlmRoleConfig, type ToolRoleConfig, createLlmRole, createToolRole, } from './workflows/roles/llm-role-factory.js'; type MetaOf,
export { type ScaffoldOptions, scaffoldWorkflow } from './workflows/scaffold.js'; 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 './defs.js';
export * from './executors/index.js'; export * from './executors/index.js';
export type { GcConfig, GcResult, GcTier } from './gc.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 * 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';
-672
View File
@@ -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<E>.
*
* 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';
+40 -24
View File
@@ -204,7 +204,7 @@ export async function rebuildSnapshot<S extends { timestamp: number }>(
storeOrStores: PulseStore | { system: PulseStore; vitals: PulseStore }, storeOrStores: PulseStore | { system: PulseStore; vitals: PulseStore },
senseKeys: string[], senseKeys: string[],
epoch?: EventRecord | null, epoch?: EventRecord | null,
options?: { systemStore?: PulseStore; workflowStore?: PulseStore }, _options?: { systemStore?: PulseStore; workflowStore?: PulseStore },
): Promise<S> { ): Promise<S> {
const isMultiStore = const isMultiStore =
typeof storeOrStores === 'object' && typeof storeOrStores === 'object' &&
@@ -552,8 +552,7 @@ export async function runPulse<S extends { timestamp: number }, E>(options: {
tickMs = 0; tickMs = 0;
} else { } else {
// Determine if there's active work // Determine if there's active work
const hasActivity = const hasActivity = effects.length > 0 || hasActiveWork?.(curr);
effects.length > 0 || (hasActiveWork && hasActiveWork(curr));
if (hasActivity) { if (hasActivity) {
// Active work detected → reset to base frequency // Active work detected → reset to base frequency
@@ -964,15 +963,32 @@ export { buildPersonasFromEvents } from './persona.js';
// ── Council v2: WorkflowType ──────────────────────────────────────── // ── Council v2: WorkflowType ────────────────────────────────────────
export { createWorkflowTicker } from './workflows/index.js'; 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 { export type {
WorkflowRule, WorkflowRule,
WorkflowTickResult, WorkflowTickResult,
} from './workflows/workflow-rule-adapter.js'; } from './workflows/workflow-rule-adapter.js';
export { createWorkflowRule } from './workflows/workflow-rule-adapter.js'; export { createWorkflowRule } from './workflows/workflow-rule-adapter.js';
export {
type SubprocessRoleConfig,
createSubprocessRole,
} from './workflows/subprocess-role.js';
export { export {
END, END,
type MetaOf, type MetaOf,
@@ -986,20 +1002,6 @@ export {
type WorkflowMessage, type WorkflowMessage,
type WorkflowType, type WorkflowType,
} from './workflows/workflow-type.js'; } 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 ───────────────────────────────────────────────── // ── Executors ─────────────────────────────────────────────────
@@ -1015,9 +1017,21 @@ export {
gcVitals, gcVitals,
runGc, runGc,
} from './gc.js'; } from './gc.js';
// ── Projection Engine ─────────────────────────────────────────── // ── Guard core (pure logic, no DB dependency — for Pulseflare) ──
export * from './projection-engine.js'; // ── 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 ─────────────────────────────────────────── // ── Guard projections ───────────────────────────────────────────
export { export {
type GuardProjectionDef, type GuardProjectionDef,
@@ -1026,6 +1040,8 @@ export {
getGuardState, getGuardState,
registerGuard, registerGuard,
} from './guard-projection.js'; } from './guard-projection.js';
// ── Projection Engine ───────────────────────────────────────────
export * from './projection-engine.js';
// ── Task Event Types ──────────────────────────────────────────── // ── Task Event Types ────────────────────────────────────────────
export type { export type {
+36 -36
View File
@@ -1,51 +1,51 @@
export interface LlmMessage { export interface LlmMessage {
role: 'system' | 'user' | 'assistant' | 'tool'; role: 'system' | 'user' | 'assistant' | 'tool';
content?: string; content?: string;
tool_calls?: Array<{ tool_calls?: Array<{
id: string; id: string;
function: { function: {
name: string; name: string;
arguments: string; arguments: string;
}; };
}>; }>;
tool_call_id?: string; tool_call_id?: string;
} }
export interface LlmTool { export interface LlmTool {
type: 'function'; type: 'function';
function: { function: {
name: string; name: string;
description: string; description: string;
parameters: Record<string, unknown>; parameters: Record<string, unknown>;
}; };
} }
export interface LlmResponse { export interface LlmResponse {
content?: string; content?: string;
tool_calls?: Array<{ tool_calls?: Array<{
id: string; id: string;
function: { function: {
name: string; name: string;
arguments: string; arguments: string;
};
}>;
usage?: {
prompt_tokens: number;
completion_tokens: number;
}; };
}>;
usage?: {
prompt_tokens: number;
completion_tokens: number;
};
} }
export interface LlmClient { export interface LlmClient {
chat(opts: { chat(opts: {
messages: LlmMessage[]; messages: LlmMessage[];
tools?: LlmTool[]; tools?: LlmTool[];
tool_choice?: 'auto' | 'required'; tool_choice?: 'auto' | 'required';
}): Promise<LlmResponse>; }): Promise<LlmResponse>;
} }
/** /**
* Create an OpenAI-compatible LLM client. * Create an OpenAI-compatible LLM client.
* Works with DashScope, LiteLLM proxy, OpenAI, etc. * Works with DashScope, LiteLLM proxy, OpenAI, etc.
*/ */
export declare function createOpenAiLlmClient(opts: { export declare function createOpenAiLlmClient(opts: {
baseUrl: string; baseUrl: string;
apiKey: string; apiKey: string;
model: string; model: string;
timeoutMs?: number; timeoutMs?: number;
}): LlmClient; }): LlmClient;
-50
View File
@@ -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);
}
},
};
}
+3 -1
View File
@@ -7,4 +7,6 @@ import type { PersonaState } from './task-events.js';
* - persona-updated only patches provided fields. * - persona-updated only patches provided fields.
* - Events are merged in occurredAt order. * - Events are merged in occurredAt order.
*/ */
export declare function buildPersonasFromEvents(store: PulseStore): Promise<Map<string, PersonaState>>; export declare function buildPersonasFromEvents(
store: PulseStore,
): Promise<Map<string, PersonaState>>;
-51
View File
@@ -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;
}
+26 -10
View File
@@ -8,28 +8,44 @@ import type { Database } from 'bun:sqlite';
/** Clear the compiled expression cache (useful for testing). */ /** Clear the compiled expression cache (useful for testing). */
export declare function clearExpressionCache(): void; export declare function clearExpressionCache(): void;
export interface ProjectionState { export interface ProjectionState {
name: string; name: string;
value: any; value: any;
lastEventId: number; lastEventId: number;
codeRev: string; codeRev: string;
updatedAt: number; 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. * Get current projection state from the database.
*/ */
export declare function getProjectionState(scopeDb: Database, projectionName: string): Promise<ProjectionState | null>; export declare function getProjectionState(
scopeDb: Database,
projectionName: string,
): Promise<ProjectionState | null>;
/** /**
* Incremental fold for a single projection. * Incremental fold for a single projection.
* Reads definition, current state, processes new events since last_event_id. * Reads definition, current state, processes new events since last_event_id.
*/ */
export declare function foldProjection(scopeDb: Database, _scopeName: string, projectionName: string, codeRev: string): Promise<ProjectionState>; export declare function foldProjection(
scopeDb: Database,
_scopeName: string,
projectionName: string,
codeRev: string,
): Promise<ProjectionState>;
/** /**
* Fold all projections in a scope with the given code revision. * Fold all projections in a scope with the given code revision.
*/ */
export declare function foldAllProjections(scopeDb: Database, scopeName: string, codeRev: string): Promise<Map<string, ProjectionState>>; export declare function foldAllProjections(
scopeDb: Database,
scopeName: string,
codeRev: string,
): Promise<Map<string, ProjectionState>>;
/** /**
* Reset all projections and replay with new code revision. * Reset all projections and replay with new code revision.
* This is the only function that DELETES from projections table. * This is the only function that DELETES from projections table.
*/ */
export declare function resetProjections(scopeDb: Database, codeRev: string): Promise<void>; export declare function resetProjections(
scopeDb: Database,
codeRev: string,
): Promise<void>;
-310
View File
@@ -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);
}
}
+9 -7
View File
@@ -2,15 +2,17 @@ import type { Rule } from '../index.js';
import type { LlmClient, LlmTool } from '../llm-client.js'; import type { LlmClient, LlmTool } from '../llm-client.js';
import type { PulseStore } from '../store.js'; import type { PulseStore } from '../store.js';
export interface AgentLoopRuleOptions { export interface AgentLoopRuleOptions {
llmClient: LlmClient; llmClient: LlmClient;
workflowStore: PulseStore; workflowStore: PulseStore;
systemPrompt?: string; systemPrompt?: string;
llmTimeoutMs?: number; llmTimeoutMs?: number;
} }
type Snapshot = Record<string, unknown> & { type Snapshot = Record<string, unknown> & {
timestamp: number; timestamp: number;
}; };
type Effect = Record<string, unknown>; type Effect = Record<string, unknown>;
export declare const EFFECT_TOOLS: LlmTool[]; export declare const EFFECT_TOOLS: LlmTool[];
export declare function createAgentLoopRule<S extends Snapshot, E extends Effect>(opts: AgentLoopRuleOptions): Rule<S, E>; export declare function createAgentLoopRule<
export {}; S extends Snapshot,
E extends Effect,
>(opts: AgentLoopRuleOptions): Rule<S, E>;
+15 -4
View File
@@ -17,20 +17,31 @@ export declare function clampTick<S, E>(min?: number, max?: number): Rule<S, E>;
* tickMs *= 2^errors, capped at maxMs. * tickMs *= 2^errors, capped at maxMs.
* Zero errors → pass through unchanged. * Zero errors → pass through unchanged.
*/ */
export declare function errorBackoff<S, E>(getErrors: (s: S) => number, maxMs?: number): Rule<S, E>; export declare function errorBackoff<S, E>(
getErrors: (s: S) => number,
maxMs?: number,
): Rule<S, E>;
/** /**
* Adaptive interval: speed up when state changes, slow down when idle. * Adaptive interval: speed up when state changes, slow down when idle.
* *
* - Change detected → fastMs * - Change detected → fastMs
* - No change → tickMs * slowFactor, capped at slowMs * - No change → tickMs * slowFactor, capped at slowMs
*/ */
export declare function adaptiveInterval<S, E>(hasChanged: (prev: S, curr: S) => boolean, fastMs?: number, slowMs?: number, slowFactor?: number): Rule<S, E>; export declare function adaptiveInterval<S, E>(
hasChanged: (prev: S, curr: S) => boolean,
fastMs?: number,
slowMs?: number,
slowFactor?: number,
): Rule<S, E>;
/** /**
* Deduplicate effects by kind (or custom key). * Deduplicate effects by kind (or custom key).
* *
* Keeps only the last occurrence of each key. * Keeps only the last occurrence of each key.
* Requires E extends { kind: string }. * Requires E extends { kind: string }.
*/ */
export declare function dedup<S, E extends { export declare function dedup<
S,
E extends {
kind: string; kind: string;
}>(key?: (e: E) => string): Rule<S, E>; },
>(key?: (e: E) => string): Rule<S, E>;
+23 -18
View File
@@ -5,26 +5,31 @@
*/ */
import type { PulseStore } from '../store.js'; import type { PulseStore } from '../store.js';
export interface HealthSnapshot { export interface HealthSnapshot {
lastRestart: Record<string, { lastRestart: Record<
ts: number; string,
count: number; {
}>; ts: number;
lastGc: { count: number;
ts: number; }
} | null; >;
lastNotify: { lastGc: {
ts: number; ts: number;
} | null; } | null;
panicCount: number; lastNotify: {
lastPromote?: { ts: number;
ts: number; } | null;
codeRev: string; panicCount: number;
prevCodeRev: string; lastPromote?: {
}; ts: number;
recentErrorCount: number; codeRev: string;
prevCodeRev: string;
};
recentErrorCount: number;
} }
/** /**
* Rebuild health field from events table. * Rebuild health field from events table.
* This function is in core package, agent cannot change. * This function is in core package, agent cannot change.
*/ */
export declare function rebuildHealth(store: PulseStore): Promise<HealthSnapshot>; export declare function rebuildHealth(
store: PulseStore,
): Promise<HealthSnapshot>;
+7 -1
View File
@@ -1,4 +1,10 @@
export { adaptiveInterval, clampTick, dedup, errorBackoff } from './builtin.js'; 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 HealthSnapshot, rebuildHealth } from './health.js';
export { type SurvivalSnapshot, survivalRules } from './survival.js'; export { type SurvivalSnapshot, survivalRules } from './survival.js';
+9 -10
View File
@@ -13,17 +13,17 @@ import type { SystemResourceData } from '../watchers/system-resource.js';
import type { HealthSnapshot } from './health.js'; import type { HealthSnapshot } from './health.js';
/** Minimal LLM health shape used by llmWatchdog (full type lives in @uncaged/pulse-openclaw) */ /** Minimal LLM health shape used by llmWatchdog (full type lives in @uncaged/pulse-openclaw) */
interface LlmHealthLike { interface LlmHealthLike {
processOk: boolean; processOk: boolean;
completionOk?: boolean; completionOk?: boolean;
} }
export interface SurvivalSnapshot { export interface SurvivalSnapshot {
timestamp: number; timestamp: number;
system?: Sensed<SystemResourceData>; system?: Sensed<SystemResourceData>;
processes?: Sensed<ProcessAliveData>; processes?: Sensed<ProcessAliveData>;
network?: Sensed<NetworkData>; network?: Sensed<NetworkData>;
errorLog?: Sensed<ErrorLogData>; errorLog?: Sensed<ErrorLogData>;
llm?: Sensed<LlmHealthLike>; llm?: Sensed<LlmHealthLike>;
health?: HealthSnapshot; health?: HealthSnapshot;
} }
/** /**
* Panic rollback - outermost fallback * Panic rollback - outermost fallback
@@ -58,4 +58,3 @@ export declare const errorEscalate: Rule<SurvivalSnapshot, SurvivalEffect>;
* Survival rules in onion order (first element is outermost layer) * Survival rules in onion order (first element is outermost layer)
*/ */
export declare const survivalRules: Rule<SurvivalSnapshot, SurvivalEffect>[]; export declare const survivalRules: Rule<SurvivalSnapshot, SurvivalEffect>[];
export {};
+89 -76
View File
@@ -6,92 +6,105 @@
*/ */
import { Database } from 'bun:sqlite'; import { Database } from 'bun:sqlite';
export interface EventRecord { export interface EventRecord {
id: number; id: number;
occurredAt: number; occurredAt: number;
kind: string; kind: string;
key?: string; key?: string;
hash?: string; hash?: string;
codeRev?: string; codeRev?: string;
meta?: string; meta?: string;
objectId?: number; objectId?: number;
} }
/** An immutable entity instance tracked in the objects table. */ /** An immutable entity instance tracked in the objects table. */
export interface ObjectInstance { export interface ObjectInstance {
id: number; id: number;
objectType: string; objectType: string;
externalId: string | null; externalId: string | null;
createdAt: number; createdAt: number;
codeRev: string; codeRev: string;
} }
export interface PulseStore { export interface PulseStore {
/** Append one event (id is auto-incremented) */ /** Append one event (id is auto-incremented) */
appendEvent(event: Omit<EventRecord, 'id'>): Promise<EventRecord>; appendEvent(event: Omit<EventRecord, 'id'>): Promise<EventRecord>;
/** Append multiple events in a transaction */ /** Append multiple events in a transaction */
appendEvents(events: Omit<EventRecord, 'id'>[]): Promise<EventRecord[]>; appendEvents(events: Omit<EventRecord, 'id'>[]): Promise<EventRecord[]>;
/** Create an immutable object instance. Returns the integer id. Idempotent on (objectType, externalId). */ /** Create an immutable object instance. Returns the integer id. Idempotent on (objectType, externalId). */
createObject(opts: { createObject(opts: {
objectType: string; objectType: string;
externalId?: string; externalId?: string;
codeRev: string; codeRev: string;
}): Promise<number>; }): Promise<number>;
/** Get an object instance by id. Returns null if not found. */ /** Get an object instance by id. Returns null if not found. */
getObjectInstance(id: number): Promise<ObjectInstance | null>; getObjectInstance(id: number): Promise<ObjectInstance | null>;
/** Query object instances by type. */ /** Query object instances by type. */
queryObjectsByType(objectType: string): Promise<ObjectInstance[]>; queryObjectsByType(objectType: string): Promise<ObjectInstance[]>;
/** Get the latest event by kind + optional key */ /** Get the latest event by kind + optional key */
getLatest(kind: string, key?: string): Promise<EventRecord | null>; getLatest(kind: string, key?: string): Promise<EventRecord | null>;
/** Get latest event with additional filters */ /** Get latest event with additional filters */
getLatestWhere(opts: { getLatestWhere(opts: {
kind: string; kind: string;
key?: string; key?: string;
codeRev?: string; codeRev?: string;
}): Promise<EventRecord | null>; }): Promise<EventRecord | null>;
/** Get recent events (newest first) */ /** Get recent events (newest first) */
getRecent(limit?: number): Promise<EventRecord[]>; getRecent(limit?: number): Promise<EventRecord[]>;
/** Query events by kind with optional filters */ /** Query events by kind with optional filters */
queryByKind(kind: string, opts?: { queryByKind(
key?: string; kind: string,
since?: number; opts?: {
codeRev?: string; key?: string;
limit?: number; since?: number;
}): Promise<EventRecord[]>; codeRev?: string;
/** Get all events after a specific event id */ limit?: number;
getAfter(afterId: number, opts?: { },
kind?: string; ): Promise<EventRecord[]>;
key?: string; /** Get all events after a specific event id */
codeRev?: string; getAfter(
}): Promise<EventRecord[]>; afterId: number,
/** Check if any events exist */ opts?: {
hasEvents(): Promise<boolean>; kind?: string;
/** Write data to CAS store. Returns hash. No-op if already exists. */ key?: string;
putObject(data: unknown): Promise<string>; codeRev?: string;
/** Read data from CAS store by hash. Returns null if not found. */ },
getObject(hash: string): Promise<unknown | null>; ): Promise<EventRecord[]>;
/** Close the database */ /** Check if any events exist */
close(): Promise<void>; hasEvents(): Promise<boolean>;
/** Delete events older than the given timestamp. Returns count of deleted rows. */ /** Write data to CAS store. Returns hash. No-op if already exists. */
archiveEvents(olderThan: number): Promise<number>; putObject(data: unknown): Promise<string>;
/** Downsample events of a specific kind+key: keep one per interval window. Returns count of deleted rows. */ /** Read data from CAS store by hash. Returns null if not found. */
downsampleEvents(kind: string, key: string, intervalMs: number, olderThan: number): Promise<number>; getObject(hash: string): Promise<unknown | null>;
/** Close the database */
close(): Promise<void>;
/** Delete events older than the given timestamp. Returns count of deleted rows. */
archiveEvents(olderThan: number): Promise<number>;
/** 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<number>;
} }
export interface CreateStoreOptions { export interface CreateStoreOptions {
eventsDbPath: string; eventsDbPath: string;
/** @deprecated Vitals now use events table via scoped store. This field is accepted but ignored. */ /** @deprecated Vitals now use events table via scoped store. This field is accepted but ignored. */
vitalsDbPath?: string; vitalsDbPath?: string;
objectsDir: string; objectsDir: string;
} }
export declare function createStore(options: CreateStoreOptions): PulseStore; export declare function createStore(options: CreateStoreOptions): PulseStore;
export interface CreateScopedStoreOptions { export interface CreateScopedStoreOptions {
basePath: string; basePath: string;
objectsDir: string; objectsDir: string;
} }
export interface ScopedStore { export interface ScopedStore {
scope(name: string): PulseStore; scope(name: string): PulseStore;
listScopes(): string[]; listScopes(): string[];
/** Get underlying Database for scope (used by projection engine) */ /** Get underlying Database for scope (used by projection engine) */
scopeDatabase(name: string): Database; scopeDatabase(name: string): Database;
putObject(data: unknown): Promise<string>; putObject(data: unknown): Promise<string>;
getObject(hash: string): Promise<unknown | null>; getObject(hash: string): Promise<unknown | null>;
close(): Promise<void>; close(): Promise<void>;
} }
export declare function createScopedStore(options: CreateScopedStoreOptions): ScopedStore; export declare function createScopedStore(
options: CreateScopedStoreOptions,
): ScopedStore;
-525
View File
@@ -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();
},
};
}
+79 -79
View File
@@ -1,121 +1,121 @@
export type TaskStatus = 'pending' | 'routing' | 'assigned' | 'closed'; export type TaskStatus = 'pending' | 'routing' | 'assigned' | 'closed';
export type TaskType = 'bug' | 'rfc' | 'action' | 'review'; export type TaskType = 'bug' | 'rfc' | 'action' | 'review';
export interface TaskCreatedMeta { export interface TaskCreatedMeta {
taskId: string; taskId: string;
projectId: string; projectId: string;
title: string; title: string;
description: string; description: string;
type: TaskType; type: TaskType;
priority: number; priority: number;
creatorId: string; creatorId: string;
} }
export interface TaskRoutingMeta { export interface TaskRoutingMeta {
taskId: string; taskId: string;
brokerSessionId: string; brokerSessionId: string;
} }
export interface TaskAssignedMeta { export interface TaskAssignedMeta {
taskId: string; taskId: string;
assigneeId: string; assigneeId: string;
assignedBy: string; assignedBy: string;
} }
export interface TaskRespondedMeta { export interface TaskRespondedMeta {
taskId: string; taskId: string;
assigneeId: string; assigneeId: string;
result: string; result: string;
} }
export interface TaskClosedMeta { export interface TaskClosedMeta {
taskId: string; taskId: string;
creatorId: string; creatorId: string;
} }
export interface ProjectCreatedMeta { export interface ProjectCreatedMeta {
projectId: string; projectId: string;
name: string; name: string;
repoDir: string; repoDir: string;
} }
export interface TaskState { export interface TaskState {
taskId: string; taskId: string;
projectId: string; projectId: string;
title: string; title: string;
description: string; description: string;
type: TaskType; type: TaskType;
priority: number; priority: number;
creatorId: string; creatorId: string;
status: TaskStatus; status: TaskStatus;
assigneeId?: string; assigneeId?: string;
lastRespondedResult?: string; lastRespondedResult?: string;
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
} }
export interface PendingTasksData { export interface PendingTasksData {
pendingCount: number; pendingCount: number;
tasks: TaskState[]; tasks: TaskState[];
byProject: Record<string, TaskState[]>; byProject: Record<string, TaskState[]>;
checkedAt: number; checkedAt: number;
} }
export interface ProjectState { export interface ProjectState {
projectId: string; projectId: string;
name: string; name: string;
repoDir: string; repoDir: string;
} }
export interface InflightBrokerData { export interface InflightBrokerData {
active: boolean; active: boolean;
} }
export type ContainerType = 'openclaw' | 'cursor' | 'claude-code' | 'hermes'; export type ContainerType = 'openclaw' | 'cursor' | 'claude-code' | 'hermes';
export type ContainerStatus = 'online' | 'offline' | 'busy'; export type ContainerStatus = 'online' | 'offline' | 'busy';
export interface PersonaRegisteredMeta { export interface PersonaRegisteredMeta {
personaId: string; personaId: string;
name: string; name: string;
container: ContainerType; container: ContainerType;
capabilities: string[]; capabilities: string[];
} }
export interface PersonaUpdatedMeta { export interface PersonaUpdatedMeta {
personaId: string; personaId: string;
container?: ContainerType; container?: ContainerType;
capabilities?: string[]; capabilities?: string[];
} }
export interface PersonaState { export interface PersonaState {
personaId: string; personaId: string;
name: string; name: string;
container: ContainerType; container: ContainerType;
capabilities: string[]; capabilities: string[];
registeredAt: number; registeredAt: number;
updatedAt: number; updatedAt: number;
} }
export interface LlmCallStartedMeta { export interface LlmCallStartedMeta {
projectId: string; projectId: string;
model?: string; model?: string;
} }
export interface LlmCallCompletedMeta { export interface LlmCallCompletedMeta {
projectId: string; projectId: string;
model?: string; model?: string;
usage?: { usage?: {
inputTokens: number; inputTokens: number;
outputTokens: number; outputTokens: number;
}; };
toolCalls?: Array<{ toolCalls?: Array<{
name: string; name: string;
arguments: Record<string, unknown>; arguments: Record<string, unknown>;
}>; }>;
durationMs: number; durationMs: number;
} }
export interface ToolResponseMeta { export interface ToolResponseMeta {
projectId: string; projectId: string;
toolCallIndex: number; toolCallIndex: number;
toolName: string; toolName: string;
result: string; result: string;
} }
export interface TraceMessage { export interface TraceMessage {
role: 'assistant' | 'tool'; role: 'assistant' | 'tool';
content?: string; content?: string;
toolCalls?: LlmCallCompletedMeta['toolCalls']; toolCalls?: LlmCallCompletedMeta['toolCalls'];
toolName?: string; toolName?: string;
result?: string; result?: string;
ts: number; ts: number;
} }
export interface AgentLoopTraceData { export interface AgentLoopTraceData {
messages: TraceMessage[]; messages: TraceMessage[];
nextCheckAt: number; nextCheckAt: number;
} }
export interface ActiveProjectsData { export interface ActiveProjectsData {
projectIds: string[]; projectIds: string[];
} }
-1
View File
@@ -1 +0,0 @@
export {};
+33 -27
View File
@@ -11,47 +11,49 @@ import type { EventRecord, PulseStore } from './store.js';
* without re-fetching from the object store. * without re-fetching from the object store.
*/ */
export interface VitalWithData<T = unknown> extends EventRecord { export interface VitalWithData<T = unknown> extends EventRecord {
data: T; data: T;
} }
/** /**
* Predicate evaluated against a recent window of vital records * Predicate evaluated against a recent window of vital records
* with their resolved data payloads. * with their resolved data payloads.
* Return `true` to fire the wake callback. * Return `true` to fire the wake callback.
*/ */
export type WakeCondition<T = unknown> = (window: VitalWithData<T>[]) => boolean; export type WakeCondition<T = unknown> = (
window: VitalWithData<T>[],
) => boolean;
/** /**
* Definition of a watcher: what to collect, how often, and when to wake. * Definition of a watcher: what to collect, how often, and when to wake.
*/ */
export interface WatcherDef<T = unknown> { export interface WatcherDef<T = unknown> {
/** Human-readable name, used in logs and the returned handle. */ /** Human-readable name, used in logs and the returned handle. */
name: string; name: string;
/** Vital key under which collected data is stored. */ /** Vital key under which collected data is stored. */
key: string; key: string;
/** /**
* Async function that collects a snapshot of data. * Async function that collects a snapshot of data.
* The returned value is persisted via CAS and referenced in the vital record. * The returned value is persisted via CAS and referenced in the vital record.
*/ */
collect: () => Promise<T>; collect: () => Promise<T>;
/** /**
* Evaluated after each collection against the last 12 vital records * Evaluated after each collection against the last 12 vital records
* with their resolved data payloads. * with their resolved data payloads.
* When it returns `true`, `wakeTick` is invoked. * When it returns `true`, `wakeTick` is invoked.
*/ */
shouldWake: WakeCondition<T>; shouldWake: WakeCondition<T>;
/** /**
* Collection interval in milliseconds. * Collection interval in milliseconds.
* @default 5000 * @default 5000
*/ */
intervalMs?: number; intervalMs?: number;
} }
/** /**
* Returned by `startWatcher`. Allows the caller to identify and stop the loop. * Returned by `startWatcher`. Allows the caller to identify and stop the loop.
*/ */
export interface WatcherHandle { export interface WatcherHandle {
/** The watcher's name, as provided in {@link WatcherDef}. */ /** The watcher's name, as provided in {@link WatcherDef}. */
name: string; name: string;
/** Stop the collection loop. The current in-flight tick completes first. */ /** Stop the collection loop. The current in-flight tick completes first. */
stop: () => void; stop: () => void;
} }
/** /**
* Start a periodic collection loop for the given watcher definition. * 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`. * @param wakeTick Callback invoked when `def.shouldWake` returns `true`.
* @returns A handle that exposes the watcher name and a `stop` function. * @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;
-90
View File
@@ -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));
}
+9 -7
View File
@@ -1,12 +1,14 @@
import type { WatcherDef } from '../watcher.js'; import type { WatcherDef } from '../watcher.js';
export interface ErrorLogData { export interface ErrorLogData {
matches: string[]; matches: string[];
source: string; source: string;
} }
export interface ErrorLogOptions { export interface ErrorLogOptions {
/** 要监控的日志文件路径列表 */ /** 要监控的日志文件路径列表 */
logFiles: string[]; logFiles: string[];
/** 触发唤醒的关键词 */ /** 触发唤醒的关键词 */
keywords?: string[]; keywords?: string[];
} }
export declare function errorLogWatcher(opts: ErrorLogOptions): WatcherDef<ErrorLogData>; export declare function errorLogWatcher(
opts: ErrorLogOptions,
): WatcherDef<ErrorLogData>;
+20 -4
View File
@@ -1,4 +1,20 @@
export { type ErrorLogData, type ErrorLogOptions, errorLogWatcher, } from './error-log.js'; export {
export { type NetworkData, type NetworkOptions, networkWatcher, } from './network.js'; type ErrorLogData,
export { type ProcessAliveData, type ProcessAliveOptions, processAliveWatcher, } from './process-alive.js'; type ErrorLogOptions,
export { type SystemResourceData, type SystemResourceOptions, systemResourceWatcher, } from './system-resource.js'; 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';
+11 -9
View File
@@ -1,15 +1,17 @@
import * as dns from 'node:dns'; import * as dns from 'node:dns';
import type { WatcherDef } from '../watcher.js'; import type { WatcherDef } from '../watcher.js';
export interface NetworkData { export interface NetworkData {
dnsOk: boolean; dnsOk: boolean;
httpOk: boolean; httpOk: boolean;
latencyMs: number; latencyMs: number;
} }
export interface NetworkOptions { export interface NetworkOptions {
dnsHost?: string; dnsHost?: string;
httpUrl?: string; httpUrl?: string;
timeoutMs?: number; timeoutMs?: number;
/** Inject DNS resolve function for testing */ /** Inject DNS resolve function for testing */
dnsResolveFn?: typeof dns.promises.resolve; dnsResolveFn?: typeof dns.promises.resolve;
} }
export declare function networkWatcher(opts?: NetworkOptions): WatcherDef<NetworkData>; export declare function networkWatcher(
opts?: NetworkOptions,
): WatcherDef<NetworkData>;
+8 -6
View File
@@ -1,12 +1,14 @@
import { execSync } from 'node:child_process'; import { execSync } from 'node:child_process';
import type { WatcherDef } from '../watcher.js'; import type { WatcherDef } from '../watcher.js';
export interface ProcessAliveData { export interface ProcessAliveData {
processes: Record<string, boolean>; processes: Record<string, boolean>;
} }
export interface ProcessAliveOptions { export interface ProcessAliveOptions {
/** 要监控的进程列表:name → 匹配命令行的关键词 */ /** 要监控的进程列表:name → 匹配命令行的关键词 */
processes: Record<string, string>; processes: Record<string, string>;
/** Inject execSync for testing */ /** Inject execSync for testing */
execSyncFn?: typeof execSync; execSyncFn?: typeof execSync;
} }
export declare function processAliveWatcher(opts: ProcessAliveOptions): WatcherDef<ProcessAliveData>; export declare function processAliveWatcher(
opts: ProcessAliveOptions,
): WatcherDef<ProcessAliveData>;
+16 -14
View File
@@ -3,20 +3,22 @@ import * as fs from 'node:fs';
import * as os from 'node:os'; import * as os from 'node:os';
import type { WatcherDef } from '../watcher.js'; import type { WatcherDef } from '../watcher.js';
export interface SystemResourceData { export interface SystemResourceData {
cpuPct: number; cpuPct: number;
memoryPct: number; memoryPct: number;
diskPct: number; diskPct: number;
swapPct: number; swapPct: number;
} }
export interface SystemResourceOptions { export interface SystemResourceOptions {
memoryThreshold?: number; memoryThreshold?: number;
diskThreshold?: number; diskThreshold?: number;
sustainedSeconds?: number; sustainedSeconds?: number;
/** Inject execSync for testing */ /** Inject execSync for testing */
execSyncFn?: typeof execSync; execSyncFn?: typeof execSync;
/** Inject fs module for testing */ /** Inject fs module for testing */
fsFn?: typeof fs; fsFn?: typeof fs;
/** Inject os module for testing */ /** Inject os module for testing */
osFn?: typeof os; osFn?: typeof os;
} }
export declare function systemResourceWatcher(opts?: SystemResourceOptions): WatcherDef<SystemResourceData>; export declare function systemResourceWatcher(
opts?: SystemResourceOptions,
): WatcherDef<SystemResourceData>;
+33 -6
View File
@@ -3,20 +3,47 @@
* *
* 小橘 🍊 (NEKO Team) * 小橘 🍊 (NEKO Team)
*/ */
export type { MetaCoderMeta, MetaTesterMeta, } from './meta.js'; export type { MetaCoderMeta, MetaTesterMeta } from './meta.js';
export { createMetaWorkflow } from './meta.js'; export { createMetaWorkflow } from './meta.js';
export { type AgentExecutorConfig, type AgentResult, type AgentRunner, createAgentExecutorRole, createCursorRunner, } from './roles/agent-executor.js'; export {
export type { LlmRoleConfig, ToolRoleConfig, } from './roles/llm-role-factory.js'; 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 { createLlmRole, createToolRole } from './roles/llm-role-factory.js';
export { createMetaCoderRole } from './roles/meta-coder-cursor.js'; export { createMetaCoderRole } from './roles/meta-coder-cursor.js';
export { createMetaTesterRole } from './roles/meta-tester.js'; export { createMetaTesterRole } from './roles/meta-tester.js';
export { createMetaCheckerRole } from './roles/meta-checker.js'; export { createMetaCheckerRole } from './roles/meta-checker.js';
export { type ScaffoldOptions, scaffoldWorkflow } from './scaffold.js'; export { type ScaffoldOptions, scaffoldWorkflow } from './scaffold.js';
export { createWorkflowRule, type WorkflowRule, type WorkflowTickResult, } from './workflow-rule-adapter.js'; export {
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'; 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'; import type { WorkflowRule } from './workflow-rule-adapter.js';
/** /**
* createWorkflowTicker — wraps multiple WorkflowRules into a single async function * createWorkflowTicker — wraps multiple WorkflowRules into a single async function
* suitable for calling at the end of a runPulse tick cycle. * suitable for calling at the end of a runPulse tick cycle.
*/ */
export declare function createWorkflowTicker(rules: WorkflowRule[]): () => Promise<void>; export declare function createWorkflowTicker(
rules: WorkflowRule[],
): () => Promise<void>;
+5 -5
View File
@@ -21,14 +21,14 @@ export type {
ToolRoleConfig, ToolRoleConfig,
} from './roles/llm-role-factory.js'; } from './roles/llm-role-factory.js';
export { createLlmRole, createToolRole } 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 { createMetaCoderRole } from './roles/meta-coder-cursor.js';
export { createMetaTesterRole } from './roles/meta-tester.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 { type ScaffoldOptions, scaffoldWorkflow } from './scaffold.js';
export {
createSubprocessRole,
type SubprocessRoleConfig,
} from './subprocess-role.js';
export { export {
createWorkflowRule, createWorkflowRule,
type WorkflowRule, type WorkflowRule,
+19 -17
View File
@@ -17,27 +17,29 @@
*/ */
import { type Role, type WorkflowType } from './workflow-type.js'; import { type Role, type WorkflowType } from './workflow-type.js';
export interface MetaCoderMeta { export interface MetaCoderMeta {
[key: string]: unknown; [key: string]: unknown;
filesChanged: string[]; filesChanged: string[];
testsPassed: boolean; testsPassed: boolean;
} }
export interface MetaCheckerMeta { export interface MetaCheckerMeta {
[key: string]: unknown; [key: string]: unknown;
pass: boolean; pass: boolean;
reason: string; reason: string;
violations?: string[]; violations?: string[];
} }
export interface MetaTesterMeta { export interface MetaTesterMeta {
[key: string]: unknown; [key: string]: unknown;
pass: boolean; pass: boolean;
reason: string; reason: string;
/** Only present when pass=true */ /** Only present when pass=true */
commitHash?: string; commitHash?: string;
pushed?: boolean; pushed?: boolean;
} }
export type MetaWorkflowRoles = { export type MetaWorkflowRoles = {
coder: Role<MetaCoderMeta>; coder: Role<MetaCoderMeta>;
checker: Role<MetaCheckerMeta>; checker: Role<MetaCheckerMeta>;
tester: Role<MetaTesterMeta>; tester: Role<MetaTesterMeta>;
}; };
export declare function createMetaWorkflow(roles: MetaWorkflowRoles): WorkflowType<MetaWorkflowRoles>; export declare function createMetaWorkflow(
roles: MetaWorkflowRoles,
): WorkflowType<MetaWorkflowRoles>;
+15 -12
View File
@@ -8,12 +8,7 @@ import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { createStore } from '../store.js'; import { createStore } from '../store.js';
import { import { createMetaWorkflow } from './meta.js';
createMetaWorkflow,
type MetaCheckerMeta,
type MetaCoderMeta,
type MetaTesterMeta,
} from './meta.js';
import { createWorkflowRule } from './workflow-rule-adapter.js'; import { createWorkflowRule } from './workflow-rule-adapter.js';
import { END, START } from './workflow-type.js'; import { END, START } from './workflow-type.js';
@@ -37,7 +32,11 @@ const mockCheckerPass = async () => ({
const mockCheckerFail = async () => ({ const mockCheckerFail = async () => ({
content: 'fail', content: 'fail',
meta: { pass: false, reason: '文件范围越界', violations: ['越界文件: package.json'] }, meta: {
pass: false,
reason: '文件范围越界',
violations: ['越界文件: package.json'],
},
}); });
const mockTesterPass = async () => ({ const mockTesterPass = async () => ({
@@ -72,10 +71,7 @@ describe('Meta Workflow', () => {
), ),
).toBe('tester'); ).toBe('tester');
expect( expect(
wf.moderator( wf.moderator({ role: 'tester', meta: { pass: true, reason: 'ok' } }, 'x'),
{ role: 'tester', meta: { pass: true, reason: 'ok' } },
'x',
),
).toBe(END); ).toBe(END);
}); });
@@ -200,7 +196,14 @@ describe('Meta Workflow', () => {
} }
// coder→checker→tester(fail)→coder→checker→tester(pass) // 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(); await store.close();
}); });
}); });
+30 -23
View File
@@ -10,40 +10,47 @@
import type { LlmClient, LlmTool } from '../../llm-client.js'; import type { LlmClient, LlmTool } from '../../llm-client.js';
import type { Role, WorkflowMessage } from '../workflow-type.js'; import type { Role, WorkflowMessage } from '../workflow-type.js';
export interface AgentResult { export interface AgentResult {
success: boolean; success: boolean;
output: string; output: string;
durationMs: number; durationMs: number;
} }
export interface AgentRunner { export interface AgentRunner {
run(prompt: string, cwd: string): Promise<AgentResult>; run(prompt: string, cwd: string): Promise<AgentResult>;
} }
/** /**
* Default agent runner — Cursor CLI. * Default agent runner — Cursor CLI.
*/ */
export declare function createCursorRunner(opts: { export declare function createCursorRunner(opts: {
agentBin: string; agentBin: string;
timeoutMs?: number; timeoutMs?: number;
}): AgentRunner; }): AgentRunner;
export interface AgentExecutorConfig<Meta> { export interface AgentExecutorConfig<Meta> {
/** Build prompt + cwd for the agent. */ /** Build prompt + cwd for the agent. */
prepPrompt: (chain: WorkflowMessage[], topicId: string) => { prepPrompt: (
prompt: string; chain: WorkflowMessage[],
cwd: string; topicId: string,
}; ) => {
/** LLM₂ structured output: tool definition for meta extraction. */ prompt: string;
parseMeta: { cwd: string;
/** System prompt for the meta-extraction LLM call. */ };
system: string; /** LLM₂ structured output: tool definition for meta extraction. */
/** Tool definition — parameters schema defines Meta shape. */ parseMeta: {
tool: LlmTool; /** System prompt for the meta-extraction LLM call. */
/** Parse tool_call arguments into Meta. Falls back to defaultMeta on failure. */ system: string;
parse: (args: string) => Meta; /** Tool definition — parameters schema defines Meta shape. */
/** Fallback when LLM₂ fails or returns no tool_call. */ tool: LlmTool;
defaultMeta: (output: string) => Meta; /** 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. * Create a pure Role from an agent executor config.
* The Role runs: prepPrompt → agent → LLM₂ parse → { content, meta }. * The Role runs: prepPrompt → agent → LLM₂ parse → { content, meta }.
*/ */
export declare function createAgentExecutorRole<Meta>(agent: AgentRunner, llm: LlmClient, config: AgentExecutorConfig<Meta>): Role<Meta>; export declare function createAgentExecutorRole<Meta>(
agent: AgentRunner,
llm: LlmClient,
config: AgentExecutorConfig<Meta>,
): Role<Meta>;
+48 -33
View File
@@ -17,47 +17,62 @@
import type { LlmClient, LlmResponse } from '../../llm-client.js'; import type { LlmClient, LlmResponse } from '../../llm-client.js';
import type { Role, RoleResult, WorkflowMessage } from '../workflow-type.js'; import type { Role, RoleResult, WorkflowMessage } from '../workflow-type.js';
export interface LlmRoleConfig<TMeta extends Record<string, unknown>> { export interface LlmRoleConfig<TMeta extends Record<string, unknown>> {
/** System prompt */ /** System prompt */
systemPrompt: string; systemPrompt: string;
/** Build user messages from the workflow chain. Default: last message content. */ /** Build user messages from the workflow chain. Default: last message content. */
buildUserMessage?: (chain: WorkflowMessage[]) => string; buildUserMessage?: (chain: WorkflowMessage[]) => string;
/** Tool definitions for structured output */ /** Tool definitions for structured output */
tools?: Array<{ tools?: Array<{
type: 'function'; type: 'function';
function: { function: {
name: string; name: string;
description: string; description: string;
parameters: Record<string, unknown>; parameters: Record<string, unknown>;
}; };
}>; }>;
/** Tool choice strategy */ /** Tool choice strategy */
toolChoice?: 'auto' | 'required'; toolChoice?: 'auto' | 'required';
/** Parse the LLM response into role result. */ /** Parse the LLM response into role result. */
parseResponse: (resp: LlmResponse, chain: WorkflowMessage[]) => RoleResult<TMeta>; parseResponse: (
resp: LlmResponse,
chain: WorkflowMessage[],
) => RoleResult<TMeta>;
} }
/** /**
* Create a reusable LLM role from config. * Create a reusable LLM role from config.
* All LLM roles share the same call skeleton — only the filling differs. * All LLM roles share the same call skeleton — only the filling differs.
*/ */
export declare function createLlmRole<TMeta extends Record<string, unknown>>(llm: LlmClient, config: LlmRoleConfig<TMeta>): Role<TMeta>; export declare function createLlmRole<TMeta extends Record<string, unknown>>(
export interface ToolRoleConfig<TMeta extends Record<string, unknown>, TToolResult = unknown> { llm: LlmClient,
systemPrompt: string; config: LlmRoleConfig<TMeta>,
buildUserMessage?: (chain: WorkflowMessage[]) => string; ): Role<TMeta>;
tool: { export interface ToolRoleConfig<
type: 'function'; TMeta extends Record<string, unknown>,
function: { TToolResult = unknown,
name: string; > {
description: string; systemPrompt: string;
parameters: Record<string, unknown>; buildUserMessage?: (chain: WorkflowMessage[]) => string;
}; tool: {
type: 'function';
function: {
name: string;
description: string;
parameters: Record<string, unknown>;
}; };
/** Default result if tool call parsing fails */ };
defaultResult: TToolResult; /** Default result if tool call parsing fails */
/** Convert parsed tool result to role result */ defaultResult: TToolResult;
toRoleResult: (parsed: TToolResult, chain: WorkflowMessage[]) => RoleResult<TMeta>; /** Convert parsed tool result to role result */
toRoleResult: (
parsed: TToolResult,
chain: WorkflowMessage[],
) => RoleResult<TMeta>;
} }
/** /**
* Create an LLM role that uses tool_choice: required for structured output. * Create an LLM role that uses tool_choice: required for structured output.
* Handles tool_call parsing and fallback automatically. * Handles tool_call parsing and fallback automatically.
*/ */
export declare function createToolRole<TMeta extends Record<string, unknown>, TToolResult = unknown>(llm: LlmClient, config: ToolRoleConfig<TMeta, TToolResult>): Role<TMeta>; export declare function createToolRole<
TMeta extends Record<string, unknown>,
TToolResult = unknown,
>(llm: LlmClient, config: ToolRoleConfig<TMeta, TToolResult>): Role<TMeta>;
+8 -8
View File
@@ -13,14 +13,14 @@
*/ */
import type { Role } from '../workflow-type.js'; import type { Role } from '../workflow-type.js';
export interface MetaCheckerMeta { export interface MetaCheckerMeta {
[key: string]: unknown; [key: string]: unknown;
pass: boolean; pass: boolean;
reason: string; reason: string;
violations?: string[]; violations?: string[];
} }
export declare function createMetaCheckerRole(opts: { export declare function createMetaCheckerRole(opts: {
/** Engine repo directory (allowed file scope) */ /** Engine repo directory (allowed file scope) */
engineDir: string; engineDir: string;
/** Extra allowed path prefixes (optional) */ /** Extra allowed path prefixes (optional) */
allowedPrefixes?: string[]; allowedPrefixes?: string[];
}): Role<MetaCheckerMeta>; }): Role<MetaCheckerMeta>;
@@ -41,7 +41,9 @@ export function createMetaCheckerRole(opts: {
let changedFiles: string[] = []; let changedFiles: string[] = [];
try { try {
// Get all uncommitted changes + last commit changes // 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); changedFiles = diffOutput.split('\n').filter(Boolean);
} catch { } catch {
// No git history — check working tree // No git history — check working tree
@@ -68,7 +70,9 @@ export function createMetaCheckerRole(opts: {
file.startsWith(prefix), file.startsWith(prefix),
); );
if (!allowed) { if (!allowed) {
violations.push(`越界文件: ${file}(只允许修改 ${allowedPrefixes.join(', ')} 下的文件)`); violations.push(
`越界文件: ${file}(只允许修改 ${allowedPrefixes.join(', ')} 下的文件)`,
);
} }
} }
+5 -1
View File
@@ -8,4 +8,8 @@ import type { LlmClient } from '../../llm-client.js';
import type { MetaCoderMeta } from '../meta.js'; import type { MetaCoderMeta } from '../meta.js';
import type { Role } from '../workflow-type.js'; import type { Role } from '../workflow-type.js';
import { type AgentRunner } from './agent-executor.js'; import { type AgentRunner } from './agent-executor.js';
export declare function createMetaCoderRole(runner: AgentRunner, llm: LlmClient, repoDir: string): Role<MetaCoderMeta>; export declare function createMetaCoderRole(
runner: AgentRunner,
llm: LlmClient,
repoDir: string,
): Role<MetaCoderMeta>;
@@ -42,11 +42,13 @@ export function createMetaCoderRole(
prepPrompt: (chain, _topicId) => { prepPrompt: (chain, _topicId) => {
const startMsg = chain.find((m) => m.role === '__start__'); const startMsg = chain.find((m) => m.role === '__start__');
const taskDescription = startMsg?.content ?? ''; const taskDescription = startMsg?.content ?? '';
// 如果有 tester 失败反馈,附加 // 如果有 tester 失败反馈,附加
const testerMsg = [...chain].reverse().find((m) => m.role === '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 = `# 任务 const prompt = `# 任务
${taskDescription} ${taskDescription}
${testerFeedback} ${testerFeedback}
+7 -7
View File
@@ -10,11 +10,11 @@
import type { MetaTesterMeta } from '../meta.js'; import type { MetaTesterMeta } from '../meta.js';
import type { Role } from '../workflow-type.js'; import type { Role } from '../workflow-type.js';
export declare function createMetaTesterRole(opts: { export declare function createMetaTesterRole(opts: {
repoDir: string; repoDir: string;
/** git remote (auto-detect if omitted) */ /** git remote (auto-detect if omitted) */
remote?: string; remote?: string;
/** git branch (default: main) */ /** git branch (default: main) */
branch?: string; branch?: string;
/** max ticks for e2e run (default: 100;下限 20 以覆盖 ping-pong 静默 + 较长 workflow) */ /** max ticks for e2e run (default: 100;下限 20 以覆盖 ping-pong 静默 + 较长 workflow) */
maxTicks?: number; maxTicks?: number;
}): Role<MetaTesterMeta>; }): Role<MetaTesterMeta>;
@@ -12,10 +12,15 @@ import { execSync } from 'node:child_process';
import { existsSync, mkdtempSync, readdirSync } from 'node:fs'; import { existsSync, mkdtempSync, readdirSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; 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 { createStore } from '../../store.js';
import type { MetaTesterMeta } from '../meta.js';
import { createWorkflowRule } from '../workflow-rule-adapter.js'; import { createWorkflowRule } from '../workflow-rule-adapter.js';
import type {
Role,
RoleResult,
WorkflowMessage,
WorkflowType,
} from '../workflow-type.js';
export function createMetaTesterRole(opts: { export function createMetaTesterRole(opts: {
repoDir: string; repoDir: string;
@@ -60,15 +65,31 @@ export function createMetaTesterRole(opts: {
const mod = await import(fullPath); const mod = await import(fullPath);
for (const [key, val] of Object.entries(mod)) { for (const [key, val] of Object.entries(mod)) {
// Direct WorkflowType export (e.g. `export const pingPong: WorkflowType`) // 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<any>); workflows.push(val as WorkflowType<any>);
} }
// Factory function (e.g. `export function createWerewolfWorkflow()`) // 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 { try {
// Call with no args first (mock mode) // Call with no args first (mock mode)
const wf = val(); 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<any>); workflows.push(wf as WorkflowType<any>);
} }
} catch { } catch {
@@ -140,9 +161,13 @@ export function createMetaTesterRole(opts: {
if (!completed) { if (!completed) {
try { try {
const allEvents = await testStore.getAfter(0); 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 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}`; diagnostic = `\n 事件链: ${roles}`;
if (lastEvent) { if (lastEvent) {
diagnostic += `\n 最后事件: ${lastEvent.kind} (id=${lastEvent.id})`; diagnostic += `\n 最后事件: ${lastEvent.kind} (id=${lastEvent.id})`;
@@ -158,11 +183,23 @@ export function createMetaTesterRole(opts: {
} }
if (completed) { 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) { } 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 { } 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 { } finally {
await testStore.close(); await testStore.close();
@@ -209,12 +246,16 @@ export function createMetaTesterRole(opts: {
commitHash = exec('git rev-parse --short HEAD'); commitHash = exec('git rev-parse --short HEAD');
// Auto-detect remote // Auto-detect remote
const remote = opts.remote ?? (() => { const remote =
try { opts.remote ??
const remotes = exec('git remote').split('\n').filter(Boolean); (() => {
return remotes[0] || null; try {
} catch { return null; } const remotes = exec('git remote').split('\n').filter(Boolean);
})(); return remotes[0] || null;
} catch {
return null;
}
})();
if (remote) { if (remote) {
try { try {
@@ -227,14 +268,19 @@ export function createMetaTesterRole(opts: {
pushed = false; pushed = false;
} }
} }
} catch (err: any) { } catch (_err: any) {
commitHash = undefined; commitHash = undefined;
pushed = false; pushed = false;
} }
return { return {
content: `e2e 验证通过\n\n${summary}\n\nCommit: ${commitHash ?? 'none'}\nPushed: ${pushed ? 'yes' : 'no'}`, 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,
},
}; };
}; };
} }
+3 -3
View File
@@ -11,8 +11,8 @@
* 小橘 🍊 (NEKO Team) * 小橘 🍊 (NEKO Team)
*/ */
export interface ScaffoldOptions { export interface ScaffoldOptions {
name: string; name: string;
roles?: string[]; roles?: string[];
workflowsDir: string; workflowsDir: string;
} }
export declare function scaffoldWorkflow(opts: ScaffoldOptions): string[]; export declare function scaffoldWorkflow(opts: ScaffoldOptions): string[];
+2 -2
View File
@@ -11,7 +11,7 @@
* 小橘 🍊 (NEKO Team) * 小橘 🍊 (NEKO Team)
*/ */
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
export interface ScaffoldOptions { export interface ScaffoldOptions {
@@ -49,7 +49,7 @@ export function scaffoldWorkflow(opts: ScaffoldOptions): string[] {
const moderatorCases = roles const moderatorCases = roles
.map((r, i) => { .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}';`; if (i === 0) return ` case START:\n return '${r}';`;
return ` case '${roles[i - 1]}':\n return '${r}';`; return ` case '${roles[i - 1]}':\n return '${r}';`;
}) })
@@ -17,7 +17,12 @@ describe('createSubprocessRole', () => {
}); });
const chain = [ 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); const result = await role(chain, 'topic-1', null as any);
@@ -32,13 +32,10 @@ const RUNNER_PATH = join(import.meta.dir, 'subprocess-runner.ts');
* Wrap a Role as a subprocess execution. * Wrap a Role as a subprocess execution.
* Returns a function matching the Role<unknown> signature. * Returns a function matching the Role<unknown> signature.
*/ */
export function createSubprocessRole(config: SubprocessRoleConfig): Role<unknown> { export function createSubprocessRole(
const { config: SubprocessRoleConfig,
rolePath, ): Role<unknown> {
roleExport, const { rolePath, roleExport, timeoutMs = 300_000, storeConfig } = config;
timeoutMs = 300_000,
storeConfig,
} = config;
return async ( return async (
chain: WorkflowMessage[], chain: WorkflowMessage[],