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