diff --git a/Taskfile.yml b/Taskfile.yml index 077542f..8419da1 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -23,13 +23,16 @@ tasks: desc: First-time setup (install deps, build, link, run setup wizard) cmds: - task: check-tools - - bun install --force - - npx --yes inngest-cli@latest --version + - cmd: bun install --force + silent: true + - cmd: npx --yes inngest-cli@latest --version + silent: true - task: install - geminiclaw setup install: desc: Build and install geminiclaw globally (bun link) + silent: true sources: - src/**/*.ts - templates/**/* @@ -44,10 +47,11 @@ tasks: start: desc: "Start all services (inngest + serve). Use -d for daemon: task start -- -d" + silent: true cmds: - task: install - - rm -f .overmind.sock - - find /tmp/tmux-$(id -u)/ -name "overmind-geminiclaw-*" -type s -delete 2>/dev/null || true + - cmd: rm -f .overmind.sock + - cmd: find /tmp/tmux-$(id -u)/ -name "overmind-geminiclaw-*" -type s -delete 2>/dev/null || true - | if echo "{{.CLI_ARGS}}" | grep -q -- '-d'; then overmind start -D @@ -61,12 +65,15 @@ tasks: stop: desc: Stop all services (gracefully quit overmind daemon) + silent: true cmd: overmind quit status: desc: Show service status + silent: true cmd: overmind status logs: desc: Tail all service logs (Ctrl-C to quit) + silent: true cmd: overmind echo diff --git a/bun.lock b/bun.lock index 8a7493e..86ac078 100644 --- a/bun.lock +++ b/bun.lock @@ -12,7 +12,7 @@ "@google/gemini-cli": "0.34.0-nightly.20260307.6c3a90645", "@mariozechner/pi-tui": "^0.54.2", "@modelcontextprotocol/sdk": "^1", - "@tobilu/qmd": "^1.0.7", + "@tobilu/qmd": "^1.1.6", "chalk": "^5", "chat": "^4.14.0", "commander": "^12", @@ -42,8 +42,8 @@ }, }, "patchedDependencies": { - "@tobilu/qmd@1.0.7": "patches/@tobilu%2Fqmd@1.0.7.patch", "@google/gemini-cli@0.34.0-nightly.20260307.6c3a90645": "patches/@google%2Fgemini-cli@0.34.0-nightly.20260307.6c3a90645.patch", + "@tobilu/qmd@1.1.6": "patches/@tobilu%2Fqmd@1.1.6.patch", }, "packages": { "@acemir/cssom": ["@acemir/cssom@0.9.31", "", {}, "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA=="], @@ -530,7 +530,7 @@ "@tinyhttp/content-disposition": ["@tinyhttp/content-disposition@2.2.4", "", {}, "sha512-5Kc5CM2Ysn3vTTArBs2vESUt0AQiWZA86yc1TI3B+lxXmtEq133C1nxXNOgnzhrivdPZIh3zLj5gDnZjoLL5GA=="], - "@tobilu/qmd": ["@tobilu/qmd@1.0.7", "", { "dependencies": { "@modelcontextprotocol/sdk": "^1.25.1", "better-sqlite3": "^11.0.0", "fast-glob": "^3.3.0", "node-llama-cpp": "^3.14.5", "picomatch": "^4.0.0", "sqlite-vec": "^0.1.7-alpha.2", "yaml": "^2.8.2", "zod": "^4.2.1" }, "optionalDependencies": { "sqlite-vec-darwin-arm64": "^0.1.7-alpha.2", "sqlite-vec-darwin-x64": "^0.1.7-alpha.2", "sqlite-vec-linux-x64": "^0.1.7-alpha.2", "sqlite-vec-win32-x64": "^0.1.7-alpha.2" }, "peerDependencies": { "typescript": "^5.9.3" }, "bin": { "qmd": "qmd" } }, "sha512-80PnqmowNe3bfMy3EXJMY6JYdD2Ak1iQCXppbgO8x28Wufrsz65yc/5lBrigW5ThEtPRYIdwSEQTFTIY+v1pKA=="], + "@tobilu/qmd": ["@tobilu/qmd@1.1.6", "", { "dependencies": { "@modelcontextprotocol/sdk": "^1.25.1", "better-sqlite3": "^11.0.0", "fast-glob": "^3.3.0", "node-llama-cpp": "^3.17.1", "picomatch": "^4.0.0", "sqlite-vec": "^0.1.7-alpha.2", "yaml": "^2.8.2", "zod": "^4.2.1" }, "optionalDependencies": { "sqlite-vec-darwin-arm64": "^0.1.7-alpha.2", "sqlite-vec-darwin-x64": "^0.1.7-alpha.2", "sqlite-vec-linux-arm64": "^0.1.7-alpha.2", "sqlite-vec-linux-x64": "^0.1.7-alpha.2", "sqlite-vec-windows-x64": "^0.1.7-alpha.2" }, "peerDependencies": { "typescript": "^5.9.3" }, "bin": { "qmd": "dist/qmd.js" } }, "sha512-euhQuXxr2Fgdmuhbsxtrwcodkwhnsfzk6alsoitt2QX2BZHs/+2GgkTKW7X3akE2SK7BGtcIuMZeAa95CEBlnA=="], "@traceloop/ai-semantic-conventions": ["@traceloop/ai-semantic-conventions@0.20.0", "", { "dependencies": { "@opentelemetry/api": "^1.9.0" } }, "sha512-bvivhZU6U8TW4TKktYnjdTi+7GE4WxI8epaGjawalSKDunmxaA+4UVFQ+4tSCBvp2Scby+gnYNaTZSrtABfOlQ=="], @@ -1634,8 +1634,6 @@ "@opentelemetry/sql-common/@opentelemetry/core": ["@opentelemetry/core@2.5.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-Dwlc+3HAZqpgTYq0MUyZABjFkcrKTePwuiFVLjahGD8cx3enqihmpAmdgNFO1R4m/sIe5afjJrA25Prqy4NXlA=="], - "@tobilu/qmd/sqlite-vec-win32-x64": ["sqlite-vec-win32-x64@file:node_modules/@tobilu/qmd/node_modules/sqlite-vec-win32-x64", {}], - "@tobilu/qmd/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "@traceloop/instrumentation-anthropic/@opentelemetry/core": ["@opentelemetry/core@2.5.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-Dwlc+3HAZqpgTYq0MUyZABjFkcrKTePwuiFVLjahGD8cx3enqihmpAmdgNFO1R4m/sIe5afjJrA25Prqy4NXlA=="], diff --git a/package.json b/package.json index 5883d0d..ec55482 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "@google/gemini-cli": "0.34.0-nightly.20260307.6c3a90645", "@mariozechner/pi-tui": "^0.54.2", "@modelcontextprotocol/sdk": "^1", - "@tobilu/qmd": "^1.0.7", + "@tobilu/qmd": "^1.1.6", "chalk": "^5", "chat": "^4.14.0", "commander": "^12", @@ -77,7 +77,7 @@ "templates/" ], "patchedDependencies": { - "@tobilu/qmd@1.0.7": "patches/@tobilu%2Fqmd@1.0.7.patch", + "@tobilu/qmd@1.1.6": "patches/@tobilu%2Fqmd@1.1.6.patch", "@google/gemini-cli@0.34.0-nightly.20260307.6c3a90645": "patches/@google%2Fgemini-cli@0.34.0-nightly.20260307.6c3a90645.patch" } } diff --git a/patches/@google%2Fgemini-cli@0.34.0-nightly.20260307.6c3a90645.patch b/patches/@google%2Fgemini-cli@0.34.0-nightly.20260307.6c3a90645.patch index bb7b505..8eb90fa 100644 --- a/patches/@google%2Fgemini-cli@0.34.0-nightly.20260307.6c3a90645.patch +++ b/patches/@google%2Fgemini-cli@0.34.0-nightly.20260307.6c3a90645.patch @@ -1,5 +1,5 @@ diff --git a/bundle/gemini.js b/bundle/gemini.js -index ea0166058a9bacdd0311e085cb112df5d6828786..72bc2eef5d0e9879cded610e9615984904a1b9e7 100755 +index ea0166058a9bacdd0311e085cb112df5d6828786..29daac5f364b13e54276d0546426488b55903c2e 100755 --- a/bundle/gemini.js +++ b/bundle/gemini.js @@ -424765,7 +424765,7 @@ var init_local_executor = __esm({ @@ -29,7 +29,15 @@ index ea0166058a9bacdd0311e085cb112df5d6828786..72bc2eef5d0e9879cded610e96159849 } } let nextMessage = { role: "user", parts: parts2 }; -@@ -571191,7 +571191,7 @@ ${thought.description}`; +@@ -571184,14 +571184,14 @@ ${thought.description}`; + await this.config.getGemini31Launched?.() ?? false + ); + const responseStream = await chat.sendMessageStream( +- { model }, ++ { model, isChatModel: true }, + nextMessage?.parts ?? [], + promptId, + pendingSend.signal, LlmRole.MAIN ); nextMessage = null; diff --git a/patches/@tobilu%2Fqmd@1.0.7.patch b/patches/@tobilu%2Fqmd@1.1.6.patch similarity index 84% rename from patches/@tobilu%2Fqmd@1.0.7.patch rename to patches/@tobilu%2Fqmd@1.1.6.patch index 28b4bd6..5498339 100644 --- a/patches/@tobilu%2Fqmd@1.0.7.patch +++ b/patches/@tobilu%2Fqmd@1.1.6.patch @@ -1,8 +1,8 @@ diff --git a/dist/llm.js b/dist/llm.js -index 6e9fa7dc6c7590ac28184196b88d391365aca433..7297b7ba616862c5536f585198cd1cf81abdf3ce 100644 +index b304389c0bc299477cd887c6f73c3266e2f1c8b6..bc91c1b94c3840c07af65edc9b36216775654739 100644 --- a/dist/llm.js +++ b/dist/llm.js -@@ -461,7 +461,7 @@ export class LlamaCpp { +@@ -485,7 +485,7 @@ export class LlamaCpp { // Qwen3 reranker template adds ~200 tokens overhead (system prompt, tags, etc.) // Chunks are max 800 tokens, so 800 + 200 + query ≈ 1100 tokens typical. // Use 2048 for safety margin. Still 17× less than auto (40960). @@ -24,10 +24,10 @@ index fd430fa49ba2785151289337c2c02d830a7f1b68..67519ba8fc86386be0ca1bbe5a7e018b export type HttpServerHandle = { httpServer: import("http").Server; diff --git a/dist/mcp.js b/dist/mcp.js -index 7484edf93601d273b4b504679518274a777b37f7..efe7081d2ddcd72b5c22c7ce8573a877dc9dc44b 100644 +index d1b09687ae86c1f22c245091ed7391879057f7c1..29946f0f109449d7d36e071b46cfdfbf01e48980 100644 --- a/dist/mcp.js +++ b/dist/mcp.js -@@ -100,7 +100,7 @@ function buildInstructions(store) { +@@ -108,7 +108,7 @@ function buildInstructions(store) { * Create an MCP server with all QMD tools, resources, and prompts registered. * Shared by both stdio and HTTP transports. */ diff --git a/src/agent/context-builder.test.ts b/src/agent/context-builder.test.ts index 78784ac..9634a74 100644 --- a/src/agent/context-builder.test.ts +++ b/src/agent/context-builder.test.ts @@ -71,7 +71,7 @@ describe('ContextBuilder', () => { it('includes memory guidelines with QMD search reference', async () => { const content = await builder.writeStaticGeminiMd(); expect(content).toContain('## Memory Management'); - expect(content).toContain('qmd_search'); + expect(content).toContain('qmd_query'); }); it('includes autonomy level restrictions when not autonomous', async () => { diff --git a/src/agent/context-builder.ts b/src/agent/context-builder.ts index a728925..d69dd67 100644 --- a/src/agent/context-builder.ts +++ b/src/agent/context-builder.ts @@ -46,6 +46,8 @@ export interface SessionContextOptions { bootstrap?: boolean; /** IANA timezone for timestamp formatting. Falls back to system timezone. */ timezone?: string; + /** Delivery target for cron jobs — `platform:channelId` (e.g. "discord:123456"). */ + deliveryTarget?: string; } // ── Truncation helper ──────────────────────────────────────────── @@ -69,6 +71,16 @@ export function truncateWithContext(content: string, maxChars: number): string { export class ContextBuilder { constructor(private workspaceRoot: string) {} + /** Resolve home channel as `platform:channelId` string. */ + private resolveHomeChannel(): string | undefined { + const config = loadConfig(); + const dc = config.channels.discord; + if (dc.enabled && dc.homeChannel) return `discord:${dc.homeChannel}`; + const sc = config.channels.slack; + if (sc.enabled && sc.homeChannel) return `slack:${sc.homeChannel}`; + return undefined; + } + /** * Check whether GEMINI.md already exists in the workspace. */ @@ -157,7 +169,7 @@ export class ContextBuilder { ); parts.push(''); parts.push( - 'To review recent activity, use `qmd_search` for keyword search or `qmd_deep_search` for hybrid search across daily logs and memory files, then `qmd_get` to drill into results.', + 'To review recent activity, use `qmd_query` for hybrid search across daily logs and memory files, then `qmd_get` to drill into results.', ); parts.push(''); @@ -166,7 +178,7 @@ export class ContextBuilder { if (autonomyLevel === 'read_only') { parts.push('## Restriction: READ_ONLY Mode'); parts.push('**This session is limited to read-only operations.**'); - parts.push('- Allowed: file reads, searches, information gathering, `qmd_search`, `qmd_get`'); + parts.push('- Allowed: file reads, searches, information gathering, `qmd_query`, `qmd_get`'); parts.push('- Prohibited: file writes/deletes, shell command execution, form submissions'); parts.push('- If asked to perform a prohibited operation, explain the restriction and decline'); parts.push(''); @@ -275,13 +287,9 @@ export class ContextBuilder { parts.push('**Rotate (every few hours):** Memory maintenance, proactive background work.'); // Inject home channel so the agent knows where to post notifications - const config = loadConfig(); - const dc = config.channels.discord; - const sc = config.channels.slack; - if (dc.enabled && dc.homeChannel) { - parts.push(`Home channel: discord:${dc.homeChannel}`); - } else if (sc.enabled && sc.homeChannel) { - parts.push(`Home channel: slack:${sc.homeChannel}`); + const homeChannel = this.resolveHomeChannel(); + if (homeChannel) { + parts.push(`Home channel: ${homeChannel}`); } parts.push(''); @@ -296,6 +304,17 @@ export class ContextBuilder { parts.push('Focus exclusively on the prompt and produce the requested output.'); parts.push('Do NOT run background heartbeat checks.'); parts.push('Do NOT respond with HEARTBEAT_OK.'); + + // Inject delivery target so the agent knows where to post results + const target = options.deliveryTarget ?? this.resolveHomeChannel(); + if (target) { + parts.push(''); + parts.push(`Delivery target: ${target}`); + parts.push( + 'Post results via `geminiclaw_post_message` to the delivery target. ' + + 'Do NOT output results as plain response text — use the tool for delivery.', + ); + } } else { parts.push(''); parts.push('### Interactive Mode'); diff --git a/src/agent/runner.test.ts b/src/agent/runner.test.ts index e0278bd..effd670 100644 --- a/src/agent/runner.test.ts +++ b/src/agent/runner.test.ts @@ -40,7 +40,7 @@ const messageDeltaEvent: MessageEvent = { const toolUseEvent: ToolUseEvent = { type: 'tool_use', - tool_name: 'qmd__qmd_search', + tool_name: 'qmd__qmd_query', tool_id: 'tool-1', parameters: { text: 'user name is Test', category: 'user' }, timestamp: '2026-02-22T10:00:03Z', @@ -124,7 +124,7 @@ describe('RunResultBuilder', () => { expect(result.toolCalls).toHaveLength(1); expect(result.toolCalls[0].id).toBe('tool-1'); - expect(result.toolCalls[0].name).toBe('qmd__qmd_search'); + expect(result.toolCalls[0].name).toBe('qmd__qmd_query'); expect(result.toolCalls[0].args).toEqual({ text: 'user name is Test', category: 'user', @@ -192,6 +192,24 @@ describe('RunResultBuilder', () => { expect(result.trigger).toBe('heartbeat'); }); + it('detects HEARTBEAT_OK appended to end of line without newline', () => { + const builder = new RunResultBuilder('heartbeat'); + builder.handleEvent({ + ...messageEvent, + content: 'Updated heartbeat-state.json.HEARTBEAT_OK', + }); + expect(builder.build().heartbeatOk).toBe(true); + }); + + it('detects HEARTBEAT_OK wrapped in markdown bold', () => { + const builder = new RunResultBuilder('heartbeat'); + builder.handleEvent({ + ...messageEvent, + content: '**HEARTBEAT_OK**', + }); + expect(builder.build().heartbeatOk).toBe(true); + }); + it('heartbeatOk is false when not present', () => { const builder = new RunResultBuilder(); builder.handleEvent(messageEvent); diff --git a/src/agent/runner.ts b/src/agent/runner.ts index 9b341a3..15f8923 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -357,8 +357,11 @@ export class RunResultBuilder { // Strip ... and ... reasoning blocks before checking — // the HEARTBEAT_OK signal must come from the actual response, not internal reasoning. const responseWithoutThink = this.result.responseText.replace(/<(think|thought)>[\s\S]*?<\/\1>/g, ''); - // HEARTBEAT_OK must appear as a standalone line, not embedded in prose. - this.result.heartbeatOk = /^\s*HEARTBEAT_OK\s*$/m.test(responseWithoutThink); + // Detect HEARTBEAT_OK: strip markdown decoration, then match at end of any line. + // LLMs sometimes append the token without a preceding newline or wrap it in + // bold/code markers — normalising these avoids false negatives (à la OpenClaw). + const normalized = responseWithoutThink.replace(/[*`~]/g, ''); + this.result.heartbeatOk = /HEARTBEAT_OK\s*$/m.test(normalized); // Safety net: if extractPendingSkillActivation() was never called by the caller, // parse all skill activations from the response text now. diff --git a/src/agent/turn/finalize.ts b/src/agent/turn/finalize.ts index 0e1a866..2ee2d49 100644 --- a/src/agent/turn/finalize.ts +++ b/src/agent/turn/finalize.ts @@ -23,10 +23,7 @@ function isBackgroundJob(ctx: DeliverContext): boolean { } function hasReplyTarget(ctx: DeliverContext): boolean { - if (!ctx.eventData.serializedThread && !ctx.eventData.reply) return false; - // Heartbeat OK doesn't need a reply — execution log channel is sufficient - if (ctx.eventData.trigger === 'heartbeat' && ctx.runResult.heartbeatOk) return false; - return true; + return !!ctx.eventData.serializedThread; } async function generateTitle(ctx: DeliverContext): Promise { diff --git a/src/agent/turn/pre-execution.ts b/src/agent/turn/pre-execution.ts index f60d230..c09065f 100644 --- a/src/agent/turn/pre-execution.ts +++ b/src/agent/turn/pre-execution.ts @@ -73,6 +73,7 @@ export async function buildAgentContext( | 'channelContextMaxChars' | 'isHomeChannel' | 'isDM' + | 'deliveryTarget' >, ): Promise<{ sessionContext: string }> { const builder = new ContextBuilder(params.workspacePath); @@ -131,6 +132,7 @@ export async function buildAgentContext( channelContext, bootstrap, timezone: params.timezone, + deliveryTarget: params.deliveryTarget, }); return { sessionContext }; diff --git a/src/agent/turn/types.ts b/src/agent/turn/types.ts index d79957d..2e39cb2 100644 --- a/src/agent/turn/types.ts +++ b/src/agent/turn/types.ts @@ -23,16 +23,10 @@ export interface AgentRunEventData { */ serializedThread?: string; /** - * Legacy reply info — kept for cron jobs and non-Chat-SDK triggers (heartbeat, manual). - * When serializedThread is present, this field is ignored. + * Delivery target for cron jobs — `platform:channelId` string (e.g. "discord:123456"). + * Injected into agent context so skills know where to post via geminiclaw_post_message. */ - reply?: { - /** Adapter identifier — matches ChannelAdapter.channelType (e.g. 'discord'). */ - channelType: string; - channelId: string; - /** Threading reference passed to sendReply as opts.replyRef. */ - replyRef?: string; - }; + deliveryTarget?: string; /** Discord/Slack channel topic (description). Injected into session context for per-channel behavior control. */ channelTopic?: string; /** Recent channel conversation context (messages + thread summaries). Serialized ChannelContextData. */ @@ -79,6 +73,8 @@ export interface RunTurnParams { channelContext?: string; /** Max chars for rendered channel context block. Sourced from config.experimental.channelContext.maxChars. */ channelContextMaxChars?: number; + /** Delivery target for cron jobs — `platform:channelId` (e.g. "discord:123456"). */ + deliveryTarget?: string; /** Sandbox mode: true (auto-detect), false (disabled), 'seatbelt', or 'docker'. */ sandbox?: SandboxMode; /** Internal flag to prevent infinite retry on context overflow. */ diff --git a/src/channels/reply.ts b/src/channels/reply.ts index 2053626..d337347 100644 --- a/src/channels/reply.ts +++ b/src/channels/reply.ts @@ -153,26 +153,6 @@ export async function deliverReply(opts: { log.info('reply sent via Chat SDK', { totalMs: Date.now() - t0, postMs: Date.now() - tChatDone }); return; } - - // Legacy path: use postToChannel for cron/heartbeat replies - const { reply } = eventData; - if (!reply) return; - - log.info('sending reply (legacy)', { - channelType: reply.channelType, - channelId: reply.channelId, - chars: replyText.length, - mediaItems: mediaItems.length, - }); - - await postToChannel({ - channelType: reply.channelType as 'discord' | 'slack', - channelId: reply.channelId, - threadRef: reply.replyRef, - text: replyText, - files: mediaItems, - config, - }); } /** diff --git a/src/config/schema.ts b/src/config/schema.ts index adb5261..8ed85ce 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -81,17 +81,6 @@ export const ConfigSchema = z.object({ * Falls back to the global `model` setting when omitted. */ model: z.string().default('flash'), - /** - * Execution log channel — every heartbeat result (including HEARTBEAT_OK) - * is posted here for continuous visibility into heartbeat health. - * Separate from `notifications` which only fires on alerts. - */ - reply: z - .object({ - channel: z.enum(['discord', 'slack']), - channelId: z.string(), - }) - .optional(), /** * Heartbeat notification destinations. * Channel notifications receive every run result (execution logs). @@ -234,17 +223,6 @@ export const ConfigSchema = z.object({ .default({}), cron: z .object({ - /** - * Default reply destination for cron jobs that don't specify their own reply field. - * Results are sent here unless overridden per-job. - * Mirrors the CronJob.reply shape. - */ - defaultReply: z - .object({ - channel: z.enum(['discord', 'slack']), - channelId: z.string(), - }) - .optional(), /** * Hours after which completed cron session JSONL files are auto-pruned. * 0 = disabled (keep forever). Default: 72 (3 days). diff --git a/src/cron/cli.ts b/src/cron/cli.ts index b652b82..dffb710 100644 --- a/src/cron/cli.ts +++ b/src/cron/cli.ts @@ -118,8 +118,8 @@ export function buildCronCommand(): Command { .option('--model ', 'Override model for this job (e.g. gemini-2.5-flash)') .option('--delete-after-run', 'Auto-delete job after run (default: true for at)') .option('--no-delete-after-run', 'Keep job after run even if one-shot') - .option('--reply-channel ', 'Reply channel type: discord or slack') - .option('--reply-channel-id ', 'Reply channel ID') + .option('--reply-channel ', 'Delivery target channel type: discord or slack') + .option('--reply-channel-id ', 'Delivery target channel ID') .action( async (options: { name: string; diff --git a/src/cron/types.ts b/src/cron/types.ts index 93d321c..0368664 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -17,12 +17,10 @@ export interface CronJob { model?: string; /** Auto-delete job from jobs.json after successful run. Default: true for `at`, false otherwise. */ deleteAfterRun?: boolean; - /** Passed to agent-run's reply field for channel responses. */ + /** Delivery target for geminiclaw_post_message. Falls back to homeChannel if omitted. */ reply?: { channel: 'discord' | 'slack'; channelId: string; - messageId?: string; - threadTs?: string; }; createdAt: string; lastRunAt?: string; diff --git a/src/inngest/agent-run.ts b/src/inngest/agent-run.ts index a77a524..cf4c772 100644 --- a/src/inngest/agent-run.ts +++ b/src/inngest/agent-run.ts @@ -75,6 +75,7 @@ export const agentRun = inngest.createFunction( channelTopic: data.channelTopic, channelContext: data.channelContext, channelContextMaxChars: config.experimental.channelContext.maxChars, + deliveryTarget: data.deliveryTarget, sandbox: config.sandbox, }; diff --git a/src/inngest/cron-scheduler.ts b/src/inngest/cron-scheduler.ts index 116ad32..afd102b 100644 --- a/src/inngest/cron-scheduler.ts +++ b/src/inngest/cron-scheduler.ts @@ -30,27 +30,17 @@ const log = createLogger('cron-scheduler'); // ── Shared helpers ─────────────────────────────────────────────── -/** Resolve the reply destination for a cron job (job → config → homeChannel). */ -function buildCronReply( - job: CronJob, - config: ReturnType, -): { channelType: string; channelId: string; replyRef?: string } | undefined { - const source = job.reply ?? config.cron.defaultReply ?? resolveHomeChannel(config); - if (!source) return undefined; - return { - channelType: source.channel, - channelId: source.channelId, - replyRef: ('threadTs' in source ? source.threadTs : undefined) as string | undefined, - }; -} - -function resolveHomeChannel(config: ReturnType): CronJob['reply'] | undefined { - if (config.channels.discord.enabled && config.channels.discord.homeChannel) { - return { channel: 'discord', channelId: config.channels.discord.homeChannel }; - } - if (config.channels.slack.enabled && config.channels.slack.homeChannel) { - return { channel: 'slack', channelId: config.channels.slack.homeChannel }; - } +/** + * Build a `platform:channelId` delivery target string for context injection. + * The agent uses this to know where to post results via `geminiclaw_post_message`. + * Falls back to homeChannel when the job has no explicit reply. + */ +function buildDeliveryTarget(job: CronJob, config: ReturnType): string | undefined { + if (job.reply) return `${job.reply.channel}:${job.reply.channelId}`; + const dc = config.channels.discord; + if (dc.enabled && dc.homeChannel) return `discord:${dc.homeChannel}`; + const sc = config.channels.slack; + if (sc.enabled && sc.homeChannel) return `slack:${sc.homeChannel}`; return undefined; } @@ -100,7 +90,7 @@ export const cronJobRunner = inngest.createFunction( trigger: 'cron', prompt: job.prompt, model: job.model, - reply: buildCronReply(job, config), + deliveryTarget: buildDeliveryTarget(job, config), }, }); log.info('dispatched', { jobId: job.id, jobName: job.name, model: job.model }); @@ -209,7 +199,7 @@ export async function fireCronJob( trigger: 'cron', prompt: job.prompt, model: job.model, - reply: buildCronReply(job, config), + deliveryTarget: buildDeliveryTarget(job, config), }, }); appendRunLog(workspacePath, job.id, { diff --git a/src/inngest/heartbeat.ts b/src/inngest/heartbeat.ts index cb0731b..e37fefe 100644 --- a/src/inngest/heartbeat.ts +++ b/src/inngest/heartbeat.ts @@ -8,7 +8,6 @@ * don't conflict with ongoing manual or channel tasks. */ -import { loadConfig } from '../config.js'; import { inngest } from './client.js'; /** @@ -20,26 +19,6 @@ function buildCronExpression(intervalMin: number): string { return `*/${intervalMin} * * * *`; } -/** - * Resolve the heartbeat reply destination from config. - * Falls back to the first enabled homeChannel when heartbeat.reply is not set. - */ -function buildHeartbeatReply(): { channelType: string; channelId: string } | undefined { - const config = loadConfig(); - const hbReply = config.heartbeat.reply; - if (hbReply) { - return { channelType: hbReply.channel, channelId: hbReply.channelId }; - } - // Fall back to homeChannel — heartbeat reply is the default FYI destination - if (config.channels.discord.enabled && config.channels.discord.homeChannel) { - return { channelType: 'discord', channelId: config.channels.discord.homeChannel }; - } - if (config.channels.slack.enabled && config.channels.slack.homeChannel) { - return { channelType: 'slack', channelId: config.channels.slack.homeChannel }; - } - return undefined; -} - /** * Create the heartbeat cron function with the given interval. * Called at server startup so the interval can be read from config. @@ -62,7 +41,6 @@ export function createHeartbeatCron(intervalMin: number) { 'Review digest, check calendar/email, and handle any needed notifications. ' + 'Post notifications to the home channel via geminiclaw_post_message. ' + 'Always respond with HEARTBEAT_OK when done.', - reply: buildHeartbeatReply(), }, }); diff --git a/src/mcp/cron-server.ts b/src/mcp/cron-server.ts index e32334f..7d7263e 100644 --- a/src/mcp/cron-server.ts +++ b/src/mcp/cron-server.ts @@ -51,7 +51,9 @@ const TOOLS = [ }, reply: { type: 'object', - description: 'Reply destination: { channel: "discord"|"slack", channelId: "..." }. Optional.', + description: + 'Delivery target for geminiclaw_post_message: { channel: "discord"|"slack", channelId: "..." }. ' + + 'Falls back to homeChannel if omitted.', }, model: { type: 'string', @@ -151,7 +153,7 @@ function formatJobSummary(job: CronJob): string { `タイムゾーン: ${job.timezone ?? '(デフォルト)'}`, `モデル: ${job.model ?? '(デフォルト)'}`, `次回実行: ${job.nextRunAt ?? '(未設定)'}`, - `返信先: ${job.reply ? `${job.reply.channel} (${job.reply.channelId})` : '(デフォルト)'}`, + `配信先: ${job.reply ? `${job.reply.channel}:${job.reply.channelId}` : '(homeChannel)'}`, `自動削除: ${job.deleteAfterRun != null ? (job.deleteAfterRun ? 'はい' : 'いいえ') : '(デフォルト)'}`, `有効: ${job.enabled ? 'はい' : 'いいえ'}`, ]; diff --git a/src/serve.ts b/src/serve.ts index fe9f5df..766b845 100644 --- a/src/serve.ts +++ b/src/serve.ts @@ -57,7 +57,13 @@ export async function createServer(port: number = 3000) { // QMD MCP — embedded in-process (no separate daemon or proxy). // Store is shared across requests; McpServer is created per-request // because the SDK only allows one transport per server instance. + // QMD internal modules aren't in the package exports map — use dynamic import + // with suppressed TS directive to bypass module resolution. + // biome-ignore lint/suspicious/noTsIgnore: QMD dist paths not in package exports map + // @ts-ignore — QMD dist paths not in package exports map const { createStore, enableProductionMode } = await import('@tobilu/qmd/dist/store.js'); + // biome-ignore lint/suspicious/noTsIgnore: QMD dist paths not in package exports map + // @ts-ignore — QMD dist paths not in package exports map const { createMcpServer: createQmdMcpServer } = await import('@tobilu/qmd/dist/mcp.js'); enableProductionMode(); const qmdStore = createStore(); @@ -364,7 +370,7 @@ export function createPreviewServer( previewApp.use((_req, res, next) => { res.setHeader( 'Content-Security-Policy', - "default-src 'none'; script-src 'unsafe-inline'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline' https:; font-src 'self' https:; frame-src https:", + "default-src 'none'; script-src 'self' 'unsafe-inline' http: https:; img-src * data: blob:; style-src 'self' 'unsafe-inline' http: https:; font-src * data:; frame-src http: https:; media-src * data: blob:", ); res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Frame-Options', 'DENY'); diff --git a/templates/.gemini/skills/cron/SKILL.md b/templates/.gemini/skills/cron/SKILL.md index 6afc4e8..e826d13 100644 --- a/templates/.gemini/skills/cron/SKILL.md +++ b/templates/.gemini/skills/cron/SKILL.md @@ -26,7 +26,7 @@ Parameters: - `schedule` (required): Schedule object (see below) - `prompt` (required): Self-contained prompt (see below) - `timezone`: IANA timezone (e.g., `Asia/Tokyo`) -- `reply`: Reply destination `{ channel: "discord"|"slack", channelId: "..." }` +- `reply`: Delivery target for `geminiclaw_post_message` `{ channel: "discord"|"slack", channelId: "..." }`. Falls back to homeChannel if omitted. `nextRunAt` and `createdAt` are set automatically by the tool. @@ -76,7 +76,7 @@ Standard 5-field cron expression. Use in combination with `timezone`. - Schedule: {human-readable description of schedule} - Prompt: {beginning or full text of prompt} - Timezone: {timezone} - - Reply to: {description of reply or "default"} + - Delivery target: {description of reply or "homeChannel"} Reply "OK" to proceed, or let me know if anything needs to be changed. ``` @@ -121,11 +121,11 @@ Criteria: Support holds + volume increasing → consider buying. No volume → p Registered jobs: cloudflare-check-open / cloudflare-analysis / cloudflare-final-decision ``` -## Reply Field +## Reply Field (Delivery Target) -Sends results to a Discord/Slack channel after job completion. -**Set this to the channel ID of the conversation where the user made the request.** -If not set, falls back to the default channel from system settings. +Specifies where the cron job posts results via `geminiclaw_post_message`. +**Set this to the channel ID where the user wants to receive results.** +If not set, falls back to homeChannel. ## Notes diff --git a/templates/.gemini/skills/daily-briefing/SKILL.md b/templates/.gemini/skills/daily-briefing/SKILL.md index ec9600b..3ea9875 100644 --- a/templates/.gemini/skills/daily-briefing/SKILL.md +++ b/templates/.gemini/skills/daily-briefing/SKILL.md @@ -14,7 +14,7 @@ Generate a concise, prioritized daily briefing. Works standalone with core Gemin 1. Gather → Pull data from available sources (parallel) 2. Analyze → Prioritize and detect conflicts/urgencies 3. Format → Produce scannable briefing -4. Deliver → Output as reply text +4. Deliver → Post to homeChannel via geminiclaw_post_message ``` ## Phase 1: Gather Data @@ -128,7 +128,9 @@ Produce a concise briefing. Target: **under 2 minutes to read**. ## Phase 4: Deliver -Output the briefing as the reply text. The cron system or channel layer handles delivery to the configured destination (Discord, Slack, etc.). +Post the briefing to homeChannel via `geminiclaw_post_message`. + +When triggered manually, reply directly in the conversation instead of posting to homeChannel. ## Cron Setup @@ -137,8 +139,7 @@ To run daily, register with the `cron` skill: ``` Schedule: { "type": "cron", "expression": "0 8 * * *" } Timezone: [user's timezone from config] -Prompt: "Run the daily-briefing skill. Output the briefing." -Reply: { "channel": "discord", "channelId": "[target channel]" } +Prompt: "Run the daily-briefing skill." ``` Adjust the hour (8 = 8:00 AM) to the user's preference. diff --git a/templates/.gemini/skills/topic-patrol/SKILL.md b/templates/.gemini/skills/topic-patrol/SKILL.md index 566d15f..1231874 100644 --- a/templates/.gemini/skills/topic-patrol/SKILL.md +++ b/templates/.gemini/skills/topic-patrol/SKILL.md @@ -127,7 +127,6 @@ Schedule: { "type": "every", "intervalMin": 360 } Timezone: [user's timezone from config] Prompt: "Run the topic-patrol skill." Model: flash -Reply: { "channel": "discord", "channelId": "[homeChannel]" } ``` ## Graceful Degradation diff --git a/templates/AGENTS.md b/templates/AGENTS.md index 22ba152..5b59d45 100644 --- a/templates/AGENTS.md +++ b/templates/AGENTS.md @@ -30,9 +30,7 @@ When you learn a lesson or make a mistake, document it so future-you doesn't rep ### Searching Memory -- `qmd_search` — BM25 keyword search across memory files -- `qmd_vector_search` — semantic search (finds related content even with different wording) -- `qmd_deep_search` — highest precision (auto-expands query + reranks all results) +- `qmd_query` — hybrid search (BM25 + vector + reranking, supports `intent` parameter for disambiguation) - `qmd_get` — read full document content by path or docid from search results ## Time