Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ target/
.blog/drafts/
deploy/lima/case.yaml
deploy/lima/cairn.yaml
plans/
52 changes: 47 additions & 5 deletions apps/construct/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
recallMemories,
saveMessage,
trackUsage,
getSetting,
setSetting,
} from "./db/queries.js";
import { generateEmbedding, estimateTokens, SIMILARITY, type WorkerModelConfig } from "@repo/cairn";
import {
Expand Down Expand Up @@ -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}`;
Expand Down
4 changes: 4 additions & 0 deletions apps/construct/src/db/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
await db.deleteFrom("settings").where("key", "=", key).execute();
}
5 changes: 2 additions & 3 deletions apps/construct/src/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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`;
Expand Down
96 changes: 96 additions & 0 deletions apps/construct/src/telegram/bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -228,6 +231,34 @@ export function createBot(db: Kysely<Database>) {
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}`;
Expand All @@ -246,6 +277,71 @@ export function createBot(db: Kysely<Database>) {
}

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();
Expand Down
Loading