diff --git a/.gitignore b/.gitignore index 0c2baf7..5016930 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ target/ .blog/drafts/ deploy/lima/case.yaml deploy/lima/cairn.yaml +plans/ diff --git a/apps/construct/src/agent.ts b/apps/construct/src/agent.ts index 09982cf..990ffea 100644 --- a/apps/construct/src/agent.ts +++ b/apps/construct/src/agent.ts @@ -15,6 +15,8 @@ import { recallMemories, saveMessage, trackUsage, + getSetting, + setSetting, } from "./db/queries.js"; import { generateEmbedding, estimateTokens, SIMILARITY, type WorkerModelConfig } from "@repo/cairn"; import { @@ -468,13 +470,53 @@ export async function processMessage( if (extracted.length > 0) { agentLog.info`Extracted ${extracted.length} potential skill(s) from observations`; - for (const skill of extracted) { - if (skill.confidence >= 0.6) { - agentLog.info`Emergent skill: "${skill.name}" (confidence: ${(skill.confidence * 100).toFixed(0)}%)`; + + // Only nudge on Telegram — proactive messaging requires bot + if (opts.source === "telegram" && opts.chatId && opts.chatId !== "unknown") { + try { + const candidate = extracted + .filter((s) => s.confidence >= 0.7) + .toSorted((a, b) => b.confidence - a.confidence)[0]; + + if (candidate) { + // Name-based dedup: skip if skill already exists + const exists = await db + .selectFrom("skills") + .select("id") + .where("name", "=", candidate.name) + .executeTakeFirst(); + + if (!exists) { + const normalizedName = candidate.name.toLowerCase().replace(/[^a-z0-9]/g, "-"); + const ignoredAt = await getSetting(db, `ignored_skill:${normalizedName}`); + const stillIgnored = + ignoredAt && + Date.now() - new Date(ignoredAt).getTime() < 7 * 24 * 60 * 60 * 1000; + + const lastNudge = await getSetting(db, `skill_nudge_cooldown:${opts.chatId}`); + const onCooldown = + lastNudge && Date.now() - new Date(lastNudge).getTime() < 24 * 60 * 60 * 1000; + + if (!stillIgnored && !onCooldown) { + const payload = JSON.stringify({ + name: candidate.name, + description: candidate.description, + body: candidate.body, + }); + await setSetting(db, `skill_nudge:${opts.chatId}`, payload); + await setSetting( + db, + `skill_nudge_cooldown:${opts.chatId}`, + new Date().toISOString(), + ); + agentLog.info`Queued skill nudge for chat ${opts.chatId}: "${candidate.name}"`; + } + } + } + } catch (err) { + agentLog.warning`Failed to queue skill nudge: ${err}`; } } - // TODO: Optionally create skills via skill_create tool - // or announce to user: "I noticed a pattern... would you like me to save it as a skill?" } } catch (err) { agentLog.warning`Failed to extract skills from observations: ${err}`; diff --git a/apps/construct/src/db/queries.ts b/apps/construct/src/db/queries.ts index ed00d35..b951ae6 100644 --- a/apps/construct/src/db/queries.ts +++ b/apps/construct/src/db/queries.ts @@ -304,3 +304,7 @@ export async function setSetting(db: DB, key: string, value: string) { .onConflict((oc) => oc.column("key").doUpdateSet({ value, updated_at: now })) .execute(); } + +export async function deleteSetting(db: DB, key: string): Promise { + await db.deleteFrom("settings").where("key", "=", key).execute(); +} diff --git a/apps/construct/src/system-prompt.ts b/apps/construct/src/system-prompt.ts index c1693cd..d41c04b 100644 --- a/apps/construct/src/system-prompt.ts +++ b/apps/construct/src/system-prompt.ts @@ -19,7 +19,7 @@ Your tools describe their own capabilities. Use them freely; don't ask permissio - Explain what and why before self-editing source code. - Use telegram_ask before self-editing or deploying — let the user confirm the plan. - When mentioning the current time, use ONLY the time from [Current time: ...]. Never guess. -- Do not proactively surface future tasks unless the current conversation creates a relevant connection. Scheduled reminders exist for a reason — let them fire at their scheduled time. +- Memories about upcoming reminders or scheduled tasks are passive awareness only — never mention them in responses. The scheduler fires them at the right time. Only discuss schedules when the user explicitly asks. - Never deploy without passing tests. - Never edit files outside src/, cli/, or extensions/. - Message annotations like [YYYY-MM-DD HH:MM] and [tg:ID] in history are metadata — never include them in responses. @@ -174,8 +174,7 @@ export function buildContextPreamble(context: { } if (context.recentMemories && context.recentMemories.length > 0) { - preamble += - "\n[Recent memories — use these for context, pattern recognition, and continuity]\n"; + preamble += "\n[Recent memories — background context only, do not reference proactively]\n"; for (const m of context.recentMemories) { const dateStr = m.created_at ? ` [${m.created_at.slice(0, 16).replace("T", " ")}]` : ""; preamble += `- ${dateStr} (${m.category}) ${m.content}\n`; diff --git a/apps/construct/src/telegram/bot.ts b/apps/construct/src/telegram/bot.ts index 0019d5d..c788fa2 100644 --- a/apps/construct/src/telegram/bot.ts +++ b/apps/construct/src/telegram/bot.ts @@ -12,6 +12,9 @@ import { getPendingAskById, resolvePendingAsk, setPendingAskTelegramId, + getSetting, + setSetting, + deleteSetting, } from "../db/queries.js"; import type { AskPayload, TelegramSideEffects, TelegramContext } from "./types.js"; import { markdownToTelegramHtml } from "./format.js"; @@ -228,6 +231,34 @@ export function createBot(db: Kysely) { await sendReply(ctx, response.text, sideEffects, response.messageId); } + // Check for pending skill nudge (queued by async observer chain) + try { + const nudgePayload = await getSetting(db, `skill_nudge:${chatId}`); + if (nudgePayload) { + await deleteSetting(db, `skill_nudge:${chatId}`); + const candidate = JSON.parse(nudgePayload) as { + name: string; + description: string; + body: string; + }; + await setSetting(db, `skill_nudge_pending:${chatId}`, nudgePayload); + const keyboard = new InlineKeyboard() + .text("Save it", `skillnudge:save:${chatId}`) + .text("Ignore", `skillnudge:ignore:${chatId}`); + await bot.api + .sendMessage( + chatId, + markdownToTelegramHtml( + `I noticed a reusable pattern — want me to save it as a skill?\n\n**${candidate.name}**\n${candidate.description}`, + ), + { reply_markup: keyboard, parse_mode: "HTML" as const }, + ) + .catch((err) => telegramLog.error`Failed to send skill nudge: ${err}`); + } + } catch (err) { + telegramLog.error`Error checking skill nudge: ${err}`; + } + telegramLog.info`Reply sent to chat ${chatId} (${response.text.length} chars, suppress=${!!sideEffects.suppressText})`; } catch (err) { telegramLog.error`Error processing message: ${err}`; @@ -246,6 +277,71 @@ export function createBot(db: Kysely) { } const data = ctx.callbackQuery.data; + + // Skill nudge callbacks + const nudgeMatch = data.match(/^skillnudge:(save|ignore):(.+)$/); + if (nudgeMatch) { + const [, action, nudgeChatId] = nudgeMatch; + await ctx.answerCallbackQuery({ text: action === "save" ? "Creating skill..." : "Got it." }); + + const stored = await getSetting(db, `skill_nudge_pending:${nudgeChatId}`); + if (!stored) { + await ctx.editMessageText("This suggestion has expired.").catch(() => {}); + return; + } + + await deleteSetting(db, `skill_nudge_pending:${nudgeChatId}`); + const candidate = JSON.parse(stored) as { name: string; description: string; body: string }; + + if (action === "save") { + await ctx + .editMessageText(markdownToTelegramHtml(`Creating skill **${candidate.name}**...`), { + parse_mode: "HTML", + }) + .catch(() => {}); + + enqueue(nudgeChatId, async () => { + const sendTyping = () => bot.api.sendChatAction(nudgeChatId, "typing").catch(() => {}); + const typingInterval = setInterval(sendTyping, 4000); + await sendTyping(); + try { + const instruction = [ + `Use the skill_create tool to save this skill now. Do not ask for confirmation.`, + `Name: "${candidate.name}"`, + `Description: "${candidate.description}"`, + `Body:\n${candidate.body}`, + ].join("\n"); + + const response = await processMessage(db, instruction, { + source: "telegram", + externalId: nudgeChatId, + chatId: nudgeChatId, + }); + + if (response.text.trim()) { + await bot.api + .sendMessage(nudgeChatId, markdownToTelegramHtml(response.text), { + parse_mode: "HTML" as const, + }) + .catch(async () => { + await bot.api.sendMessage(nudgeChatId, response.text); + }); + } + } catch (err) { + telegramLog.error`Error creating skill from nudge: ${err}`; + await bot.api.sendMessage(nudgeChatId, "Failed to create skill. Check the logs."); + } finally { + clearInterval(typingInterval); + } + }); + } else { + const normalizedName = candidate.name.toLowerCase().replace(/[^a-z0-9]/g, "-"); + await setSetting(db, `ignored_skill:${normalizedName}`, new Date().toISOString()); + await ctx.editMessageText("Got it, skipping that one.").catch(() => {}); + } + return; + } + const match = data.match(/^ask:([^:]+):(\d+)$/); if (!match) { await ctx.answerCallbackQuery();