From d0d9fee14dc4846bcd76ff7d6e31990e3e52bc6d Mon Sep 17 00:00:00 2001 From: Joy88 Date: Sat, 11 Apr 2026 20:52:29 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20exploration=20loop=20=E2=80=94=20He?= =?UTF-8?q?rmes-style=20self-improvement=20for=20Alice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an autonomous "閒時探索" loop: at scheduled off-market ticks (or manual trigger via tool), Alice runs a 4-phase cycle: 1. Guard — skip if the user is actively chatting (session mtime guard) 2. Recall — load relevant skills from data/skills/ by keyword triggers 3. Explore — askWithSession with Alice persona + recalled skills injected; stream tool_use events to count depth 4. Reflect — if tool depth >= threshold, ask Alice to emit a markdown skill (or SKIP); persist to data/skills/{id}.md; Brain commit Design: - File-driven — every skill is a markdown file with yaml-ish frontmatter (id, triggers, created, usageCount, confidence, lastUsedAt, summary) - Recall is simple keyword matching, not embeddings — enough at N<200 - Prune uses usage×confidence÷age so recent high-signal skills stay - Sonnet 4.6 is the configured default model (bounded subscription cost) - Disabled by default in exploration.json — flip enabled: true to start New files: src/domain/exploration/{types,skill-curator,explorer,scheduler,index}.ts src/domain/exploration/__tests__/{skill-curator,explorer}.spec.ts src/tool/exploration.ts (explorationRunNow / explorationStatus / skillList / skillDelete tools) Wiring: src/core/config.ts — new exploration section in Config + sectionSchemas src/main.ts — build curator + explorer + scheduler, register tools Tests: 17 new tests (9 curator, 8 explorer) covering persist/recall/prune/ markUsed round-trip, full loop happy path, SKIP reflection, below- threshold skip, user-active pause, and reflection parser edge cases. Full backend test suite: 915/915 green. Typecheck clean. (UI test failures in this run are pre-existing worktree symlink artifacts — confirmed passing in the real working tree.) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/core/config.ts | 7 +- .../exploration/__tests__/explorer.spec.ts | 278 ++++++++++++ .../__tests__/skill-curator.spec.ts | 155 +++++++ src/domain/exploration/explorer.ts | 405 ++++++++++++++++++ src/domain/exploration/index.ts | 7 + src/domain/exploration/scheduler.ts | 88 ++++ src/domain/exploration/skill-curator.ts | 301 +++++++++++++ src/domain/exploration/types.ts | 137 ++++++ src/main.ts | 26 ++ src/tool/exploration.ts | 140 ++++++ 10 files changed, 1543 insertions(+), 1 deletion(-) create mode 100644 src/domain/exploration/__tests__/explorer.spec.ts create mode 100644 src/domain/exploration/__tests__/skill-curator.spec.ts create mode 100644 src/domain/exploration/explorer.ts create mode 100644 src/domain/exploration/index.ts create mode 100644 src/domain/exploration/scheduler.ts create mode 100644 src/domain/exploration/skill-curator.ts create mode 100644 src/domain/exploration/types.ts create mode 100644 src/tool/exploration.ts diff --git a/src/core/config.ts b/src/core/config.ts index 4dcc8255..2b08f1e1 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { readFile, writeFile, mkdir, unlink } from 'fs/promises' import { resolve } from 'path' import { newsCollectorSchema } from '../domain/news/config.js' +import { explorationConfigSchema } from '../domain/exploration/types.js' const CONFIG_DIR = resolve('data/config') @@ -286,6 +287,7 @@ export type Config = { connectors: z.infer news: z.infer tools: z.infer + exploration: z.infer } // ==================== Loader ==================== @@ -318,7 +320,7 @@ async function parseAndSeed(filename: string, schema: z.ZodType, raw: unkn } export async function loadConfig(): Promise { - const files = ['engine.json', 'agent.json', 'crypto.json', 'securities.json', 'market-data.json', 'compaction.json', 'ai-provider-manager.json', 'heartbeat.json', 'snapshot.json', 'connectors.json', 'news.json', 'tools.json'] as const + const files = ['engine.json', 'agent.json', 'crypto.json', 'securities.json', 'market-data.json', 'compaction.json', 'ai-provider-manager.json', 'heartbeat.json', 'snapshot.json', 'connectors.json', 'news.json', 'tools.json', 'exploration.json'] as const const raws = await Promise.all(files.map((f) => loadJsonFile(f))) // TODO: remove all migration blocks before v1.0 — no stable release yet, breaking changes are fine @@ -484,6 +486,7 @@ export async function loadConfig(): Promise { connectors: await parseAndSeed(files[9], connectorsSchema, raws[9]), news: await parseAndSeed(files[10], newsCollectorSchema, raws[10]), tools: await parseAndSeed(files[11], toolsSchema, raws[11]), + exploration: await parseAndSeed(files[12], explorationConfigSchema, raws[12]), } } @@ -653,6 +656,7 @@ const sectionSchemas: Record = { connectors: connectorsSchema, news: newsCollectorSchema, tools: toolsSchema, + exploration: explorationConfigSchema, } const sectionFiles: Record = { @@ -668,6 +672,7 @@ const sectionFiles: Record = { connectors: 'connectors.json', news: 'news.json', tools: 'tools.json', + exploration: 'exploration.json', } /** All valid config section names (derived from sectionSchemas). */ diff --git a/src/domain/exploration/__tests__/explorer.spec.ts b/src/domain/exploration/__tests__/explorer.spec.ts new file mode 100644 index 00000000..ebeae06f --- /dev/null +++ b/src/domain/exploration/__tests__/explorer.spec.ts @@ -0,0 +1,278 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { mkdtemp, rm, writeFile, mkdir } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createExplorer, __internal } from '../explorer.js' +import { createSkillCurator } from '../skill-curator.js' +import { explorationConfigSchema } from '../types.js' +import type { ExplorationConfig } from '../types.js' + +// ==================== Fakes ==================== + +interface FakeStreamOpts { + toolCalls: number + finalText: string +} + +function makeFakeStream({ toolCalls, finalText }: FakeStreamOpts) { + const result = { text: finalText, media: [] as unknown[] } + return { + async *[Symbol.asyncIterator]() { + for (let i = 0; i < toolCalls; i++) { + yield { type: 'tool_use' as const, id: `t${i}`, name: 'fake', input: {} } + } + yield { type: 'text' as const, text: finalText } + yield { type: 'done' as const, result } + }, + then( + resolve?: ((value: typeof result) => T | PromiseLike) | null, + ) { + return Promise.resolve(result).then(resolve) + }, + } +} + +function makeFakeAgentCenter(opts: { + streamOpts: FakeStreamOpts + reflectionText: string +}) { + const askMock = vi.fn(async () => ({ + text: opts.reflectionText, + media: [], + })) + const askWithSessionMock = vi.fn(() => makeFakeStream(opts.streamOpts)) + return { + ask: askMock, + askWithSession: askWithSessionMock, + } +} + +function makeFakeBrain() { + const commits: string[] = [] + return { + commits, + updateFrontalLobe: vi.fn((content: string) => { + commits.push(content) + return { success: true, message: 'ok' } + }), + } +} + +function makeFakeEventLog() { + const events: Array<{ type: string; payload: unknown }> = [] + return { + events, + append: vi.fn(async (type: string, payload: T) => { + events.push({ type, payload }) + return { seq: events.length, ts: Date.now(), type, payload } + }), + recent: vi.fn(() => []), + } +} + +function makeFakeConnectorCenter() { + return { notify: vi.fn(async () => undefined) } +} + +// ==================== Helpers ==================== + +function baseConfig(overrides: Partial = {}): ExplorationConfig { + return explorationConfigSchema.parse({ + enabled: true, + pauseIfUserActiveWithinMin: 0, // disable guard by default in tests + ...overrides, + }) +} + +// ==================== Tests ==================== + +describe('Explorer', () => { + let skillsDir: string + let sessionsDir: string + + beforeEach(async () => { + skillsDir = await mkdtemp(join(tmpdir(), 'alice-skills-')) + sessionsDir = await mkdtemp(join(tmpdir(), 'alice-sessions-')) + }) + + afterEach(async () => { + await rm(skillsDir, { recursive: true, force: true }) + await rm(sessionsDir, { recursive: true, force: true }) + }) + + it('runs the full loop and persists a skill when reflection returns JSON', async () => { + const curator = createSkillCurator({ skillsDir }) + const agentCenter = makeFakeAgentCenter({ + streamOpts: { toolCalls: 5, finalText: 'INSIGHT: VIX 低檔反轉的 signal' }, + reflectionText: JSON.stringify({ + triggers: ['vix', '反轉'], + confidence: 0.8, + summary: 'VIX 低檔反轉', + body: '# VIX 低檔反轉\n\n## When to load\n當 VIX < 15 且 fear_greed > 80\n\n## Procedure\n1. 查 VIX 日線\n2. 查 fear & greed\n', + }), + }) + const brain = makeFakeBrain() + const eventLog = makeFakeEventLog() + const connectorCenter = makeFakeConnectorCenter() + const config = baseConfig() + + const explorer = createExplorer({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + agentCenter: agentCenter as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + eventLog: eventLog as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + connectorCenter: connectorCenter as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + brain: brain as any, + skillCurator: curator, + config, + sessionsDir, + }) + + const result = await explorer.run({ source: 'manual', topic: 'test topic' }) + + expect(result.ok).toBe(true) + expect(result.toolCalls).toBe(5) + expect(result.createdSkillId).toBeTruthy() + expect(result.summary).toContain('INSIGHT') + + const skills = await curator.list() + expect(skills).toHaveLength(1) + expect(skills[0].frontmatter.triggers).toEqual(['vix', '反轉']) + + // Brain commit recorded + expect(brain.updateFrontalLobe).toHaveBeenCalledOnce() + expect(brain.commits[0]).toContain('Exploration') + expect(brain.commits[0]).toContain('New skill:') + + // Event log saw the expected sequence + const types = eventLog.events.map((e) => e.type) + expect(types).toContain('exploration.started') + expect(types).toContain('exploration.recall.completed') + expect(types).toContain('exploration.explore.completed') + expect(types).toContain('exploration.skill.created') + expect(types).toContain('exploration.completed') + }) + + it('skips skill creation when reflection returns SKIP', async () => { + const curator = createSkillCurator({ skillsDir }) + const agentCenter = makeFakeAgentCenter({ + streamOpts: { toolCalls: 3, finalText: 'no new insight' }, + reflectionText: 'SKIP', + }) + const config = baseConfig() + + const explorer = createExplorer({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + agentCenter: agentCenter as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + eventLog: makeFakeEventLog() as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + connectorCenter: makeFakeConnectorCenter() as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + brain: makeFakeBrain() as any, + skillCurator: curator, + config, + sessionsDir, + }) + + const result = await explorer.run({ source: 'manual', topic: 'x' }) + expect(result.createdSkillId).toBeNull() + expect(await curator.list()).toHaveLength(0) + }) + + it('skips reflection entirely when tool call count is below threshold', async () => { + const curator = createSkillCurator({ skillsDir }) + const agentCenter = makeFakeAgentCenter({ + streamOpts: { toolCalls: 1, finalText: 'too shallow' }, + reflectionText: 'should not be called', + }) + const config = baseConfig({ + reflection: { minToolCalls: 5, maxSkills: 100 }, + }) + + const explorer = createExplorer({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + agentCenter: agentCenter as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + eventLog: makeFakeEventLog() as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + connectorCenter: makeFakeConnectorCenter() as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + brain: makeFakeBrain() as any, + skillCurator: curator, + config, + sessionsDir, + }) + + await explorer.run({ source: 'manual' }) + expect(agentCenter.ask).not.toHaveBeenCalled() + expect(await curator.list()).toHaveLength(0) + }) + + it('pauses when a user session was modified recently', async () => { + // Write a recent jsonl file in the sessions dir (outside exploration namespace) + await mkdir(join(sessionsDir, 'chat'), { recursive: true }) + await writeFile(join(sessionsDir, 'chat', 'live.jsonl'), '{}\n') + + const curator = createSkillCurator({ skillsDir }) + const agentCenter = makeFakeAgentCenter({ + streamOpts: { toolCalls: 0, finalText: '' }, + reflectionText: '', + }) + const config = baseConfig({ pauseIfUserActiveWithinMin: 60 }) + + const explorer = createExplorer({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + agentCenter: agentCenter as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + eventLog: makeFakeEventLog() as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + connectorCenter: makeFakeConnectorCenter() as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + brain: makeFakeBrain() as any, + skillCurator: curator, + config, + sessionsDir, + }) + + const result = await explorer.run({ source: 'cron' }) + expect(result.ok).toBe(false) + expect(result.error).toBe('paused_user_active') + expect(agentCenter.askWithSession).not.toHaveBeenCalled() + }) +}) + +describe('parseReflection', () => { + it('accepts plain JSON', () => { + const r = __internal.parseReflection( + JSON.stringify({ + triggers: ['a'], + confidence: 0.5, + body: '# x', + }), + ) + expect(r.skip).toBe(false) + expect(r.triggers).toEqual(['a']) + expect(r.body).toBe('# x') + }) + + it('strips markdown code fences', () => { + const r = __internal.parseReflection( + '```json\n{"triggers": ["a"], "confidence": 0.5, "body": "# x"}\n```', + ) + expect(r.skip).toBe(false) + expect(r.triggers).toEqual(['a']) + }) + + it('returns skip on SKIP literal', () => { + expect(__internal.parseReflection('SKIP').skip).toBe(true) + expect(__internal.parseReflection('SKIP — not enough').skip).toBe(true) + }) + + it('returns skip on malformed JSON', () => { + expect(__internal.parseReflection('not json').skip).toBe(true) + expect(__internal.parseReflection('{"triggers": [], "body": ""}').skip).toBe(true) + }) +}) diff --git a/src/domain/exploration/__tests__/skill-curator.spec.ts b/src/domain/exploration/__tests__/skill-curator.spec.ts new file mode 100644 index 00000000..ac12836d --- /dev/null +++ b/src/domain/exploration/__tests__/skill-curator.spec.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, rm, readFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createSkillCurator, __internal } from '../skill-curator.js' + +describe('SkillCurator', () => { + let dir: string + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'alice-skills-')) + }) + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }) + }) + + describe('persist + list', () => { + it('writes a skill with frontmatter and loads it back', async () => { + const curator = createSkillCurator({ skillsDir: dir }) + const skill = await curator.persist({ + triggers: ['tsmc', '法說'], + body: '# TSMC 法說前部位\n\n## When to load\ntest', + confidence: 0.8, + summary: 'test summary', + }) + expect(skill.frontmatter.id).toMatch(/^\d{4}-\d{2}-\d{2}-/) + expect(skill.frontmatter.triggers).toEqual(['tsmc', '法說']) + expect(skill.frontmatter.confidence).toBe(0.8) + expect(skill.frontmatter.usageCount).toBe(0) + + const all = await curator.list() + expect(all).toHaveLength(1) + expect(all[0].frontmatter.id).toBe(skill.frontmatter.id) + expect(all[0].body).toContain('TSMC 法說前部位') + }) + + it('persists frontmatter in a readable yaml-ish form', async () => { + const curator = createSkillCurator({ skillsDir: dir }) + const skill = await curator.persist({ + triggers: ['alpha', 'beta'], + body: '# heading\n\ncontent', + confidence: 0.33, + }) + const raw = await readFile(skill.filePath, 'utf-8') + expect(raw).toMatch(/^---\n/) + expect(raw).toContain('triggers: ["alpha", "beta"]') + expect(raw).toContain('confidence: 0.33') + expect(raw).toContain('# heading') + }) + }) + + describe('recall', () => { + it('returns skills whose triggers match the context', async () => { + const curator = createSkillCurator({ skillsDir: dir }) + await curator.persist({ + triggers: ['tsmc', '半導體'], + body: '# tsmc skill', + confidence: 0.7, + }) + await curator.persist({ + triggers: ['btc', 'crypto'], + body: '# btc skill', + confidence: 0.9, + }) + + const hits = await curator.recall({ context: '今天 TSMC 法說會漲停' }) + expect(hits).toHaveLength(1) + expect(hits[0].body).toContain('tsmc skill') + }) + + it('returns empty list when no triggers match', async () => { + const curator = createSkillCurator({ skillsDir: dir }) + await curator.persist({ triggers: ['nvda'], body: '# nvda', confidence: 0.5 }) + const hits = await curator.recall({ context: '完全無關的文字' }) + expect(hits).toEqual([]) + }) + + it('sorts by trigger hit count, then confidence', async () => { + const curator = createSkillCurator({ skillsDir: dir }) + await curator.persist({ triggers: ['vix'], body: '# a', confidence: 0.2 }) + await curator.persist({ triggers: ['vix', 'fear'], body: '# b', confidence: 0.3 }) + await curator.persist({ triggers: ['vix'], body: '# c', confidence: 0.9 }) + + const hits = await curator.recall({ context: 'vix fear 指標' }) + expect(hits).toHaveLength(3) + // b has 2 trigger hits, ranks first + expect(hits[0].body).toContain('# b') + // c has 1 hit but higher confidence than a + expect(hits[1].body).toContain('# c') + expect(hits[2].body).toContain('# a') + }) + }) + + describe('markUsed', () => { + it('bumps usageCount and sets lastUsedAt', async () => { + const curator = createSkillCurator({ skillsDir: dir }) + const skill = await curator.persist({ + triggers: ['foo'], + body: '# foo', + }) + expect(skill.frontmatter.usageCount).toBe(0) + + await curator.markUsed([skill.frontmatter.id]) + const reloaded = await curator.load(skill.frontmatter.id) + expect(reloaded?.frontmatter.usageCount).toBe(1) + expect(reloaded?.frontmatter.lastUsedAt).toBeTruthy() + }) + }) + + describe('prune', () => { + it('removes lowest-scoring skills when over capacity', async () => { + const curator = createSkillCurator({ skillsDir: dir }) + const a = await curator.persist({ triggers: ['a'], body: '# a', confidence: 0.1 }) + const b = await curator.persist({ triggers: ['b'], body: '# b', confidence: 0.9 }) + const c = await curator.persist({ triggers: ['c'], body: '# c', confidence: 0.9 }) + await curator.markUsed([b.frontmatter.id, c.frontmatter.id]) + + const pruned = await curator.prune(2) + expect(pruned).toEqual([a.frontmatter.id]) + + const remaining = await curator.list() + expect(remaining.map((s) => s.frontmatter.id).sort()).toEqual( + [b.frontmatter.id, c.frontmatter.id].sort(), + ) + }) + + it('returns empty array when under capacity', async () => { + const curator = createSkillCurator({ skillsDir: dir }) + await curator.persist({ triggers: ['a'], body: '# a' }) + const pruned = await curator.prune(10) + expect(pruned).toEqual([]) + }) + }) + + describe('frontmatter parsing round-trip', () => { + it('survives a parse → serialize cycle', () => { + const fm = { + id: '2026-04-11-foo-1234', + triggers: ['a', 'b with space'], + created: '2026-04-11T10:00:00.000Z', + usageCount: 5, + confidence: 0.7, + lastUsedAt: '2026-04-11T11:00:00.000Z', + summary: 'one liner', + } + const serialized = __internal.serializeFrontmatter(fm) + const roundTripped = __internal.parseFrontmatter( + `${serialized}\n\n# body\n\ncontent`, + ) + expect(roundTripped.frontmatter).toEqual(fm) + expect(roundTripped.body).toBe('# body\n\ncontent') + }) + }) +}) diff --git a/src/domain/exploration/explorer.ts b/src/domain/exploration/explorer.ts new file mode 100644 index 00000000..f625f195 --- /dev/null +++ b/src/domain/exploration/explorer.ts @@ -0,0 +1,405 @@ +/** + * Explorer — the core exploration loop. + * + * Four phases per run: + * 1. Guard — skip if user is actively chatting (last session mtime within N minutes) + * 2. Recall — load relevant skills based on market/portfolio context + * 3. Explore — askWithSession with Alice persona + skills injected + * Streams tool_use events to count the depth of the exploration. + * 4. Reflect — if depth >= threshold, ask Alice to write a skill markdown; + * persist to data/skills/ and mark Brain commit. + * + * Hard design choices: + * - Always Sonnet 4.6 (user preference — see exploration config.model) + * - No automatic push notifications (user sees results by inspecting + * data/skills/ + brain commits; can opt in later via notifyChannels) + * - All state is file-driven — no in-memory accumulation across runs + */ + +import { readdir, stat } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { SessionStore } from '../../core/session.js' +import type { AgentCenter } from '../../core/agent-center.js' +import type { EventLog, EventLogEntry } from '../../core/event-log.js' +import type { ConnectorCenter } from '../../core/connector-center.js' +import type { Brain } from '../brain/index.js' +import type { SkillCurator } from './skill-curator.js' +import { EXPLORATION_EVENTS } from './types.js' +import type { + ExplorationConfig, + ExplorationResult, + ExplorationTrigger, + Skill, +} from './types.js' + +export interface ExplorerDeps { + agentCenter: AgentCenter + eventLog: EventLog + connectorCenter: ConnectorCenter + brain: Brain + skillCurator: SkillCurator + config: ExplorationConfig + /** Where session JSONLs live — used for user-active detection. */ + sessionsDir?: string + /** Injected clock for tests. */ + now?: () => number +} + +export interface Explorer { + run(trigger: ExplorationTrigger): Promise + lastRunAt: number | null + lastError: string | null +} + +// ==================== Prompts ==================== + +const EXPLORATION_TOPICS = [ + '挑一個你最近最好奇、但還沒深挖過的台股題材(不要挑熱門大型權值股),用工具查資料、驗證你的假設,然後寫下你學到什麼', + '複盤最近一次你認為判斷失誤的決策(如果有),挖出真正的原因(不是表面的)', + '找一個反直覺的市場現象(例如 VIX 低點後反而沒有下跌),用數據驗證是真是假', + '挑一個你持倉裡最不確定的部位,用至少三個資料來源交叉驗證它現在的處境', + '研究一個你平常忽略的指標(例如匯率、長短天期利差、原油 beta),看它對你的交易有沒有新的 signal', + '找一個巨人傑/沈萬鈞/菲比斯的交易原則,用最新的台股資料測試它在 2026 還有沒有效', +] + +function pickTopic(trigger: ExplorationTrigger): string { + if (trigger.topic) return trigger.topic + // Rotate based on hour-of-day so consecutive runs don't repeat. + const idx = new Date().getHours() % EXPLORATION_TOPICS.length + return EXPLORATION_TOPICS[idx] +} + +function buildExplorationPrompt( + topic: string, + recalled: Skill[], + contextNotes?: string, +): string { + let recalledBlock = '' + if (recalled.length > 0) { + const lines = recalled.map((s) => { + const summary = s.frontmatter.summary ?? s.body.split('\n')[0].replace(/^#+\s*/, '') + return `- [${s.frontmatter.id}] ${summary}(confidence ${s.frontmatter.confidence.toFixed(1)})` + }) + recalledBlock = `\n\n## 你之前學到的相關經驗(先讀過再開始探索)\n${lines.join('\n')}\n\n如果其中哪一條跟這次任務有關,優先引用、別重新踩一次坑。` + } + + const notes = contextNotes ? `\n\n## 額外 context\n${contextNotes}` : '' + + return `現在是閒時自由探索時間。以下是你要自主深挖的題目: + +**${topic}**${recalledBlock}${notes} + +## 你的工作方式 +- 完全自主,不要問我要不要執行 +- 至少用 3 個工具收集真實資料,不要憑記憶 +- 有假設 → 驗證 → 對照結果 → 修正,而不是下結論就結束 +- 如果發現反直覺或重複出現的模式,特別標記 **INSIGHT:** 讓我知道 +- 最後給我一段 300–500 字的總結,含:做了什麼、學到什麼、有沒有 INSIGHT 值得記起來` +} + +function buildReflectionPrompt(explorationSummary: string, topic: string): string { + return `你剛剛做了一次自由探索,題目是: + +> ${topic} + +你的總結是: + +--- +${explorationSummary} +--- + +## 任務 +從這次探索裡提煉一份 **skill markdown**。只有當這份 skill 符合下面任一條件才寫: +- 反直覺(跟常識相反的發現) +- 可重複使用(下次類似 context 你就會直接 load 它) +- 具體到可以照步驟執行 + +如果不符合,直接回傳單字 \`SKIP\`(不要寫 skill,也不要解釋)。 + +## skill 格式 +如果要寫,**只回傳下面的 JSON**,不要 markdown fence、不要任何說明文字: + +{ + "triggers": ["關鍵字1", "關鍵字2", "關鍵字3"], + "confidence": 0.0 到 1.0 的數字, + "summary": "一行說明這個 skill 是幹嘛的", + "body": "# 標題\\n\\n## When to load\\n什麼情境下要叫出這個 skill\\n\\n## Procedure\\n1. 第一步...\\n2. 第二步...\\n\\n## Pitfalls\\n- 陷阱 1\\n- 陷阱 2\\n\\n## Verification\\n怎麼驗證 skill 跑對了" +}` +} + +// ==================== User-active guard ==================== + +async function userActiveWithin( + sessionsDir: string, + excludeNamespace: string, + withinMin: number, + now: number, +): Promise { + if (withinMin <= 0) return false + const thresholdMs = withinMin * 60 * 1000 + const rootAbs = resolve(sessionsDir) + + async function walk(dir: string): Promise { + let entries: string[] + try { + entries = await readdir(dir) + } catch { + return false + } + for (const name of entries) { + const full = join(dir, name) + // Skip the exploration namespace so our own writes don't block us. + const rel = full.slice(rootAbs.length + 1) + if (rel.startsWith(excludeNamespace)) continue + + let st: Awaited> + try { + st = await stat(full) + } catch { + continue + } + + if (st.isDirectory()) { + if (await walk(full)) return true + continue + } + if (!name.endsWith('.jsonl')) continue + if (now - st.mtimeMs < thresholdMs) return true + } + return false + } + + return walk(rootAbs) +} + +// ==================== Reflection parsing ==================== + +interface ParsedReflection { + skip: boolean + triggers?: string[] + confidence?: number + summary?: string + body?: string +} + +function parseReflection(raw: string): ParsedReflection { + const trimmed = raw.trim() + if (trimmed.toUpperCase() === 'SKIP' || trimmed.startsWith('SKIP')) { + return { skip: true } + } + // Tolerate code fences even though we asked not to use them. + let jsonStr = trimmed + const fenceMatch = /```(?:json)?\s*([\s\S]*?)\s*```/i.exec(trimmed) + if (fenceMatch) jsonStr = fenceMatch[1] + // Grab the first {...} block. + const start = jsonStr.indexOf('{') + const end = jsonStr.lastIndexOf('}') + if (start === -1 || end <= start) return { skip: true } + + try { + const parsed = JSON.parse(jsonStr.slice(start, end + 1)) as { + triggers?: unknown + confidence?: unknown + summary?: unknown + body?: unknown + } + const triggers = Array.isArray(parsed.triggers) + ? parsed.triggers.filter((t): t is string => typeof t === 'string') + : [] + const confidence = + typeof parsed.confidence === 'number' + ? Math.max(0, Math.min(1, parsed.confidence)) + : 0.5 + const summary = typeof parsed.summary === 'string' ? parsed.summary : undefined + const body = typeof parsed.body === 'string' ? parsed.body : undefined + if (!body || triggers.length === 0) return { skip: true } + return { skip: false, triggers, confidence, summary, body } + } catch { + return { skip: true } + } +} + +// ==================== Explorer ==================== + +export function createExplorer(deps: ExplorerDeps): Explorer { + const sessionNs = deps.config.sessionNamespace + const sessionsDir = deps.sessionsDir ?? 'data/sessions' + const now = deps.now ?? (() => Date.now()) + + let lastRunAt: number | null = null + let lastError: string | null = null + + async function run(trigger: ExplorationTrigger): Promise { + const startedAtIso = new Date().toISOString() + const startMs = now() + lastError = null + + // -------------- Phase 0: Guard -------------- + const active = await userActiveWithin( + sessionsDir, + sessionNs, + deps.config.pauseIfUserActiveWithinMin, + startMs, + ) + if (active) { + await deps.eventLog.append(EXPLORATION_EVENTS.PAUSED_USER_ACTIVE, { + trigger: trigger.source, + startedAt: startedAtIso, + }) + console.log('Exploration skipped: user active within %d min', deps.config.pauseIfUserActiveWithinMin) + return { + ok: false, + startedAt: startedAtIso, + durationMs: now() - startMs, + toolCalls: 0, + summary: '', + recalledSkillIds: [], + createdSkillId: null, + error: 'paused_user_active', + } + } + + await deps.eventLog.append(EXPLORATION_EVENTS.STARTED, { + trigger: trigger.source, + topic: trigger.topic ?? null, + startedAt: startedAtIso, + }) + console.log('Exploration started (trigger=%s)', trigger.source) + + // -------------- Phase 1: Recall -------------- + const topic = pickTopic(trigger) + const recalled = await deps.skillCurator.recall({ + context: [topic, trigger.contextNotes ?? ''].join(' '), + limit: 5, + }) + const recalledIds = recalled.map((s) => s.frontmatter.id) + await deps.eventLog.append(EXPLORATION_EVENTS.RECALL_COMPLETED, { + recalledIds, + count: recalled.length, + }) + + // -------------- Phase 2: Explore -------------- + const session = new SessionStore(sessionNs) + const prompt = buildExplorationPrompt(topic, recalled, trigger.contextNotes) + let summaryText = '' + let toolCalls = 0 + try { + const stream = deps.agentCenter.askWithSession(prompt, session, { + historyPreamble: '以下是你最近的自由探索紀錄:', + }) + for await (const event of stream) { + if (event.type === 'tool_use') toolCalls += 1 + if (event.type === 'done') summaryText = event.result.text + } + } catch (err) { + lastError = String(err) + await deps.eventLog.append(EXPLORATION_EVENTS.EXPLORE_FAILED, { + error: lastError, + durationMs: now() - startMs, + }) + console.error('Exploration failed: %s', err) + return { + ok: false, + startedAt: startedAtIso, + durationMs: now() - startMs, + toolCalls, + summary: '', + recalledSkillIds: recalledIds, + createdSkillId: null, + error: lastError, + } + } + + await deps.eventLog.append(EXPLORATION_EVENTS.EXPLORE_COMPLETED, { + toolCalls, + summaryLength: summaryText.length, + durationMs: now() - startMs, + }) + + // Mark the recalled skills as used (bumps usageCount, updates lastUsedAt). + if (recalledIds.length > 0) { + await deps.skillCurator.markUsed(recalledIds) + } + + // -------------- Phase 3: Reflect -------------- + let createdSkillId: string | null = null + const shouldReflect = toolCalls >= deps.config.reflection.minToolCalls + if (shouldReflect && summaryText.trim().length > 0) { + const reflectionPrompt = buildReflectionPrompt(summaryText, topic) + try { + const reflectionResult = await deps.agentCenter.ask(reflectionPrompt) + const parsed = parseReflection(reflectionResult.text) + if (!parsed.skip && parsed.body && parsed.triggers) { + const skill = await deps.skillCurator.persist({ + triggers: parsed.triggers, + body: parsed.body, + confidence: parsed.confidence, + summary: parsed.summary, + }) + createdSkillId = skill.frontmatter.id + await deps.eventLog.append(EXPLORATION_EVENTS.SKILL_CREATED, { + id: createdSkillId, + triggers: parsed.triggers, + confidence: parsed.confidence, + }) + // Prune if over capacity. + const pruned = await deps.skillCurator.prune(deps.config.reflection.maxSkills) + if (pruned.length > 0) { + await deps.eventLog.append(EXPLORATION_EVENTS.SKILL_PRUNED, { ids: pruned }) + } + } + } catch (err) { + console.warn('Reflection failed (non-fatal): %s', err) + } + } + + // -------------- Phase 4: Brain commit -------------- + const brainNote = [ + `🔍 Exploration @ ${startedAtIso.slice(11, 16)}`, + `Topic: ${topic}`, + `Tool calls: ${toolCalls}`, + createdSkillId ? `New skill: ${createdSkillId}` : 'No new skill', + '', + summaryText.slice(0, 800), + ].join('\n') + deps.brain.updateFrontalLobe(brainNote) + + await deps.eventLog.append(EXPLORATION_EVENTS.COMPLETED, { + ok: true, + toolCalls, + createdSkillId, + durationMs: now() - startMs, + }) + + lastRunAt = now() + return { + ok: true, + startedAt: startedAtIso, + durationMs: now() - startMs, + toolCalls, + summary: summaryText, + recalledSkillIds: recalledIds, + createdSkillId, + } + } + + return { + run, + get lastRunAt() { + return lastRunAt + }, + get lastError() { + return lastError + }, + } +} + +/** Exported for tests. */ +export const __internal = { + pickTopic, + buildExplorationPrompt, + buildReflectionPrompt, + parseReflection, + userActiveWithin, + EXPLORATION_TOPICS, +} diff --git a/src/domain/exploration/index.ts b/src/domain/exploration/index.ts new file mode 100644 index 00000000..a77126c7 --- /dev/null +++ b/src/domain/exploration/index.ts @@ -0,0 +1,7 @@ +export * from './types.js' +export { createSkillCurator, serializeSkill, generateSkillId } from './skill-curator.js' +export type { SkillCurator, RecallOptions, PersistSkillInput, CuratorConfig } from './skill-curator.js' +export { createExplorer } from './explorer.js' +export type { Explorer, ExplorerDeps } from './explorer.js' +export { createExplorationScheduler } from './scheduler.js' +export type { ExplorationScheduler } from './scheduler.js' diff --git a/src/domain/exploration/scheduler.ts b/src/domain/exploration/scheduler.ts new file mode 100644 index 00000000..5d2fd6cc --- /dev/null +++ b/src/domain/exploration/scheduler.ts @@ -0,0 +1,88 @@ +/** + * ExplorationScheduler — independent node-cron driver for the explorer loop. + * + * Mirrors the pattern in strategy/scheduler.ts: idempotent start/stop, + * in-memory running guard, per-expression validation. + */ + +import * as cron from 'node-cron' +import type { Explorer } from './explorer.js' +import type { ExplorationConfig } from './types.js' + +export interface ExplorationScheduler { + start(): void + stop(): void + runNow(): Promise + isRunning(): boolean +} + +export function createExplorationScheduler(opts: { + explorer: Explorer + config: ExplorationConfig +}): ExplorationScheduler { + let task: cron.ScheduledTask | null = null + let running = false + + function start(): void { + if (task) return + const { enabled, schedule: scheduleCfg } = opts.config + if (!enabled || !scheduleCfg.enabled) { + console.log( + 'Exploration scheduler disabled (enabled=%s, schedule.enabled=%s)', + enabled, + scheduleCfg.enabled, + ) + return + } + if (!cron.validate(scheduleCfg.cronExpression)) { + throw new Error(`Invalid exploration cron expression: ${scheduleCfg.cronExpression}`) + } + + task = cron.schedule( + scheduleCfg.cronExpression, + async () => { + if (running) { + console.warn('Exploration already running, skipping this tick') + return + } + running = true + try { + await opts.explorer.run({ source: 'cron' }) + } catch (err) { + console.error('Scheduled exploration failed: %s', err) + } finally { + running = false + } + }, + { timezone: scheduleCfg.timezone }, + ) + + console.log( + 'Exploration scheduler started: %s (%s)', + scheduleCfg.cronExpression, + scheduleCfg.timezone, + ) + } + + function stop(): void { + if (task) { + task.stop() + task = null + console.log('Exploration scheduler stopped') + } + } + + async function runNow(): Promise { + if (running) { + throw new Error('Exploration is already running') + } + running = true + try { + await opts.explorer.run({ source: 'manual' }) + } finally { + running = false + } + } + + return { start, stop, runNow, isRunning: () => running } +} diff --git a/src/domain/exploration/skill-curator.ts b/src/domain/exploration/skill-curator.ts new file mode 100644 index 00000000..c71a996d --- /dev/null +++ b/src/domain/exploration/skill-curator.ts @@ -0,0 +1,301 @@ +/** + * SkillCurator — manages the lifecycle of skill markdown files. + * + * Each skill lives at {skillsDir}/{id}.md with YAML frontmatter + markdown body. + * The curator handles loading, recall (keyword match), persistence, and LRU pruning. + * + * This is intentionally simple: no FTS5, no embeddings. Keyword triggers and + * recency + confidence ordering are enough at this scale (hundreds of skills). + */ + +import { readFile, writeFile, readdir, mkdir, unlink, stat } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import type { Skill, SkillFrontmatter } from './types.js' + +// ==================== Parsing ==================== + +const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n?/ + +/** Parse YAML-ish frontmatter. Only handles the keys we emit ourselves. */ +function parseFrontmatter(raw: string): { frontmatter: SkillFrontmatter; body: string } { + const match = FRONTMATTER_RE.exec(raw) + if (!match) { + throw new Error('Skill file missing frontmatter delimiters') + } + const fmBlock = match[1] + const body = raw.slice(match[0].length).trim() + + const fm: Partial = {} + for (const line of fmBlock.split('\n')) { + const idx = line.indexOf(':') + if (idx === -1) continue + const key = line.slice(0, idx).trim() + const rawValue = line.slice(idx + 1).trim() + if (rawValue === '') continue + + switch (key) { + case 'id': + case 'created': + case 'lastUsedAt': + case 'summary': + fm[key] = stripQuotes(rawValue) + break + case 'usageCount': { + const n = Number(rawValue) + if (!Number.isNaN(n)) fm.usageCount = n + break + } + case 'confidence': { + const n = Number(rawValue) + if (!Number.isNaN(n)) fm.confidence = n + break + } + case 'triggers': + fm.triggers = parseYamlList(rawValue) + break + default: + break + } + } + + if (!fm.id || !fm.created || !fm.triggers) { + throw new Error(`Skill frontmatter missing required keys: ${JSON.stringify(fm)}`) + } + return { + frontmatter: { + id: fm.id, + created: fm.created, + triggers: fm.triggers, + usageCount: fm.usageCount ?? 0, + confidence: fm.confidence ?? 0.5, + lastUsedAt: fm.lastUsedAt, + summary: fm.summary, + }, + body, + } +} + +function stripQuotes(s: string): string { + if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) { + return s.slice(1, -1) + } + return s +} + +function parseYamlList(raw: string): string[] { + // Accepts either "[a, b, c]" or "- a\n- b" (inline form only for simplicity) + const trimmed = raw.trim() + if (trimmed.startsWith('[') && trimmed.endsWith(']')) { + return trimmed + .slice(1, -1) + .split(',') + .map((s) => stripQuotes(s.trim())) + .filter(Boolean) + } + // Fallback: comma-separated + return trimmed.split(',').map((s) => stripQuotes(s.trim())).filter(Boolean) +} + +function serializeFrontmatter(fm: SkillFrontmatter): string { + const lines: string[] = ['---'] + lines.push(`id: ${fm.id}`) + lines.push(`triggers: [${fm.triggers.map((t) => JSON.stringify(t)).join(', ')}]`) + lines.push(`created: ${fm.created}`) + lines.push(`usageCount: ${fm.usageCount}`) + lines.push(`confidence: ${fm.confidence}`) + if (fm.lastUsedAt) lines.push(`lastUsedAt: ${fm.lastUsedAt}`) + if (fm.summary) lines.push(`summary: ${JSON.stringify(fm.summary)}`) + lines.push('---') + return lines.join('\n') +} + +/** Extract the first markdown body for a skill file (frontmatter + body). */ +export function serializeSkill(skill: Pick): string { + return `${serializeFrontmatter(skill.frontmatter)}\n\n${skill.body.trim()}\n` +} + +// ==================== Curator ==================== + +export interface SkillCurator { + list(): Promise + load(id: string): Promise + recall(opts: RecallOptions): Promise + persist(input: PersistSkillInput): Promise + markUsed(ids: string[]): Promise + prune(maxSkills: number): Promise + remove(id: string): Promise +} + +export interface RecallOptions { + /** Free-form context text. Triggers matching any substring in here are considered hits. */ + context: string + /** Explicit keyword triggers (in addition to context scan). */ + keywords?: string[] + /** How many skills to return at most. */ + limit?: number +} + +export interface PersistSkillInput { + triggers: string[] + body: string + confidence?: number + summary?: string + /** Optional explicit id. If missing, generated from date + slugified first heading. */ + id?: string +} + +export interface CuratorConfig { + skillsDir: string +} + +// ==================== Implementation ==================== + +export function createSkillCurator(config: CuratorConfig): SkillCurator { + const dir = resolve(config.skillsDir) + + async function ensureDir(): Promise { + await mkdir(dir, { recursive: true }) + } + + async function list(): Promise { + await ensureDir() + const entries = await readdir(dir) + const files = entries.filter((f) => f.endsWith('.md')) + const skills: Skill[] = [] + for (const file of files) { + const filePath = join(dir, file) + try { + const raw = await readFile(filePath, 'utf-8') + const parsed = parseFrontmatter(raw) + skills.push({ ...parsed, filePath }) + } catch (err) { + console.warn('Skipping malformed skill file %s: %s', filePath, err) + } + } + return skills + } + + async function load(id: string): Promise { + const all = await list() + return all.find((s) => s.frontmatter.id === id) ?? null + } + + async function recall(opts: RecallOptions): Promise { + const all = await list() + const limit = opts.limit ?? 5 + const haystack = [opts.context, ...(opts.keywords ?? [])].join(' ').toLowerCase() + if (!haystack.trim()) return [] + + const scored = all + .map((skill) => { + const triggerHits = skill.frontmatter.triggers.filter((t) => + haystack.includes(t.toLowerCase()), + ).length + // Base score: trigger match weighted most, confidence + recency break ties. + const recency = skill.frontmatter.lastUsedAt + ? Math.max(0, 30 - daysSince(skill.frontmatter.lastUsedAt)) / 30 + : 0 + const score = + triggerHits * 10 + skill.frontmatter.confidence * 2 + recency + return { skill, score, triggerHits } + }) + .filter(({ triggerHits }) => triggerHits > 0) + .sort((a, b) => b.score - a.score) + .slice(0, limit) + .map(({ skill }) => skill) + + return scored + } + + async function persist(input: PersistSkillInput): Promise { + await ensureDir() + const id = input.id ?? generateSkillId(input.body) + const now = new Date().toISOString() + const frontmatter: SkillFrontmatter = { + id, + triggers: input.triggers, + created: now, + usageCount: 0, + confidence: input.confidence ?? 0.5, + summary: input.summary, + } + const filePath = join(dir, `${id}.md`) + const skill: Skill = { frontmatter, body: input.body.trim(), filePath } + await writeFile(filePath, serializeSkill(skill), 'utf-8') + return skill + } + + async function markUsed(ids: string[]): Promise { + if (ids.length === 0) return + const all = await list() + const now = new Date().toISOString() + for (const skill of all) { + if (!ids.includes(skill.frontmatter.id)) continue + skill.frontmatter.usageCount += 1 + skill.frontmatter.lastUsedAt = now + await writeFile(skill.filePath, serializeSkill(skill), 'utf-8') + } + } + + async function prune(maxSkills: number): Promise { + const all = await list() + if (all.length <= maxSkills) return [] + + // Higher score = more worth keeping. + // Prune the lowest scores first. Score combines usage, confidence, and recency. + const scored = all.map((skill) => { + const lastSeen = skill.frontmatter.lastUsedAt ?? skill.frontmatter.created + const ageDays = daysSince(lastSeen) + const score = + ((skill.frontmatter.usageCount + 1) * + (skill.frontmatter.confidence + 0.1)) / + (ageDays + 1) + return { skill, score } + }) + scored.sort((a, b) => a.score - b.score) + + const toPrune = scored.slice(0, all.length - maxSkills) + const pruned: string[] = [] + for (const { skill } of toPrune) { + await unlink(skill.filePath) + pruned.push(skill.frontmatter.id) + } + return pruned + } + + async function remove(id: string): Promise { + const skill = await load(id) + if (!skill) return false + await unlink(skill.filePath) + return true + } + + return { list, load, recall, persist, markUsed, prune, remove } +} + +// ==================== Helpers ==================== + +function daysSince(iso: string): number { + const then = Date.parse(iso) + if (Number.isNaN(then)) return Number.MAX_SAFE_INTEGER + return (Date.now() - then) / (1000 * 60 * 60 * 24) +} + +export function generateSkillId(body: string): string { + const date = new Date().toISOString().slice(0, 10) + const firstHeading = body + .split('\n') + .find((line) => line.startsWith('#')) + ?.replace(/^#+\s*/, '') + .trim() + const slug = (firstHeading ?? 'exploration') + .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, '-') + .replace(/(^-|-$)/g, '') + .slice(0, 40) + const rand = Math.random().toString(36).slice(2, 6) + return `${date}-${slug || 'exploration'}-${rand}` +} + +/** Exposed for tests. */ +export const __internal = { parseFrontmatter, serializeFrontmatter, daysSince } diff --git a/src/domain/exploration/types.ts b/src/domain/exploration/types.ts new file mode 100644 index 00000000..0634f6a1 --- /dev/null +++ b/src/domain/exploration/types.ts @@ -0,0 +1,137 @@ +/** + * Exploration loop — autonomous self-improvement, Hermes-style. + * + * Architecture: + * [cron trigger or manual] + * → recall (skill-curator loads relevant skills from data/skills/) + * → explore (askWithSession with skills injected as system context) + * → reflect (LLM decides if a new skill is worth persisting, writes markdown) + * → brain commit (record insight in frontal lobe) + * + * Design principles: + * - File-driven: skills are markdown files in data/skills/{slug}.md + * - Sonnet 4.6 only (user preference, keeps Max subscription tokens bounded) + * - Pauses automatically when the user is actively using ALICE (to avoid stealing tokens) + * - Skills have frontmatter for indexing; recall is keyword-based (no FTS5/embedding yet) + */ + +import { z } from 'zod' + +// ==================== Config ==================== + +export const explorationConfigSchema = z.object({ + enabled: z.boolean().default(false), + /** Model used for both exploration and reflection. Hardcoded Sonnet per user request. */ + model: z.string().default('claude-sonnet-4-6'), + /** Where skill markdown files live. */ + skillsDir: z.string().default('data/skills'), + /** Directory for exploration session JSONLs. */ + sessionNamespace: z.string().default('exploration/autonomous'), + schedule: z.object({ + enabled: z.boolean().default(false), + /** Cron expression (node-cron). Default: hourly during off-market hours TW. */ + cronExpression: z.string().default('0 14-23,0-8 * * *'), + timezone: z.string().default('Asia/Taipei'), + }).default({ + enabled: false, + cronExpression: '0 14-23,0-8 * * *', + timezone: 'Asia/Taipei', + }), + reflection: z.object({ + /** Minimum tool-call count before reflection is considered worthwhile. */ + minToolCalls: z.number().int().nonnegative().default(3), + /** Maximum skills kept in library; oldest-unused pruned first. */ + maxSkills: z.number().int().positive().default(200), + }).default({ + minToolCalls: 3, + maxSkills: 200, + }), + /** Push high-confidence insights to this channel. Empty = silent mode. */ + notifyChannels: z.array(z.string()).default([]), + /** Skip the loop if the last user chat interaction was within N minutes. */ + pauseIfUserActiveWithinMin: z.number().int().nonnegative().default(10), +}) + +export type ExplorationConfig = z.infer + +// ==================== Skill ==================== + +/** + * A skill is a structured markdown file: + * + * --- + * id: 2026-04-11-tsmc-earnings-pre-positioning + * triggers: [tsmc, earnings, 半導體] + * created: 2026-04-11T06:07:00+08:00 + * usageCount: 0 + * confidence: 0.7 + * --- + * + * # 台積電法說前部位調整 + * + * ## When to load + * ... + * + * ## Procedure + * ... + */ +export interface SkillFrontmatter { + id: string + triggers: string[] + created: string + usageCount: number + confidence: number + /** ISO timestamp of last recall (skill-curator uses this for LRU pruning). */ + lastUsedAt?: string + /** Optional one-line summary for compact listings. */ + summary?: string +} + +export interface Skill { + frontmatter: SkillFrontmatter + body: string + filePath: string +} + +// ==================== Trigger & result ==================== + +export type ExplorationTriggerSource = 'manual' | 'cron' | 'tool' + +export interface ExplorationTrigger { + source: ExplorationTriggerSource + /** Optional seed idea — if missing, explorer picks one from a rotation. */ + topic?: string + /** Optional extra context to inject into the prompt (e.g. "portfolio down 2% today"). */ + contextNotes?: string +} + +export interface ExplorationResult { + ok: boolean + startedAt: string + durationMs: number + /** Number of tool calls the agent made during exploration. */ + toolCalls: number + /** The agent's final text output. */ + summary: string + /** Skills that were loaded before exploration. */ + recalledSkillIds: string[] + /** Skill that was created from this exploration, if any. */ + createdSkillId: string | null + error?: string +} + +// ==================== Event types ==================== + +export const EXPLORATION_EVENTS = { + STARTED: 'exploration.started', + RECALL_COMPLETED: 'exploration.recall.completed', + EXPLORE_COMPLETED: 'exploration.explore.completed', + EXPLORE_FAILED: 'exploration.explore.failed', + SKILL_CREATED: 'exploration.skill.created', + SKILL_PRUNED: 'exploration.skill.pruned', + PAUSED_USER_ACTIVE: 'exploration.paused.user_active', + COMPLETED: 'exploration.completed', +} as const + +export type ExplorationEventType = + typeof EXPLORATION_EVENTS[keyof typeof EXPLORATION_EVENTS] diff --git a/src/main.ts b/src/main.ts index c53622fa..666fcc70 100644 --- a/src/main.ts +++ b/src/main.ts @@ -40,6 +40,8 @@ import { createEventLog } from './core/event-log.js' import { createToolCallLog } from './core/tool-call-log.js' import { createCronEngine, createCronListener, createCronTools } from './task/cron/index.js' import { createHeartbeat } from './task/heartbeat/index.js' +import { createExplorer, createExplorationScheduler, createSkillCurator } from './domain/exploration/index.js' +import { createExplorationTools } from './tool/exploration.js' import { NewsCollectorStore, NewsCollector } from './domain/news/index.js' import { createNewsArchiveTools } from './tool/news.js' @@ -277,6 +279,30 @@ async function main() { console.log(`heartbeat: enabled (every ${config.heartbeat.every})`) } + // ==================== Exploration Loop (Hermes-style self-exploration) ==================== + + const skillCurator = createSkillCurator({ skillsDir: config.exploration.skillsDir }) + const explorer = createExplorer({ + agentCenter, + eventLog, + connectorCenter, + brain, + skillCurator, + config: config.exploration, + }) + const explorationScheduler = createExplorationScheduler({ + explorer, + config: config.exploration, + }) + explorationScheduler.start() + toolCenter.register( + createExplorationTools(explorer, explorationScheduler, skillCurator, eventLog), + 'exploration', + ) + if (config.exploration.enabled) { + console.log(`exploration: loop enabled (model=${config.exploration.model}, skillsDir=${config.exploration.skillsDir})`) + } + // ==================== News Collector ==================== let newsCollector: NewsCollector | null = null diff --git a/src/tool/exploration.ts b/src/tool/exploration.ts new file mode 100644 index 00000000..73ad3489 --- /dev/null +++ b/src/tool/exploration.ts @@ -0,0 +1,140 @@ +import { tool } from 'ai' +import { z } from 'zod' +import type { Explorer } from '@/domain/exploration/explorer' +import type { ExplorationScheduler } from '@/domain/exploration/scheduler' +import type { SkillCurator } from '@/domain/exploration/skill-curator' +import type { EventLog } from '@/core/event-log' +import { EXPLORATION_EVENTS } from '@/domain/exploration/types.js' + +/** + * Create exploration AI tools (trigger self-exploration, list skills, check status) + * + * Tools: + * - explorationRunNow: fire a self-exploration cycle immediately + * - explorationStatus: last run, recent events, skill count + * - skillList: list the most recent / highest-confidence skills + * - skillDelete: remove a skill by id (user-invoked cleanup) + */ +export function createExplorationTools( + explorer: Explorer, + scheduler: ExplorationScheduler, + curator: SkillCurator, + eventLog: EventLog, +) { + return { + explorationRunNow: tool({ + description: ` +Trigger an immediate self-exploration cycle. +Alice will pick a topic (or use the one you provide), recall relevant past skills, +explore autonomously with tools, then decide whether to persist a new skill. + +Use this when the user asks Alice to "think about X", "research X", "explore X", +or to "go and learn something" while they are away. + `.trim(), + inputSchema: z.object({ + topic: z.string().optional().describe( + '自由文字題目;留空就讓 Alice 自己從輪替主題裡挑一個', + ), + contextNotes: z.string().optional().describe('額外 context,例如當前持倉狀態'), + }), + execute: async ({ topic, contextNotes }) => { + if (scheduler.isRunning()) { + return { status: 'already_running', message: 'Exploration 正在跑,請稍等' } + } + // Fire and forget — tool returns immediately; explorer runs async. + explorer + .run({ source: 'tool', topic, contextNotes }) + .catch((err) => console.error('Exploration tool run failed: %s', err)) + return { + status: 'started', + message: topic ? `已啟動探索:${topic}` : '已啟動自主探索(輪替主題)', + } + }, + }), + + explorationStatus: tool({ + description: ` +Check the exploration scheduler status and recent exploration events. +Returns: last run time, whether a run is in-flight, last error (if any), +skill library size, and recent exploration event log entries. + `.trim(), + inputSchema: z.object({ + limit: z + .number() + .int() + .positive() + .default(5) + .describe('Number of recent events to return'), + }), + execute: async ({ limit }) => { + const skills = await curator.list() + const allTypes = new Set(Object.values(EXPLORATION_EVENTS)) + const recent = eventLog.recent({ limit: 200 }) + const filtered = recent + .filter((e) => allTypes.has(e.type)) + .slice(-limit) + .reverse() + return { + isRunning: scheduler.isRunning(), + lastRunAt: explorer.lastRunAt, + lastError: explorer.lastError, + skillCount: skills.length, + recentEvents: filtered.map((e) => ({ + seq: e.seq, + type: e.type, + ts: e.ts, + payload: e.payload, + })), + } + }, + }), + + skillList: tool({ + description: ` +List skills currently in the library. Use this when the user asks "what have you learned" +or wants to audit what Alice has persisted. + `.trim(), + inputSchema: z.object({ + limit: z.number().int().positive().default(20), + }), + execute: async ({ limit }) => { + const skills = await curator.list() + skills.sort((a, b) => { + // Highest confidence first, then most recent lastUsedAt + const ca = a.frontmatter.confidence + const cb = b.frontmatter.confidence + if (ca !== cb) return cb - ca + const la = a.frontmatter.lastUsedAt ?? a.frontmatter.created + const lb = b.frontmatter.lastUsedAt ?? b.frontmatter.created + return lb.localeCompare(la) + }) + return { + total: skills.length, + skills: skills.slice(0, limit).map((s) => ({ + id: s.frontmatter.id, + summary: s.frontmatter.summary ?? null, + triggers: s.frontmatter.triggers, + confidence: s.frontmatter.confidence, + usageCount: s.frontmatter.usageCount, + created: s.frontmatter.created, + lastUsedAt: s.frontmatter.lastUsedAt ?? null, + })), + } + }, + }), + + skillDelete: tool({ + description: ` +Delete a skill by id. Use only when the user explicitly asks to forget a skill +(because it was noise, wrong, or obsolete). + `.trim(), + inputSchema: z.object({ + id: z.string().describe('The exact skill id as listed by skillList'), + }), + execute: async ({ id }) => { + const removed = await curator.remove(id) + return { removed, id } + }, + }), + } +} From df8b8636229bb4679524d9e6723cb62bc48b569e Mon Sep 17 00:00:00 2001 From: Joy88 Date: Sat, 11 Apr 2026 21:28:42 +0800 Subject: [PATCH 2/2] tune(exploration): concentrate runs during TW sleep hours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous default: every hour 14:00–23:00 and 00:00–08:00 TW (≈16 runs spread across off-peak). New default: every 30 minutes from 23:00–06:30 TW only. → ~16 runs concentrated during sleep, zero runs during the day. Rationale: per user request, maximise autonomous research while the user is asleep; minimise token contention during peak trading and work hours when the user is actively chatting with Alice. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/domain/exploration/types.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/domain/exploration/types.ts b/src/domain/exploration/types.ts index 0634f6a1..bd70c1a6 100644 --- a/src/domain/exploration/types.ts +++ b/src/domain/exploration/types.ts @@ -29,12 +29,17 @@ export const explorationConfigSchema = z.object({ sessionNamespace: z.string().default('exploration/autonomous'), schedule: z.object({ enabled: z.boolean().default(false), - /** Cron expression (node-cron). Default: hourly during off-market hours TW. */ - cronExpression: z.string().default('0 14-23,0-8 * * *'), + /** + * Cron expression (node-cron). + * Default: every 30 min during TW sleep hours (23:00–06:30). + * No runs during peak hours (台股盤中、美股盤中、白天活動時段). + * → ~16 runs/night, 0 runs during the day. + */ + cronExpression: z.string().default('0,30 23,0-6 * * *'), timezone: z.string().default('Asia/Taipei'), }).default({ enabled: false, - cronExpression: '0 14-23,0-8 * * *', + cronExpression: '0,30 23,0-6 * * *', timezone: 'Asia/Taipei', }), reflection: z.object({