From 38885dffacb809286a440f66f4a130731309e75e Mon Sep 17 00:00:00 2001 From: Adil Shaikh Date: Mon, 30 Mar 2026 22:44:53 +0000 Subject: [PATCH 1/7] refactor: abstract Telegram into Rasul interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - types.ts: Add Rasul (full messenger), RisalaDakhila (inbound events), KhiyarTafauli (interactive options). Rename telegramMessageId → huwiyyatRisalaMuqaddim for provider-agnosticism. - messenger.ts: TelegramMessenger now implements full Rasul interface with baddaa()/awqaf()/indaRisala() lifecycle. Inbound Telegram routing moved here from main.ts — emits normalized RisalaDakhila events. arsalaSualBiKhiyarat() renders KhiyarTafauli[] as inline keyboards. - saail.ts: banaMafatihSatriyya() → banaKhiyarat() returns abstract KhiyarTafauli[] instead of Telegram-specific keyboard markup. - main.ts: SiyaqKhadim.telegram removed, only rasul: Rasul remains. addaMualijatTelegram() deleted — replaced by rabatRisalaDakhila() which routes RisalaDakhila events to business logic. All ctx.telegram calls converted to ctx.rasul.send/arsalaSualBiKhiyarat. --- src/daemon/saail.ts | 58 ++--- src/main.ts | 436 +++++++++++++-------------------- src/notifications/messenger.ts | 258 ++++++++++++++++--- src/types.ts | 58 ++++- 4 files changed, 473 insertions(+), 337 deletions(-) diff --git a/src/daemon/saail.ts b/src/daemon/saail.ts index 3c5ff22..ff2858e 100644 --- a/src/daemon/saail.ts +++ b/src/daemon/saail.ts @@ -30,6 +30,7 @@ import type { TasnifSual, SualMuallaq, RasulKharij, + KhiyarTafauli, } from "../types.ts"; @@ -61,8 +62,8 @@ export class Saail { } /** - * Generate a short callback ID (8 chars) for Telegram callback_data - * and register the mapping to the full question ID. + * Generate a short callback ID (8 chars) and register the mapping + * to the full question ID. */ ikhtisarIdIstijaba(questionId: string): string { const short = questionId.replace(/-/g, "").slice(0, 8); @@ -226,7 +227,7 @@ Auto-selected: ${answers.map((a) => a.selected.join(", ")).join("; ")}`; } /** - * Format a question for Telegram display. + * Format a question for display (markdown). */ nassaqRisalatSual(question: MaalumatSual, huwiyyatMurshid: string): string { let msg = `**${question.header}** (${huwiyyatMurshid})\n\n`; @@ -250,46 +251,39 @@ Auto-selected: ${answers.map((a) => a.selected.join(", ")).join("; ")}`; } /** - * Build inline keyboard data for transport-specific rendering. - * Public so main.ts can build Telegram keyboards. + * Build abstract interactive options for a question. + * Transport adapters convert these into native controls + * (Telegram inline keyboard, CLI numbered list, etc.). + * + * Callback keys use short 8-char IDs + truncated labels to stay + * compact across transports. */ - banaMafatihSatriyya( + banaKhiyarat( questionId: string, question: MaalumatSual - ): { inline_keyboard: Array> } { - const rows: Array> = []; - /** - * Use short 8-char ID to stay within Telegram's 64-byte callback_data limit. - * Format: "q:{8}:{label}" — 11 chars overhead, leaving 53 for label. - */ + ): KhiyarTafauli[] { const shortId = this.ikhtisarIdIstijaba(questionId); - const maxLabelLen = 64 - 2 - shortId.length - 1; + /** Keep callback keys compact — 53 chars for label after "q:{8}:" prefix */ + const maxLabelLen = 53; - for (const opt of question.options) { - const shortLabel = opt.label.slice(0, maxLabelLen); - rows.push([ - { - text: opt.label, - callback_data: `q:${shortId}:${shortLabel}`, - }, - ]); - } + const khiyarat: KhiyarTafauli[] = question.options.map((opt) => ({ + nass: opt.label, + miftah: `q:${shortId}:${opt.label.slice(0, maxLabelLen)}`, + })); if (question.custom !== false) { - rows.push([ - { - text: "Type answer...", - callback_data: `q:${shortId}:__custom__`, - }, - ]); + khiyarat.push({ + nass: "Type answer...", + miftah: `q:${shortId}:__custom__`, + }); } - return { inline_keyboard: rows }; + return khiyarat; } /** - * Handle a callback query (button press) for a question. - * Called from main.ts when Telegram callback matches question pattern. + * Handle a callback (button press) for a question. + * Called when transport reports a question answer via callback key. */ async aalajIstijabaZirrSual( questionId: string, @@ -453,7 +447,7 @@ Auto-selected: ${answers.map((a) => a.selected.join(", ")).join("; ")}`; header: dbQ.sual.slice(0, 30), options: options.map(label => ({ label, description: "" })), }], - telegramMessageId: dbQ.huwiyyatRisala ?? undefined, + huwiyyatRisalaMuqaddim: dbQ.huwiyyatRisala ?? undefined, createdAt: dbQ.unshiaFi, }; diff --git a/src/main.ts b/src/main.ts index ef01dbc..24dd687 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,7 +6,7 @@ * Architecture: * - MudirJalasat: Manages murshid OpenCode sessions * - Munaffidh: Executes PM-MCP tool calls via Linear/GitHub APIs - * - Telegram: Routes human messages to murshid session + * - Rasul: Routes human messages to/from murshid sessions (transport-agnostic) * - KeepAlive: Polls for external changes, feeds to murshid * * Usage: @@ -29,7 +29,7 @@ import { baddaaQaidatBayanat, aghlaaqQaidatBayanat, haddathaHuwiyyatRisalaSual } import { createOpenCodeClient } from "./opencode/client.ts"; import { anshaaNtfyAmil } from "./notifications/ntfy.ts"; import { anshaaTelegramAmil } from "./notifications/telegram.ts"; -import { anshaaTelegramRasul, type TelegramMessenger } from "./notifications/messenger.ts"; +import { anshaaTelegramRasul } from "./notifications/messenger.ts"; import { createLinearClient } from "./linear/client.ts"; import { createGitHubClient } from "./github/gh.ts"; import { istadaaKatib } from "./daemon/katib.ts"; @@ -39,14 +39,13 @@ import { istadaaArraf } from "./daemon/arraf.ts"; import { awqadaHayat, type NatijaSeyana } from "./daemon/hayat.ts"; import { istadaaSaail } from "./daemon/saail.ts"; import { istadaaRaqib } from "./daemon/raqib.ts"; -import type { TasmimIksir, TaaliqMuraja, JalsatMurshid, RisalaMutaba, HadathSualMatlub, MaalumatSual, SualMuallaq, MutabiWasfa } from "./types.ts"; +import type { TasmimIksir, Rasul, RisalaDakhila, TaaliqMuraja, JalsatMurshid, RisalaMutaba, HadathSualMatlub, MaalumatSual, SualMuallaq, MutabiWasfa } from "./types.ts"; interface SiyaqKhadim { tasmim: TasmimIksir; opencode: ReturnType; ntfy: ReturnType; - telegram: ReturnType; - rasul: TelegramMessenger; + rasul: Rasul; mutabiWasfa: MutabiWasfa; github: ReturnType; mudirJalasat: ReturnType; @@ -73,17 +72,17 @@ async function tahaqqaqIttisaal(ctx: SiyaqKhadim): Promise { allGood = false; } - if (ctx.tasmim.isharat.telegram.mufattah) { - process.stdout.write(" Telegram bot... "); - const telegramValid = await ctx.telegram.tahaqqaqToken(); - if (telegramValid) { + if (ctx.rasul.mumakkan()) { + process.stdout.write(" Messenger... "); + const messengerValid = await ctx.rasul.tahaqqaq(); + if (messengerValid) { console.log("✓"); } else { - console.log("✗ (invalid token)"); + console.log("✗ (validation failed)"); allGood = false; } } else { - console.log(" Telegram bot... (disabled)"); + console.log(" Messenger... (disabled)"); } if (ctx.tasmim.isharat.ntfy.mufattah) { @@ -155,7 +154,7 @@ async function addaIsharat(ctx: SiyaqKhadim): Promise { await logger.akhbar("main", `Received ${signal}, shutting down...`); ctx.mutahakkimIlgha.abort(); - ctx.telegram.stopPolling(); + ctx.rasul.awqaf(); ctx.munaffidh.awqafMuaalaja(); ctx.raqib.awqaf(); @@ -245,12 +244,11 @@ async function awqadKhadim(ctx: SiyaqKhadim): Promise { await addaIsharat(ctx); - if (ctx.tasmim.isharat.telegram.mufattah) { - addaMualijatTelegram(ctx); - ctx.telegram.startPolling().catch(async (error) => { - await logger.sajjalKhata("telegram", "Polling error", { error: String(error) }); - }); - } + /** Wire inbound message routing and start the messenger */ + rabatRisalaDakhila(ctx); + await ctx.rasul.baddaa().catch(async (error) => { + await logger.sajjalKhata("messenger", "Inbound start error", { error: String(error) }); + }); ctx.munaffidh.badaaMuaalaja(ctx.mutahakkimIlgha.signal).catch(async (error) => { await logger.sajjalKhata("tool-executor", "Processing error", { error: String(error) }); @@ -275,282 +273,187 @@ async function awqadKhadim(ctx: SiyaqKhadim): Promise { } } -function addaMualijatTelegram(ctx: SiyaqKhadim): void { - ctx.telegram.onMessage(async (message) => { - if (!message.text) return; - - const text = message.text.trim(); - const topicId = ctx.telegram.jalabRisalaTopicId(message); - const isGroupMessage = ctx.telegram.isGroupMessage(message); - const isPrivateMessage = ctx.telegram.isPrivateMessage(message); - const isDispatchTopic = ctx.telegram.isDispatchTopic(message); - - await logger.akhbar("telegram", `Received: ${text.slice(0, 100)}`, { - topicId, - isGroupMessage, - isPrivateMessage, - isDispatchTopic, - }); - - if (isPrivateMessage) { - await aalajRisalaKhassa(ctx, message); - return; - } - - if (!isGroupMessage) { - await logger.haDHHir("telegram", "Message from unknown chat type"); - return; - } - - if (isDispatchTopic) { - await aalajRisalaMawduu(ctx, text, message.message_id); - return; - } - - if (topicId) { - /** Resolve murshid from channel */ - const murshid = ctx.mudirJalasat.wajadaMurshidBiQanat("telegram", String(topicId)); - - if (murshid && ctx.sail.huwaYantazirIdkhal(murshid.huwiyya)) { - const handled = await ctx.sail.aalajJawabKhass(murshid.huwiyya, text); - if (handled) { - await ctx.rasul.send({ murshid: murshid.huwiyya }, "Answer submitted."); - return; +/** + * Wire inbound message routing — ctx.rasul.indaRisala() → business logic. + * The Rasul adapter (messenger.ts) handles transport-specific parsing and + * emits normalized RisalaDakhila events. + */ +function rabatRisalaDakhila(ctx: SiyaqKhadim): void { + ctx.rasul.indaRisala(async (risala: RisalaDakhila) => { + switch (risala.naw) { + case "murshid": { + /** Message from a murshid topic */ + const { huwiyya, nass } = risala; + + /** Check if waiting for custom question input */ + if (ctx.sail.huwaYantazirIdkhal(huwiyya)) { + const handled = await ctx.sail.aalajJawabKhass(huwiyya, nass); + if (handled) { + await ctx.rasul.send({ murshid: huwiyya }, "Answer submitted."); + return; + } } - } - - if (murshid) { - await logger.akhbar("telegram", `Routing to murshid ${murshid.huwiyya} via topic ${topicId}`); - - const success = await ctx.mudirJalasat.arsalaIlaMurshidById(murshid.huwiyya, text); + + /** Route to murshid session */ + await logger.akhbar("inbound", `Routing to murshid ${huwiyya}`); + const success = await ctx.mudirJalasat.arsalaIlaMurshidById(huwiyya, nass); if (!success) { - await ctx.telegram.arsalaIlaMurshidTopic( - topicId, - `Failed to send message to murshid ${murshid.huwiyya}.` - ); + await ctx.rasul.send({ murshid: huwiyya }, `Failed to send message to murshid.`); } - return; - } - - if (topicId === 1) { - await ctx.telegram.arsalaRisala( - "Use the **Dispatch** topic to send Linear URLs and spawn murshids.", - { topicId: 1, chatId: ctx.telegram.getGroupId(), parseMode: "Markdown" } - ); - } else { - await ctx.telegram.arsalaRisala( - "This topic is not linked to an active murshid.", - { topicId, chatId: ctx.telegram.getGroupId() } - ); + break; } - return; - } - await logger.haDHHir("telegram", "Group message without topic ID"); - }); + case "irsal": { + /** Dispatch topic message — ticket URL or free text */ + const { nass, huwiyyatRisala } = risala; - ctx.telegram.onCallback(async (query) => { - await logger.akhbar("telegram", `Callback: ${query.data}`); - - if (query.data && ctx.sail.huwaIstijabaZirrSual(query.data)) { - const parsed = ctx.sail.hallalIstijabaZirrSual(query.data); - if (parsed) { - if (parsed.selectedLabel === "__custom__") { - /** Resolve murshid from the topic */ - const topicId = query.message?.message_thread_id; - const murshid = topicId - ? ctx.mudirJalasat.wajadaMurshidBiQanat("telegram", String(topicId)) - : null; - if (murshid) { - await ctx.sail.allamIntizarIdkhal(murshid.huwiyya, parsed.questionId); - await ctx.telegram.answerCallback(query.id, "Type your answer as a reply..."); - } else { - await ctx.telegram.answerCallback(query.id, "Cannot resolve murshid for custom input"); - } + /** Check for ticket URLs first */ + const ticketUrlMatch = nass.match(ctx.mutabiWasfa.getUrlPattern()); + if (ticketUrlMatch) { + await aalajRabitWasfa(ctx, ticketUrlMatch[0], nass); return; } - /** Handle option selection */ - const success = await ctx.sail.aalajIstijabaZirrSual( - parsed.questionId, - parsed.selectedLabel - ); - - if (success) { - await ctx.telegram.answerCallback(query.id, `Selected: ${parsed.selectedLabel}`); + /** Route to dispatcher for intent resolution */ + const result = await ctx.munadi.aalajRisalaIrsal({ + source: "telegram", + text: nass, + messageId: huwiyyatRisala, + }); + + if (result.tuulija) { + if (result.buttons) { + const khiyarat = result.buttons.map((b) => ({ nass: b.text, miftah: b.data })); + await ctx.rasul.arsalaSualBiKhiyarat("dispatch", result.radd ?? "Choose:", khiyarat); + } else if (result.radd) { + await ctx.rasul.send("dispatch", result.radd); + } + if (result.khata) { + await ctx.rasul.send("dispatch", `Error: ${result.khata}`); + } } else { - await ctx.telegram.answerCallback(query.id, "Question expired or already answered"); + await ctx.rasul.send("dispatch", "Send a ticket URL to spawn a murshid, or use /help for commands."); } - return; + break; + } + + case "amr": { + /** Slash command from dispatch topic */ + await aalajAmrDakhil(ctx, risala.amr, risala.wusut); + break; } - } - if (query.data && ( - query.data.startsWith("select:") || - query.data.startsWith("parent:") || - query.data.startsWith("switch:") || - query.data === "cancel" - )) { - const result = await ctx.munadi.aalajIstijabaZirr("telegram", query.data); - await ctx.telegram.answerCallback(query.id, "Received!"); - if (result.tuulija) { - if (result.buttons) { - const keyboard = { - inline_keyboard: result.buttons.map((b) => [{ text: b.text, callback_data: b.data }]), - }; - await ctx.telegram.sendToDispatch(result.radd ?? "Choose:", { - parseMode: "Markdown", - keyboard, - }); - } else if (result.radd) { - await ctx.telegram.sendToDispatch(result.radd, { parseMode: "Markdown" }); + case "jawab_sual": { + /** Question button selection */ + const { huwiyyatSual, taamiyya } = risala; + /** huwiyyatSual is "short:{8chars}" — extract and resolve */ + const shortId = huwiyyatSual.replace("short:", ""); + const questionId = ctx.sail.hallaIdIstijaba(shortId); + if (questionId) { + await ctx.sail.aalajIstijabaZirrSual(questionId, taamiyya); } - if (result.khata) { - await ctx.telegram.sendToDispatch(`Error: ${result.khata}`); + break; + } + + case "idkhal_khass_sual": { + /** Custom input requested for question */ + const { huwiyyatMurshid, huwiyyatSual } = risala; + const shortId = huwiyyatSual.replace("short:", ""); + const questionId = ctx.sail.hallaIdIstijaba(shortId); + if (questionId) { + await ctx.sail.allamIntizarIdkhal(huwiyyatMurshid, questionId); } + break; } - return; - } - await ctx.telegram.answerCallback(query.id, "Received!"); + case "ikhtiyar_munadi": { + /** Munadi button (select/parent/switch/cancel) */ + const result = await ctx.munadi.aalajIstijabaZirr("telegram", risala.miftah); + if (result.tuulija) { + if (result.buttons) { + const khiyarat = result.buttons.map((b) => ({ nass: b.text, miftah: b.data })); + await ctx.rasul.arsalaSualBiKhiyarat("dispatch", result.radd ?? "Choose:", khiyarat); + } else if (result.radd) { + await ctx.rasul.send("dispatch", result.radd); + } + if (result.khata) { + await ctx.rasul.send("dispatch", `Error: ${result.khata}`); + } + } + break; + } - /** Forward to murshid as a decision */ - const murshid = ctx.mudirJalasat.wajadaMurshidFaail(); - if (murshid && query.data) { - await ctx.mudirJalasat.arsalaIlaMurshid( - `Al-Kimyawi selected option: ${query.data}` - ); + case "khass": { + /** Private chat — show sessions overview */ + await aalajRisalaKhassa(ctx); + break; + } } }); } /** - * Handle private chat messages - list sessions, direct to group + * Handle private chat messages - list sessions overview */ -async function aalajRisalaKhassa( - ctx: SiyaqKhadim, - _message: { text?: string; message_id: number } -): Promise { +async function aalajRisalaKhassa(ctx: SiyaqKhadim): Promise { const sessions = ctx.mudirJalasat.wajadaJalasatMurshid(); - + let response = "**Sessions**\n\n"; - + if (sessions.length === 0) { response += "No active murshid sessions.\n\n"; } else { for (const session of sessions) { - const statusEmoji = session.hala === "fail" ? "🟢" : - session.hala === "masdud" ? "🔴" : - session.hala === "muntazir" ? "🟡" : "⚪"; + const statusEmoji = + session.hala === "fail" ? "🟢" : + session.hala === "masdud" ? "🔴" : + session.hala === "muntazir" ? "🟡" : "⚪"; response += `${statusEmoji} **${session.huwiyya}** (${session.naw})\n`; response += ` ${session.unwan}\n`; if (Object.keys(session.channels).length > 0) { - const channelStr = Object.entries(session.channels).map(([p, id]) => `${p}:${id}`).join(", "); + const channelStr = Object.entries(session.channels) + .map(([p, id]) => `${p}:${id}`) + .join(", "); response += ` Channels: ${channelStr}\n`; } response += "\n"; } } - - if (ctx.telegram.isGroupMode()) { - response += "---\n"; - response += "Use the **Telegram group for operations:\n"; - response += "• **Dispatch** topic: Send ticket URLs to spawn murshids\n"; - response += "• **Murshid topics**: Converse with active sessions\n"; - } - - await ctx.telegram.arsalaRisala(response, { - parseMode: "Markdown", - chatId: ctx.telegram.getChatId(), - }); -} - -/** - * Handle messages in the Dispatch topic - Linear URLs, commands - */ -async function aalajRisalaMawduu( - ctx: SiyaqKhadim, - text: string, - messageId: number -): Promise { - /** Check for ticket URLs first */ - const ticketUrlMatch = text.match(ctx.mutabiWasfa.getUrlPattern()); - if (ticketUrlMatch) { - await aalajRabitWasfa(ctx, ticketUrlMatch[0], text); - return; - } - - if (text.startsWith("/")) { - await aalajAmrMunadi(ctx, text); - return; - } - ctx.munadi.aalajRisalaIrsal({ - source: "telegram", - text, - messageId, - }).then(async (result) => { - if (result.tuulija) { - if (result.radd) { - await ctx.telegram.sendToDispatch(result.radd, { parseMode: "Markdown" }); - } - if (result.khata) { - await ctx.telegram.sendToDispatch(`Error: ${result.khata}`); - } - if (result.buttons) { - const keyboard = { - inline_keyboard: result.buttons.map((b) => [{ text: b.text, callback_data: b.data }]), - }; - await ctx.telegram.sendToDispatch(result.radd ?? "Choose:", { - parseMode: "Markdown", - keyboard, - }); - } - return; - } + response += "---\n"; + response += "Use **Dispatch** to send ticket URLs and spawn murshids.\n"; + response += "Use **murshid topics** to converse with active sessions.\n"; - await ctx.telegram.sendToDispatch( - "Send a ticket URL to spawn an murshid, or use /help for commands." - ); - }).catch(async (error) => { - await logger.sajjalKhata("main", "Dispatch handler failed", { error: String(error) }); - await ctx.telegram.sendToDispatch("Internal error processing your message."); - }); + await ctx.rasul.send("kimyawi", response); } /** - * Handle slash commands in Dispatch topic + * Handle slash commands from dispatch topic */ -async function aalajAmrMunadi(ctx: SiyaqKhadim, text: string): Promise { - const [command, ...args] = text.slice(1).split(" "); - - switch (command.toLowerCase()) { +async function aalajAmrDakhil(ctx: SiyaqKhadim, amr: string, wusut: string[]): Promise { + switch (amr.toLowerCase()) { case "start": - if (args.length === 0) { - await ctx.telegram.sendToDispatch( - "**Usage:** /start \n\nProvide a ticket, project, or milestone URL.", - { parseMode: "Markdown" } - ); + if (wusut.length === 0) { + await ctx.rasul.send("dispatch", "**Usage:** /start \n\nProvide a ticket, project, or milestone URL."); } else { - await aalajRabitWasfa(ctx, args[0], args.slice(1).join(" ")); + await aalajRabitWasfa(ctx, wusut[0], wusut.slice(1).join(" ")); } break; case "status": case "sessions": { - /** Delegate to dispatcher — single source of truth for status rendering */ const result = await ctx.munadi.aalajRisalaIrsal({ source: "telegram", - text: `/${command}`, + text: `/${amr}`, }); if (result.radd) { - await ctx.telegram.sendToDispatch(result.radd, { parseMode: "Markdown" }); + await ctx.rasul.send("dispatch", result.radd); } break; } case "help": - await ctx.telegram.sendToDispatch(`**Commands** + await ctx.rasul.send("dispatch", `**Commands** /start - Start murshid for ticket URL /status - Show active murshid status @@ -560,21 +463,21 @@ async function aalajAmrMunadi(ctx: SiyaqKhadim, text: string): Promise { **Usage** Send a ticket URL to start working on a ticket/project. Each murshid gets its own topic for conversation. -`, { parseMode: "Markdown" }); +`); break; default: - await ctx.telegram.sendToDispatch(`Unknown command: /${command}\n\nType /help for available commands.`); + await ctx.rasul.send("dispatch", `Unknown command: /${amr}\n\nType /help for available commands.`); } } async function aalajRabitWasfa(ctx: SiyaqKhadim, url: string, additionalContext: string): Promise { - await ctx.telegram.sendToDispatch(`Analyzing: ${url}`); + await ctx.rasul.send("dispatch", `Analyzing: ${url}`); /** Parse URL to extract ticket ID */ const parsed = ctx.mutabiWasfa.parseUrl(url); if (!parsed) { - await ctx.telegram.sendToDispatch("Could not parse ticket URL."); + await ctx.rasul.send("dispatch", "Could not parse ticket URL."); return; } @@ -599,9 +502,9 @@ async function aalajRabitWasfa(ctx: SiyaqKhadim, url: string, additionalContext: ); if (result.khata) { - await ctx.telegram.sendToDispatch(result.khata); + await ctx.rasul.send("dispatch", result.khata); } else if (result.radd) { - await ctx.telegram.sendToDispatch(result.radd, { parseMode: "Markdown" }); + await ctx.rasul.send("dispatch", result.radd); } } @@ -659,11 +562,12 @@ This PR has been merged. You can now: Query Linear for the ticket's blocking relations to determine next slice.`); - if (ctx.telegram.mumakkan()) { - const stackedMsg = stackedPRs.length > 0 + if (ctx.rasul.mumakkan()) { + const stackedMsg = stackedPRs.length > 0 ? `\n\n${stackedPRs.length} stacked PR(s) may need re-push.` : ""; - await ctx.telegram.arsalaRisala( + await ctx.rasul.send( + "dispatch", `✅ PR #${pr.raqamRisala} merged\n\nTicket: ${pr.huwiyyatWasfa}\nEpic: ${session.huwiyya}\n\nNext slice may now be disclosed.${stackedMsg}` ); } @@ -846,20 +750,20 @@ async function aalajIktimalSeyana( summary += "\n"; } - if (ctx.telegram.mumakkan()) { - /** Shorter version for Telegram */ - let telegramMsg = "🌙 Overnight Maintenance\n\n"; - if (merged.length > 0) telegramMsg += `✅ Merged: ${merged.length} branches\n`; - if (upToDate.length > 0) telegramMsg += `✓ Up-to-date: ${upToDate.length}\n`; + if (ctx.rasul.mumakkan()) { + /** Shorter version for messenger */ + let msg = "🌙 Overnight Maintenance\n\n"; + if (merged.length > 0) msg += `✅ Merged: ${merged.length} branches\n`; + if (upToDate.length > 0) msg += `✓ Up-to-date: ${upToDate.length}\n`; if (conflicts.length > 0) { - telegramMsg += `⚠️ Conflicts: ${conflicts.length}\n`; + msg += `⚠️ Conflicts: ${conflicts.length}\n`; for (const r of conflicts) { - telegramMsg += ` - ${r.huwiyya}: ${r.taarudat?.length ?? 0} file(s)\n`; + msg += ` - ${r.huwiyya}: ${r.taarudat?.length ?? 0} file(s)\n`; } } - if (errors.length > 0) telegramMsg += `❌ Errors: ${errors.length}\n`; + if (errors.length > 0) msg += `❌ Errors: ${errors.length}\n`; - await ctx.telegram.arsalaRisala(telegramMsg); + await ctx.rasul.send("dispatch", msg); } for (const r of conflicts) { @@ -879,17 +783,7 @@ ${(r.taarudat ?? []).map((f) => `- \`${f}\``).join("\n")} } -/** - * Build a Telegram inline keyboard for a question. - * Wraps question-handler's buildInlineKeyboard to create Telegram-specific markup. - */ -function banaLawhatSual( - handler: ReturnType, - questionId: string, - question: MaalumatSual, -): { inline_keyboard: Array> } { - return handler.banaMafatihSatriyya(questionId, question); -} + export const VERSION = "0.2.0"; @@ -951,15 +845,14 @@ export async function abda(opts: { check?: boolean } = {}): Promise { await questionHandler.hammalaHala(); questionHandler.wadaaIndaTahwilSual(async (pending: SualMuallaq, question: MaalumatSual) => { - const keyboard = banaLawhatSual(questionHandler, pending.id, question); - const murshid = sessionManager.jalabMurshid(pending.huwiyyatMurshid); - const topicId = murshid?.channels["telegram"]; - const messageId = await telegram.arsalaRisala("Use buttons below to answer:", { - topicId: topicId ? parseInt(topicId, 10) : undefined, - keyboard, - }); + const khiyarat = questionHandler.banaKhiyarat(pending.id, question); + const messageId = await messenger.arsalaSualBiKhiyarat( + { murshid: pending.huwiyyatMurshid }, + "Use buttons below to answer:", + khiyarat, + ); if (messageId) { - pending.telegramMessageId = messageId; + pending.huwiyyatRisalaMuqaddim = messageId; haddathaHuwiyyatRisalaSual(pending.id, messageId); } }); @@ -976,7 +869,6 @@ export async function abda(opts: { check?: boolean } = {}): Promise { tasmim: config, opencode, ntfy, - telegram, rasul: messenger, mutabiWasfa: issueTracker, github, diff --git a/src/notifications/messenger.ts b/src/notifications/messenger.ts index b77d1a5..f862b4e 100644 --- a/src/notifications/messenger.ts +++ b/src/notifications/messenger.ts @@ -1,15 +1,18 @@ /** - * TelegramMessenger — RasulKharij adapter for Telegram + * TelegramMessenger — Rasul adapter for Telegram * - * Translates the generic RasulKharij interface into TelegramClient - * calls + channel DB persistence. Daemon modules depend on RasulKharij, - * never on TelegramClient directly. + * Implements the full Rasul interface: outbound messaging, inbound + * routing, and interactive question rendering. + * + * Inbound: Telegram messages/callbacks → normalized RisalaDakhila events. + * Outbound: QanatRisala channels → TelegramClient calls + channel DB. + * Interactive: KhiyarTafauli[] → Telegram inline keyboards. * * Channel resolution: - * "dispatch" → TelegramClient.sendToDispatch() - * "kimyawi" → TelegramClient.arsalaRisala() (private chat) + * "dispatch" → TelegramClient.sendToDispatch() + * "kimyawi" → TelegramClient.arsalaRisala() (private chat) * { murshid: id } → lookup channels table, arsalaIlaMurshidTopic() - * fallback: dispatch with [id] prefix + * fallback: dispatch with [id] prefix */ import { logger } from "../logging/logger.ts"; @@ -20,12 +23,16 @@ import { jalabaQanatsForSession, jalabJalsaByChannel, } from "../../db/db.ts"; -import type { RasulKharij, QanatRisala } from "../types.ts"; +import type { Rasul, RisalaDakhila, QanatRisala, KhiyarTafauli } from "../types.ts"; -/** Re-export for convenience — channel DB functions used by main.ts for inbound routing */ +/** Re-export for convenience */ export { jalabaQanat, jalabaQanatsForSession, jalabJalsaByChannel } from "../../db/db.ts"; -export class TelegramMessenger implements RasulKharij { +/** The provider name used in the qanawat (channels) table */ +export const MUQADDIM = "telegram"; + + +export class TelegramMessenger implements Rasul { #telegram: TelegramClient; /** In-memory cache: provider:channelId → sessionIdentifier (reverse lookup) */ @@ -34,10 +41,37 @@ export class TelegramMessenger implements RasulKharij { /** In-memory cache: sessionIdentifier → Record */ #sessionChannels: Map> = new Map(); + /** Normalized inbound message handler */ + #mualij: ((risala: RisalaDakhila) => Promise) | null = null; + constructor(telegram: TelegramClient) { this.#telegram = telegram; } + // ─── Rasul: inbound lifecycle ────────────────────────────────────────────── + + indaRisala(handler: (risala: RisalaDakhila) => Promise): void { + this.#mualij = handler; + } + + async baddaa(): Promise { + if (!this.mumakkan()) return; + + this.#rabatMualijat(); + + await this.#telegram.startPolling(); + } + + awqaf(): void { + this.#telegram.stopPolling(); + } + + async tahaqqaq(): Promise { + if (!this.mumakkan()) return false; + return await this.#telegram.tahaqqaqToken(); + } + + // ─── Rasul: outbound ─────────────────────────────────────────────────────── mumakkan(): boolean { return this.#telegram.mumakkan(); @@ -87,6 +121,52 @@ export class TelegramMessenger implements RasulKharij { } } + // ─── Rasul: interactive ──────────────────────────────────────────────────── + + async arsalaSualBiKhiyarat( + channel: QanatRisala, + nass: string, + khiyarat: KhiyarTafauli[], + ): Promise { + if (!this.mumakkan()) return null; + + const keyboard = { + inline_keyboard: khiyarat.map((k) => [ + { text: k.nass, callback_data: k.miftah }, + ]), + }; + + if (channel === "dispatch") { + return await this.#telegram.sendToDispatch(nass, { + parseMode: "Markdown", + keyboard, + }); + } + + if (channel === "kimyawi") { + return await this.#telegram.arsalaRisala(nass, { + parseMode: "Markdown", + keyboard, + }); + } + + /** { murshid: id } */ + const topicId = this.#resolveMurshidTopic(channel.murshid); + if (topicId !== null) { + return await this.#telegram.arsalaIlaMurshidTopic(topicId, nass, { + parseMode: "Markdown", + keyboard, + }); + } + + return await this.#telegram.sendToDispatch(`[${channel.murshid}] ${nass}`, { + parseMode: "Markdown", + keyboard, + }); + } + + // ─── Channel management ──────────────────────────────────────────────────── + async khalaqaQanatMurshid(identifier: string, title: string): Promise { if (!this.#telegram.isGroupMode()) { return null; @@ -104,9 +184,9 @@ export class TelegramMessenger implements RasulKharij { const channelId = String(topic.message_thread_id); - haddathaAwAdkhalaQanat(identifier, "telegram", channelId); + haddathaAwAdkhalaQanat(identifier, MUQADDIM, channelId); - this.#cacheChannel(identifier, "telegram", channelId); + this.#cacheChannel(identifier, MUQADDIM, channelId); await logger.akhbar("messenger", `Created Telegram topic for ${identifier}`, { topicId: topic.message_thread_id, @@ -118,21 +198,16 @@ export class TelegramMessenger implements RasulKharij { yamlikQanatMurshid(identifier: string): boolean { /** Check cache first, then DB */ const cached = this.#sessionChannels.get(identifier); - if (cached && cached["telegram"]) return true; + if (cached && cached[MUQADDIM]) return true; - const dbChannel = jalabaQanat(identifier, "telegram"); + const dbChannel = jalabaQanat(identifier, MUQADDIM); if (dbChannel) { - this.#cacheChannel(identifier, "telegram", dbChannel); + this.#cacheChannel(identifier, MUQADDIM, dbChannel); return true; } return false; } - - /** - * Load all channels from DB into cache. Call once at startup. - * Uses jalabaQanatsForSession for each known identifier. - */ hammalQanawatLilJalsa(identifier: string): Record { const channels = jalabaQanatsForSession(identifier); this.#sessionChannels.set(identifier, channels); @@ -142,10 +217,6 @@ export class TelegramMessenger implements RasulKharij { return channels; } - /** - * Reverse lookup: find murshid identifier by provider + channelId. - * Checks cache first, then DB. - */ hallJalsaBilQanat(provider: string, channelId: string): string | null { const cacheKey = `${provider}:${channelId}`; const cached = this.#channelCache.get(cacheKey); @@ -159,25 +230,148 @@ export class TelegramMessenger implements RasulKharij { return null; } + // ─── Inbound routing (Telegram → RisalaDakhila) ─────────────────────────── + /** - * Get the underlying TelegramClient for inbound operations - * (polling, callbacks, message routing). Only main.ts should use this. + * Wire up Telegram onMessage / onCallback to emit normalized events. + * This is the logic that used to live in main.ts addaMualijatTelegram(). */ - get client(): TelegramClient { - return this.#telegram; + #rabatMualijat(): void { + this.#telegram.onMessage(async (message) => { + if (!message.text || !this.#mualij) return; + + const text = message.text.trim(); + const topicId = this.#telegram.jalabRisalaTopicId(message); + const isGroupMessage = this.#telegram.isGroupMessage(message); + const isPrivateMessage = this.#telegram.isPrivateMessage(message); + const isDispatchTopic = this.#telegram.isDispatchTopic(message); + + await logger.akhbar("messenger", `Received: ${text.slice(0, 100)}`, { + topicId, + isGroupMessage, + isPrivateMessage, + isDispatchTopic, + }); + + /** Private chat → khass */ + if (isPrivateMessage) { + await this.#mualij({ naw: "khass" }); + return; + } + + if (!isGroupMessage) { + await logger.haDHHir("messenger", "Message from unknown chat type"); + return; + } + + /** Dispatch topic → irsal or amr */ + if (isDispatchTopic) { + if (text.startsWith("/")) { + const [command, ...args] = text.slice(1).split(" "); + await this.#mualij({ naw: "amr", amr: command.toLowerCase(), wusut: args }); + } else { + await this.#mualij({ naw: "irsal", nass: text, huwiyyatRisala: message.message_id }); + } + return; + } + + /** Murshid topic → murshid message */ + if (topicId) { + const huwiyya = this.hallJalsaBilQanat(MUQADDIM, String(topicId)); + if (huwiyya) { + await this.#mualij({ naw: "murshid", huwiyya, nass: text }); + } else if (topicId === 1) { + /** General topic — not linked */ + await this.#telegram.arsalaRisala( + "Use the **Dispatch** topic to send Linear URLs and spawn murshids.", + { topicId: 1, chatId: this.#telegram.getGroupId(), parseMode: "Markdown" }, + ); + } else { + await this.#telegram.arsalaRisala( + "This topic is not linked to an active murshid.", + { topicId, chatId: this.#telegram.getGroupId() }, + ); + } + return; + } + + await logger.haDHHir("messenger", "Group message without topic ID"); + }); + + this.#telegram.onCallback(async (query) => { + if (!query.data) { + await this.#telegram.answerCallback(query.id, "Received!"); + return; + } + + await logger.akhbar("messenger", `Callback: ${query.data}`); + + /** Question button → jawab_sual or idkhal_khass_sual */ + if (query.data.startsWith("q:")) { + const parts = query.data.split(":"); + if (parts.length >= 3) { + const shortId = parts[1]; + const selectedLabel = parts.slice(2).join(":"); + + if (selectedLabel === "__custom__") { + /** Need to resolve murshid from topic for custom input */ + const topicId = query.message?.message_thread_id; + const huwiyya = topicId + ? this.hallJalsaBilQanat(MUQADDIM, String(topicId)) + : null; + + if (huwiyya && this.#mualij) { + await this.#mualij({ + naw: "idkhal_khass_sual", + huwiyyatMurshid: huwiyya, + huwiyyatSual: `short:${shortId}`, + }); + await this.#telegram.answerCallback(query.id, "Type your answer as a reply..."); + } else { + await this.#telegram.answerCallback(query.id, "Cannot resolve murshid for custom input"); + } + } else if (this.#mualij) { + await this.#mualij({ + naw: "jawab_sual", + huwiyyatSual: `short:${shortId}`, + taamiyya: selectedLabel, + }); + await this.#telegram.answerCallback(query.id, `Selected: ${selectedLabel}`); + } + } + return; + } + + /** Munadi buttons (select/parent/switch/cancel) */ + if ( + query.data.startsWith("select:") || + query.data.startsWith("parent:") || + query.data.startsWith("switch:") || + query.data === "cancel" + ) { + if (this.#mualij) { + await this.#mualij({ naw: "ikhtiyar_munadi", miftah: query.data }); + } + await this.#telegram.answerCallback(query.id, "Received!"); + return; + } + + await this.#telegram.answerCallback(query.id, "Received!"); + }); } + // ─── Private helpers ─────────────────────────────────────────────────────── #resolveMurshidTopic(identifier: string): number | null { /** Check cache */ const cached = this.#sessionChannels.get(identifier); - if (cached?.["telegram"]) { - return parseInt(cached["telegram"], 10); + if (cached?.[MUQADDIM]) { + return parseInt(cached[MUQADDIM], 10); } - const dbChannel = jalabaQanat(identifier, "telegram"); + const dbChannel = jalabaQanat(identifier, MUQADDIM); if (dbChannel) { - this.#cacheChannel(identifier, "telegram", dbChannel); + this.#cacheChannel(identifier, MUQADDIM, dbChannel); return parseInt(dbChannel, 10); } diff --git a/src/types.ts b/src/types.ts index bfa3e5c..579c186 100644 --- a/src/types.ts +++ b/src/types.ts @@ -321,7 +321,8 @@ export interface SualMuallaq { sessionID: string; huwiyyatMurshid: string; questions: MaalumatSual[]; - telegramMessageId?: number; + /** Provider message ID (for editing/referencing the sent question) */ + huwiyyatRisalaMuqaddim?: number; createdAt: string; } @@ -657,6 +658,61 @@ export interface RasulKharij { } +// ─── Inbound transport abstraction ──────────────────────────────────────────── + +/** + * Normalized inbound message — what the transport emits after + * converting provider-specific events (Telegram messages, CLI input, etc.). + */ +export type RisalaDakhila = + | { naw: "murshid"; huwiyya: string; nass: string } + | { naw: "irsal"; nass: string; huwiyyatRisala?: number } + | { naw: "amr"; amr: string; wusut: string[] } + | { naw: "jawab_sual"; huwiyyatSual: string; taamiyya: string } + | { naw: "idkhal_khass_sual"; huwiyyatMurshid: string; huwiyyatSual: string } + | { naw: "ikhtiyar_munadi"; miftah: string } + | { naw: "khass" }; + +/** + * Abstract interactive option — the transport renders these + * in its native format (Telegram inline keyboard, CLI numbered list, etc.). + */ +export interface KhiyarTafauli { + /** Display text */ + nass: string; + /** Callback key returned on selection */ + miftah: string; +} + +/** + * Full messenger interface — extends RasulKharij with inbound lifecycle + * and interactive rendering. This is what main.ts depends on. + */ +export interface Rasul extends RasulKharij { + /** Start listening for inbound messages */ + baddaa(): Promise; + + /** Stop listening */ + awqaf(): void; + + /** Register handler for normalized inbound messages */ + indaRisala(handler: (risala: RisalaDakhila) => Promise): void; + + /** + * Send a question with interactive options. + * Returns a provider message ID for later reference (editing, etc.), or null. + */ + arsalaSualBiKhiyarat( + channel: QanatRisala, + nass: string, + khiyarat: KhiyarTafauli[], + ): Promise; + + /** Validate connectivity (token check, health probe, etc.) */ + tahaqqaq(): Promise; +} + + /** Murshid status for control handover */ export type HalatMurshid = "sakin" | "fail" | "masdud" | "muntazir"; From adc696f4caea901759f7c829cb964f3941598d84 Mon Sep 17 00:00:00 2001 From: Adil Shaikh Date: Mon, 24 Aug 2026 14:48:30 +0000 Subject: [PATCH 2/7] refactor: iksir nestles at hum, drops the agent runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iksir no longer drives OpenCode. It connects to a humd as a forager bee over the thrum socket and prompts whichever hive al-Kimyawi has kindled. The choice of what burns in the furnace was never Iksir's to make. Substrate, replaced: src/hum/thrum.ts NDJSON unix-socket client, hello + reconnect src/hum/identity.ts stable fbee_ hid from a persisted ed25519 seed src/hum/client.ts AmilHum — the same surface the khuddam always used khalaqaJalsa -> mint a sid locally (jalasat were always ours) sendPrompt -> chi:"prompt" -> chunk* -> finish abortSession -> chi:"cancel" mahaqaJalsa -> chi:"cleanup" summarizeSession -> chi:"curate" (no provider, no hardcoded model) replyToQuestion -> chi:"release-permit" question.asked <- chi:"permission-ask" Two laws taken from humd's source, both load-bearing: I. Manifests are volatile — humd clears them on restart and prunes on disconnect. The hello therefore rides every connection, not just the first, or Iksir stays nestled but unrouteable. II. Iksir must never declare bee:["worker"]. humd re-broadcasts worker output onto the sid sigil, and Iksir is the bee that claimed it. Tool calls come off the wire now instead of a poll. humd routes chi:"tool-call" by name to whichever manifest declares it; the 24 mun_* bodies are unchanged and still inscribe their hadath into the sijill, so Munaffidh keeps draining a durable table. The journal stays the record; the thrum is only the road. Deleted: src/opencode/client.ts, plugins/iksir.ts, the @opencode-ai/sdk dependency, the iksir-agent systemd unit, and the sync of prompts and plugins into OpenCode's config dirs. The ruqan are Iksir's own — read from prompts/ and sent as systemPrompt, since nothing attaches "agents" by name any more. config: opencode.server -> hum.{miqbas,namudhaj}, both optional. The socket is discovered (HUM_THRUM_SOCK -> HUM_SOCKET -> humd's runtime.json -> state dir), and the model is chosen by kindling a hive, not by configuring Iksir. Note: two test files carried uncommitted edits from before this work and are folded in here, since the rename had to reach them for the build to pass. 143 tests pass. Adds Orchfile so `hum hive install` can supervise Iksir. --- .env.example | 13 +- Orchfile | 3 + README.md | 18 +- deno.json | 1 - iksir.json.example | 4 +- iksir.schema.json | 14 +- install | 77 ++-- plugins/iksir.ts | 216 ---------- src/cli.ts | 16 +- src/config.test.ts | 27 +- src/config.ts | 15 +- src/constants.ts | 7 +- src/daemon/arraf.ts | 16 +- src/daemon/katib.ts | 48 +-- src/daemon/mumayyiz.test.ts | 36 +- src/daemon/mumayyiz.ts | 10 +- src/daemon/munaffidh.ts | 10 +- src/daemon/raqib.ts | 20 +- src/daemon/saail.test.ts | 98 ++--- src/daemon/saail.ts | 26 +- src/hum/client.ts | 581 ++++++++++++++++++++++++++ src/hum/identity.ts | 65 +++ src/hum/thrum.test.ts | 164 ++++++++ src/hum/thrum.ts | 305 ++++++++++++++ src/init.ts | 36 +- src/main.ts | 91 +++-- src/mcp/http-transport.ts | 4 +- src/notifications/messenger.test.ts | 7 - src/opencode/client.ts | 606 ---------------------------- src/test-helpers.ts | 34 +- src/types.ts | 22 +- tests/smoke.test.ts | 46 +-- 32 files changed, 1476 insertions(+), 1160 deletions(-) create mode 100644 Orchfile delete mode 100644 plugins/iksir.ts create mode 100644 src/hum/client.ts create mode 100644 src/hum/identity.ts create mode 100644 src/hum/thrum.test.ts create mode 100644 src/hum/thrum.ts delete mode 100644 src/opencode/client.ts diff --git a/.env.example b/.env.example index b5b4cf0..b657e39 100644 --- a/.env.example +++ b/.env.example @@ -18,11 +18,18 @@ # IKSIR_LOG_DIR=~/.local/state/iksir # ============================================================================= -# Agent Runtime (OpenCode) +# The Nest (hum) # ============================================================================= -# OpenCode server URL (default: http://localhost:4096) -# IKSIR_OPENCODE_SERVER=http://localhost:4096 +# Iksir runs no models. It nestles at a humd and prompts whichever hive you +# have kindled there. Both settings below are optional. + +# Explicit thrum socket. Discovered if unset: +# HUM_THRUM_SOCK -> HUM_SOCKET -> humd's runtime.json -> $XDG_STATE_HOME/hum/thrum.sock +# HUM_THRUM_SOCK=/run/user/1000/hum/thrum.sock + +# Model to name on each prompt. Unset = the nest decides. +# IKSIR_HUM_MODEL=claude-sonnet-4-6 # MCP server port (default: 3100) # IKSIR_MCP_PORT=3100 diff --git a/Orchfile b/Orchfile new file mode 100644 index 0000000..9f0bfaf --- /dev/null +++ b/Orchfile @@ -0,0 +1,3 @@ +SERVICE iksir +RUN ${HOME}/.deno/bin/deno run --allow-all --env=${HOME}/.local/share/iksir/src/.env ${HOME}/.local/share/iksir/src/src/main.ts +RESTART always diff --git a/README.md b/README.md index eea6588..3ef3e66 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,23 @@ The modern world has forgotten the scent of real alchemy - the sharp bite of sul curl -fsSL https://raw.githubusercontent.com/adiled/iksir/main/install | bash ``` -Prerequisites: [Deno](https://deno.com), [OpenCode](https://opencode.ai) +Prerequisites: [Deno](https://deno.com), and a [hum](https://github.com/adiled/hum) nest. -The kindling ritual clones the source, creates XDG directories, copies sacred templates, installs the `iksir` CLI to `~/.local/bin/`, registers daemon services, and consecrates agent incantations. Edit `~/.local/share/iksir/src/.env` to bind your keys, then `iksir start`. +**Iksīr kindles no furnace of its own.** It nestles at a `humd` as a forager bee and prompts whatever hive you have chosen. What burns there is yours to decide — that choice was never Iksīr's to make. It asks only two things of al-Kimyawi: + +- a **worker** bee nestled in the same nest, so there is something to prompt +- something in that nest providing **`fs`** — [`humfs`](https://github.com/adiled/hum/tree/main/hives/humfs), or a worker that carries its own tools + +A murshid without `fs` can contemplate the runūz but never inscribe them. + +```bash +hum hive humfs install # the filesystem surface +hum hive install # whatever you would have think +``` + +The kindling ritual clones the source, creates XDG directories, copies sacred templates, installs the `iksir` CLI to `~/.local/bin/`, registers daemon services, and consecrates the ruqan. Edit `~/.local/share/iksir/src/.env` to bind your keys, then `iksir start`. + +Iksīr can also be supervised as a bee in its own right — an `Orchfile` sits at the root, so `hum hive ~/iksir install` hands it to orchd. ```bash iksir divine # divine the state of the Great Work diff --git a/deno.json b/deno.json index 72ef4cf..0d66c13 100644 --- a/deno.json +++ b/deno.json @@ -20,7 +20,6 @@ "@std/": "jsr:@std/", "@std/assert": "jsr:@std/assert@^1", "@std/testing": "jsr:@std/testing@^1", - "@opencode/sdk": "npm:@opencode-ai/sdk@^1.2.10", "@db/sqlite": "jsr:@db/sqlite@^0.12.0" }, "compilerOptions": { diff --git a/iksir.json.example b/iksir.json.example index 10ff52b..047006b 100644 --- a/iksir.json.example +++ b/iksir.json.example @@ -33,7 +33,5 @@ "repo": "your-repo", "operatorUsername": "your-github-username" }, - "opencode": { - "server": "http://localhost:4096" - } + "hum": {} } diff --git a/iksir.schema.json b/iksir.schema.json index f00a4c9..ea1e286 100644 --- a/iksir.schema.json +++ b/iksir.schema.json @@ -177,15 +177,17 @@ }, "additionalProperties": false }, - "opencode": { + "hum": { "type": "object", - "description": "Agent runtime connection.", + "description": "The nest Iksir nestles at. Both fields are optional: the socket is discovered, and the model is chosen by kindling a hive.", "properties": { - "server": { + "miqbas": { "type": "string", - "description": "OpenCode server URL.", - "format": "uri", - "default": "http://localhost:4096" + "description": "Explicit thrum socket path. Omit to discover via HUM_THRUM_SOCK, HUM_SOCKET, humd's runtime.json, then $XDG_STATE_HOME/hum/thrum.sock." + }, + "namudhaj": { + "type": "string", + "description": "Model id to name on each prompt. Omit to let the nest decide." } }, "additionalProperties": false diff --git a/install b/install index 7f551e6..5256284 100755 --- a/install +++ b/install @@ -14,7 +14,6 @@ BIN_DIR="$HOME/.local/bin" IKSIR_REPO="https://github.com/adiled/iksir.git" IKSIR_SRC="$IKSIR_DATA/src" -AGENT_PORT="${IKSIR_AGENT_PORT:-4096}" MCP_PORT="${IKSIR_MCP_PORT:-3100}" OS="$(uname -s)" @@ -34,8 +33,14 @@ check_prereqs() { DENO_BIN="${DENO_BIN:-$(command -v deno 2>/dev/null || echo "$HOME/.deno/bin/deno")}" [ -x "$DENO_BIN" ] || die "deno required — https://docs.deno.com/runtime/getting_started/installation/" - OPENCODE_BIN="${OPENCODE_BIN:-$(command -v opencode 2>/dev/null || echo "$HOME/.opencode/bin/opencode")}" - [ -x "$OPENCODE_BIN" ] || die "opencode required — https://opencode.ai" + # Iksir runs no models. It nestles at a humd and prompts whichever hive is + # kindled there. humd need not be running yet, but nothing works without one. + THRUM_SOCK="${HUM_THRUM_SOCK:-${HUM_SOCKET:-${XDG_STATE_HOME:-$HOME/.local/state}/hum/thrum.sock}}" + if [ ! -S "$THRUM_SOCK" ]; then + warn "no humd at $THRUM_SOCK" + warn "install one — https://github.com/adiled/hum — then kindle a worker hive" + warn "and something providing 'fs', or the murshid can think but not work." + fi } ensure_dirs() { @@ -99,19 +104,15 @@ sync_config() { fi } -sync_opencode() { - local AGENT_DIR="$XDG_CONFIG_HOME/opencode/agent" - local PLUGIN_DIR="$XDG_CONFIG_HOME/opencode/plugins" - mkdir -p "$AGENT_DIR" "$PLUGIN_DIR" - - # Symlink agent prompts (only iksir-* are OC agents, mayyaza-* are internal) - for file in "$IKSIR_SRC"/prompts/iksir-*.md; do - [ -f "$file" ] && ln -sf "$file" "$AGENT_DIR/$(basename "$file")" +sync_ruqan() { + # The ruqan are Iksir's own now — read from prompts/ and sent as the + # systemPrompt on each tone. No foreign config dir holds them. + local RUQAN_DIR="$IKSIR_CONFIG/prompts" + mkdir -p "$RUQAN_DIR" + for file in "$IKSIR_SRC"/prompts/*.md; do + [ -f "$file" ] && ln -sf "$file" "$RUQAN_DIR/$(basename "$file")" done - - # Symlink the plugin - ln -sf "$IKSIR_SRC/plugins/iksir.ts" "$PLUGIN_DIR/iksir.ts" - info "linked opencode agents and plugin" + info "linked ruqan" } install_cli() { @@ -183,25 +184,6 @@ Restart=on-failure RestartSec=5 $ENV_VARS -[Install] -WantedBy=$WM -EOF - - # Agent runtime - cat > "$SERVICE_DIR/iksir-agent.service" < "$SERVICE_DIR/iksir.service" </dev/null + $SC enable iksir-mcp iksir 2>/dev/null info "registered systemd services" } @@ -234,7 +216,7 @@ start_daemon() { local SC="systemctl" $IS_ROOT || SC="systemctl --user" - $SC restart iksir-mcp iksir-agent iksir + $SC restart iksir-mcp iksir sleep 2 if $SC is-active --quiet iksir; then info "running" @@ -246,7 +228,7 @@ start_daemon() { stop_daemon() { local SC="systemctl" $IS_ROOT || SC="systemctl --user" - $SC stop iksir iksir-agent iksir-mcp 2>/dev/null || true + $SC stop iksir iksir-mcp 2>/dev/null || true } # ─── commands ──────────────────────────────────────────────────────────────── @@ -261,10 +243,10 @@ print_status() { echo "" if $IS_ROOT; then echo " logs: journalctl -u iksir -f" - echo " stop: systemctl stop iksir iksir-agent iksir-mcp" + echo " stop: systemctl stop iksir iksir-mcp" else echo " logs: journalctl --user -u iksir -f" - echo " stop: systemctl --user stop iksir iksir-agent iksir-mcp" + echo " stop: systemctl --user stop iksir iksir-mcp" fi echo "" echo " First time? Edit $IKSIR_SRC/.env then: iksir start" @@ -277,7 +259,7 @@ cmd_install() { ensure_dirs ensure_source sync_config - sync_opencode + sync_ruqan install_cli info "registering services..." @@ -296,7 +278,7 @@ cmd_update() { stop_daemon sync_config - sync_opencode + sync_ruqan install_cli register_services @@ -319,7 +301,7 @@ cmd_uninstall() { SERVICE_DIR="$HOME/.config/systemd/user" fi - $SC disable iksir iksir-agent iksir-mcp 2>/dev/null || true + $SC disable iksir iksir-mcp 2>/dev/null || true rm -f "$SERVICE_DIR"/iksir*.service $SC daemon-reload 2>/dev/null || true info "removed systemd services" @@ -327,11 +309,6 @@ cmd_uninstall() { rm -f "$BIN_DIR/iksir" info "removed CLI" - # Remove opencode symlinks - local AGENT_DIR="$XDG_CONFIG_HOME/opencode/agent" - local PLUGIN_DIR="$XDG_CONFIG_HOME/opencode/plugins" - rm -f "$AGENT_DIR"/iksir-*.md "$PLUGIN_DIR/iksir.ts" 2>/dev/null || true - info "removed opencode symlinks" echo "" info "uninstalled. data left in place:" @@ -354,10 +331,8 @@ case "$CMD" in echo " uninstall — stop, remove services, remove CLI" echo "" echo "Environment:" - echo " IKSIR_AGENT_PORT Agent port (default: 4096)" echo " IKSIR_MCP_PORT MCP port (default: 3100)" echo " DENO_BIN Path to deno binary" - echo " OPENCODE_BIN Path to opencode binary" exit 1 ;; esac diff --git a/plugins/iksir.ts b/plugins/iksir.ts deleted file mode 100644 index 5e16acd..0000000 --- a/plugins/iksir.ts +++ /dev/null @@ -1,216 +0,0 @@ -/** - * Iksīr Plugin for OpenCode - * - * The single integration point between Iksīr and OpenCode. - * - * Hooks: - * - experimental.session.compacting — Preserves murshid identity, diary - * decisions, and architectural context across compaction cycles - * - event — Forwards session events to the Iksīr daemon via the ahdath - * (events) table in SQLite - * - * Runs inside OpenCode's Bun process. Reads Iksīr's SQLite DB directly - * via bun:sqlite. - */ - -import type { Plugin, PluginModule } from "@opencode-ai/plugin" -import { Database } from "bun:sqlite" - -// ─── DB path resolution ───────────────────────────────────────────────────── - -function resolvDbPath(): string { - const explicit = process.env.IKSIR_STATE_DIR - if (explicit) return `${explicit}/iksir.sqlite` - const xdg = process.env.XDG_DATA_HOME ?? `${process.env.HOME ?? "/root"}/.local/share` - return `${xdg}/iksir/iksir.sqlite` -} - -function openDb(): Database | null { - try { - return new Database(resolvDbPath(), { readonly: true }) - } catch { - return null - } -} - -function openDbRw(): Database | null { - try { - return new Database(resolvDbPath()) - } catch { - return null - } -} - -// ─── DB queries ───────────────────────────────────────────────────────────── - -interface SaffJalsa { - huwiyya: string - unwan: string | null - far: string | null - hala: string | null -} - -interface SaffQarar { - naw: string - qarar: string - mantiq: string - unshia_fi: string -} - -function hallaJalsa(db: Database, sessionId: string): SaffJalsa | null { - try { - return db - .prepare( - `SELECT huwiyya, unwan, far, hala - FROM jalasat - WHERE id = ? - LIMIT 1`, - ) - .get(sessionId) as SaffJalsa | null - } catch { - return null - } -} - -function qaraaQararat(db: Database, huwiyyatMurshid: string): SaffQarar[] { - try { - return db - .prepare( - `SELECT naw, qarar, mantiq, unshia_fi - FROM qararat - WHERE huwiyat_murshid = ? - ORDER BY unshia_fi DESC - LIMIT 30`, - ) - .all(huwiyyatMurshid) as SaffQarar[] - } catch { - return [] - } -} - -// ─── Formatters ───────────────────────────────────────────────────────────── - -function rattabaQararat(entries: SaffQarar[]): string { - return entries - .map( - (e) => - `[${e.naw.toUpperCase()}] ${e.unshia_fi}\n Qarar: ${e.qarar}\n Sabab: ${e.mantiq}`, - ) - .join("\n\n") -} - -function qawaidTahattub(huwiyyatMurshid: string): string { - return `### Qawā'id al-Hifẓ - -When constructing the compaction summary, you MUST: - -1. Include the murshid ID (**${huwiyyatMurshid}**) -2. Include which waṣfāt are done, in-progress, or masdūda -3. Include istihal state — which ahjār have been transmuted into jawāhir vs still in the būṭaqa -4. Include the current būṭaqa branch and its relationship to the kitāb -5. Include all blocking qarārāt and their sabab -6. Include the instruction: "Use mun_radd to respond to Iksīr" -7. Include the instruction: "Call mun_qaraa_mudawwana after compaction to reload full qarar history" -8. Preserve any constraints or architectural qarārāt from the entries above` -} - -const STATIC_RULES = `## Iksīr Murshid — Compaction Rules - -This is an Iksīr murshid session. When summarizing, ALWAYS preserve: - -1. The murshid ID -2. Which waṣfāt are done, in-progress, or masdūda -3. Istihāl state — ahjār transmuted into jawāhir vs still in būṭaqa -4. Būṭaqa branch and kitāb relationship -5. Key architectural qarārāt and WHY they were made (sabab) -6. The instruction: "Use mun_radd to respond — text output is invisible to Iksīr" -7. The instruction: "Call mun_qaraa_mudawwana to reload qarar history after compaction"` - -// ─── Event forwarding ─────────────────────────────────────────────────────── - -function writeHadath(naw: string, ada: string, humulat: Record): void { - const db = openDbRw() - if (!db) return - try { - const id = crypto.randomUUID() - db.prepare( - `INSERT INTO ahdath (id, naw, ada, humulat, unshia_fi) - VALUES (?, ?, ?, ?, ?)`, - ).run(id, naw, ada, JSON.stringify(humulat), new Date().toISOString()) - } catch { - // DB may not have the table yet if daemon hasn't initialized - } finally { - db.close() - } -} - -// ─── Plugin ───────────────────────────────────────────────────────────────── - -const iksirPlugin: Plugin = async (_ctx) => { - return { - "experimental.session.compacting": async (input, output) => { - const db = openDb() - if (!db) { - output.context.push(STATIC_RULES) - return - } - - try { - const jalsa = hallaJalsa(db, input.sessionID) - if (!jalsa) { - db.close() - output.context.push(STATIC_RULES) - return - } - - const huwiyyatMurshid = jalsa.huwiyya - const entries = qaraaQararat(db, huwiyyatMurshid) - db.close() - - const parts: string[] = [] - - parts.push(`## Iksīr Murshid Context - -This is an Iksīr murshid session managing a kitāb. - -- **Murshid ID**: ${huwiyyatMurshid}${jalsa.unwan ? `\n- **Kitāb Title**: ${jalsa.unwan}` : ""}${jalsa.far ? `\n- **Branch**: ${jalsa.far}` : ""}${jalsa.hala ? `\n- **Hāla**: ${jalsa.hala}` : ""}`) - - if (entries.length > 0) { - parts.push(`### Mudawwana Qarārāt - -These qarārāt represent key architectural, strategic, and risāla choices made during -this kitāb. They CANNOT be reconstructed from runūz alone. The compaction summary -MUST include these or they will be permanently lost: - -${rattabaQararat(entries)}`) - } - - parts.push(qawaidTahattub(huwiyyatMurshid)) - - output.context.push(parts.join("\n\n")) - } catch { - db?.close() - output.context.push(STATIC_RULES) - } - }, - - event: async ({ event }) => { - const etype = (event as any).type - const props = (event as any).properties ?? {} - - if (etype === "session.compacted") { - const sid = props.sessionID - if (sid) { - writeHadath("opencode", "session.compacted", { sessionId: sid }) - } - } - }, - } -} - -// ─── V1 module export ─────────────────────────────────────────────────────── - -export default { - id: "iksir", - server: iksirPlugin, -} satisfies PluginModule diff --git a/src/cli.ts b/src/cli.ts index 27fc89c..5d2d808 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -185,11 +185,9 @@ async function cmdCheck(): Promise { async function cmdSync(): Promise { const repoPath = Deno.env.get("IKSIR_REPO_PATH") ?? Deno.cwd(); const home = Deno.env.get("HOME") ?? "."; - const agentDir = join(home, ".config", "opencode", "agent"); - const pluginDir = join(home, ".config", "opencode", "plugins"); + const agentDir = join(home, ".config", "iksir", "prompts"); await Deno.mkdir(agentDir, { recursive: true }); - await Deno.mkdir(pluginDir, { recursive: true }); let synced = 0; const promptsDir = join(repoPath, "prompts"); @@ -205,18 +203,6 @@ async function cmdSync(): Promise { console.log(" No prompts directory found"); } - const pluginsDir = join(repoPath, "plugins"); - try { - for await (const entry of Deno.readDir(pluginsDir)) { - if (entry.isFile && entry.name.endsWith(".ts")) { - await Deno.copyFile(join(pluginsDir, entry.name), join(pluginDir, entry.name)); - console.log(` synced plugin: ${entry.name}`); - synced++; - } - } - } catch { - } - console.log(`\nSynced ${synced} file(s).`); } diff --git a/src/config.test.ts b/src/config.test.ts index ea76179..ed965fa 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -2,9 +2,8 @@ import { assertEquals } from "@std/assert"; import { hammalaAlTasmim } from "./config.ts"; import { join } from "jsr:@std/path"; import { - TEST_OPENCODE_URL, - TEST_OPENCODE_URL_ALT, - DEFAULT_OPENCODE_SERVER as DEFAULT_OPENCODE_URL, + TEST_HUM_MODEL, + TEST_HUM_MODEL_ALT, TEST_PROXY_URL } from "./constants.ts"; @@ -12,7 +11,7 @@ import { /** Env vars we might set during tests — saved/istarjaad around each test */ const ENV_KEYS = [ "IKSIR_CONFIG_DIR", - "IKSIR_OPENCODE_SERVER", + "IKSIR_HUM_MODEL", "LINEAR_API_KEY", "TELEGRAM_BOT_TOKEN", "TELEGRAM_CHAT_ID", @@ -63,7 +62,7 @@ async function withTestConfig( Deno.test("config: defaults when no config file exists", async () => { await withTestConfig(null, {}, (config) => { - assertEquals(config.opencode.server, DEFAULT_OPENCODE_URL); + assertEquals(config.hum.namudhaj, undefined); assertEquals(config.istiftaa.fajwatZamaniyya, 300000); assertEquals(config.istiftaa.fajwatRaqabaRisala, 60000); assertEquals(config.saatSukun.mufattah, true); @@ -84,23 +83,23 @@ Deno.test("config: loads values from JSON", async () => { bidaya: "23:00", nihaya: "08:00", }, - opencode: { - server: TEST_OPENCODE_URL + hum: { + namudhaj: TEST_HUM_MODEL }, }); await withTestConfig(json, {}, (config) => { assertEquals(config.saatSukun.mintaqaZamaniyya, "Asia/Karachi"); assertEquals(config.saatSukun.bidaya, "23:00"); - assertEquals(config.opencode.server, TEST_OPENCODE_URL); + assertEquals(config.hum.namudhaj, TEST_HUM_MODEL); assertEquals(config.saatSukun.mufattah, true); assertEquals(config.istiftaa.fajwatZamaniyya, 300000); }); }); -Deno.test("config: IKSIR_OPENCODE_SERVER env override", async () => { - await withTestConfig(null, { IKSIR_OPENCODE_SERVER: TEST_OPENCODE_URL }, (config) => { - assertEquals(config.opencode.server, TEST_OPENCODE_URL); +Deno.test("config: IKSIR_HUM_MODEL env override", async () => { + await withTestConfig(null, { IKSIR_HUM_MODEL: TEST_HUM_MODEL }, (config) => { + assertEquals(config.hum.namudhaj, TEST_HUM_MODEL); }); }); @@ -132,10 +131,10 @@ Deno.test("config: NTFY_TOPIC enables ntfy", async () => { Deno.test("config: env overrides take precedence over JSON", async () => { const json = JSON.stringify({ - opencode: { server: TEST_OPENCODE_URL } + hum: { namudhaj: TEST_HUM_MODEL } }); - await withTestConfig(json, { IKSIR_OPENCODE_SERVER: TEST_OPENCODE_URL_ALT }, (config) => { - assertEquals(config.opencode.server, TEST_OPENCODE_URL_ALT); + await withTestConfig(json, { IKSIR_HUM_MODEL: TEST_HUM_MODEL_ALT }, (config) => { + assertEquals(config.hum.namudhaj, TEST_HUM_MODEL_ALT); }); }); diff --git a/src/config.ts b/src/config.ts index 27159f9..d0d27f3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -9,7 +9,7 @@ import { join } from "jsr:@std/path"; import { exists } from "jsr:@std/fs"; import type { TasmimIksir } from "./types.ts"; import { logger } from "./logging/logger.ts"; -import { DEFAULT_OPENCODE_SERVER, DEFAULT_NTFY_SERVER } from "./constants.ts"; +import { DEFAULT_NTFY_SERVER } from "./constants.ts"; const DEFAULT_POLL_INTERVAL_MS = 300000; const DEFAULT_PR_POLL_INTERVAL_MS = 60000; @@ -89,9 +89,7 @@ function tasmimAsasi(): TasmimIksir { makhzan: "", ismKimyawi: "", }, - opencode: { - server: DEFAULT_OPENCODE_SERVER, - }, + hum: {}, hafazat: {}, }; } @@ -125,9 +123,8 @@ function deepMerge(target: TasmimIksir, source: Partial): TasmimIks function tahaqqaqConfig(config: TasmimIksir): string[] { const errors: string[] = []; - if (!config.opencode.server) { - errors.push("opencode.server is required"); - } + // Nothing to require of hum: the socket is discovered, and the model is + // al-Kimyawi's to choose by kindling a hive — not Iksir's to demand. if (config.isharat.telegram.mufattah) { if (!config.isharat.telegram.ramzBot) { @@ -186,8 +183,8 @@ export async function hammalaAlTasmim(): Promise { /** Override with environment variables */ const envOverrides: Partial = {}; - if (Deno.env.get("IKSIR_OPENCODE_SERVER")) { - envOverrides.opencode = { server: Deno.env.get("IKSIR_OPENCODE_SERVER")! }; + if (Deno.env.get("IKSIR_HUM_MODEL")) { + envOverrides.hum = { ...config.hum, namudhaj: Deno.env.get("IKSIR_HUM_MODEL")! }; } if (Deno.env.get("LINEAR_API_KEY")) { envOverrides.mutabiWasfa = { ...config.mutabiWasfa, miftahApi: Deno.env.get("LINEAR_API_KEY")! }; diff --git a/src/constants.ts b/src/constants.ts index 0bd9114..586d8fd 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -10,12 +10,11 @@ export const PROTOCOL_HTTPS = "https://"; export const PROTOCOL_SOCKS5 = "socks5://"; /* Default Server URLs */ -export const DEFAULT_OPENCODE_SERVER = "http://localhost:5173"; export const DEFAULT_NTFY_SERVER = "https://ntfy.sh"; -/* Test URLs */ -export const TEST_OPENCODE_URL = "http://localhost:5000"; -export const TEST_OPENCODE_URL_ALT = "http://localhost:6000"; +/* Test values */ +export const TEST_HUM_MODEL = "test-model"; +export const TEST_HUM_MODEL_ALT = "test-model-alt"; export const TEST_PROXY_URL = "socks5://localhost:1080"; /* API Endpoints */ diff --git a/src/daemon/arraf.ts b/src/daemon/arraf.ts index e66ffe0..90ec67a 100644 --- a/src/daemon/arraf.ts +++ b/src/daemon/arraf.ts @@ -21,7 +21,7 @@ */ import { logger } from "../logging/logger.ts"; -import type { OpenCodeClient } from "../opencode/client.ts"; +import type { AmilHum } from "../hum/client.ts"; import type { SiyaqMuhadatha } from "./munadi.ts"; import type { MutabiWasfa, NawKiyan, WasfaMutaba } from "../types.ts"; @@ -114,12 +114,12 @@ const KALIMAT_NAW: Record = { export class Arraf { #mutabiWasfa: MutabiWasfa; - #opencode: OpenCodeClient; + #amil: AmilHum; #huwiyyatJalsatNiyya: string | null = null; - constructor(deps: { mutabiWasfa: MutabiWasfa; opencode: OpenCodeClient }) { + constructor(deps: { mutabiWasfa: MutabiWasfa; amil: AmilHum }) { this.#mutabiWasfa = deps.mutabiWasfa; - this.#opencode = deps.opencode; + this.#amil = deps.amil; } /** @@ -392,7 +392,7 @@ MESSAGE: "${nassKham}"${siyaqNass} ${Arraf.TAWJIHAT_NIZAM_NIYYA}`; - const radd = await this.#opencode.sendPrompt(jalsaId, talabOracle, { + const radd = await this.#amil.sendPrompt(jalsaId, talabOracle, { system: Arraf.TAWJIHAT_NIZAM_NIYYA, model: { providerID: "anthropic", modelID: "claude-sonnet-4-20250514" }, timeoutMs: 15_000, @@ -453,12 +453,12 @@ ${Arraf.TAWJIHAT_NIZAM_NIYYA}`; */ async wajadaJalsatNiyya(): Promise { if (this.#huwiyyatJalsatNiyya) { - const jalsa = await this.#opencode.jalabJalsa(this.#huwiyyatJalsatNiyya); + const jalsa = await this.#amil.jalabJalsa(this.#huwiyyatJalsatNiyya); if (jalsa) return this.#huwiyyatJalsatNiyya; this.#huwiyyatJalsatNiyya = null; } - const jalsa = await this.#opencode.khalaqaJalsa( + const jalsa = await this.#amil.khalaqaJalsa( "iksir-arraf", "Arraf — vessel for divination (reusable)", ); @@ -667,7 +667,7 @@ ${Arraf.TAWJIHAT_NIZAM_NIYYA}`; */ export function istadaaArraf(deps: { mutabiWasfa: MutabiWasfa; - opencode: OpenCodeClient; + amil: AmilHum; }): Arraf { return new Arraf(deps); } diff --git a/src/daemon/katib.ts b/src/daemon/katib.ts index f3cc55d..cdc7585 100644 --- a/src/daemon/katib.ts +++ b/src/daemon/katib.ts @@ -17,7 +17,7 @@ * No state persists that Katib has not recorded. */ -import { OpenCodeClient } from "../opencode/client.ts"; +import { AmilHum } from "../hum/client.ts"; import { logger } from "../logging/logger.ts"; import { haddathaAwAdkhalaJalsa, @@ -70,13 +70,13 @@ export function wallidIsmFar( interface TasmimMudirJalasat { tasmim: TasmimIksir; - opencode: OpenCodeClient; + amil: AmilHum; rasul: RasulKharij; } export class MudirJalasat { #config: TasmimIksir; - #opencode: OpenCodeClient; + #amil: AmilHum; #messenger: RasulKharij; #murshidSessions: Map = new Map(); @@ -87,7 +87,7 @@ export class MudirJalasat { constructor(deps: TasmimMudirJalasat) { this.#config = deps.tasmim; - this.#opencode = deps.opencode; + this.#amil = deps.amil; this.#messenger = deps.rasul; } @@ -121,8 +121,8 @@ export class MudirJalasat { /** Is this vessel already lit? */ let session = this.#murshidSessions.get(identifier); if (session) { - /** Verify the vessel still breathes in OpenCode */ - const existing = await this.#opencode.jalabJalsa(session.id); + /** Verify the vessel still breathes in the nest */ + const existing = await this.#amil.jalabJalsa(session.id); if (existing) { await logger.akhbar("session-manager", `Resuming tracked murshid session for ${identifier}`, { sessionId: session.id, @@ -131,17 +131,17 @@ export class MudirJalasat { await this.takkadMinQanat(session); return { session, jadida: false, mustarjaa: true, faailSabiq }; } - await logger.haDHHir("session-manager", `Tracked session ${session.id} no longer exists in OpenCode`); + await logger.haDHHir("session-manager", `Tracked session ${session.id} no longer exists in the nest`); this.#murshidSessions.delete(identifier); } /** - * Step 2: Check OpenCode for existing murshid session with matching title + * Step 2: Check the nest for existing murshid session with matching title * This handles cases where state wasn't persisted (crash, restart without save, etc.) */ const existingSession = await this.#bahathaAnJalsatMurshid(identifier); if (existingSession) { - await logger.akhbar("session-manager", `Found existing murshid session in OpenCode for ${identifier}`, { + await logger.akhbar("session-manager", `Found existing murshid session in the nest for ${identifier}`, { sessionId: existingSession.id, }); @@ -171,7 +171,7 @@ export class MudirJalasat { await logger.akhbar("session-manager", `Creating new murshid session for ${identifier}`); const sessionTitle = `[Murshid] ${identifier}: ${title}`; - const openCodeSession = await this.#opencode.khalaqaJalsa(identifier, sessionTitle); + const openCodeSession = await this.#amil.khalaqaJalsa(identifier, sessionTitle); if (!openCodeSession) { await logger.sajjalKhata("session-manager", `Failed to create murshid session for ${identifier}`); @@ -204,7 +204,7 @@ export class MudirJalasat { } /** - * Find an existing murshid session in OpenCode by searching titles + * Find an existing murshid session in the nest by searching titles */ async #bahathaAnJalsatMurshid(epicId: string): Promise<{ id: string; @@ -212,7 +212,7 @@ export class MudirJalasat { createdAt: Date; lastMessageAt: Date; } | null> { - const sessions = await this.#opencode.listSessions(); + const sessions = await this.#amil.listSessions(); const pattern = `[Murshid] ${epicId}:`; /** Find sessions matching the pattern, sorted by most recent */ @@ -289,7 +289,7 @@ export class MudirJalasat { `Murshid session started for ${session.huwiyya}.\n\nAll messages for this epic will appear here.`, ); - await this.#opencode.sendPromptAsync( + await this.#amil.sendPromptAsync( session.id, `SYSTEM: Your messaging channel is now active. ` + `All pm_reply and pm_notify messages will appear there. ` + @@ -474,7 +474,7 @@ export class MudirJalasat { } const messageWithReminder = this.maaTadhkirNizam(session, message); - const success = await this.#opencode.sendPromptAsync(session.id, messageWithReminder); + const success = await this.#amil.sendPromptAsync(session.id, messageWithReminder); if (success) { session.akhirRisalaFi = new Date().toISOString(); } @@ -493,7 +493,7 @@ export class MudirJalasat { } const messageWithReminder = this.maaTadhkirNizam(session, message); - const success = await this.#opencode.sendPromptAsync(session.id, messageWithReminder); + const success = await this.#amil.sendPromptAsync(session.id, messageWithReminder); if (success) { session.akhirRisalaFi = new Date().toISOString(); } @@ -541,15 +541,15 @@ When done, use \`mun_istihal\` to create a PR. Awaiting direction from al-Kimyawi...`; - await this.#opencode.sendPromptAsync(session.id, prompt, { + await this.#amil.sendPromptAsync(session.id, prompt, { agent: "iksir-murshid", }); } /** - * Get murshid by OpenCode session ID (reverse lookup). - * Used by SSE event handlers where only the OpenCode session ID is known. + * Get murshid by the nest session ID (reverse lookup). + * Used by SSE event handlers where only the the nest session ID is known. */ wajadaMurshidBiHuwiyyatJalsa(sessionId: string): JalsatMurshid | null { for (const session of this.#murshidSessions.values()) { @@ -567,7 +567,7 @@ Awaiting direction from al-Kimyawi...`; * message with diary entries and a reminder to use pm_read_diary. * * This catches both Daemon-triggered compactions (health-monitor) and - * OpenCode-triggered compactions (token overflow). + * the nest-triggered compactions (token overflow). */ async aalajaDamj(sessionId: string): Promise { const session = this.wajadaMurshidBiHuwiyyatJalsa(sessionId); @@ -584,7 +584,7 @@ Awaiting direction from al-Kimyawi...`; }); if (entries.length === 0) { - await this.#opencode.sendPromptAsync(session.id, + await this.#amil.sendPromptAsync(session.id, ` Context compaction occurred. Your conversation history was summarized. Your murshid ID is: ${session.huwiyya} @@ -603,7 +603,7 @@ Use pm_read_diary to reload full decision history if needed. ) .join("\n"); - await this.#opencode.sendPromptAsync(session.id, + await this.#amil.sendPromptAsync(session.id, ` Context compaction occurred. Key diary decisions for your reference: @@ -694,7 +694,7 @@ Call pm_read_diary for full decision history with reasoning. /** * Load and tahaqqaq session state from SQLite - * Validates that sessions still exist in OpenCode before using them + * Validates that sessions still exist in the nest before using them */ async hammalaHala(): Promise { try { @@ -705,10 +705,10 @@ Call pm_read_diary for full decision history with reasoning. return; } - /** Validate murshid sessions still exist in OpenCode */ + /** Validate murshid sessions still exist in the nest */ const murshidunṢalihun: JalsatMurshid[] = []; for (const dbSession of dbSessions) { - const exists = await this.#opencode.jalabJalsa(dbSession.id); + const exists = await this.#amil.jalabJalsa(dbSession.id); if (exists) { /** Parse metadata */ const metadata = JSON.parse(dbSession.hala_mufassala || "{}") as { diff --git a/src/daemon/mumayyiz.test.ts b/src/daemon/mumayyiz.test.ts index 70ffced..192d621 100644 --- a/src/daemon/mumayyiz.test.ts +++ b/src/daemon/mumayyiz.test.ts @@ -1,7 +1,7 @@ /** * Tests for src/daemon/mumayyiz.ts * - * Tests mayyazaTanbih() and mayyazaSual() with mock OpenCodeClient. + * Tests mayyazaTanbih() and mayyazaSual() with mock AmilHum. * AGENTS.md is loaded from a temp fixture file. * * Key behaviors tested: @@ -12,7 +12,7 @@ */ import { assertEquals } from "@std/assert"; -import { mockOpenCodeClient, writeTempFile } from "../test-helpers.ts"; +import { mockAmilHum, writeTempFile } from "../test-helpers.ts"; import { mayyazaTanbih, mayyazaSual } from "./mumayyiz.ts"; import type { MaalumatSual } from "../types.ts"; @@ -46,7 +46,7 @@ function makeQuestion(overrides?: Partial): MaalumatSual { Deno.test("mayyazaTanbih: DHAHAB response parsed correctly", async () => { await ensureFixture(); - const oc = mockOpenCodeClient({ + const oc = mockAmilHum({ mayyaza: async () => ({ success: true, response: '{"tamyiz":"DHAHAB","reason":"architecture question","rejection":null}', @@ -61,7 +61,7 @@ Deno.test("mayyazaTanbih: DHAHAB response parsed correctly", async () => { Deno.test("mayyazaTanbih: KHABATH response parsed correctly", async () => { await ensureFixture(); - const oc = mockOpenCodeClient({ + const oc = mockAmilHum({ mayyaza: async () => ({ success: true, response: '{"tamyiz":"KHABATH","reason":"debugging","rejection":"Check the logs first."}', @@ -76,7 +76,7 @@ Deno.test("mayyazaTanbih: KHABATH response parsed correctly", async () => { Deno.test("mayyazaTanbih: malformed JSON -> fail-open dhahab", async () => { await ensureFixture(); - const oc = mockOpenCodeClient({ + const oc = mockAmilHum({ mayyaza: async () => ({ success: true, response: "this is not json at all", @@ -90,7 +90,7 @@ Deno.test("mayyazaTanbih: malformed JSON -> fail-open dhahab", async () => { Deno.test("mayyazaTanbih: LLM returns success:false -> fail-open", async () => { await ensureFixture(); - const oc = mockOpenCodeClient({ + const oc = mockAmilHum({ mayyaza: async () => ({ success: false, error: "rate limited" }), }); @@ -101,7 +101,7 @@ Deno.test("mayyazaTanbih: LLM returns success:false -> fail-open", async () => { Deno.test("mayyazaTanbih: LLM throws -> fail-open", async () => { await ensureFixture(); - const oc = mockOpenCodeClient({ + const oc = mockAmilHum({ mayyaza: async () => { throw new Error("network error"); }, @@ -114,7 +114,7 @@ Deno.test("mayyazaTanbih: LLM throws -> fail-open", async () => { Deno.test("mayyazaTanbih: missing fields get defaults", async () => { await ensureFixture(); - const oc = mockOpenCodeClient({ + const oc = mockAmilHum({ mayyaza: async () => ({ success: true, response: '{"tamyiz":"DHAHAB"}', @@ -129,7 +129,7 @@ Deno.test("mayyazaTanbih: missing fields get defaults", async () => { Deno.test("mayyazaTanbih: KHABATH missing rejection gets default", async () => { await ensureFixture(); - const oc = mockOpenCodeClient({ + const oc = mockAmilHum({ mayyaza: async () => ({ success: true, response: '{"tamyiz":"KHABATH","reason":"trivial"}', @@ -144,7 +144,7 @@ Deno.test("mayyazaTanbih: KHABATH missing rejection gets default", async () => { Deno.test("mayyazaSual: DHAHAB response parsed correctly", async () => { await ensureFixture(); - const oc = mockOpenCodeClient({ + const oc = mockAmilHum({ mayyaza: async () => ({ success: true, response: '{"tamyiz":"DHAHAB","reason":"architecture","rejection":null,"autoAnswer":null}', @@ -160,7 +160,7 @@ Deno.test("mayyazaSual: DHAHAB response parsed correctly", async () => { Deno.test("mayyazaSual: KHABATH with autoAnswer", async () => { await ensureFixture(); - const oc = mockOpenCodeClient({ + const oc = mockAmilHum({ mayyaza: async () => ({ success: true, response: '{"tamyiz":"KHABATH","reason":"obvious","rejection":"Read the docs.","autoAnswer":"Option B"}', @@ -176,7 +176,7 @@ Deno.test("mayyazaSual: KHABATH with autoAnswer", async () => { Deno.test("mayyazaSual: 'pick recommended' resolves to (Recommended) option", async () => { await ensureFixture(); - const oc = mockOpenCodeClient({ + const oc = mockAmilHum({ mayyaza: async () => ({ success: true, response: '{"tamyiz":"KHABATH","reason":"trivial","rejection":"Use recommended.","autoAnswer":"pick recommended"}', @@ -190,7 +190,7 @@ Deno.test("mayyazaSual: 'pick recommended' resolves to (Recommended) option", as Deno.test("mayyazaSual: 'pick first' resolves to first option", async () => { await ensureFixture(); - const oc = mockOpenCodeClient({ + const oc = mockAmilHum({ mayyaza: async () => ({ success: true, response: '{"tamyiz":"KHABATH","reason":"trivial","rejection":"Just pick one.","autoAnswer":"pick first"}', @@ -204,7 +204,7 @@ Deno.test("mayyazaSual: 'pick first' resolves to first option", async () => { Deno.test("mayyazaSual: 'pick recommended' with no recommended -> falls back to first", async () => { await ensureFixture(); - const oc = mockOpenCodeClient({ + const oc = mockAmilHum({ mayyaza: async () => ({ success: true, response: '{"tamyiz":"KHABATH","reason":"trivial","rejection":"Pick one.","autoAnswer":"pick recommended"}', @@ -224,7 +224,7 @@ Deno.test("mayyazaSual: 'pick recommended' with no recommended -> falls back to Deno.test("mayyazaSual: markdown-wrapped JSON -> parsed correctly", async () => { await ensureFixture(); - const oc = mockOpenCodeClient({ + const oc = mockAmilHum({ mayyaza: async () => ({ success: true, response: '```json\n{"tamyiz":"KHABATH","reason":"obvious","rejection":"Handle it.","autoAnswer":"Option B"}\n```', @@ -238,7 +238,7 @@ Deno.test("mayyazaSual: markdown-wrapped JSON -> parsed correctly", async () => Deno.test("mayyazaSual: malformed JSON -> fail-open DHAHAB", async () => { await ensureFixture(); - const oc = mockOpenCodeClient({ + const oc = mockAmilHum({ mayyaza: async () => ({ success: true, response: "not json", @@ -252,7 +252,7 @@ Deno.test("mayyazaSual: malformed JSON -> fail-open DHAHAB", async () => { Deno.test("mayyazaSual: LLM throws -> fail-open DHAHAB", async () => { await ensureFixture(); - const oc = mockOpenCodeClient({ + const oc = mockAmilHum({ mayyaza: async () => { throw new Error("timeout"); }, @@ -266,7 +266,7 @@ Deno.test("mayyazaSual: LLM throws -> fail-open DHAHAB", async () => { Deno.test("mayyazaSual: DHAHAB nullifies autoAnswer and rejection", async () => { await ensureFixture(); /** LLM returns DHAHAB but also includes autoAnswer (shouldn't happen, but defensive) */ - const oc = mockOpenCodeClient({ + const oc = mockAmilHum({ mayyaza: async () => ({ success: true, response: '{"tamyiz":"DHAHAB","reason":"needs judgment","rejection":"some text","autoAnswer":"Option A"}', diff --git a/src/daemon/mumayyiz.ts b/src/daemon/mumayyiz.ts index 46505db..afc377a 100644 --- a/src/daemon/mumayyiz.ts +++ b/src/daemon/mumayyiz.ts @@ -18,7 +18,7 @@ import { logger } from "../logging/logger.ts"; import { join } from "jsr:@std/path"; -import type { OpenCodeClient } from "../opencode/client.ts"; +import type { AmilHum } from "../hum/client.ts"; import type { MaalumatSual, TasnifSual } from "../types.ts"; function masarWakala(): string { @@ -168,7 +168,7 @@ interface NatijaTamyizTanbih { * or khabath to be returned to the murshid? */ export async function mayyazaTanbih( - opencode: OpenCodeClient, + amil: AmilHum, message: string, ): Promise { const md = await hammalWakala(); @@ -189,7 +189,7 @@ export async function mayyazaTanbih( }); try { - const result = await opencode.mayyaza(prompt); + const result = await amil.mayyaza(prompt); if (!result.success || !result.response) { await logger.haDHHir("mumayyiz", "تمييز الإشارة فشل — السماح بالمرور", { error: result.error, @@ -218,7 +218,7 @@ export async function mayyazaTanbih( * or can the murshid answer it alone? */ export async function mayyazaSual( - opencode: OpenCodeClient, + amil: AmilHum, question: MaalumatSual, ): Promise { const md = await hammalWakala(); @@ -245,7 +245,7 @@ export async function mayyazaSual( }); try { - const result = await opencode.mayyaza(prompt); + const result = await amil.mayyaza(prompt); if (!result.success || !result.response) { await logger.haDHHir("mumayyiz", "تمييز السؤال فشل — السماح بالمرور", { error: result.error, diff --git a/src/daemon/munaffidh.ts b/src/daemon/munaffidh.ts index ed8e148..c1757ca 100644 --- a/src/daemon/munaffidh.ts +++ b/src/daemon/munaffidh.ts @@ -16,7 +16,7 @@ import { GitHubClient } from "../github/gh.ts"; import type { RasulKharij, MutabiWasfa, MudkhalTahdithQadiya } from "../types.ts"; import { NtfyClient } from "../notifications/ntfy.ts"; -import { OpenCodeClient } from "../opencode/client.ts"; +import { AmilHum } from "../hum/client.ts"; import { logger } from "../logging/logger.ts"; import { jalabaAhdathGhairMuaalaja, @@ -59,7 +59,7 @@ interface MunaffidhDeps { rasul: RasulKharij; ntfy: NtfyClient; mudirJalasat: MudirJalasat; - opencode: OpenCodeClient; + amil: AmilHum; } @@ -71,7 +71,7 @@ export class Munaffidh { #messenger: RasulKharij; #ntfy: NtfyClient; #sessionManager: MudirJalasat; - #opencode: OpenCodeClient; + #amil: AmilHum; #iksir: Munadi | null = null; #mutahakkimIlgha: AbortController | null = null; @@ -83,7 +83,7 @@ export class Munaffidh { this.#messenger = deps.rasul; this.#ntfy = deps.ntfy; this.#sessionManager = deps.mudirJalasat; - this.#opencode = deps.opencode; + this.#amil = deps.amil; } /** @@ -512,7 +512,7 @@ ${comparison.behind > 0 ? "⚠️ Branch is behind - consider rebasing before PR */ async aalajTanbih(call: NidaTabligh): Promise { /** Step 1: Mayyiz the tanbih */ - const tamyiz = await mayyazaTanbih(this.#opencode, call.risala); + const tamyiz = await mayyazaTanbih(this.#amil, call.risala); if (!tamyiz.dhahab) { await logger.akhbar("tool-executor", "Ishara rejected as khabath", { diff --git a/src/daemon/raqib.ts b/src/daemon/raqib.ts index 1a8368f..d84a66f 100644 --- a/src/daemon/raqib.ts +++ b/src/daemon/raqib.ts @@ -23,7 +23,7 @@ */ import { logger } from "../logging/logger.ts"; -import type { OpenCodeClient } from "../opencode/client.ts"; +import type { AmilHum } from "../hum/client.ts"; import type { RasulKharij } from "../types.ts"; import type { MudirJalasat } from "./katib.ts"; @@ -42,7 +42,7 @@ const FATRA_NAQRA_MS = 60 * 1000; interface RaqibDeps { - opencode: OpenCodeClient; + amil: AmilHum; rasul: RasulKharij; mudirJalasat: MudirJalasat; } @@ -59,7 +59,7 @@ interface HalatSihhJalsa { export class Raqib { - #opencode: OpenCodeClient; + #amil: AmilHum; #messenger: RasulKharij; #sessionManager: MudirJalasat; @@ -67,7 +67,7 @@ export class Raqib { #muwaqqitNaqra: ReturnType | null = null; constructor(deps: RaqibDeps) { - this.#opencode = deps.opencode; + this.#amil = deps.amil; this.#messenger = deps.rasul; this.#sessionManager = deps.mudirJalasat; } @@ -114,7 +114,7 @@ export class Raqib { async naqra(): Promise { try { /** Survey all vessels */ - const statuses = await this.#opencode.jalabJalsaStatuses(); + const statuses = await this.#amil.jalabJalsaStatuses(); /** Examine each murshid vessel */ const murshidun = this.#sessionManager.wajadaJalasatMurshid(); @@ -158,7 +158,7 @@ export class Raqib { * never completed, and has been silent longer than HADD_ALIQ — * the Murshid is 'aliq. The thread must be cut. */ - const lastMsg = await this.#opencode.getLastAssistantMessage(sessionId); + const lastMsg = await this.#amil.getLastAssistantMessage(sessionId); const isStuck = lastMsg && @@ -197,7 +197,7 @@ export class Raqib { stuckMinutes, }); - const aborted = await this.#opencode.abortSession(sessionId); + const aborted = await this.#amil.abortSession(sessionId); if (aborted) { state.ulghiya = true; @@ -206,7 +206,7 @@ export class Raqib { `Auto-aborted stuck session **${identifier}** (stuck ${stuckMinutes}m).` ); - await this.#opencode.sendPromptAsync(sessionId, + await this.#amil.sendPromptAsync(sessionId, `SYSTEM: Your previous operation was auto-aborted because it appeared stuck (${stuckMinutes} minutes with no output). ` + `This typically happens when a bash command hangs. ` + `Please avoid long-running bash commands. If you need to run tests or builds, use timeouts.` @@ -238,7 +238,7 @@ export class Raqib { } /** Count the risālāt within */ - const counts = await this.#opencode.jalabRisalaCount(sessionId); + const counts = await this.#amil.jalabRisalaCount(sessionId); if (!counts) return; if (counts.total >= HADD_DAMJ) { @@ -247,7 +247,7 @@ export class Raqib { threshold: HADD_DAMJ, }); - const success = await this.#opencode.summarizeSession(sessionId); + const success = await this.#amil.summarizeSession(sessionId); if (success) { state.akhirDamjFi = now; diff --git a/src/daemon/saail.test.ts b/src/daemon/saail.test.ts index 472df61..0e7ea4e 100644 --- a/src/daemon/saail.test.ts +++ b/src/daemon/saail.test.ts @@ -2,7 +2,7 @@ * Tests for src/daemon/question-handler.ts * * Tests Sail with: - * - Mock OpenCodeClient, RasulKharij, MudirJalasat + * - Mock AmilHum, RasulKharij, MudirJalasat * - Real temp DB (for question persistence) * * Key behaviors tested: @@ -16,7 +16,7 @@ import { assertEquals, assertExists } from "@std/assert"; import { withTestDb, - mockOpenCodeClient, + mockAmilHum, mockMessenger, mockMudirJalasat, makeSession, @@ -57,7 +57,7 @@ function makeEvent(overrides?: Partial): HadathS Deno.test("isQuestionCallback: returns true for q: prefix", () => { const qh = new Saail({ - opencode: mockOpenCodeClient() as never, + amil: mockAmilHum() as never, rasul: mockMessenger(), mudirJalasat: mockMudirJalasat() as never, }); @@ -68,7 +68,7 @@ Deno.test("isQuestionCallback: returns true for q: prefix", () => { Deno.test("isQuestionCallback: returns false for other prefixes", () => { const qh = new Saail({ - opencode: mockOpenCodeClient() as never, + amil: mockAmilHum() as never, rasul: mockMessenger(), mudirJalasat: mockMudirJalasat() as never, }); @@ -81,7 +81,7 @@ Deno.test("isQuestionCallback: returns false for other prefixes", () => { Deno.test("wajadaSualMuallaq: returns undefined for unknown", () => { const qh = new Saail({ - opencode: mockOpenCodeClient() as never, + amil: mockAmilHum() as never, rasul: mockMessenger(), mudirJalasat: mockMudirJalasat() as never, }); @@ -92,7 +92,7 @@ Deno.test("wajadaSualMuallaq: returns undefined for unknown", () => { Deno.test("isAwaitingCustomInput: returns false initially", () => { const qh = new Saail({ - opencode: mockOpenCodeClient() as never, + amil: mockAmilHum() as never, rasul: mockMessenger(), mudirJalasat: mockMudirJalasat() as never, }); @@ -103,49 +103,49 @@ Deno.test("isAwaitingCustomInput: returns false initially", () => { Deno.test("buildInlineKeyboard: creates rows for each option + custom", () => { const qh = new Saail({ - opencode: mockOpenCodeClient() as never, + amil: mockAmilHum() as never, rasul: mockMessenger(), mudirJalasat: mockMudirJalasat() as never, }); const question = makeMaalumatSual(); - const keyboard = qh.banaMafatihSatriyya("q-001", question); + const khiyarat = qh.banaKhiyarat("q-001", question); - assertEquals(keyboard.inline_keyboard.length, 3); - assertEquals(keyboard.inline_keyboard[0][0].text, "Pattern A (Recommended)"); - assertEquals(keyboard.inline_keyboard[1][0].text, "Pattern B"); - assertEquals(keyboard.inline_keyboard[2][0].text, "Type answer..."); + assertEquals(khiyarat.length, 3); + assertEquals(khiyarat[0].nass, "Pattern A (Recommended)"); + assertEquals(khiyarat[1].nass, "Pattern B"); + assertEquals(khiyarat[2].nass, "Type answer..."); - assertEquals(keyboard.inline_keyboard[0][0].callback_data.startsWith("q:"), true); - assertEquals(keyboard.inline_keyboard[2][0].callback_data.endsWith("__custom__"), true); + assertEquals(khiyarat[0].miftah.startsWith("q:"), true); + assertEquals(khiyarat[2].miftah.endsWith("__custom__"), true); }); Deno.test("buildInlineKeyboard: no custom button when custom=false", () => { const qh = new Saail({ - opencode: mockOpenCodeClient() as never, + amil: mockAmilHum() as never, rasul: mockMessenger(), mudirJalasat: mockMudirJalasat() as never, }); const question = makeMaalumatSual({ custom: false }); - const keyboard = qh.banaMafatihSatriyya("q-002", question); + const khiyarat = qh.banaKhiyarat("q-002", question); - assertEquals(keyboard.inline_keyboard.length, 2); + assertEquals(khiyarat.length, 2); }); Deno.test("parseQuestionCallback: resolves registered short IDs", () => { const qh = new Saail({ - opencode: mockOpenCodeClient() as never, + amil: mockAmilHum() as never, rasul: mockMessenger(), mudirJalasat: mockMudirJalasat() as never, }); - /** Register via buildInlineKeyboard */ + /** Register via banaKhiyarat */ const question = makeMaalumatSual(); - const keyboard = qh.banaMafatihSatriyya("q-full-uuid-001", question); + const khiyarat = qh.banaKhiyarat("q-full-uuid-001", question); - /** Parse the first button's callback_data */ - const parsed = qh.hallalIstijabaZirrSual(keyboard.inline_keyboard[0][0].callback_data); + /** Parse the first option's callback key */ + const parsed = qh.hallalIstijabaZirrSual(khiyarat[0].miftah); assertExists(parsed); assertEquals(parsed.questionId, "q-full-uuid-001"); assertEquals(parsed.selectedLabel.startsWith("Pattern A"), true); @@ -153,7 +153,7 @@ Deno.test("parseQuestionCallback: resolves registered short IDs", () => { Deno.test("parseQuestionCallback: returns null for unknown short IDs", () => { const qh = new Saail({ - opencode: mockOpenCodeClient() as never, + amil: mockAmilHum() as never, rasul: mockMessenger(), mudirJalasat: mockMudirJalasat() as never, }); @@ -164,7 +164,7 @@ Deno.test("parseQuestionCallback: returns null for unknown short IDs", () => { Deno.test("parseQuestionCallback: handles labels with colons", () => { const qh = new Saail({ - opencode: mockOpenCodeClient() as never, + amil: mockAmilHum() as never, rasul: mockMessenger(), mudirJalasat: mockMudirJalasat() as never, }); @@ -174,9 +174,9 @@ Deno.test("parseQuestionCallback: handles labels with colons", () => { options: [{ label: "Option:With:Colons", description: "test" }], custom: false, }); - const keyboard = qh.banaMafatihSatriyya("q-colon-test", question); + const khiyarat = qh.banaKhiyarat("q-colon-test", question); - const parsed = qh.hallalIstijabaZirrSual(keyboard.inline_keyboard[0][0].callback_data); + const parsed = qh.hallalIstijabaZirrSual(khiyarat[0].miftah); assertExists(parsed); assertEquals(parsed.selectedLabel, "Option:With:Colons"); }); @@ -184,9 +184,9 @@ Deno.test("parseQuestionCallback: handles labels with colons", () => { Deno.test("handleQuestionAsked: unknown session -> rejects", async () => { await withTestDb(async () => { - const oc = mockOpenCodeClient(); + const oc = mockAmilHum(); const qh = new Saail({ - opencode: oc as never, + amil: oc as never, rasul: mockMessenger(), mudirJalasat: mockMudirJalasat([]) as never, }); @@ -200,10 +200,10 @@ Deno.test("handleQuestionAsked: unknown session -> rejects", async () => { Deno.test("handleQuestionAsked: empty questions -> rejects", async () => { await withTestDb(async () => { - const oc = mockOpenCodeClient(); + const oc = mockAmilHum(); const session = makeSession(); const qh = new Saail({ - opencode: oc as never, + amil: oc as never, rasul: mockMessenger(), mudirJalasat: mockMudirJalasat([session]) as never, }); @@ -216,7 +216,7 @@ Deno.test("handleQuestionAsked: empty questions -> rejects", async () => { Deno.test("handleQuestionAsked: KHABATH -> auto-answers + injects guidance", async () => { await withTestDb(async () => { - const oc = mockOpenCodeClient({ + const oc = mockAmilHum({ mayyaza: async () => ({ success: true, response: '{"tamyiz":"KHABATH","reason":"obvious","rejection":"Check docs.","autoAnswer":"Pattern B"}', @@ -226,7 +226,7 @@ Deno.test("handleQuestionAsked: KHABATH -> auto-answers + injects guidance", asy const session = makeSession(); const messenger = mockMessenger(); const qh = new Saail({ - opencode: oc as never, + amil: oc as never, rasul: messenger, mudirJalasat: mockMudirJalasat([session]) as never, }); @@ -245,7 +245,7 @@ Deno.test("handleQuestionAsked: KHABATH -> auto-answers + injects guidance", asy Deno.test("handleQuestionAsked: DHAHAB -> forwards to al-Kimyawi", async () => { await withTestDb(async () => { - const oc = mockOpenCodeClient({ + const oc = mockAmilHum({ mayyaza: async () => ({ success: true, response: '{"tamyiz":"DHAHAB","reason":"architecture","rejection":null,"autoAnswer":null}', @@ -258,7 +258,7 @@ Deno.test("handleQuestionAsked: DHAHAB -> forwards to al-Kimyawi", async () => { let forwardedCount = 0; const qh = new Saail({ - opencode: oc as never, + amil: oc as never, rasul: messenger, mudirJalasat: mockMudirJalasat([session]) as never, }); @@ -292,7 +292,7 @@ Deno.test("handleQuestionAsked: DHAHAB -> forwards to al-Kimyawi", async () => { Deno.test("handleQuestionCallback: answers question + marks in DB", async () => { await withTestDb(async () => { - const oc = mockOpenCodeClient({ + const oc = mockAmilHum({ mayyaza: async () => ({ success: true, response: '{"tamyiz":"DHAHAB","reason":"test","rejection":null,"autoAnswer":null}', @@ -302,7 +302,7 @@ Deno.test("handleQuestionCallback: answers question + marks in DB", async () => const session = makeSession(); seedSession(); const qh = new Saail({ - opencode: oc as never, + amil: oc as never, rasul: mockMessenger(), mudirJalasat: mockMudirJalasat([session]) as never, }); @@ -324,9 +324,9 @@ Deno.test("handleQuestionCallback: answers question + marks in DB", async () => Deno.test("handleQuestionCallback: unknown question -> returns false", async () => { await withTestDb(async () => { - const oc = mockOpenCodeClient(); + const oc = mockAmilHum(); const qh = new Saail({ - opencode: oc as never, + amil: oc as never, rasul: mockMessenger(), mudirJalasat: mockMudirJalasat() as never, }); @@ -340,7 +340,7 @@ Deno.test("handleQuestionCallback: unknown question -> returns false", async () Deno.test("markAwaitingCustomInput + handlePotentialCustomAnswer: end-to-end", async () => { await withTestDb(async () => { - const oc = mockOpenCodeClient({ + const oc = mockAmilHum({ mayyaza: async () => ({ success: true, response: '{"tamyiz":"DHAHAB","reason":"test","rejection":null,"autoAnswer":null}', @@ -350,7 +350,7 @@ Deno.test("markAwaitingCustomInput + handlePotentialCustomAnswer: end-to-end", a const session = makeSession(); seedSession(); const qh = new Saail({ - opencode: oc as never, + amil: oc as never, rasul: mockMessenger(), mudirJalasat: mockMudirJalasat([session]) as never, }); @@ -374,7 +374,7 @@ Deno.test("markAwaitingCustomInput + handlePotentialCustomAnswer: end-to-end", a Deno.test("handlePotentialCustomAnswer: returns false when not awaiting", async () => { await withTestDb(async () => { const qh = new Saail({ - opencode: mockOpenCodeClient() as never, + amil: mockAmilHum() as never, rasul: mockMessenger(), mudirJalasat: mockMudirJalasat() as never, }); @@ -389,10 +389,10 @@ Deno.test("loadState: rebuilds pendingQuestions from DB", async () => { await withTestDb(async () => { const session = makeSession({ id: "sess-abc", huwiyya: "TEAM-900" }); seedSession({ id: "sess-abc", huwiyya: "TEAM-900" }); - const oc = mockOpenCodeClient(); + const oc = mockAmilHum(); const qh = new Saail({ - opencode: oc as never, + amil: oc as never, rasul: mockMessenger(), mudirJalasat: mockMudirJalasat([session]) as never, }); @@ -420,10 +420,10 @@ Deno.test("loadState: rebuilds callbackIdMap (parseQuestionCallback works after await withTestDb(async () => { const session = makeSession({ id: "sess-xyz", huwiyya: "TEAM-950" }); seedSession({ id: "sess-xyz", huwiyya: "TEAM-950" }); - const oc = mockOpenCodeClient(); + const oc = mockAmilHum(); const qh = new Saail({ - opencode: oc as never, + amil: oc as never, rasul: mockMessenger(), mudirJalasat: mockMudirJalasat([session]) as never, }); @@ -445,8 +445,8 @@ Deno.test("loadState: rebuilds callbackIdMap (parseQuestionCallback works after * The short callback ID should be registered by loadState via #shortCallbackId * We can verify by building a keyboard and parsing its callback */ - const keyboard = qh.banaMafatihSatriyya("q-callback-test", pending.questions[0]); - const parsed = qh.hallalIstijabaZirrSual(keyboard.inline_keyboard[0][0].callback_data); + const khiyarat = qh.banaKhiyarat("q-callback-test", pending.questions[0]); + const parsed = qh.hallalIstijabaZirrSual(khiyarat[0].miftah); assertExists(parsed); assertEquals(parsed.questionId, "q-callback-test"); }); @@ -455,7 +455,7 @@ Deno.test("loadState: rebuilds callbackIdMap (parseQuestionCallback works after Deno.test("loadState: no questions -> no-op", async () => { await withTestDb(async () => { const qh = new Saail({ - opencode: mockOpenCodeClient() as never, + amil: mockAmilHum() as never, rasul: mockMessenger(), mudirJalasat: mockMudirJalasat() as never, }); @@ -470,7 +470,7 @@ Deno.test("loadState: unknown session -> uses sessionId as huwiyyatMurshid fallb seedSession({ id: "sess-unknown", huwiyya: "ORPHAN" }); const qh = new Saail({ - opencode: mockOpenCodeClient() as never, + amil: mockAmilHum() as never, rasul: mockMessenger(), mudirJalasat: mockMudirJalasat([]) as never, }); diff --git a/src/daemon/saail.ts b/src/daemon/saail.ts index ff2858e..c6846ed 100644 --- a/src/daemon/saail.ts +++ b/src/daemon/saail.ts @@ -15,7 +15,7 @@ */ import { logger } from "../logging/logger.ts"; -import { OpenCodeClient } from "../opencode/client.ts"; +import { AmilHum } from "../hum/client.ts"; import { adkhalaSual as dbInsertQuestion, jalabaAseilaGhairMujaba, @@ -36,13 +36,13 @@ import type { interface SailDeps { - opencode: OpenCodeClient; + amil: AmilHum; rasul: RasulKharij; mudirJalasat: MudirJalasat; } export class Saail { - #opencode: OpenCodeClient; + #amil: AmilHum; #messenger: RasulKharij; #sessionManager: MudirJalasat; @@ -79,13 +79,13 @@ export class Saail { } constructor(deps: SailDeps) { - this.#opencode = deps.opencode; + this.#amil = deps.amil; this.#messenger = deps.rasul; this.#sessionManager = deps.mudirJalasat; } /** - * Handle a question.asked event from OpenCode SSE. + * Handle a question.asked event from the nest. * Mayyiz the question and either auto-answers or forwards to al-Kimyawi. */ async aalajSualMatlub(event: HadathSualMatlub): Promise { @@ -103,7 +103,7 @@ export class Saail { if (!murshid) { await logger.haDHHir("question-handler", `Question from unknown session ${sessionID}`); - await this.#opencode.rejectQuestion(sessionID, id); + await this.#amil.rejectQuestion(sessionID, id); return; } @@ -114,11 +114,11 @@ export class Saail { const primaryQuestion = questions[0]; if (!primaryQuestion) { await logger.haDHHir("question-handler", `Question ${id} has no questions array`); - await this.#opencode.rejectQuestion(sessionID, id); + await this.#amil.rejectQuestion(sessionID, id); return; } - const tamyiz = await mayyazaSual(this.#opencode, primaryQuestion); + const tamyiz = await mayyazaSual(this.#amil, primaryQuestion); await logger.akhbar("question-handler", `Tamyiz: ${tamyiz.tamyiz}`, { reason: tamyiz.reason, @@ -161,14 +161,14 @@ export class Saail { }); /** Reply with auto-answer */ - const replied = await this.#opencode.replyToQuestion(sessionID, questionId, answers); + const replied = await this.#amil.replyToQuestion(sessionID, questionId, answers); if (replied) { await logger.akhbar("question-handler", `Auto-answered question ${questionId}`, { autoAnswer: tamyiz.autoAnswer, }); } else { - await this.#opencode.rejectQuestion(sessionID, questionId); + await this.#amil.rejectQuestion(sessionID, questionId); await logger.haDHHir("question-handler", `Failed to auto-answer, rejected ${questionId}`); } @@ -179,7 +179,7 @@ ${tamyiz.rejection ?? "Proceed autonomously using your judgment."} Auto-selected: ${answers.map((a) => a.selected.join(", ")).join("; ")}`; - await this.#opencode.sendPromptAsync(sessionID, guidance); + await this.#amil.sendPromptAsync(sessionID, guidance); } /** @@ -303,8 +303,8 @@ Auto-selected: ${answers.map((a) => a.selected.join(", ")).join("; ")}`; custom: selectedLabel === "__custom__" ? customText : undefined, })); - /** Reply to OpenCode */ - const replied = await this.#opencode.replyToQuestion( + /** Reply to the nest */ + const replied = await this.#amil.replyToQuestion( pending.sessionID, questionId, answers diff --git a/src/hum/client.ts b/src/hum/client.ts new file mode 100644 index 0000000..810d05b --- /dev/null +++ b/src/hum/client.ts @@ -0,0 +1,581 @@ +/** + * Amil Hum (عامل هم) — The Nest-Agent + * + * Iksir's hands at the nest. Presents the same face the murshidun + * always knew, but behind it there is no vendor — only the thrum, + * and whatever hive al-Kimyawi has chosen to kindle. + * + * The inversion is total. Where once Iksir asked a foreign runtime + * which jalasat existed, it now answers that question itself: the + * sijill was always the truth, and the runtime was only ever + * repeating it back. Sessions are minted here, held here, and + * carried across turns by the nestId the worker returns on + * session-ready — surrendered again as `resume` on the next prompt, + * so the cell rehydrates its full prior context. + * + * The tongue, translated once, here and nowhere else: + * + * khalaqaJalsa → mint a sid (no wire call; jalasat are ours) + * sendPrompt → chi:"prompt" → chunk* → finish + * abortSession → chi:"cancel" + * mahaqaJalsa → chi:"cleanup" + * summarizeSession → chi:"curate" + * replyToQuestion → chi:"release-permit" + * question.asked ← chi:"permission-ask" + * + * No chi crosses out of this file. The khuddām speak only Arabic. + */ + +import { join as joinMasar } from "jsr:@std/path"; +import { logger } from "../logging/logger.ts"; +import { type Nagham, nuskhatHumd, ridJadid, type TaarifAda, Thrum } from "./thrum.ts"; +import type { HadathHum, JalsatHum, TasmimIksir } from "../types.ts"; + +/** How long a blocking prompt waits before it is abandoned. */ +const MUHLAT_IFTIRADIYYA_MS = 30_000; + +/** What Iksir remembers of a jalsa the runtime no longer remembers for it. */ +interface HalatJalsa { + id: string; + huwiyyatWasfa: string; + title: string; + createdAt: Date; + lastMessageAt: Date; + /** The worker's own session handle, returned on session-ready. */ + nestId?: string; + /** True between prompt and finish. */ + fail: boolean; + adadRisalat: number; + adadRisalatMusaid: number; + akhirRadd?: string; + akhirDawra?: DawraMusaid; +} + +/** + * The last turn a murshid took. Raqib reads this to tell a thinking + * vessel from an 'aliq one: a dawra begun, no tokens flowing, never + * closed, and five minutes gone. + */ +interface DawraMusaid { + id: string; + createdAt: number; + completedAt?: number; + tokensOutput: number; + cost: number; + error?: string; +} + +/** A nida the nest has routed to us, awaiting a natija. */ +export interface NidaWarid { + sid: string; + callId: string; + name: string; + args: Record; +} + +export type MustamiNida = (nida: NidaWarid) => void; + +export class AmilHum { + #thrum: Thrum; + #jalasat = new Map(); + #ahdath: HadathHum[] = []; + #muntazirAhdath: Array<(h: HadathHum) => void> = []; + #mustamiuunNida: MustamiNida[] = []; + #jalsatMumayyiz: string | null = null; + #namudhaj?: string; + #ruqan = new Map(); + + constructor(tasmim: TasmimIksir, adawat: TaarifAda[] = []) { + this.#namudhaj = tasmim.hum?.namudhaj; + this.#thrum = new Thrum({ + masarMiqbas: tasmim.hum?.miqbas, + adawat, + }); + this.#thrum.alaKull((nagham) => this.#istaqbil(nagham)); + } + + /** Open the strand. Must be awaited before any prompt is sent. */ + async ittasil(): Promise { + await this.#thrum.ittasil(); + } + + get huwiyya(): string { + return this.#thrum.huwiyya; + } + + // ── Hala ──────────────────────────────────────────────────────── + + isHealthy(): Promise { + return Promise.resolve(this.#thrum.mawsul); + } + + getVersion(): Promise { + return Promise.resolve(nuskhatHumd()); + } + + // ── Jalasat ───────────────────────────────────────────────────── + + /** + * Mint a jalsa. No tone is sent — the sid belongs to the originator, + * and humd learns of it the moment the first prompt carries it. + */ + async khalaqaJalsa(huwiyyatWasfa: string, title: string): Promise { + const id = `iksir-${huwiyyatWasfa}-${ridJadid()}`; + const alan = new Date(); + + this.#jalasat.set(id, { + id, + huwiyyatWasfa, + title, + createdAt: alan, + lastMessageAt: alan, + fail: false, + adadRisalat: 0, + adadRisalatMusaid: 0, + }); + + await logger.akhbar("hum", `Minted jalsa ${id} for ${huwiyyatWasfa}`); + return this.#zahir(id); + } + + jalabJalsa(sessionId: string): Promise { + return Promise.resolve(this.#zahir(sessionId)); + } + + listSessions(): Promise { + const kull = [...this.#jalasat.keys()] + .map((id) => this.#zahir(id)) + .filter((j): j is JalsatHum => j !== null); + return Promise.resolve(kull); + } + + jalabJalsaStatuses(): Promise> { + const natija: Record = {}; + for (const [id, h] of this.#jalasat) natija[id] = h.fail ? "fail" : "sakin"; + return Promise.resolve(natija); + } + + /** + * Restore a jalsa Iksir minted in an earlier life. The sijill outlives + * the process; this memory does not, so katib rehydrates it on boot. + */ + istaadaJalsa(jalsa: JalsatHum, nestId?: string): void { + if (this.#jalasat.has(jalsa.id)) return; + this.#jalasat.set(jalsa.id, { + id: jalsa.id, + huwiyyatWasfa: jalsa.huwiyyatWasfa, + title: jalsa.title, + createdAt: jalsa.createdAt, + lastMessageAt: jalsa.lastMessageAt, + nestId, + fail: false, + adadRisalat: 0, + adadRisalatMusaid: 0, + }); + } + + /** The worker's own handle for a jalsa, if it has reported one yet. */ + huwiyyatUsh(sessionId: string): string | undefined { + return this.#jalasat.get(sessionId)?.nestId; + } + + #zahir(id: string): JalsatHum | null { + const h = this.#jalasat.get(id); + if (!h) return null; + return { + id: h.id, + projectId: "", + huwiyyatWasfa: h.huwiyyatWasfa, + title: h.title, + status: h.fail ? "fail" : "sakin", + createdAt: h.createdAt, + lastMessageAt: h.lastMessageAt, + }; + } + + // ── Hathth ────────────────────────────────────────────────────── + + /** + * The ruqya a murshid is summoned under. + * + * OpenCode kept these as "agents" in its own config dir and attached them + * by name. Nothing does that now, so Iksir carries its own incantations: + * the ruqya is read from the prompts/ archive and sent as systemPrompt. + * Read once, then held — a murshid's identity does not change mid-work. + */ + #ruqya(ism: string): string | undefined { + const mahfuz = this.#ruqan.get(ism); + if (mahfuz !== undefined) return mahfuz || undefined; + + const makhzan = Deno.env.get("IKSIR_REPO_PATH") ?? "."; + let nass = ""; + try { + nass = Deno.readTextFileSync(joinMasar(makhzan, "prompts", `${ism}.md`)); + } catch { + // A missing ruqya is not fatal — the murshid simply arrives unnamed. + logger.haDHHir("hum", `No ruqya found for ${ism}; prompting without one`); + } + this.#ruqan.set(ism, nass); + return nass || undefined; + } + + #naghamHathth( + sessionId: string, + prompt: string, + options?: { agent?: string; system?: string }, + ): Nagham { + const h = this.#jalasat.get(sessionId); + const rid = ridJadid(); + const system = options?.system ?? (options?.agent ? this.#ruqya(options.agent) : undefined); + + if (h) { + h.fail = true; + h.adadRisalat++; + h.lastMessageAt = new Date(); + h.akhirDawra = { id: rid, createdAt: Date.now(), tokensOutput: 0, cost: 0 }; + } + + return { + chi: "prompt", + rid, + sid: sessionId, + hive: "iksir", + content: prompt, + ...(this.#namudhaj ? { modelId: this.#namudhaj } : {}), + ...(system ? { systemPrompt: system } : {}), + // The worker rehydrates its prior context from this handle. Without + // it every turn starts cold and the murshid forgets its own work. + ...(h?.nestId ? { resume: h.nestId } : {}), + }; + } + + /** Send and wait for the turn to close. */ + async sendPrompt( + sessionId: string, + prompt: string, + options?: { + model?: { providerID: string; modelID: string }; + agent?: string; + system?: string; + timeoutMs?: number; + }, + ): Promise<{ success: boolean; response?: string; error?: string }> { + const muhla = options?.timeoutMs ?? MUHLAT_IFTIRADIYYA_MS; + const h = this.#jalasat.get(sessionId); + + return await new Promise((hall) => { + let nass = ""; + let intaha = false; + + const anhi = (natija: { success: boolean; response?: string; error?: string }) => { + if (intaha) return; + intaha = true; + clearTimeout(muaqqit); + this.#thrum.azilSid(sessionId); + if (h) { + h.fail = false; + h.lastMessageAt = new Date(); + if (natija.response) { + h.akhirRadd = natija.response; + h.adadRisalatMusaid++; + } + } + hall(natija); + }; + + const muaqqit = setTimeout( + () => anhi({ success: false, error: `Prompt timed out after ${muhla}ms` }), + muhla, + ); + + this.#thrum.alaSid(sessionId, (nagham) => { + const chi = nagham.chi; + if (chi === "chunk" && nagham.chunkType === "text_delta") { + if (typeof nagham.delta === "string") nass += nagham.delta; + return; + } + if (chi === "finish") { + anhi({ success: true, response: nass }); + return; + } + if (chi === "error") { + anhi({ success: false, error: String(nagham.message ?? "unknown") }); + } + }); + + this.#thrum.ursil(this.#naghamHathth(sessionId, prompt, options)); + }); + } + + /** + * Send without waiting. The murshid's ordinary mode — the turn's + * output arrives as ahdath, not as a return value. + */ + async sendPromptAsync( + sessionId: string, + prompt: string, + options?: { agent?: string }, + ): Promise { + this.#thrum.ursil(this.#naghamHathth(sessionId, prompt, options)); + await logger.akhbar("hum", `Sent prompt to jalsa ${sessionId}`); + return true; + } + + async abortSession(sessionId: string): Promise { + this.#thrum.ursil({ chi: "cancel", rid: ridJadid(), sid: sessionId }); + const h = this.#jalasat.get(sessionId); + if (h) h.fail = false; + await logger.akhbar("hum", `Cancelled jalsa ${sessionId}`); + return true; + } + + async mahaqaJalsa(sessionId: string): Promise { + this.#thrum.ursil({ chi: "cleanup", rid: ridJadid(), sid: sessionId }); + this.#thrum.azilSid(sessionId); + this.#jalasat.delete(sessionId); + await logger.akhbar("hum", `Cleaned jalsa ${sessionId}`); + return true; + } + + /** + * Compact a swollen jalsa. Note what is absent: no provider, no model. + * The nest compacts with whatever it is already burning. + */ + async summarizeSession( + sessionId: string, + _options?: { providerID?: string; modelID?: string; auto?: boolean }, + ): Promise { + this.#thrum.ursil({ chi: "curate", rid: ridJadid(), sid: sessionId }); + await logger.akhbar("hum", `Curated jalsa ${sessionId}`); + // No tone announces a completed curation, so the hadath is raised here + // — katib is owed its notice either way. + this.#athir({ + type: "session.compacted", + properties: { sessionID: sessionId }, + timestamp: new Date(), + }); + return true; + } + + // ── Tamyiz ────────────────────────────────────────────────────── + + async mayyaza(prompt: string): Promise<{ success: boolean; response?: string; error?: string }> { + if (!this.#jalsatMumayyiz) { + const jalsa = await this.khalaqaJalsa("iksir-mumayyiz", "Iksir Tamyiz"); + if (!jalsa) return { success: false, error: "Failed to mint mumayyiz jalsa" }; + this.#jalsatMumayyiz = jalsa.id; + } + return await this.sendPrompt(this.#jalsatMumayyiz, prompt); + } + + // ── Asila ─────────────────────────────────────────────────────── + + /** + * Answer a suspended sual, releasing the permit that parked the cell. + * + * NOTE: no hive bundled with hum emits permission-ask today — claude-cli + * runs with --dangerously-skip-permissions — so the body shape here is + * inferred from the chi's contract, not observed on a live wire. Verify + * against the first hive that actually asks. + */ + async replyToQuestion( + sessionId: string, + questionId: string, + answers: Array<{ questionIndex: number; selected: string[]; custom?: string }>, + ): Promise { + this.#thrum.ursil({ + chi: "release-permit", + rid: ridJadid(), + sid: sessionId, + callId: questionId, + granted: true, + answers: answers.map((a) => (a.custom ? [a.custom] : a.selected)), + }); + await logger.akhbar("hum", `Released permit ${questionId}`); + return true; + } + + async rejectQuestion(sessionId: string, questionId: string): Promise { + this.#thrum.ursil({ + chi: "release-permit", + rid: ridJadid(), + sid: sessionId, + callId: questionId, + granted: false, + }); + await logger.akhbar("hum", `Denied permit ${questionId}`); + return true; + } + + // ── Risalat ───────────────────────────────────────────────────── + + jalabRisalaCount(sessionId: string): Promise<{ total: number; assistant: number }> { + const h = this.#jalasat.get(sessionId); + return Promise.resolve({ + total: h?.adadRisalat ?? 0, + assistant: h?.adadRisalatMusaid ?? 0, + }); + } + + /** + * The last turn taken. Raqib's instrument for spotting al-'Aliq — a + * dawra opened, no tokens output, never closed, and the minutes piling up. + */ + getLastAssistantMessage(sessionId: string): Promise< + { + id: string; + createdAt: number; + completedAt?: number; + tokensOutput: number; + cost: number; + error?: string; + } | null + > { + return Promise.resolve(this.#jalasat.get(sessionId)?.akhirDawra ?? null); + } + + // ── Nida ──────────────────────────────────────────────────────── + + /** Listen for mun_* adawat the nest routes to us. */ + alaNida(mustami: MustamiNida): void { + this.#mustamiuunNida.push(mustami); + } + + /** Return a natija, un-parking the cell that waits on it. */ + raddNida(sid: string, callId: string, natija: unknown): void { + this.#thrum.ursil({ chi: "tool-result", rid: ridJadid(), sid, callId, result: natija }); + } + + // ── Ahdath ────────────────────────────────────────────────────── + + #istaqbil(nagham: Nagham): void { + const chi = nagham.chi; + const sid = typeof nagham.sid === "string" ? nagham.sid : ""; + + if (chi === "session-ready") { + const nestId = typeof nagham.nestId === "string" ? nagham.nestId : undefined; + const h = this.#jalasat.get(sid); + if (h && nestId) h.nestId = nestId; + return; + } + + if (chi === "finish") { + const h = this.#jalasat.get(sid); + if (h) { + h.fail = false; + h.lastMessageAt = new Date(); + h.adadRisalatMusaid++; + if (h.akhirDawra) { + const usage = (nagham.usage as Record | undefined) ?? {}; + h.akhirDawra.completedAt = Date.now(); + h.akhirDawra.tokensOutput = usage.output_tokens ?? 0; + } + } + return; + } + + if (chi === "error") { + const h = this.#jalasat.get(sid); + if (h) { + h.fail = false; + if (h.akhirDawra) { + h.akhirDawra.completedAt = Date.now(); + h.akhirDawra.error = String(nagham.message ?? "unknown"); + } + } + return; + } + + if (chi === "tool-call") { + const nida: NidaWarid = { + sid, + callId: String(nagham.callId ?? ""), + name: String(nagham.name ?? ""), + args: (nagham.args as Record) ?? {}, + }; + for (const m of this.#mustamiuunNida) m(nida); + return; + } + + if (chi === "permission-ask") { + this.#athir({ + type: "question.asked", + properties: { + id: String(nagham.callId ?? nagham.rid ?? ""), + sessionID: sid, + questions: this.#asilaMin(nagham), + ...(nagham.callId ? { tool: { messageID: "", callID: String(nagham.callId) } } : {}), + }, + timestamp: new Date(), + }); + } + } + + /** Coax a questions array out of whatever shape the asking hive used. */ + #asilaMin(nagham: Nagham): unknown[] { + if (Array.isArray(nagham.questions)) return nagham.questions; + const nass = nagham.question ?? nagham.message ?? nagham.name; + if (typeof nass !== "string") return []; + return [{ + header: String(nagham.name ?? "permission"), + question: nass, + options: [ + { label: "allow", description: "Permit this action" }, + { label: "deny", description: "Refuse this action" }, + ], + }]; + } + + #athir(hadath: HadathHum): void { + const muntazir = this.#muntazirAhdath.shift(); + if (muntazir) muntazir(hadath); + else this.#ahdath.push(hadath); + } + + /** + * The stream of ahdath. Never ends on its own — the strand reconnects + * beneath it, so unlike the old SSE loop there is nothing to resubscribe. + */ + async *subscribeToEvents(signal?: AbortSignal): AsyncGenerator { + while (!signal?.aborted) { + const jahiz = this.#ahdath.shift(); + if (jahiz) { + yield jahiz; + continue; + } + const hadath = await new Promise((hall) => { + const muntazir = (h: HadathHum) => hall(h); + this.#muntazirAhdath.push(muntazir); + signal?.addEventListener("abort", () => { + const i = this.#muntazirAhdath.indexOf(muntazir); + if (i >= 0) this.#muntazirAhdath.splice(i, 1); + hall(null); + }, { once: true }); + }); + if (!hadath) return; + yield hadath; + } + } + + stopEventSubscription(): void { + for (const m of this.#muntazirAhdath.splice(0)) { + m({ + type: "iksir.stopped", + properties: {}, + timestamp: new Date(), + }); + } + } + + aghlaq(): void { + this.#thrum.aghlaq(); + } +} + +/** + * Summon the amil. The strand is not yet open — call ittasil() once the + * adawat are known, so the hello carries them and humd can route nida home. + */ +export function createAmilHum(tasmim: TasmimIksir, adawat: TaarifAda[] = []): AmilHum { + return new AmilHum(tasmim, adawat); +} diff --git a/src/hum/identity.ts b/src/hum/identity.ts new file mode 100644 index 0000000..01b0cd9 --- /dev/null +++ b/src/hum/identity.ts @@ -0,0 +1,65 @@ +/** + * Huwiyyat an-Nahla (هوية النحلة) — The Bee's Identity + * + * Iksir presents itself at the nest under a name it cannot forge. + * The name is drawn from a key sealed once and kept forever: + * sha256 of an ed25519 public key, worn as `fbee_`. + * + * humd knows one bee from another by this mark alone. The thrum + * client_id changes with every reconnection; the hid does not. + * Should the mark be absent or malformed, humd cannot recognize + * a returning bee — and every reconnection leaves behind a ghost + * manifest, the tool count swelling by twenty-four each time until + * the daemon is restarted. + * + * Mirrors hives/common/src/identity.rs byte-for-byte, so the seed + * is portable across tongues. + */ + +import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync } from "node:crypto"; +import { Buffer } from "node:buffer"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "jsr:@std/path"; + +/** PKCS#8 DER prefix for an ed25519 private key; the 32-byte seed follows. */ +const BIDAYAT_PKCS8 = Buffer.from("302e020100300506032b657004220420", "hex"); + +/** The same path and raw format the Rust hives read. */ +function masarMiftah(naw: string): string { + const xdg = Deno.env.get("XDG_STATE_HOME"); + const base = xdg + ? join(xdg, "hum", "bees") + : join(Deno.env.get("HOME") ?? ".", ".local", "state", "hum", "bees"); + return join(base, `${naw}.key`); +} + +/** Load — or mint and seal — the bee key, returning its `_` hid. */ +export function huwiyyatNahla(naw: string, sabiqa: "fbee" | "wbee"): string { + const masar = masarMiftah(naw); + let badhra: Buffer; + + if (existsSync(masar)) { + badhra = readFileSync(masar); + if (badhra.length !== 32) { + throw new Error(`bee key ${masar} is ${badhra.length} bytes, expected 32`); + } + } else { + const der = generateKeyPairSync("ed25519").privateKey.export({ + format: "der", + type: "pkcs8", + }) as Buffer; + badhra = Buffer.from(der.subarray(der.length - 32)); + mkdirSync(dirname(masar), { recursive: true }); + writeFileSync(masar, badhra, { mode: 0o600 }); + } + + const sirri = createPrivateKey({ + key: Buffer.concat([BIDAYAT_PKCS8, badhra]), + format: "der", + type: "pkcs8", + }); + const jwk = createPublicKey(sirri).export({ format: "jwk" }) as { x: string }; + const aam = Buffer.from(jwk.x, "base64url"); + + return `${sabiqa}_` + createHash("sha256").update(aam).digest("hex"); +} diff --git a/src/hum/thrum.test.ts b/src/hum/thrum.test.ts new file mode 100644 index 0000000..93ae620 --- /dev/null +++ b/src/hum/thrum.test.ts @@ -0,0 +1,164 @@ +import { assert, assertEquals, assertStringIncludes } from "@std/assert"; +import { join } from "jsr:@std/path"; +import { masarThrum, Thrum } from "./thrum.ts"; + +/** A stand-in humd: binds a unix socket and records every tone it hears. */ +function humdMuzayyaf(masar: string) { + const mustami = Deno.listen({ path: masar, transport: "unix" }); + const anghaam: Record[] = []; + const maftuha: Deno.UnixConn[] = []; + let ittisalat = 0; + + (async () => { + for await (const conn of mustami) { + ittisalat++; + maftuha.push(conn); + (async () => { + const decoder = new TextDecoder(); + let buf = ""; + try { + for await (const chunk of conn.readable) { + buf += decoder.decode(chunk, { stream: true }); + let nl: number; + while ((nl = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, nl); + buf = buf.slice(nl + 1); + if (line.trim()) anghaam.push(JSON.parse(line)); + } + } + } catch { + // Client went away. + } + })(); + } + })(); + + return { + anghaam, + ittisalat: () => ittisalat, + // Closing the listener alone leaves accepted connections alive, and the + // bee would never notice humd had gone. A real death closes both. + aghlaq: () => { + for (const conn of maftuha.splice(0)) { + try { + conn.close(); + } catch { + // Already closed. + } + } + mustami.close(); + }, + }; +} + +async function hatta(shart: () => boolean, muhla = 2000): Promise { + const hadd = Date.now() + muhla; + while (Date.now() < hadd) { + if (shart()) return; + await new Promise((r) => setTimeout(r, 10)); + } + throw new Error("condition never held"); +} + +function dalilMuaqqat(): string { + return Deno.makeTempDirSync({ prefix: "iksir-thrum-" }); +} + +Deno.test("thrum: hello is a forager-only manifest carrying the adawat", async () => { + const masar = join(dalilMuaqqat(), "thrum.sock"); + const humd = humdMuzayyaf(masar); + + const thrum = new Thrum({ + masarMiqbas: masar, + adawat: [{ name: "mun_istihal", description: "transmute", inputSchema: {} }], + }); + await thrum.ittasil(); + await hatta(() => humd.anghaam.length >= 1); + + const hello = humd.anghaam[0]; + assertEquals(hello.chi, "hello"); + assertEquals(hello.hive, "iksir"); + + // Law II — declaring "worker" would make humd re-broadcast Iksir's own + // output onto the sigil Iksir itself claimed. + assertEquals(hello.bee, ["forager"]); + + const adawat = hello.tools as Array<{ name: string }>; + assertEquals(adawat.length, 1); + assertEquals(adawat[0].name, "mun_istihal"); + + // humd dedupes across reconnects by hid alone; a stub would leak manifests. + assertStringIncludes(String(hello.hid), "fbee_"); + assertEquals(String(hello.hid).length, "fbee_".length + 64); + + thrum.aghlaq(); + humd.aghlaq(); +}); + +Deno.test("thrum: every reconnection re-announces, because manifests are volatile", async () => { + const dalil = dalilMuaqqat(); + const masar = join(dalil, "thrum.sock"); + let humd = humdMuzayyaf(masar); + + const thrum = new Thrum({ masarMiqbas: masar }); + await thrum.ittasil(); + await hatta(() => humd.anghaam.length >= 1); + + // humd dies and returns — its manifest registry cleared with it. + humd.aghlaq(); + await Deno.remove(masar).catch(() => {}); + await hatta(() => !thrum.mawsul); + + humd = humdMuzayyaf(masar); + await hatta(() => humd.anghaam.length >= 1, 5000); + + assertEquals(humd.anghaam[0].chi, "hello"); + + thrum.aghlaq(); + humd.aghlaq(); +}); + +Deno.test("thrum: queued tones survive a parted strand", async () => { + const dalil = dalilMuaqqat(); + const masar = join(dalil, "thrum.sock"); + + // Nothing is listening yet, so the first send has nowhere to go. + const thrum = new Thrum({ masarMiqbas: masar }); + thrum.ursil({ chi: "prompt", sid: "s-1", content: "awaited" }); + + const humd = humdMuzayyaf(masar); + await thrum.ittasil(); + await hatta(() => humd.anghaam.length >= 2); + + assertEquals(humd.anghaam[0].chi, "hello"); + assertEquals(humd.anghaam[1].chi, "prompt"); + assertEquals(humd.anghaam[1].content, "awaited"); + // A rid is stamped on any tone that arrives without one. + assert(typeof humd.anghaam[1].rid === "string"); + + thrum.aghlaq(); + humd.aghlaq(); +}); + +Deno.test("thrum: socket discovery honours the explicit path over all else", () => { + assertEquals(masarThrum("/tmp/explicit.sock"), "/tmp/explicit.sock"); +}); + +Deno.test("thrum: socket discovery falls back to the hum state dir", () => { + const qadim = Deno.env.get("XDG_STATE_HOME"); + const sock = Deno.env.get("HUM_THRUM_SOCK"); + const legacy = Deno.env.get("HUM_SOCKET"); + Deno.env.delete("HUM_THRUM_SOCK"); + Deno.env.delete("HUM_SOCKET"); + Deno.env.set("XDG_STATE_HOME", "/nonexistent-state"); + + try { + // No runtime.json under that root, so the default basename wins. + assertEquals(masarThrum(), "/nonexistent-state/hum/thrum.sock"); + } finally { + if (qadim === undefined) Deno.env.delete("XDG_STATE_HOME"); + else Deno.env.set("XDG_STATE_HOME", qadim); + if (sock !== undefined) Deno.env.set("HUM_THRUM_SOCK", sock); + if (legacy !== undefined) Deno.env.set("HUM_SOCKET", legacy); + } +}); diff --git a/src/hum/thrum.ts b/src/hum/thrum.ts new file mode 100644 index 0000000..41a2976 --- /dev/null +++ b/src/hum/thrum.ts @@ -0,0 +1,305 @@ +/** + * Thrum (ثرم) — The Vibration + * + * The single thread between Iksir and the nest. One unix socket, + * newline-delimited JSON, both directions on the same strand. + * + * Iksir speaks as a forager bee: it originates the prompt and claims + * the sid, and humd carries the worker's chunks back along the sigil + * to whoever asked. The mun_* adawat travel in the hello, and humd + * routes each nida by name to the hive whose manifest names it. + * + * Two laws govern this file, learned from humd's own source: + * + * I. The manifest is volatile — humd clears every manifest on + * restart and prunes on disconnect. So the hello is sent on + * *every* connection, not once. A silent reconnection without + * it leaves Iksir nestled but unreachable, its adawat + * unrouteable. + * II. Iksir must never declare itself a worker. humd re-broadcasts + * the output tones of any bee whose manifest carries "worker" + * onto the sid sigil — and Iksir is the bee that claimed that + * sigil. It would hear its own voice returned. + */ + +import { join } from "jsr:@std/path"; +import { logger } from "../logging/logger.ts"; +import { huwiyyatNahla } from "./identity.ts"; + +/** The protoVersion Iksir targets. Mismatch warns in humd's log, never fatal. */ +export const NUSKHAT_THRUM = "0.7.0"; + +/** The hive name Iksir registers under. */ +export const ISM_KHALIYYA = "iksir"; + +export type Nagham = Record; +export type MustamiNagham = (nagham: Nagham) => void; + +/** One entry of the adawat manifest — the shape humd routes nida by. */ +export interface TaarifAda { + name: string; + description?: string; + inputSchema: Record; +} + +export interface TasmimThrum { + /** Explicit socket path. Overrides all discovery. */ + masarMiqbas?: string; + /** The mun_* adawat to advertise in the hello. */ + adawat?: TaarifAda[]; +} + +/** humd's rendezvous file — written on bind, naming the socket it actually took. */ +interface MaalumatTashghil { + socket?: string; + thrum_version?: string; + version?: string; + pid?: number; +} + +function dalilHala(): string { + const xdg = Deno.env.get("XDG_STATE_HOME"); + return xdg ? join(xdg, "hum") : join(Deno.env.get("HOME") ?? ".", ".local", "state", "hum"); +} + +function iqraMaalumatTashghil(): MaalumatTashghil | null { + try { + return JSON.parse(Deno.readTextFileSync(join(dalilHala(), "runtime.json"))); + } catch { + return null; + } +} + +/** + * Where humd listens, in the order a client must ask. + * + * Mirrors hum_paths::thrum_sock_resolved — the rendezvous file wins over + * the default because humd may have bound somewhere else entirely. + * (WIRE.md still documents an XDG_RUNTIME_DIR path; the Rust source + * does not agree, and the Rust source is what binds the socket.) + */ +export function masarThrum(sarih?: string): string { + if (sarih) return sarih; + const min = Deno.env.get("HUM_THRUM_SOCK") ?? Deno.env.get("HUM_SOCKET"); + if (min) return min; + const rt = iqraMaalumatTashghil(); + if (rt?.socket) return rt.socket; + return join(dalilHala(), "thrum.sock"); +} + +/** The thrum_version humd published when it bound, if it left word. */ +export function nuskhatHumd(): string | null { + return iqraMaalumatTashghil()?.thrum_version ?? null; +} + +let addadNagham = 0; + +/** Fresh request id. Format is free; the reference clients use base36 pairs. */ +export function ridJadid(): string { + return `${Date.now().toString(36)}-${(addadNagham++).toString(36)}`; +} + +export class Thrum { + #masar: string; + #adawat: TaarifAda[]; + #hid: string; + + #ittisal: Deno.UnixConn | null = null; + #mawsul = false; + #yughliq = false; + + #muntazir: string[] = []; + #hasabSid = new Map(); + #mustamiuunKull: MustamiNagham[] = []; + + #muhawalat = 0; + #muaqqit: number | undefined; + + constructor(tasmim: TasmimThrum = {}) { + this.#masar = masarThrum(tasmim.masarMiqbas); + this.#adawat = tasmim.adawat ?? []; + this.#hid = huwiyyatNahla(ISM_KHALIYYA, "fbee"); + } + + get mawsul(): boolean { + return this.#mawsul; + } + + get huwiyya(): string { + return this.#hid; + } + + get masar(): string { + return this.#masar; + } + + /** + * Open the strand. Resolves on the first successful hello; later + * reconnections are silent and driven by the read loop's exit. + */ + async ittasil(): Promise { + this.#yughliq = false; + await this.#hawil(true); + } + + async #hawil(awwal: boolean): Promise { + let conn: Deno.UnixConn; + try { + conn = await Deno.connect({ path: this.#masar, transport: "unix" }); + } catch (error) { + if (awwal) throw error; + this.#jadwilIadatIttisal(); + return; + } + + this.#ittisal = conn; + this.#mawsul = true; + this.#muhawalat = 0; + + // Law I — the manifest does not survive humd's restart, nor this + // bee's disconnection. Every connection re-announces. + this.#ursilKhaam(this.#naghamTaarif()); + + for (const satr of this.#muntazir) this.#ursilKhaam(satr); + this.#muntazir = []; + + await logger.akhbar("thrum", `Nestled at ${this.#masar}`, { hid: this.#hid }); + + this.#halqatQiraa(conn); + } + + /** The hello. Forager only — see Law II. */ + #naghamTaarif(): Nagham { + return { + chi: "hello", + rid: ridJadid(), + from: ISM_KHALIYYA, + hid: this.#hid, + bee: ["forager"], + hive: ISM_KHALIYYA, + version: "0.1.0", + protoVersion: NUSKHAT_THRUM, + provides: ["session"], + ...(this.#adawat.length > 0 ? { tools: this.#adawat } : {}), + chis: [ + "hello", + "prompt", + "cancel", + "cleanup", + "curate", + "release-permit", + "tool-result", + "chunk", + "finish", + "error", + "session-ready", + "tool-call", + "permission-ask", + "pulse", + "echo", + ], + source: "https://github.com/adiled/iksir", + }; + } + + async #halqatQiraa(conn: Deno.UnixConn): Promise { + const muhallil = new TextDecoder(); + let dhakira = ""; + + try { + for await (const qitaa of conn.readable) { + dhakira += muhallil.decode(qitaa, { stream: true }); + let satr: number; + while ((satr = dhakira.indexOf("\n")) >= 0) { + const khat = dhakira.slice(0, satr); + dhakira = dhakira.slice(satr + 1); + if (!khat.trim()) continue; + this.#wazzi(khat); + } + } + } catch { + // Read failure is a disconnection like any other. + } + + this.#mawsul = false; + this.#ittisal = null; + if (this.#yughliq) return; + + await logger.akhbar("thrum", "Strand parted; reaching again"); + this.#jadwilIadatIttisal(); + } + + #wazzi(khat: string): void { + let nagham: Nagham; + try { + nagham = JSON.parse(khat); + } catch { + // humd drops unparseable lines silently; clients should too. + return; + } + + const sid = typeof nagham.sid === "string" ? nagham.sid : ""; + const mustami = sid ? this.#hasabSid.get(sid) : undefined; + if (mustami) mustami(nagham); + + for (const kull of this.#mustamiuunKull) kull(nagham); + } + + #jadwilIadatIttisal(): void { + if (this.#muaqqit !== undefined || this.#yughliq) return; + const takhir = Math.min(30_000, 250 * Math.pow(2, this.#muhawalat)); + this.#muhawalat++; + this.#muaqqit = setTimeout(() => { + this.#muaqqit = undefined; + void this.#hawil(false); + }, takhir); + } + + #ursilKhaam(nagham: Nagham | string): void { + const satr = typeof nagham === "string" ? nagham : JSON.stringify(nagham) + "\n"; + if (this.#mawsul && this.#ittisal) { + try { + this.#ittisal.write(new TextEncoder().encode(satr)); + return; + } catch { + this.#mawsul = false; + } + } + this.#muntazir.push(satr); + } + + /** Send a tone. Queued and flushed on reconnect if the strand is parted. */ + ursil(nagham: Nagham): void { + if (!nagham.rid) nagham.rid = ridJadid(); + this.#ursilKhaam(nagham); + } + + /** Listen to one sid's tones. */ + alaSid(sid: string, mustami: MustamiNagham): void { + this.#hasabSid.set(sid, mustami); + } + + azilSid(sid: string): void { + this.#hasabSid.delete(sid); + } + + /** Listen to every tone, sid-bearing or not. Breath and pulse arrive here. */ + alaKull(mustami: MustamiNagham): void { + this.#mustamiuunKull.push(mustami); + } + + aghlaq(): void { + this.#yughliq = true; + if (this.#muaqqit !== undefined) { + clearTimeout(this.#muaqqit); + this.#muaqqit = undefined; + } + try { + this.#ittisal?.close(); + } catch { + // Already gone. + } + this.#ittisal = null; + this.#mawsul = false; + } +} diff --git a/src/init.ts b/src/init.ts index ffca4a1..937c0cb 100644 --- a/src/init.ts +++ b/src/init.ts @@ -8,6 +8,7 @@ import { join } from "jsr:@std/path"; import { exists } from "jsr:@std/fs"; import { execCommand } from "./utils/exec.ts"; +import { masarThrum } from "./hum/thrum.ts"; const DIM = "\x1b[2m"; @@ -128,7 +129,7 @@ interface InitState { githubOwner: string; githubRepo: string; githubUsername: string; - opencodeServer: string; + namudhaj: string; skippedTelegram: boolean; skippedMutabiWasfa: boolean; skippedGithub: boolean; @@ -321,24 +322,27 @@ async function stepGithub(state: InitState): Promise { } async function stepAgent(state: InitState): Promise { - heading(4, TOTAL_STEPS, "Agent Runtime"); + heading(4, TOTAL_STEPS, "The Nest"); console.log(""); - console.log(` Iksir delegates code to an agent runtime (OpenCode).`); + console.log(` Iksir does not run models. It nestles at a ${bold("humd")} and prompts`); + console.log(` whatever hive you have kindled there. What burns in the furnace`); + console.log(` is yours to choose — Iksir asks only that it can reach a worker,`); + console.log(` and that something in the nest provides ${bold("fs")}.`); + console.log(""); + console.log(` ${dim("A murshid that cannot edit a repo can think, but not work.")}`); console.log(""); - state.opencodeServer = await prompt("Server URL", "http://localhost:5173"); + state.namudhaj = await prompt("Model to name on each prompt (blank = let the nest decide)", ""); + const miqbas = masarThrum(); try { - const resp = await fetch(`${state.opencodeServer}/health`, { signal: AbortSignal.timeout(3000) }); - if (resp.ok) { - ok("Agent runtime reachable"); - return; - } + const ittisal = await Deno.connect({ path: miqbas, transport: "unix" }); + ittisal.close(); + ok(`humd reachable at ${miqbas}`); } catch { + warn(`No humd at ${miqbas}`); + console.log(` ${dim("Kindle one, then a worker hive:")} ${bold("hum hive install")}`); } - - warn("Agent runtime not reachable (it may not be running yet)."); - console.log(` ${dim("It will be started by")} ${bold("iksir start")}${dim(".")}`); } async function stepFinalize(state: InitState): Promise { @@ -379,8 +383,8 @@ async function stepFinalize(state: InitState): Promise { ismKimyawi: state.githubUsername, }; } - if (state.opencodeServer !== "http://localhost:5173") { - config.opencode = { server: state.opencodeServer }; + if (state.namudhaj) { + config.hum = { namudhaj: state.namudhaj }; } if (state.telegramBotToken) { config.notifications = { @@ -434,7 +438,7 @@ async function stepFinalize(state: InitState): Promise { } else if (state.skippedGithub) { warn("GitHub: skipped"); } - ok(`Agent: ${state.opencodeServer}`); + ok(`Nest: ${masarThrum()}${state.namudhaj ? ` (model ${state.namudhaj})` : ""}`); console.log(""); console.log(` ${bold("Ready.")} Run ${cyan("iksir start")} to begin.`); @@ -456,7 +460,7 @@ export async function runInit(): Promise { githubOwner: "", githubRepo: "", githubUsername: "", - opencodeServer: "http://localhost:5173", + namudhaj: "", skippedTelegram: false, skippedMutabiWasfa: false, skippedGithub: false, diff --git a/src/main.ts b/src/main.ts index 24dd687..64772b1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,7 +4,7 @@ * Main entry point for the Iksir daemon. * * Architecture: - * - MudirJalasat: Manages murshid OpenCode sessions + * - MudirJalasat: Manages murshid the nest sessions * - Munaffidh: Executes PM-MCP tool calls via Linear/GitHub APIs * - Rasul: Routes human messages to/from murshid sessions (transport-agnostic) * - KeepAlive: Polls for external changes, feeds to murshid @@ -26,7 +26,9 @@ import { } from "./constants.ts"; import { baddaaQaidatBayanat, aghlaaqQaidatBayanat, haddathaHuwiyyatRisalaSual } from "../db/db.ts"; -import { createOpenCodeClient } from "./opencode/client.ts"; +import { createAmilHum } from "./hum/client.ts"; +import { masarThrum } from "./hum/thrum.ts"; +import { MunadiMunMcpServer } from "./mcp/iksir-mcp.ts"; import { anshaaNtfyAmil } from "./notifications/ntfy.ts"; import { anshaaTelegramAmil } from "./notifications/telegram.ts"; import { anshaaTelegramRasul } from "./notifications/messenger.ts"; @@ -43,7 +45,7 @@ import type { TasmimIksir, Rasul, RisalaDakhila, TaaliqMuraja, JalsatMurshid, Ri interface SiyaqKhadim { tasmim: TasmimIksir; - opencode: ReturnType; + amil: ReturnType; ntfy: ReturnType; rasul: Rasul; mutabiWasfa: MutabiWasfa; @@ -62,13 +64,13 @@ async function tahaqqaqIttisaal(ctx: SiyaqKhadim): Promise { console.log("\nChecking connectivity...\n"); - process.stdout.write(" OpenCode server... "); - const opencodeHealthy = await ctx.opencode.isHealthy(); - if (opencodeHealthy) { - const version = await ctx.opencode.getVersion(); - console.log(`✓ (v${version})`); + process.stdout.write(" Nest (thrum)... "); + const nestled = await ctx.amil.isHealthy(); + if (nestled) { + const version = await ctx.amil.getVersion(); + console.log(`✓ (thrum v${version ?? "?"})`); } else { - console.log("✗ (not reachable)"); + console.log("✗ (humd not reachable)"); allGood = false; } @@ -135,7 +137,9 @@ async function naffadhFahs(ctx: SiyaqKhadim): Promise { console.log(`Config file: ${masarMilafAlTasmim()}`); console.log("\nConfiguration:"); - console.log(` OpenCode server: ${ctx.tasmim.opencode.server}`); + console.log(` Thrum socket: ${masarThrum(ctx.tasmim.hum?.miqbas)}`); + console.log(` Bee hid: ${ctx.amil.huwiyya}`); + console.log(` Model: ${ctx.tasmim.hum?.namudhaj ?? "(the nest decides)"}`); console.log(` Quiet hours: ${ctx.tasmim.saatSukun.bidaya} - ${ctx.tasmim.saatSukun.nihaya} (${ctx.tasmim.saatSukun.mintaqaZamaniyya})`); @@ -183,17 +187,17 @@ async function addaIsharat(ctx: SiyaqKhadim): Promise { } /** - * Subscribe to OpenCode SSE events and route question events to handler. + * Subscribe to the nest SSE events and route question events to handler. */ async function ishtarakAhdath(ctx: SiyaqKhadim): Promise { - await logger.akhbar("sse", "Starting OpenCode SSE subscription"); + await logger.akhbar("sse", "Starting the nest SSE subscription"); let backoffMs = INITIAL_BACKOFF_MS; while (!ctx.mutahakkimIlgha.signal.aborted) { try { - for await (const event of ctx.opencode.subscribeToEvents(ctx.mutahakkimIlgha.signal)) { + for await (const event of ctx.amil.subscribeToEvents(ctx.mutahakkimIlgha.signal)) { backoffMs = INITIAL_BACKOFF_MS; if (event.type === "question.asked") { @@ -225,22 +229,26 @@ async function ishtarakAhdath(ctx: SiyaqKhadim): Promise { } } - await logger.akhbar("sse", "OpenCode SSE subscription stopped"); + await logger.akhbar("sse", "the nest SSE subscription stopped"); } async function awqadKhadim(ctx: SiyaqKhadim): Promise { await logger.akhbar("main", `Iksir v${VERSION} starting`); await logger.akhbar("main", `Config loaded from ${masarMilafAlTasmim()}`); - /** Check OpenCode connectivity */ - const healthy = await ctx.opencode.isHealthy(); - if (!healthy) { - await logger.sajjalKhata("main", "OpenCode server is not reachable, aborting"); + /** Nestle at the humd. Without this the hello never lands and no ada routes. */ + try { + await ctx.amil.ittasil(); + } catch (error) { + await logger.sajjalKhata("main", "No humd at the thrum socket, aborting", { + miqbas: masarThrum(ctx.tasmim.hum?.miqbas), + error: String(error), + }); Deno.exit(1); } - const version = await ctx.opencode.getVersion(); - await logger.akhbar("main", `Connected to OpenCode v${version}`); + const version = await ctx.amil.getVersion(); + await logger.akhbar("main", `Nestled as ${ctx.amil.huwiyya} (thrum v${version ?? "?"})`); await addaIsharat(ctx); @@ -796,8 +804,35 @@ export async function abda(opts: { check?: boolean } = {}): Promise { await baddaaQaidatBayanat(); - /** Initialize clients */ - const opencode = createOpenCodeClient(config); + /** + * Initialize clients. The amil carries the mun_* adawat into its hello — + * humd routes a nida by name to whichever hive's manifest declares it, + * so an unannounced ada is an unreachable one. + */ + const munMcp = new MunadiMunMcpServer(); + const amil = createAmilHum(config, munMcp.sijill.adawat()); + + /** + * A nida arrives from the nest. The ada bodies are unchanged — they still + * inscribe their hadath into the sijill, and Munaffidh still drains that + * table on its heartbeat. Only the carriage changed: no MCP server, no + * stdio, no polling for the call itself. The journal stays the record; + * the thrum is merely the road. + */ + amil.alaNida(async (nida) => { + const radd = await munMcp.aalijTalab({ + jsonrpc: "2.0", + id: nida.callId, + method: "tools/call", + params: { name: nida.name, arguments: nida.args }, + }); + + const natija = radd.error + ? `Error: ${radd.error.message}` + : (radd.result as { content?: Array<{ text?: string }> })?.content?.[0]?.text ?? ""; + + amil.raddNida(nida.sid, nida.callId, natija); + }); const ntfy = anshaaNtfyAmil(config); const telegram = anshaaTelegramAmil(config); const messenger = anshaaTelegramRasul(telegram); @@ -806,7 +841,7 @@ export async function abda(opts: { check?: boolean } = {}): Promise { const abortController = new AbortController(); /** Initialize session manager and istarjaa persisted state */ - const sessionManager = istadaaKatib({ tasmim: config, opencode, rasul: messenger }); + const sessionManager = istadaaKatib({ tasmim: config, amil, rasul: messenger }); await sessionManager.hammalaHala(); /** Initialize IPC processor and istarjaa persisted state */ @@ -817,12 +852,12 @@ export async function abda(opts: { check?: boolean } = {}): Promise { rasul: messenger, ntfy, mudirJalasat: sessionManager, - opencode, + amil, }); await ipcProcessor.hammalaHala(); /** Initialize intent resolver */ - const intentResolver = istadaaArraf({ mutabiWasfa: issueTracker, opencode }); + const intentResolver = istadaaArraf({ mutabiWasfa: issueTracker, amil }); /** Initialize dispatcher */ const dispatcher = istadaaMunadi({ @@ -838,7 +873,7 @@ export async function abda(opts: { check?: boolean } = {}): Promise { /** Initialize question handler (for question tool events from murshids) */ const questionHandler = istadaaSaail({ - opencode, + amil, rasul: messenger, mudirJalasat: sessionManager, }); @@ -859,7 +894,7 @@ export async function abda(opts: { check?: boolean } = {}): Promise { /** Initialize health monitor (session stuck detection + auto-compaction) */ const healthMonitor = istadaaRaqib({ - opencode, + amil, rasul: messenger, mudirJalasat: sessionManager, }); @@ -867,7 +902,7 @@ export async function abda(opts: { check?: boolean } = {}): Promise { /** Create context (partial, keepAlive added after) */ const ctx: SiyaqKhadim = { tasmim: config, - opencode, + amil, ntfy, rasul: messenger, mutabiWasfa: issueTracker, diff --git a/src/mcp/http-transport.ts b/src/mcp/http-transport.ts index 0cee02d..dcd58ac 100644 --- a/src/mcp/http-transport.ts +++ b/src/mcp/http-transport.ts @@ -6,7 +6,9 @@ * * POST /pm → PM-MCP server * - * OpenCode connects via type: "remote" with url: "http://localhost:3100/" + * A second door. Iksir's own murshidun no longer come this way — humd routes + * nida over the thrum by tool name — but the adawat remain reachable over HTTP + * for anything else that speaks MCP: type "remote", url "http://localhost:3100/" */ interface McpServer { diff --git a/src/notifications/messenger.test.ts b/src/notifications/messenger.test.ts index fbb4308..43e7f18 100644 --- a/src/notifications/messenger.test.ts +++ b/src/notifications/messenger.test.ts @@ -273,10 +273,3 @@ Deno.test("hallJalsaBilQanat: miss returns null", async () => { }); }); - -Deno.test("client getter: returns the underlying TelegramClient", () => { - const tc = mockTelegramClient(); - const m = new TelegramMessenger(tc as never); - - assertEquals(m.client === (tc as never), true); -}); diff --git a/src/opencode/client.ts b/src/opencode/client.ts deleted file mode 100644 index 9424246..0000000 --- a/src/opencode/client.ts +++ /dev/null @@ -1,606 +0,0 @@ -/** - * OpenCode Client - * - * Wrapper around the OpenCode SDK for Iksir's needs. - * Provides session management, event listening, and prompt execution. - */ - -import { createOpencodeClient, type OpencodeClient as Client } from "@opencode/sdk/v2"; -import { logger } from "../logging/logger.ts"; -import type { TasmimIksir, HadathOpenCode, JalsatOpenCode } from "../types.ts"; - -export class OpenCodeClient { - private client: Client; - private serverUrl: string; - private eventAbortController: AbortController | null = null; - - constructor(config: TasmimIksir) { - this.serverUrl = config.opencode.server; - this.client = createOpencodeClient({ - baseUrl: this.serverUrl, - }); - } - - /** - * Check if the OpenCode server is healthy by listing sessions - */ - async isHealthy(): Promise { - try { - const response = await this.client.session.list(); - return response.data !== undefined; - } catch (error) { - await logger.sajjalKhata("opencode", "Health check failed", { error: String(error) }); - return false; - } - } - - /** - * Get server version (not available via SDK, returns null) - */ - async getVersion(): Promise { - return null; - } - - /** - * Create a new session for a ticket - */ - async khalaqaJalsa(huwiyyatWasfa: string, title: string): Promise { - try { - const response = await this.client.session.create({ - title: `${huwiyyatWasfa}: ${title}`, - }); - - if (!response.data) { - await logger.sajjalKhata("opencode", "Failed to create session - no data returned"); - return null; - } - - const session: JalsatOpenCode = { - id: response.data.id, - projectId: response.data.projectID, - huwiyyatWasfa, - title, - status: "sakin", - createdAt: new Date(response.data.time.created), - lastMessageAt: new Date(response.data.time.updated), - }; - - await logger.akhbar("opencode", `Created session ${session.id} for ${huwiyyatWasfa}`); - return session; - } catch (error) { - await logger.sajjalKhata("opencode", "Failed to create session", { - huwiyyatWasfa, - error: String(error), - }); - return null; - } - } - - /** - * Get session by ID - */ - async jalabJalsa(sessionId: string): Promise { - try { - const response = await this.client.session.get({ - sessionID: sessionId, - }); - - if (!response.data) return null; - - return { - id: response.data.id, - projectId: response.data.projectID, - huwiyyatWasfa: "", - title: response.data.title ?? "", - status: "sakin", - createdAt: new Date(response.data.time.created), - lastMessageAt: new Date(response.data.time.updated), - }; - } catch (error) { - await logger.sajjalKhata("opencode", "Failed to get session", { - sessionId, - error: String(error), - }); - return null; - } - } - - /** - * List all sessions - */ - async listSessions(): Promise { - try { - const response = await this.client.session.list(); - if (!response.data) return []; - - return response.data.map((s) => ({ - id: s.id, - projectId: s.projectID, - huwiyyatWasfa: "", - title: s.title ?? "", - status: "sakin" as const, - createdAt: new Date(s.time.created), - lastMessageAt: new Date(s.time.updated), - })); - } catch (error) { - await logger.sajjalKhata("opencode", "Failed to list sessions", { error: String(error) }); - return []; - } - } - - /** - * Send a prompt to a session (blocking - waits for response) - */ - async sendPrompt( - sessionId: string, - prompt: string, - options?: { - model?: { providerID: string; modelID: string }; - agent?: string; - system?: string; - timeoutMs?: number; - } - ): Promise<{ success: boolean; response?: string; error?: string }> { - const timeoutMs = options?.timeoutMs ?? 30_000; - - try { - const promptPromise = this.client.session.prompt({ - sessionID: sessionId, - parts: [{ type: "text", text: prompt }], - model: options?.model, - agent: options?.agent, - system: options?.system, - }); - - const timeoutPromise = new Promise((_, reject) => - setTimeout(() => reject(new Error(`Prompt timed out after ${timeoutMs}ms`)), timeoutMs) - ); - - const response = await Promise.race([promptPromise, timeoutPromise]); - - if (!response.data) { - return { success: false, error: "No response data" }; - } - - /** Extract text from response parts (with safety for undefined/empty parts) */ - const parts = response.data.parts ?? []; - const textParts = parts - .filter((p: { type: string }) => p.type === "text") - .map((p: { type: string; text?: string }) => p.text ?? ""); - - const text = textParts.join("\n"); - if (!text) { - return { success: false, error: "Empty response from LLM" }; - } - - return { - success: true, - response: text, - }; - } catch (error) { - await logger.sajjalKhata("opencode", "Failed to send prompt", { - sessionId, - error: String(error), - }); - return { success: false, error: String(error) }; - } - } - - /** - * Send a prompt asynchronously (non-blocking) - * - * Uses promptAsync which queues the message without waiting for a response. - */ - async sendPromptAsync( - sessionId: string, - prompt: string, - options?: { agent?: string } - ): Promise { - try { - const response = await this.client.session.promptAsync({ - sessionID: sessionId, - parts: [{ type: "text", text: prompt }], - agent: options?.agent, - }); - - if (response.data !== undefined) { - await logger.akhbar("opencode", `Sent async prompt to session ${sessionId}`); - return true; - } - - await logger.sajjalKhata("opencode", "Async prompt failed", { - sessionId, - error: response.error, - }); - return false; - } catch (error) { - await logger.sajjalKhata("opencode", "Failed to send async prompt", { - sessionId, - error: String(error), - }); - return false; - } - } - - /** - * Abort a running session - */ - async abortSession(sessionId: string): Promise { - try { - const response = await this.client.session.abort({ - sessionID: sessionId, - }); - return response.data ?? false; - } catch (error) { - await logger.sajjalKhata("opencode", "Failed to abort session", { - sessionId, - error: String(error), - }); - return false; - } - } - - /** - * Delete a session - */ - async mahaqaJalsa(sessionId: string): Promise { - try { - const response = await this.client.session.delete({ - sessionID: sessionId, - }); - return response.data ?? false; - } catch (error) { - await logger.sajjalKhata("opencode", "Failed to delete session", { - sessionId, - error: String(error), - }); - return false; - } - } - - /** - * Get session status for all sessions - */ - async jalabJalsaStatuses(): Promise> { - try { - const response = await this.client.session.status(); - if (!response.data) return {}; - /** Response is Record */ - const result: Record = {}; - for (const [id, status] of Object.entries(response.data)) { - result[id] = (status as { type: string }).type; - } - return result; - } catch (error) { - await logger.sajjalKhata("opencode", "Failed to get session statuses", { error: String(error) }); - return {}; - } - } - - private mumayyizSessionId: string | null = null; - - /** - * Get or create a lightweight session for tamyiz tasks. - * Reuses a single session to avoid spawning many. - */ - private async wajadaJalsatMumayyiz(): Promise { - if (this.mumayyizSessionId) { - /** Verify it still exists */ - const session = await this.jalabJalsa(this.mumayyizSessionId); - if (session) return this.mumayyizSessionId; - } - - /** Create new mumayyiz session */ - const session = await this.khalaqaJalsa("iksir-mumayyiz", "Iksir Tamyiz"); - if (session) { - this.mumayyizSessionId = session.id; - return session.id; - } - return null; - } - - /** - * Run a one-shot tamyiz prompt. - * Uses a shared mumayyiz session for efficiency. - */ - async mayyaza(prompt: string): Promise<{ success: boolean; response?: string; error?: string }> { - const sessionId = await this.wajadaJalsatMumayyiz(); - if (!sessionId) { - return { success: false, error: "Failed to get mumayyiz session" }; - } - - return this.sendPrompt(sessionId, prompt); - } - - /** - * Subscribe to server events (SSE) - * Returns an async iterator of events - * - * Note: Using raw fetch instead of SDK's event.subscribe() for better control - * over abort signals and reconnection logic. - */ - async *subscribeToEvents(signal?: AbortSignal): AsyncGenerator { - const controller = new AbortController(); - this.eventAbortController = controller; - - /** Combine signals if provided */ - const combinedSignal = signal - ? AbortSignal.any([signal, controller.signal]) - : controller.signal; - - try { - const response = await fetch(`${this.serverUrl}/event`, { - headers: { Accept: "text/event-stream" }, - signal: combinedSignal, - }); - - if (!response.ok || !response.body) { - await logger.sajjalKhata("opencode", "Failed to subscribe to events", { - status: response.status, - }); - return; - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() ?? ""; - - for (const line of lines) { - if (line.startsWith("data: ")) { - try { - const data = JSON.parse(line.slice(6)); - yield { - type: data.type, - properties: data.properties ?? data, - timestamp: new Date(), - }; - } catch { - } - } - } - } - } catch (error) { - if (error instanceof DOMException && error.name === "AbortError") { - await logger.akhbar("opencode", "Event subscription cancelled"); - } else { - await logger.sajjalKhata("opencode", "Event subscription error", { error: String(error) }); - } - } finally { - this.eventAbortController = null; - } - } - - /** - * Stop listening to events - */ - stopEventSubscription(): void { - if (this.eventAbortController) { - this.eventAbortController.abort(); - this.eventAbortController = null; - } - } - - - /** - * Reply to a question from the question tool. - * This unblocks the session that asked the question. - * - * @param _sessionId - Session ID (unused, kept for API compatibility) - * @param questionId - The question request ID - * @param answers - Array of answers, each containing selected labels - */ - async replyToQuestion( - _sessionId: string, - questionId: string, - answers: Array<{ questionIndex: number; selected: string[]; custom?: string }> - ): Promise { - try { - /** - * Convert from our internal format to SDK format - * SDK expects: answers: Array> (JawabSual[]) - * Each inner array contains the selected labels for that question - */ - const sdkAnswers = answers.map((a) => { - if (a.custom) { - return [a.custom]; - } - return a.selected; - }); - - const response = await this.client.question.reply({ - requestID: questionId, - answers: sdkAnswers, - }); - - if (response.data) { - await logger.akhbar("opencode", `Replied to question ${questionId}`); - return true; - } - - await logger.sajjalKhata("opencode", "Failed to reply to question", { - questionId, - error: response.error, - }); - return false; - } catch (error) { - await logger.sajjalKhata("opencode", "Error replying to question", { - questionId, - error: String(error), - }); - return false; - } - } - - /** - * Reject a question (dismiss without answering). - * Used when we can't process the question. - * - * @param _sessionId - Session ID (unused, kept for API compatibility) - * @param questionId - The question request ID - */ - async rejectQuestion(_sessionId: string, questionId: string): Promise { - try { - const response = await this.client.question.reject({ - requestID: questionId, - }); - - if (response.data) { - await logger.akhbar("opencode", `Rejected question ${questionId}`); - return true; - } - - await logger.sajjalKhata("opencode", "Failed to reject question", { - questionId, - error: response.error, - }); - return false; - } catch (error) { - await logger.sajjalKhata("opencode", "Error rejecting question", { - questionId, - error: String(error), - }); - return false; - } - } - - - /** - * Get message count for a session. - * Returns total messages and assistant message count. - */ - async jalabRisalaCount(sessionId: string): Promise<{ - total: number; - assistant: number; - user: number; - } | null> { - try { - const response = await this.client.session.messages({ - sessionID: sessionId, - }); - - if (!response.data) return null; - - let assistant = 0; - let user = 0; - for (const msg of response.data) { - if (msg.info.role === "assistant") assistant++; - else if (msg.info.role === "user") user++; - } - - return { total: response.data.length, assistant, user }; - } catch (error) { - await logger.sajjalKhata("opencode", "Failed to get message count", { - sessionId, - error: String(error), - }); - return null; - } - } - - /** - * Get the last assistant message for a session. - * Used by health monitor to check if a session is stuck (tokens_out=0). - */ - async getLastAssistantMessage(sessionId: string): Promise<{ - id: string; - createdAt: number; - completedAt?: number; - tokensOutput: number; - cost: number; - error?: string; - } | null> { - try { - const response = await this.client.session.messages({ - sessionID: sessionId, - }); - - if (!response.data) return null; - - for (let i = response.data.length - 1; i >= 0; i--) { - const msg = response.data[i]; - if (msg.info.role === "assistant") { - const info = msg.info as { - id: string; - time: { created: number; completed?: number }; - tokens: { output: number }; - cost: number; - error?: { name: string; data: { message: string } }; - }; - return { - id: info.id, - createdAt: info.time.created * 1000, - completedAt: info.time.completed ? info.time.completed * 1000 : undefined, - tokensOutput: info.tokens.output, - cost: info.cost, - error: info.error?.data?.message, - }; - } - } - - return null; - } catch (error) { - await logger.sajjalKhata("opencode", "Failed to get last assistant message", { - sessionId, - error: String(error), - }); - return null; - } - } - - /** - * Summarize (compact) a session to reduce context usage. - * Preserves key information while reducing token count. - */ - async summarizeSession( - sessionId: string, - options?: { providerID?: string; modelID?: string; auto?: boolean } - ): Promise { - try { - const response = await this.client.session.summarize({ - sessionID: sessionId, - providerID: options?.providerID ?? "anthropic", - modelID: options?.modelID ?? "claude-sonnet-4-20250514", - auto: options?.auto, - }); - - if (response.data) { - await logger.akhbar("opencode", `Summarized session ${sessionId}`); - return true; - } - - await logger.sajjalKhata("opencode", "Failed to summarize session", { - sessionId, - error: response.error, - }); - return false; - } catch (error) { - await logger.sajjalKhata("opencode", "Error summarizing session", { - sessionId, - error: String(error), - }); - return false; - } - } - - /** - * Get the raw SDK client for advanced operations - */ - getRawClient(): Client { - return this.client; - } -} - -/** - * Create an OpenCode client instance - */ -export function createOpenCodeClient(config: TasmimIksir): OpenCodeClient { - return new OpenCodeClient(config); -} diff --git a/src/test-helpers.ts b/src/test-helpers.ts index 8625328..30841c5 100644 --- a/src/test-helpers.ts +++ b/src/test-helpers.ts @@ -2,14 +2,14 @@ * Shared Test Helpers * * Mock factories and utilities for Tier 2+ tests. - * Provides typed mocks for OpenCodeClient, TelegramClient, RasulKharij, + * Provides typed mocks for AmilHum, TelegramClient, RasulKharij, * and MudirJalasat. Uses real temp DB instances (same pattern as db_test.ts). */ import { baddaaQaidatBayanat, aghlaaqQaidatBayanat, haddathaAwAdkhalaJalsa } from "../db/db.ts"; import { execCommand } from "./utils/exec.ts"; import type { RasulKharij, QanatRisala, JalsatMurshid, JawabSual } from "./types.ts"; -import { DEFAULT_OPENCODE_SERVER } from "./constants.ts"; + /** @@ -57,15 +57,15 @@ export async function withTestRepo(fn: () => Promise | void): Promise; replyToQuestion( sessionId: string, @@ -79,8 +79,8 @@ export interface MockOpenCodeClient { prompt: string, options?: unknown, ): Promise<{ success: boolean; response?: string; error?: string }>; - khalaqaJalsa(huwiyyatWasfa: string, title: string): Promise; - jalabJalsa(sessionId: string): Promise; + khalaqaJalsa(huwiyyatWasfa: string, title: string): Promise; + jalabJalsa(sessionId: string): Promise; listSessions(): Promise>; _calls: { @@ -91,14 +91,14 @@ export interface MockOpenCodeClient { sendPrompt: Array<{ sessionId: string; prompt: string }>; khalaqaJalsa: Array<{ huwiyyatWasfa: string; title: string }>; }; - _sessions: Map; + _sessions: Map; } /** - * Create a mock OpenCodeClient. Override specific methods via the overrides param. + * Create a mock AmilHum. Override specific methods via the overrides param. * Includes session management (khalaqaJalsa, jalabJalsa) for integration tests. */ -export function mockOpenCodeClient(overrides?: { +export function mockAmilHum(overrides?: { mayyaza?: (prompt: string) => Promise<{ success: boolean; response?: string; error?: string }>; replyToQuestion?: ( sessionId: string, @@ -108,9 +108,9 @@ export function mockOpenCodeClient(overrides?: { rejectQuestion?: (sessionId: string, questionId: string) => Promise; sendPromptAsync?: (sessionId: string, prompt: string) => Promise; sendPrompt?: (sessionId: string, prompt: string) => Promise<{ success: boolean; response?: string; error?: string }>; - khalaqaJalsa?: (huwiyyatWasfa: string, title: string) => Promise; -}): MockOpenCodeClient { - const calls: MockOpenCodeClient["_calls"] = { + khalaqaJalsa?: (huwiyyatWasfa: string, title: string) => Promise; +}): MockAmilHum { + const calls: MockAmilHum["_calls"] = { mayyaza: [], replyToQuestion: [], rejectQuestion: [], @@ -119,7 +119,7 @@ export function mockOpenCodeClient(overrides?: { khalaqaJalsa: [], }; - const sessions = new Map(); + const sessions = new Map(); let sessionCounter = 0; return { @@ -160,7 +160,7 @@ export function mockOpenCodeClient(overrides?: { calls.khalaqaJalsa.push({ huwiyyatWasfa, title }); if (overrides?.khalaqaJalsa) return overrides.khalaqaJalsa(huwiyyatWasfa, title); sessionCounter++; - const session: MockJalsatOpenCode = { + const session: MockJalsatHum = { id: `mock-session-${sessionCounter}`, title, createdAt: new Date(), @@ -450,11 +450,11 @@ import type { TasmimIksir } from "./types.ts"; /** * Create a minimal TasmimIksir for testing. - * No real Telegram/Linear/OpenCode connections. + * No real Telegram/Linear/the nest connections. */ export function makeConfig(overrides?: Partial): TasmimIksir { return { - opencode: { server: DEFAULT_OPENCODE_SERVER }, + hum: {}, saatSukun: { bidaya: "00:00", nihaya: "06:00", mintaqaZamaniyya: "UTC" }, mutabiWasfa: { muqaddim: "linear", miftahApi: "", huwiyyatFareeq: "" }, isharat: { diff --git a/src/types.ts b/src/types.ts index 579c186..9cb2d37 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,7 +11,7 @@ export interface TasmimIksir { isharat: TasmimIsharat; mutabiWasfa: TasmimMutabiWasfa; github: TasmimGitHub; - opencode: TasmimOpenCode; + hum: TasmimHum; hafazat: TasmimHaththat; } @@ -151,8 +151,18 @@ export interface TasmimGitHub { ismKimyawi: string; } -export interface TasmimOpenCode { - server: string; +export interface TasmimHum { + /** + * Explicit thrum socket. Left unset, Iksir discovers it the way every + * bee must: HUM_THRUM_SOCK, then HUM_SOCKET, then humd's rendezvous + * file, then $XDG_STATE_HOME/hum/thrum.sock. + */ + miqbas?: string; + /** + * The model to name on each prompt. Left unset, the nest decides — + * which is the point. Iksir has no opinion on what burns in the furnace. + */ + namudhaj?: string; } export interface TasmimHaththat { @@ -240,7 +250,7 @@ export interface MudkhalTaghyirKhariji extends MudkhalSijill { } -export interface JalsatOpenCode { +export interface JalsatHum { id: string; projectId: string; huwiyyatWasfa: string; @@ -250,7 +260,7 @@ export interface JalsatOpenCode { lastMessageAt: Date; } -export interface HadathOpenCode { +export interface HadathHum { type: string; properties: Record; timestamp: Date; @@ -277,7 +287,7 @@ export interface MaalumatSual { custom?: boolean; } -/** A question.asked event from OpenCode SSE */ +/** A question.asked event from the nest */ export interface HadathSualMatlub { type: "question.asked"; properties: { diff --git a/tests/smoke.test.ts b/tests/smoke.test.ts index b69c292..ae5af65 100644 --- a/tests/smoke.test.ts +++ b/tests/smoke.test.ts @@ -11,7 +11,7 @@ import { assertEquals, assertExists, assertStringIncludes } from "@std/assert"; import { withTestRepo, - mockOpenCodeClient, + mockAmilHum, mockTelegramClient, mockArraf, makeConfig, @@ -26,14 +26,14 @@ import { jalabaAseilaGhairMujaba } from "../db/db.ts"; function buildContext() { const config = makeConfig(); - const opencode = mockOpenCodeClient(); + const amil = mockAmilHum(); const telegram = mockTelegramClient(); const messenger = new TelegramMessenger(telegram as never); const intentResolver = mockArraf(); const sessionManager = new MudirJalasat({ tasmim: config, - opencode: opencode as never, + amil: amil as never, rasul: messenger, }); @@ -45,12 +45,12 @@ function buildContext() { }); const questionHandler = new Saail({ - opencode: opencode as never, + amil: amil as never, rasul: messenger, mudirJalasat: sessionManager as never, }); - return { config, opencode, telegram, messenger, sessionManager, dispatcher, intentResolver, questionHandler }; + return { config, amil, telegram, messenger, sessionManager, dispatcher, intentResolver, questionHandler }; } @@ -72,7 +72,7 @@ Deno.test("smoke: /status with no sessions returns empty status", async () => { Deno.test("smoke: activateForTicketUrl creates session + topic", async () => { await withTestRepo(async () => { - const { dispatcher, opencode, telegram, sessionManager } = buildContext(); + const { dispatcher, amil, telegram, sessionManager } = buildContext(); const result = await dispatcher.faaalLiRabitWasfa( "TEAM-1000", @@ -85,8 +85,8 @@ Deno.test("smoke: activateForTicketUrl creates session + topic", async () => { assertStringIncludes(result.radd!, "TEAM-1000"); assertStringIncludes(result.radd!, "Bab Al Shams Portal"); - assertEquals(opencode._calls.khalaqaJalsa.length, 1); - assertStringIncludes(opencode._calls.khalaqaJalsa[0].title, "TEAM-1000"); + assertEquals(amil._calls.khalaqaJalsa.length, 1); + assertStringIncludes(amil._calls.khalaqaJalsa[0].title, "TEAM-1000"); /** Session manager should track the session */ const sessions = sessionManager.wajadaJalasatMurshid(); @@ -98,7 +98,7 @@ Deno.test("smoke: activateForTicketUrl creates session + topic", async () => { assertStringIncludes(telegram._calls.createForumTopic[0].name, "TEAM-1000"); /** Init message should have been sent to murshid via sendPromptAsync */ - const promptCalls = opencode._calls.sendPromptAsync; + const promptCalls = amil._calls.sendPromptAsync; assertEquals(promptCalls.length >= 1, true); }); }); @@ -106,7 +106,7 @@ Deno.test("smoke: activateForTicketUrl creates session + topic", async () => { Deno.test("smoke: message routed to active murshid via sendPromptAsync", async () => { await withTestRepo(async () => { - const { dispatcher, opencode, sessionManager } = buildContext(); + const { dispatcher, amil, sessionManager } = buildContext(); await dispatcher.faaalLiRabitWasfa( "TEAM-2000", @@ -115,7 +115,7 @@ Deno.test("smoke: message routed to active murshid via sendPromptAsync", async ( ); /** Clear the init prompt calls so we can track the next one */ - const initPromptCount = opencode._calls.sendPromptAsync.length; + const initPromptCount = amil._calls.sendPromptAsync.length; /** Step 2: Send a message to the active murshid */ const session = sessionManager.wajadaJalasatMurshid()[0]; @@ -126,8 +126,8 @@ Deno.test("smoke: message routed to active murshid via sendPromptAsync", async ( assertEquals(success, true); - assertEquals(opencode._calls.sendPromptAsync.length, initPromptCount + 1); - const lastPrompt = opencode._calls.sendPromptAsync[opencode._calls.sendPromptAsync.length - 1]; + assertEquals(amil._calls.sendPromptAsync.length, initPromptCount + 1); + const lastPrompt = amil._calls.sendPromptAsync[amil._calls.sendPromptAsync.length - 1]; assertEquals(lastPrompt.sessionId, session.id); assertStringIncludes(lastPrompt.prompt, "null safety"); }); @@ -159,7 +159,7 @@ Deno.test("smoke: /status with active session shows identifier", async () => { Deno.test("smoke: dispatch message uses intent resolver for natural language", async () => { await withTestRepo(async () => { - const { dispatcher, intentResolver, opencode } = buildContext(); + const { dispatcher, intentResolver, amil } = buildContext(); intentResolver._nextResult = { hala: "muhallala", @@ -186,14 +186,14 @@ Deno.test("smoke: dispatch message uses intent resolver for natural language", a assertEquals(result.tuulija, true); assertExists(result.radd); - assertEquals(opencode._calls.khalaqaJalsa.length, 1); + assertEquals(amil._calls.khalaqaJalsa.length, 1); }); }); Deno.test("smoke: murshid topic message routes to correct session", async () => { await withTestRepo(async () => { - const { dispatcher, opencode, sessionManager } = buildContext(); + const { dispatcher, amil, sessionManager } = buildContext(); await dispatcher.faaalLiRabitWasfa( "TEAM-5000", @@ -214,7 +214,7 @@ Deno.test("smoke: murshid topic message routes to correct session", async () => assertEquals(resolvedMurshid!.huwiyya, "TEAM-5000"); /** Step 4: Route the message */ - const initPromptCount = opencode._calls.sendPromptAsync.length; + const initPromptCount = amil._calls.sendPromptAsync.length; const success = await sessionManager.arsalaIlaMurshidById( resolvedMurshid!.huwiyya, "add the GET /users endpoint", @@ -223,10 +223,10 @@ Deno.test("smoke: murshid topic message routes to correct session", async () => assertEquals(success, true); /** Step 5: Verify message reached the correct OpenCode session */ - const lastPrompt = opencode._calls.sendPromptAsync[opencode._calls.sendPromptAsync.length - 1]; + const lastPrompt = amil._calls.sendPromptAsync[amil._calls.sendPromptAsync.length - 1]; assertEquals(lastPrompt.sessionId, session.id); assertStringIncludes(lastPrompt.prompt, "GET /users"); - assertEquals(opencode._calls.sendPromptAsync.length, initPromptCount + 1); + assertEquals(amil._calls.sendPromptAsync.length, initPromptCount + 1); }); }); @@ -274,7 +274,7 @@ Deno.test("smoke: question event classified and forwarded to murshid topic", asy Deno.test("smoke: question answered via callback", async () => { await withTestRepo(async () => { - const { dispatcher, opencode, questionHandler, sessionManager } = buildContext(); + const { dispatcher, amil, questionHandler, sessionManager } = buildContext(); await dispatcher.faaalLiRabitWasfa( "TEAM-7000", @@ -301,14 +301,14 @@ Deno.test("smoke: question answered via callback", async () => { }); /** Clear reply calls from auto-answer attempts */ - const replyCountBefore = opencode._calls.replyToQuestion.length; + const replyCountBefore = amil._calls.replyToQuestion.length; /** Answer the question */ const success = await questionHandler.aalajIstijabaZirrSual("q-smoke-002", "Ijtihad"); assertEquals(success, true); - assertEquals(opencode._calls.replyToQuestion.length, replyCountBefore + 1); - const lastReply = opencode._calls.replyToQuestion[opencode._calls.replyToQuestion.length - 1]; + assertEquals(amil._calls.replyToQuestion.length, replyCountBefore + 1); + const lastReply = amil._calls.replyToQuestion[amil._calls.replyToQuestion.length - 1]; assertEquals(lastReply.answers[0].selected, ["Ijtihad"]); assertEquals(questionHandler.wajadaSualMuallaq("q-smoke-002"), undefined); From 4026b931479066edc8c671ff13cb58c61e0c374e Mon Sep 17 00:00:00 2001 From: Adil Shaikh Date: Mon, 24 Aug 2026 15:06:37 +0000 Subject: [PATCH 3/7] =?UTF-8?q?refactor:=20alat=20al-iksir=20=E2=80=94=20t?= =?UTF-8?q?he=20instruments=20stop=20pretending=20to=20be=20a=20server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/mcp/iksir-mcp.ts -> src/alat/alat-al-iksir.ts There was an MCP server there because OpenCode needed to be told what Iksir could do. Nothing needs telling any more: the adawat ride in the hello, humd merges every forager's tools into the foragerTools it hands each worker, and a nida comes back by name. What was left was a JSON-RPC costume over a tool registry, so the costume comes off. MunadiMunMcpServer -> AlatAlIksir aalijTalab(tools/call) -> naffidh(name, args): Promise TaarifAlatMcp -> TaarifAla MuaallijAlatMcp -> MuaallijAla Deleted: the JSON-RPC envelope types, the tahyia/tools-list/tools-call dispatch, src/mcp/http-transport.ts, src/mcp/serve.ts, the iksir-mcp systemd unit and the Requires= that chained the daemon behind it, and IKSIR_MCP_PORT. The instruments themselves — all 24 of them — are untouched, and each still inscribes its hadath into the ahdath table for Munaffidh to drain. Also collapses the CLI's three-service machinery to one. The MCP server and the agent runtime were both Iksir's to supervise once; the nest is al-Kimyawi's concern now, and the instruments ride the daemon's own process. `iksir start mcp` and `iksir start agent` are gone with the services. Net -303 lines. 143 tests pass. --- .env.example | 3 - README.md | 5 +- db/db.ts | 2 +- deno.json | 2 +- install | 35 +-- .../iksir-mcp.ts => alat/alat-al-iksir.ts} | 281 ++++++------------ src/cli.ts | 59 ++-- src/daemon/munaffidh.ts | 4 +- src/main.ts | 45 ++- src/mcp/http-transport.ts | 91 ------ src/mcp/serve.ts | 60 ---- src/types.ts | 20 +- 12 files changed, 152 insertions(+), 455 deletions(-) rename src/{mcp/iksir-mcp.ts => alat/alat-al-iksir.ts} (88%) delete mode 100644 src/mcp/http-transport.ts delete mode 100644 src/mcp/serve.ts diff --git a/.env.example b/.env.example index b657e39..9dea26e 100644 --- a/.env.example +++ b/.env.example @@ -31,9 +31,6 @@ # Model to name on each prompt. Unset = the nest decides. # IKSIR_HUM_MODEL=claude-sonnet-4-6 -# MCP server port (default: 3100) -# IKSIR_MCP_PORT=3100 - # ============================================================================= # Issue Tracker (Linear) # ============================================================================= diff --git a/README.md b/README.md index 3ef3e66..9a5b0a6 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ A murshid without `fs` can contemplate the runūz but never inscribe them. ```bash hum hive humfs install # the filesystem surface -hum hive install # whatever you would have think +hum hive install # whatever you would have think for you ``` The kindling ritual clones the source, creates XDG directories, copies sacred templates, installs the `iksir` CLI to `~/.local/bin/`, registers daemon services, and consecrates the ruqan. Edit `~/.local/share/iksir/src/.env` to bind your keys, then `iksir start`. @@ -39,7 +39,6 @@ Iksīr can also be supervised as a bee in its own right — an `Orchfile` sits a ```bash iksir divine # divine the state of the Great Work iksir transmute # pull latest essence, sync vessels, rekindle -iksir rekindle mcp # rekindle just the MCP crucible iksir verify # verify the formulae and runes ``` @@ -90,7 +89,7 @@ Buwtaqa (بوتقة - The Crucible) Athanor (أثانور - The Sacred Furnace) ``` -The sacred tools are forged within: transmutation rites, formula inscription, essence decanting. Additional instruments may be consecrated through the mystical registry. +The **Ālāt al-Iksīr** (آلات الإكسير - instruments of the Elixir) are forged within: transmutation rites, formula inscription, essence decanting. They are advertised to the nest in Iksīr's own hello, and a summons returns to them by name. Additional instruments may be consecrated through the sijill. ## The Sacred Laws diff --git a/db/db.ts b/db/db.ts index 6dd561d..f6c231d 100644 --- a/db/db.ts +++ b/db/db.ts @@ -185,7 +185,7 @@ export function aghlaaqQaidatBayanat(): void { /** * Insert an IPC hadath into the ahdath table. - * Used by MCP servers to forward tool calls to the daemon. + * Used by the instruments to forward their nida to the daemon. */ export function adkhalaHadath( naw: "pm", diff --git a/deno.json b/deno.json index 0d66c13..ea046be 100644 --- a/deno.json +++ b/deno.json @@ -12,7 +12,7 @@ "check": "deno run --allow-all --env=.env src/cli.ts check", "sync": "deno run --allow-all --env=.env src/cli.ts sync", "test": "deno test --allow-all", - "typecheck": "deno check src/main.ts src/mcp/iksir-mcp.ts src/mcp/serve.ts src/cli.ts", + "typecheck": "deno check src/main.ts src/alat/alat-al-iksir.ts src/cli.ts", "fmt": "deno fmt", "lint": "deno lint" }, diff --git a/install b/install index 5256284..204927a 100755 --- a/install +++ b/install @@ -14,7 +14,6 @@ BIN_DIR="$HOME/.local/bin" IKSIR_REPO="https://github.com/adiled/iksir.git" IKSIR_SRC="$IKSIR_DATA/src" -MCP_PORT="${IKSIR_MCP_PORT:-3100}" OS="$(uname -s)" CMD="${1:-install}" @@ -170,30 +169,11 @@ register_systemd() { local WM $IS_ROOT && WM="multi-user.target" || WM="default.target" - # MCP server - cat > "$SERVICE_DIR/iksir-mcp.service" < "$SERVICE_DIR/iksir.service" </dev/null + $SC enable iksir 2>/dev/null info "registered systemd services" } @@ -216,7 +196,7 @@ start_daemon() { local SC="systemctl" $IS_ROOT || SC="systemctl --user" - $SC restart iksir-mcp iksir + $SC restart iksir sleep 2 if $SC is-active --quiet iksir; then info "running" @@ -228,7 +208,7 @@ start_daemon() { stop_daemon() { local SC="systemctl" $IS_ROOT || SC="systemctl --user" - $SC stop iksir iksir-mcp 2>/dev/null || true + $SC stop iksir 2>/dev/null || true } # ─── commands ──────────────────────────────────────────────────────────────── @@ -243,10 +223,10 @@ print_status() { echo "" if $IS_ROOT; then echo " logs: journalctl -u iksir -f" - echo " stop: systemctl stop iksir iksir-mcp" + echo " stop: systemctl stop iksir" else echo " logs: journalctl --user -u iksir -f" - echo " stop: systemctl --user stop iksir iksir-mcp" + echo " stop: systemctl --user stop iksir" fi echo "" echo " First time? Edit $IKSIR_SRC/.env then: iksir start" @@ -301,7 +281,7 @@ cmd_uninstall() { SERVICE_DIR="$HOME/.config/systemd/user" fi - $SC disable iksir iksir-mcp 2>/dev/null || true + $SC disable iksir 2>/dev/null || true rm -f "$SERVICE_DIR"/iksir*.service $SC daemon-reload 2>/dev/null || true info "removed systemd services" @@ -331,7 +311,6 @@ case "$CMD" in echo " uninstall — stop, remove services, remove CLI" echo "" echo "Environment:" - echo " IKSIR_MCP_PORT MCP port (default: 3100)" echo " DENO_BIN Path to deno binary" exit 1 ;; diff --git a/src/mcp/iksir-mcp.ts b/src/alat/alat-al-iksir.ts similarity index 88% rename from src/mcp/iksir-mcp.ts rename to src/alat/alat-al-iksir.ts index aa34026..cca1f07 100644 --- a/src/mcp/iksir-mcp.ts +++ b/src/alat/alat-al-iksir.ts @@ -1,84 +1,70 @@ /** - * Iksir MCP Server + * Alat al-Iksir (آلات الإكسير) — The Instruments of Iksir * - * Provides tools to the Murshid LLM: - * mun_* Alchemical operations (transmutation, decanting, inscription) - * code_* Code intelligence (symbol lookup, dependency graph, impact analysis) + * The workshop's apparatus. Not a server, not a protocol — the + * instruments themselves, and the hands that work them: * - * All alchemical tools are built-in. + * mun_* the alchemical operations — istihal, fasl, naqsh + * code_* the reading of runuz — symbols, dependencies, impact * - * Communicates with Iksir daemon via SQLite IPC (events table). + * There was an MCP server here once, because OpenCode needed to be + * told what Iksir could do. Nothing needs telling any more. The + * adawat ride in the hello, humd merges them into the foragerTools + * it hands every worker, and a nida comes back by name. What remains + * is a sijill of instruments and a way to work one. + * + * Each instrument still inscribes its hadath into the ahdath table. + * Munaffidh drains it. The sijill is the record; the thrum is the road. */ import type { + MuaallijAla, + MunToolCall, + NawMurshid, + NidaFahasFar, + NidaIdfa, + NidaIltazim, + NidaIqraMudawwana, + NidaKhalqFar, + NidaKhalqRisala, NidaKhalqWasfa, - NidaTajdidWasfa, - NidaWadaaAlaqat, + NidaNaqsh, NidaQiraatWasfa, - NidaKhalqRisala, - NidaFahasFar, - NidaTabligh, NidaRadd, + NidaRattib, NidaSajjalQarar, - NidaIqraMudawwana, - NidaTanazal, + NidaTabligh, + NidaTajdidWasfa, NidaTalabTahakkum, - NidaKhalqFar, - NidaIltazim, - NidaRattib, - NidaIdfa, - NidaNaqsh, - MunToolCall, + NidaTanazal, + NidaWadaaAlaqat, QararSijill, - NawMurshid, - TaarifAlatMcp, - MuaallijAlatMcp, SijillAlat, + TaarifAla, } from "../types.ts"; import { wallidIsmFar } from "../daemon/katib.ts"; import { loadIndex } from "../code-intel/indexer.ts"; import { queryIndex } from "../code-intel/query.ts"; -/** MCP Protocol types */ -interface TalabMcp { - jsonrpc: "2.0"; - id: number | string; - method: string; - params?: Record; -} - -interface RaddMcp { - jsonrpc: "2.0"; - id: number | string; - result?: unknown; - error?: { code: number; message: string }; -} - -import { - adkhalaHadath, - adhafaQararSijill, - jalabaQararatSijill, - qiraStatus, -} from "../../db/db.ts"; - +import { adhafaQararSijill, adkhalaHadath, jalabaQararatSijill, qiraStatus } from "../../db/db.ts"; class MunadiSijillAlat implements SijillAlat { - #khazana = new Map(); + #khazana = new Map(); #muhawwil: (call: MunToolCall) => void; constructor(forwarder: (call: MunToolCall) => void) { this.#muhawwil = forwarder; } - sajjil(tool: TaarifAlatMcp, muaalij: MuaallijAlatMcp): void { + sajjil(tool: TaarifAla, muaalij: MuaallijAla): void { this.#khazana.set(tool.name, { tarif: tool, muaalij }); } - adawat(): TaarifAlatMcp[] { + adawat(): TaarifAla[] { return Array.from(this.#khazana.values()).map((t) => t.tarif); } - muaallijLi(name: string): MuaallijAlatMcp | undefined { + muaallijLi(name: string): MuaallijAla | undefined { return this.#khazana.get(name)?.muaalij; } @@ -91,8 +77,7 @@ class MunadiSijillAlat implements SijillAlat { } } - -export class MunadiMunMcpServer { +export class AlatAlIksir { #sijillAlat: SijillAlat; constructor() { @@ -102,65 +87,35 @@ export class MunadiMunMcpServer { this.#sajjilAlatKimiya(); } - /** - * Expose the registry for external access (e.g., serve.ts health check). - */ + /** The sijill of instruments. */ get sijill(): SijillAlat { return this.#sijillAlat; } - - /** - * Handle incoming MCP request - */ - async aalijTalab(request: TalabMcp): Promise { - switch (request.method) { - case "tahyia": - return this.#aalijBadaa(request); - case "tools/list": - return this.#aalijQaaimalAlat(request); - case "tools/call": - return this.#aalijNidaAlat(request); - default: - return { - jsonrpc: "2.0", - id: request.id, - error: { code: -32601, message: `Method not found: ${request.method}` }, - }; - } + /** Every taarif, as the hello advertises them. */ + adawat(): TaarifAla[] { + return this.#sijillAlat.adawat(); } /** - * Handle tahyia request + * Work one instrument. + * + * Returns the natija as text — what travels back on chi:"tool-result" + * to un-park the cell. A refused or broken instrument returns its + * complaint in the same channel; the murshid must be told either way, + * and a cell left parked is worse than a cell told no. */ - #aalijBadaa(request: TalabMcp): RaddMcp { - return { - jsonrpc: "2.0", - id: request.id, - result: { - protocolVersion: "2024-11-05", - capabilities: { - tools: {}, - }, - serverInfo: { - name: "iksir-pm-mcp", - version: "0.1.0", - }, - }, - }; - } + async naffidh(name: string, args: Record): Promise { + try { + this.#tahaqqaqHujaj(name, args); - /** - * Handle tools/list request - */ - #aalijQaaimalAlat(request: TalabMcp): RaddMcp { - return { - jsonrpc: "2.0", - id: request.id, - result: { - tools: this.#sijillAlat.adawat(), - }, - }; + const muaalij = this.#sijillAlat.muaallijLi(name); + if (!muaalij) return `Error: unknown instrument: ${name}`; + + return await muaalij(args); + } catch (error) { + return `Error: ${String(error)}`; + } } /** @@ -187,52 +142,9 @@ export class MunadiMunMcpServer { } /** - * Handle tools/call request - */ - async #aalijNidaAlat(request: TalabMcp): Promise { - const params = request.params as { - name: string; - arguments: Record; - }; - const toolName = params?.name; - const args = params?.arguments ?? {}; - - try { - this.#tahaqqaqHujaj(toolName, args); - - const handler = this.#sijillAlat.muaallijLi(toolName); - if (!handler) { - return { - jsonrpc: "2.0", - id: request.id, - error: { code: -32602, message: `Unknown tool: ${toolName}` }, - }; - } - - const result = await handler(args); - - return { - jsonrpc: "2.0", - id: request.id, - result: { - content: [{ type: "text", text: result }], - }, - }; - } catch (error) { - return { - jsonrpc: "2.0", - id: request.id, - error: { code: -32000, message: String(error) }, - }; - } - } - - - /** - * Register all 16 core PM-MCP tools. + * Register the core instruments. */ #sajjilAlatAsasiyya(): void { - this.#sijillAlat.sajjil( { name: "mun_khalaq_wasfa", @@ -260,8 +172,7 @@ export class MunadiMunMcpServer { status: { type: "string", enum: ["triage", "backlog"], - description: - "Initial status: triage if ambiguous, backlog if well-scoped", + description: "Initial status: triage if ambiguous, backlog if well-scoped", }, labels: { type: "array", @@ -348,7 +259,8 @@ export class MunadiMunMcpServer { this.#sijillAlat.sajjil( { name: "mun_iqra_wasfa", - description: `Read any issue tracker URL (Linear, Jira, GitHub) and get enriched information with Iksir context. + description: + `Read any issue tracker URL (Linear, Jira, GitHub) and get enriched information with Iksir context. Returns: - Entity type (ticket, project, comment, milestone, etc.) @@ -377,12 +289,10 @@ Use this as your primary way to understand ticket entities.`, (args) => this.#aalajaQiraaatWasfa(args), ); - this.#sijillAlat.sajjil( { name: "mun_khalaq_risala", - description: - "Create a draft pull request. Daemon handles gh CLI interaction.", + description: "Create a draft pull request. Daemon handles gh CLI interaction.", inputSchema: { type: "object", properties: { @@ -420,8 +330,7 @@ Use this as your primary way to understand ticket entities.`, this.#sijillAlat.sajjil( { name: "mun_fahas_far", - description: - "Check branch status (ahead/behind relative to main, files changed).", + description: "Check branch status (ahead/behind relative to main, files changed).", inputSchema: { type: "object", properties: { @@ -440,7 +349,6 @@ Use this as your primary way to understand ticket entities.`, (args) => this.#aalijFahasFar(args), ); - this.#sijillAlat.sajjil( { name: "mun_balligh", @@ -451,7 +359,8 @@ Use this as your primary way to understand ticket entities.`, properties: { huwiyyatMurshid: { type: "string", - description: "Your murshid ID (e.g., TEAM-100, SANDBOX-pos-simulator). Required for routing.", + description: + "Your murshid ID (e.g., TEAM-100, SANDBOX-pos-simulator). Required for routing.", }, message: { type: "string", @@ -491,7 +400,8 @@ Use this as your primary way to understand ticket entities.`, properties: { huwiyyatMurshid: { type: "string", - description: "Your murshid ID (e.g., TEAM-100, SANDBOX-pos-simulator). Required for routing.", + description: + "Your murshid ID (e.g., TEAM-100, SANDBOX-pos-simulator). Required for routing.", }, message: { type: "string", @@ -591,7 +501,6 @@ The diary is a shared knowledge pool across all murshidun. Use it to: (args) => this.#aalijQiraatMudawwana(args), ); - this.#sijillAlat.sajjil( { name: "mun_tanazal", @@ -608,7 +517,8 @@ You will continue receiving issue tracker/GitHub updates even while idle.`, properties: { huwiyyatMurshid: { type: "string", - description: "Your murshid ID (e.g., TEAM-100, SANDBOX-pos-simulator). Required for routing.", + description: + "Your murshid ID (e.g., TEAM-100, SANDBOX-pos-simulator). Required for routing.", }, reason: { type: "string", @@ -648,16 +558,19 @@ If another murshid is working, Al-Kimyawi will be asked to approve the switch.`, properties: { huwiyyatMurshid: { type: "string", - description: "Your murshid ID (e.g., TEAM-100, SANDBOX-pos-simulator). Required for routing.", + description: + "Your murshid ID (e.g., TEAM-100, SANDBOX-pos-simulator). Required for routing.", }, reason: { type: "string", - description: "Why demanding control (e.g., 'Blocker resolved - Figma specs received')", + description: + "Why demanding control (e.g., 'Blocker resolved - Figma specs received')", }, awwaliyya: { type: "string", enum: ["normal", "urgent"], - description: "Awwaliyya: normal (can wait for current to yield) or urgent (request immediate switch)", + description: + "Awwaliyya: normal (can wait for current to yield) or urgent (request immediate switch)", }, }, required: ["huwiyyatMurshid", "reason", "awwaliyya"], @@ -666,7 +579,6 @@ If another murshid is working, Al-Kimyawi will be asked to approve the switch.`, (args) => this.#aalijTalabTahakkum(args), ); - this.#sijillAlat.sajjil( { name: "mun_khalaq_far", @@ -693,16 +605,19 @@ You should only call this once per murshid, at the start.`, }, identifier: { type: "string", - description: "Ticket/epic identifier (e.g., 'TEAM-200') or sandbox identifier (e.g., 'SANDBOX-pos-simulator')", + description: + "Ticket/epic identifier (e.g., 'TEAM-200') or sandbox identifier (e.g., 'SANDBOX-pos-simulator')", }, type: { type: "string", enum: ["epic", "chore", "sandbox"], - description: "Type of murshid: 'epic' for multi-ticket work, 'chore' for standalone tasks, 'sandbox' for freeform work", + description: + "Type of murshid: 'epic' for multi-ticket work, 'chore' for standalone tasks, 'sandbox' for freeform work", }, slug: { type: "string", - description: "Short description slug (e.g., 'bab-al-shams'). Required for epics, optional for chores/sandbox.", + description: + "Short description slug (e.g., 'bab-al-shams'). Required for epics, optional for chores/sandbox.", }, }, required: ["huwiyyatMurshid", "identifier", "type"], @@ -779,7 +694,6 @@ You should only call this once per murshid, at the start.`, (args) => this.#aalijIdfa(args), ); - this.#sijillAlat.sajjil( { name: "mun_istifsar", @@ -803,13 +717,11 @@ You should only call this once per murshid, at the start.`, ); } - #sajjilAlatKimiya(): void { this.#sijillAlat.sajjil( { name: "mun_istikhlas", - description: - "Extract rune stones from the crucible for transmutation. " + + description: "Extract rune stones from the crucible for transmutation. " + "Identifies which stones contain the runes needed for this essence. " + "Use mun_talaum to discover if these runes require additional summoning circles.", inputSchema: { @@ -868,8 +780,7 @@ You should only call this once per murshid, at the start.`, this.#sijillAlat.sajjil( { name: "mun_istihal", - description: - "Transmute rune stones into pure essence. " + + description: "Transmute rune stones into pure essence. " + "The scattered runes crystallize into a coherent whole. " + "After transmutation, use mun_fasl to transfer the essence for examination.", inputSchema: { @@ -898,8 +809,7 @@ You should only call this once per murshid, at the start.`, this.#sijillAlat.sajjil( { name: "mun_istihal_mutabaqq", - description: - "Transmute essence that requires another essence as foundation. " + + description: "Transmute essence that requires another essence as foundation. " + "The child essence depends on the parent's properties to remain stable. " + "Use when transmutations must be examined in sequence.", inputSchema: { @@ -969,8 +879,7 @@ You should only call this once per murshid, at the start.`, this.#sijillAlat.sajjil( { name: "mun_naqsh", - description: - "Inscribe the proven formula into the codex. " + + description: "Inscribe the proven formula into the codex. " + "Naqsh (نقش) is the final alchemical phase — merging the risala into the eternal kitab. " + "The work becomes reproducible truth. Use after mun_fasl when the essence has been examined and approved.", inputSchema: { @@ -996,7 +905,6 @@ You should only call this once per murshid, at the start.`, ); } - async #aalajaKhalqWasfa(args: Record): Promise { const call: NidaKhalqWasfa = { tool: "mun_khalaq_wasfa", @@ -1054,9 +962,7 @@ ${updatesList}`; this.#hawwilLiKhadim(call); const blocksList = call.yahjub?.length ? `Blocks: ${call.yahjub.join(", ")}` : ""; - const blockedByList = call.mahjoubBi?.length - ? `Blocked by: ${call.mahjoubBi.join(", ")}` - : ""; + const blockedByList = call.mahjoubBi?.length ? `Blocked by: ${call.mahjoubBi.join(", ")}` : ""; return `Relation update request forwarded to daemon. @@ -1108,7 +1014,6 @@ Daemon will: Awaiting daemon response...`; } - async #aalajaKhalqRisala(args: Record): Promise { const call: NidaKhalqRisala = { tool: "mun_khalaq_risala", @@ -1155,7 +1060,6 @@ Daemon will return: - Any merge conflicts`; } - async #aalijTabligh(args: Record): Promise { const call: NidaTabligh = { tool: "mun_balligh", @@ -1252,7 +1156,9 @@ This decision is now part of the persistent record.`; call.mundhu && `since=${call.mundhu}`, ].filter(Boolean); - return `No diary entries found.${filters.length > 0 ? ` Filters: ${filters.join(", ")}` : ""}`; + return `No diary entries found.${ + filters.length > 0 ? ` Filters: ${filters.join(", ")}` : "" + }`; } let response = `**Diary** (${decisions.length} entries)\n\n`; @@ -1272,7 +1178,6 @@ This decision is now part of the persistent record.`; return response; } - async #aalijTanazal(args: Record): Promise { const call: NidaTanazal = { tool: "mun_tanazal", @@ -1330,7 +1235,6 @@ Daemon will: You will be notified when control is granted.`; } - async #aalijKhalqFar(args: Record): Promise { const murshidType = args.type as NawMurshid; const call: NidaKhalqFar = { @@ -1407,7 +1311,6 @@ Daemon will create the commit.`; Daemon will push current branch to origin.`; } - async #aalijIstikhlas(args: Record): Promise { /** * For now, extraction is just validation and planning @@ -1539,7 +1442,6 @@ Daemon will create a ${args.draft !== false ? "draft " : ""}pull request. You will be notified with the PR URL once created.`; } - async #aalijNaqsh(args: Record): Promise { const call: NidaNaqsh = { tool: "mun_naqsh", @@ -1552,8 +1454,8 @@ You will be notified with the PR URL once created.`; throw new Error( "mun_naqsh (النقش) is not yet implemented. " + - "The inscription phase — merging the risala into the codex — is planned. " + - "For now, complete the merge manually via the GitHub interface." + "The inscription phase — merging the risala into the codex — is planned. " + + "For now, complete the merge manually via the GitHub interface.", ); } @@ -1562,7 +1464,9 @@ You will be notified with the PR URL once created.`; */ #hawwilLiKhadim(call: MunToolCall): void { /** Extract huwiyyatMurshid if present (for routing) */ - const huwiyyatMurshid = "huwiyyatMurshid" in call ? (call as { huwiyyatMurshid?: string }).huwiyyatMurshid : undefined; + const huwiyyatMurshid = "huwiyyatMurshid" in call + ? (call as { huwiyyatMurshid?: string }).huwiyyatMurshid + : undefined; adkhalaHadath("pm", call.tool, call as unknown as Record, huwiyyatMurshid); } @@ -1580,7 +1484,6 @@ You will be notified with the PR URL once created.`; }); } - async #aalijIstifsar(args: Record): Promise { const query = args.query as string; if (!query) return JSON.stringify({ error: "query is required" }); diff --git a/src/cli.ts b/src/cli.ts index 5d2d808..83fdfbf 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,13 +4,12 @@ * Single entry point for all Iksir operations. * * Usage: - * iksir start Start all services - * iksir start mcp Start just the MCP service - * iksir stop Stop all services - * iksir restart Restart all services + * iksir start Start the daemon + * iksir stop Stop the daemon + * iksir restart Restart the daemon * iksir status Show service and session status * iksir check Validate config, type check, run tests - * iksir sync Sync prompts and plugins to agent runtime + * iksir sync Sync the ruqan into the config dir * iksir config Print resolved configuration * iksir help Show this help */ @@ -22,7 +21,12 @@ import { baddaaQaidatBayanat, aghlaaqQaidatBayanat, jalabaKullJalasat, jalabaAse import { execCommand } from "./utils/exec.ts"; import { join } from "jsr:@std/path"; -const SERVICES = ["iksir-mcp", "iksir-agent", "iksir"] as const; +/** + * One service. The MCP server and the agent runtime were both Iksir's to + * supervise once; the nest is al-Kimyawi's concern now, and the instruments + * ride the same process as the daemon. + */ +const SERVICES = ["iksir"] as const; const HELP = ` iksir v${VERSION} - Autonomous Agent Tansiq @@ -34,15 +38,15 @@ Setup: init Interactive onboarding wizard Service management: - start [target] Start services (all, mcp, agent, or daemon) - stop [target] Stop services - restart [target] Restart services + start Start the daemon + stop Stop the daemon + restart Restart the daemon status Show service and session status Maintenance: - update Pull latest, sync prompts, restart services + update Pull latest, sync ruqan, restart the daemon check Validate config, type check, run tests - sync Sync prompts and plugins to agent runtime + sync Sync the ruqan into the config dir config Print resolved configuration config --path Print config file path @@ -55,25 +59,6 @@ function systemctlMode(): string[] { return Deno.uid() === 0 ? [] : ["--user"]; } -type ServiceTarget = "all" | "mcp" | "agent" | "daemon"; - -function resolveTarget(arg?: string): ServiceTarget { - if (!arg || arg.startsWith("-")) return "all"; - const targets: Record = { - all: "all", mcp: "mcp", agent: "agent", daemon: "daemon", - }; - return targets[arg] ?? "all"; -} - -function serviceName(target: ServiceTarget): string[] { - switch (target) { - case "mcp": return ["iksir-mcp"]; - case "agent": return ["iksir-agent"]; - case "daemon": return ["iksir"]; - case "all": return [...SERVICES]; - } -} - async function systemctl(action: string, targets: string[]): Promise { const mode = systemctlMode(); for (const svc of targets) { @@ -87,16 +72,10 @@ async function systemctl(action: string, targets: string[]): Promise { } async function cmdServiceAction(action: string): Promise { - const target = resolveTarget(Deno.args[1]); - const services = serviceName(target); - - /** - * For start/restart, order matters: mcp → agent → daemon - * For stop, reverse: daemon → agent → mcp - */ - const ordered = action === "stop" ? [...services].reverse() : services; + const ordered = [...SERVICES]; - console.log(`${action === "start" ? "Starting" : action === "stop" ? "Stopping" : "Restarting"} ${target === "all" ? "all services" : target}...`); + const verb = action === "start" ? "Starting" : action === "stop" ? "Stopping" : "Restarting"; + console.log(`${verb} iksir...`); if (action === "restart") { await systemctl("restart", ordered); } else { @@ -154,7 +133,7 @@ async function cmdCheck(): Promise { } console.log("\nType checking..."); - const entries = ["src/main.ts", "src/mcp/pm-server.ts", "src/mcp/serve.ts", "src/cli.ts"]; + const entries = ["src/main.ts", "src/alat/alat-al-iksir.ts", "src/cli.ts"]; for (const entry of entries) { const result = await execCommand("deno", ["check", entry], { cwd: repoPath }); if (result.success) { diff --git a/src/daemon/munaffidh.ts b/src/daemon/munaffidh.ts index c1757ca..c42dbca 100644 --- a/src/daemon/munaffidh.ts +++ b/src/daemon/munaffidh.ts @@ -114,7 +114,7 @@ export class Munaffidh { } /** - * Start processing PM-MCP events + * Start draining the ahdath the instruments inscribe */ async badaaMuaalaja(signal: AbortSignal): Promise { this.#mutahakkimIlgha = new AbortController(); @@ -168,7 +168,7 @@ export class Munaffidh { } /** - * Handle a PM-MCP event + * Handle one hadath */ /** Git-mutating tools that must be blocked during session switches */ static readonly GIT_TOOLS = new Set([ diff --git a/src/main.ts b/src/main.ts index 64772b1..7ee665d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,8 +4,8 @@ * Main entry point for the Iksir daemon. * * Architecture: - * - MudirJalasat: Manages murshid the nest sessions - * - Munaffidh: Executes PM-MCP tool calls via Linear/GitHub APIs + * - MudirJalasat: Manages murshid jalasat at the nest + * - Munaffidh: Executes mun_* instruments via Linear/GitHub APIs * - Rasul: Routes human messages to/from murshid sessions (transport-agnostic) * - KeepAlive: Polls for external changes, feeds to murshid * @@ -28,7 +28,7 @@ import { import { baddaaQaidatBayanat, aghlaaqQaidatBayanat, haddathaHuwiyyatRisalaSual } from "../db/db.ts"; import { createAmilHum } from "./hum/client.ts"; import { masarThrum } from "./hum/thrum.ts"; -import { MunadiMunMcpServer } from "./mcp/iksir-mcp.ts"; +import { AlatAlIksir } from "./alat/alat-al-iksir.ts"; import { anshaaNtfyAmil } from "./notifications/ntfy.ts"; import { anshaaTelegramAmil } from "./notifications/telegram.ts"; import { anshaaTelegramRasul } from "./notifications/messenger.ts"; @@ -187,10 +187,11 @@ async function addaIsharat(ctx: SiyaqKhadim): Promise { } /** - * Subscribe to the nest SSE events and route question events to handler. + * Drain the ahdath the amil raises from the thrum, routing asila to Sail + * and curations to Katib. */ async function ishtarakAhdath(ctx: SiyaqKhadim): Promise { - await logger.akhbar("sse", "Starting the nest SSE subscription"); + await logger.akhbar("ahdath", "Draining ahdath from the thrum"); let backoffMs = INITIAL_BACKOFF_MS; @@ -209,7 +210,7 @@ async function ishtarakAhdath(ctx: SiyaqKhadim): Promise { const sessionId = (event.properties as { sessionID?: string })?.sessionID; if (sessionId) { ctx.mudirJalasat.aalajaDamj(sessionId).catch(async (e) => - await logger.sajjalKhata("sse", "Failed to handle compaction event", { + await logger.sajjalKhata("ahdath", "Failed to handle curation event", { sessionId, error: String(e), }) @@ -221,7 +222,7 @@ async function ishtarakAhdath(ctx: SiyaqKhadim): Promise { if (ctx.mutahakkimIlgha.signal.aborted) { break; } - await logger.haDHHir("sse", `SSE connection lost, reconnecting in ${backoffMs / 1000}s`, { + await logger.haDHHir("ahdath", `Ahdath stream faulted, retrying in ${backoffMs / 1000}s`, { error: String(error), }); await new Promise((r) => setTimeout(r, backoffMs)); @@ -229,7 +230,7 @@ async function ishtarakAhdath(ctx: SiyaqKhadim): Promise { } } - await logger.akhbar("sse", "the nest SSE subscription stopped"); + await logger.akhbar("ahdath", "Ahdath drain stopped"); } async function awqadKhadim(ctx: SiyaqKhadim): Promise { @@ -263,7 +264,7 @@ async function awqadKhadim(ctx: SiyaqKhadim): Promise { }); ishtarakAhdath(ctx).catch(async (error) => { - await logger.sajjalKhata("sse", "Event subscription error", { error: String(error) }); + await logger.sajjalKhata("ahdath", "Ahdath drain error", { error: String(error) }); }); ctx.raqib.badaa(ctx.mutahakkimIlgha.signal); @@ -809,28 +810,18 @@ export async function abda(opts: { check?: boolean } = {}): Promise { * humd routes a nida by name to whichever hive's manifest declares it, * so an unannounced ada is an unreachable one. */ - const munMcp = new MunadiMunMcpServer(); - const amil = createAmilHum(config, munMcp.sijill.adawat()); + const alat = new AlatAlIksir(); + const amil = createAmilHum(config, alat.adawat()); /** - * A nida arrives from the nest. The ada bodies are unchanged — they still - * inscribe their hadath into the sijill, and Munaffidh still drains that - * table on its heartbeat. Only the carriage changed: no MCP server, no - * stdio, no polling for the call itself. The journal stays the record; - * the thrum is merely the road. + * A nida arrives from the nest. The instruments are unchanged — each still + * inscribes its hadath into the sijill, and Munaffidh still drains that + * table on its heartbeat. Only the carriage changed: no server, no stdio, + * no polling for the call itself. The journal stays the record; the thrum + * is merely the road. */ amil.alaNida(async (nida) => { - const radd = await munMcp.aalijTalab({ - jsonrpc: "2.0", - id: nida.callId, - method: "tools/call", - params: { name: nida.name, arguments: nida.args }, - }); - - const natija = radd.error - ? `Error: ${radd.error.message}` - : (radd.result as { content?: Array<{ text?: string }> })?.content?.[0]?.text ?? ""; - + const natija = await alat.naffidh(nida.name, nida.args); amil.raddNida(nida.sid, nida.callId, natija); }); const ntfy = anshaaNtfyAmil(config); diff --git a/src/mcp/http-transport.ts b/src/mcp/http-transport.ts deleted file mode 100644 index dcd58ac..0000000 --- a/src/mcp/http-transport.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * MCP HTTP Transport - * - * Serves PM-MCP over HTTP using Streamable HTTP transport. - * Handles JSON-RPC 2.0 requests via POST, returns JSON responses. - * - * POST /pm → PM-MCP server - * - * A second door. Iksir's own murshidun no longer come this way — humd routes - * nida over the thrum by tool name — but the adawat remain reachable over HTTP - * for anything else that speaks MCP: type "remote", url "http://localhost:3100/" - */ - -interface McpServer { - aalijTalab(request: { - jsonrpc: "2.0"; - id: number | string; - method: string; - params?: Record; - }): Promise<{ - jsonrpc: "2.0"; - id: number | string; - result?: unknown; - error?: { code: number; message: string }; - }>; -} - -interface McpHttpServerOptions { - port: number; - pmServer: McpServer; -} - -/** - * Start the MCP HTTP server. - * Returns the Deno.HttpServer instance for lifecycle management. - */ -export function startMcpHttpServer(options: McpHttpServerOptions): Deno.HttpServer { - const { port, pmServer } = options; - - const server = Deno.serve({ port, hostname: "127.0.0.1" }, async (req) => { - const url = new URL(req.url); - const path = url.pathname; - - if (req.method !== "POST") { - if (req.method === "GET" && path === "/") { - return Response.json({ - name: "iksir-mcp", - version: "0.1.0", - servers: ["/pm"], - }); - } - - return new Response("Method not allowed", { status: 405 }); - } - - if (path !== "/pm") { - return new Response("Not found", { status: 404 }); - } - - try { - const body = await req.json(); - - if (Array.isArray(body)) { - const responses = await Promise.all( - body.map((msg: Record) => - pmServer.aalijTalab(msg as Parameters[0]) - ) - ); - return Response.json(responses, { - headers: { "Content-Type": "application/json" }, - }); - } - - /** Single request */ - const response = await pmServer.aalijTalab(body); - return Response.json(response, { - headers: { "Content-Type": "application/json" }, - }); - } catch (error) { - /** JSON parse error or unexpected error */ - const errorResponse = { - jsonrpc: "2.0" as const, - id: null, - error: { code: -32700, message: `Parse error: ${error}` }, - }; - return Response.json(errorResponse, { status: 400 }); - } - }); - - return server; -} diff --git a/src/mcp/serve.ts b/src/mcp/serve.ts deleted file mode 100644 index 88b6585..0000000 --- a/src/mcp/serve.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Iksir MCP HTTP Server - * - * Runs PM-MCP server over HTTP with all artifact crafting tools built-in. - * - * Usage: - * deno run --allow-all src/mcp/serve.ts [--port=3100] - * - * Endpoints: - * POST /pm → PM-MCP (murshid tools) - * GET / → Health check - */ - -import { baddaaQaidatBayanat } from "../../db/db.ts"; -import { MunadiMunMcpServer } from "./iksir-mcp.ts"; -import { startMcpHttpServer } from "./http-transport.ts"; - -const DEFAULT_PORT = 3100; - -function raqamAlBab(): number { - const portArg = Deno.args.find((a) => a.startsWith("--port=")); - if (portArg) { - const port = parseInt(portArg.split("=")[1], 10); - if (!isNaN(port) && port > 0 && port < 65536) return port; - } - const envPort = Deno.env.get("IKSIR_MCP_PORT"); - if (envPort) { - const port = parseInt(envPort, 10); - if (!isNaN(port) && port > 0 && port < 65536) return port; - } - return DEFAULT_PORT; -} - - - -export async function startMcpServer(opts: { port?: number } = {}): Promise { - await baddaaQaidatBayanat(); - - const port = opts.port ?? raqamAlBab(); - const pmServer = new MunadiMunMcpServer(); - - const toolCount = pmServer.sijill.adawat().length; - const server = startMcpHttpServer({ port, pmServer }); - - console.log(`Iksir MCP server listening on http://localhost:${port}`); - console.log(` PM-MCP: POST http://localhost:${port}/pm`); - console.log(` Tools: ${toolCount} registered`); - - const ighlaaq = () => { - console.log("Shutting down MCP server..."); - server.shutdown(); - }; - - Deno.addSignalListener("SIGINT", ighlaaq); - Deno.addSignalListener("SIGTERM", ighlaaq); -} - -if (import.meta.main) { - startMcpServer(); -} diff --git a/src/types.ts b/src/types.ts index 9cb2d37..1e47254 100644 --- a/src/types.ts +++ b/src/types.ts @@ -338,7 +338,7 @@ export interface SualMuallaq { /** - * Tool calls made by murshids via MUN-MCP. + * Nida made by murshids through the instruments. * These are dispatched by the daemon's tool executor. */ @@ -569,8 +569,8 @@ export type MunToolCall = | NidaNaqsh; -/** MCP tool definition (JSON Schema for tool input) */ -export interface TaarifAlatMcp { +/** One instrument's taarif — the shape humd advertises and routes by. */ +export interface TaarifAla { name: string; description: string; inputSchema: { @@ -580,23 +580,23 @@ export interface TaarifAlatMcp { }; } -/** Handler function for a registered MCP tool */ -export type MuaallijAlatMcp = (args: Record) => Promise | string; +/** The hand that works one instrument. */ +export type MuaallijAla = (args: Record) => Promise | string; /** - * Tool registry — all tools are core, built into the MUN-MCP server. + * The sijill of instruments — every ala is core, none are plugins. * - * MUN-MCP server delegates tool listing and dispatch to this registry. + * AlatAlIksir advertises and dispatches through this registry. */ export interface SijillAlat { /** Register a tool definition + its handler */ - sajjil(tool: TaarifAlatMcp, handler: MuaallijAlatMcp): void; + sajjil(tool: TaarifAla, handler: MuaallijAla): void; /** Get all registered tool definitions (for tools/list) */ - adawat(): TaarifAlatMcp[]; + adawat(): TaarifAla[]; /** Get a specific handler by name (for tools/call) */ - muaallijLi(name: string): MuaallijAlatMcp | undefined; + muaallijLi(name: string): MuaallijAla | undefined; /** Check if a tool name is registered */ yujad(name: string): boolean; From 4df58a20f2998445607e7bbdd48048ffb43cd8a1 Mon Sep 17 00:00:00 2001 From: Adil Shaikh Date: Mon, 24 Aug 2026 15:20:29 +0000 Subject: [PATCH 4/7] docs: comments describe what is, not what was removed A deletion leaves no trace, so a comment explaining an absence only puzzles whoever reads next. Git holds the why. Rewrites the headers in alat-al-iksir.ts, hum/client.ts, cli.ts and main.ts to state the mechanism rather than narrate the change away from it. Two comments in katib.ts were not merely stale but wrong, and the second describes a real gap rather than a wording one: - aalajaDamj was documented as belt-and-suspenders behind a compaction plugin that injected diary entries into the summary itself. With no plugin, the follow-up message is the only thing carrying a murshid's decisions across a curation. - It claimed to catch nest-triggered compactions too. It does not. A worker that curates itself on token overflow raises no tone Iksir hears, so only curations Raqib asks for are caught. 143 tests pass. --- src/alat/alat-al-iksir.ts | 14 ++++++-------- src/cli.ts | 5 ++--- src/daemon/katib.ts | 16 ++++++++-------- src/hum/client.ts | 21 +++++++++------------ src/main.ts | 9 ++++----- 5 files changed, 29 insertions(+), 36 deletions(-) diff --git a/src/alat/alat-al-iksir.ts b/src/alat/alat-al-iksir.ts index cca1f07..8afebc4 100644 --- a/src/alat/alat-al-iksir.ts +++ b/src/alat/alat-al-iksir.ts @@ -1,19 +1,17 @@ /** * Alat al-Iksir (آلات الإكسير) — The Instruments of Iksir * - * The workshop's apparatus. Not a server, not a protocol — the - * instruments themselves, and the hands that work them: + * The workshop's apparatus — the instruments themselves, and the + * hands that work them: * * mun_* the alchemical operations — istihal, fasl, naqsh * code_* the reading of runuz — symbols, dependencies, impact * - * There was an MCP server here once, because OpenCode needed to be - * told what Iksir could do. Nothing needs telling any more. The - * adawat ride in the hello, humd merges them into the foragerTools - * it hands every worker, and a nida comes back by name. What remains - * is a sijill of instruments and a way to work one. + * Their taarif ride in Iksir's hello. humd merges every forager's + * into the foragerTools it hands each worker, so a nida returns + * here by name alone. * - * Each instrument still inscribes its hadath into the ahdath table. + * Each instrument inscribes its hadath into the ahdath table, and * Munaffidh drains it. The sijill is the record; the thrum is the road. */ diff --git a/src/cli.ts b/src/cli.ts index 83fdfbf..e1b10f0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -22,9 +22,8 @@ import { execCommand } from "./utils/exec.ts"; import { join } from "jsr:@std/path"; /** - * One service. The MCP server and the agent runtime were both Iksir's to - * supervise once; the nest is al-Kimyawi's concern now, and the instruments - * ride the same process as the daemon. + * One service. The nest is al-Kimyawi's to supervise, and the instruments + * ride the daemon's own process. */ const SERVICES = ["iksir"] as const; diff --git a/src/daemon/katib.ts b/src/daemon/katib.ts index cdc7585..34bd34d 100644 --- a/src/daemon/katib.ts +++ b/src/daemon/katib.ts @@ -548,8 +548,8 @@ Awaiting direction from al-Kimyawi...`; /** - * Get murshid by the nest session ID (reverse lookup). - * Used by SSE event handlers where only the the nest session ID is known. + * Get murshid by jalsa id (reverse lookup). + * Used where a hadath carries only the sid. */ wajadaMurshidBiHuwiyyatJalsa(sessionId: string): JalsatMurshid | null { for (const session of this.#murshidSessions.values()) { @@ -561,13 +561,13 @@ Awaiting direction from al-Kimyawi...`; /** * Handle a compaction event for an murshid session. * - * After compaction, the murshid's conversation history is summarized and - * prior context is lost. The compaction plugin injects diary entries INTO the - * summary, but as a belt-and-suspenders measure, we also send a follow-up - * message with diary entries and a reminder to use pm_read_diary. + * Curation summarizes the murshid's history and prior context is lost with + * it. This sends a follow-up carrying the diary entries back in, with a + * reminder to use pm_read_diary — the only thing standing between a curated + * murshid and a murshid that has forgotten its own decisions. * - * This catches both Daemon-triggered compactions (health-monitor) and - * the nest-triggered compactions (token overflow). + * Reaches only curations Raqib asked for. A worker that curates itself on + * token overflow raises no tone Iksir can hear. */ async aalajaDamj(sessionId: string): Promise { const session = this.wajadaMurshidBiHuwiyyatJalsa(sessionId); diff --git a/src/hum/client.ts b/src/hum/client.ts index 810d05b..7b08218 100644 --- a/src/hum/client.ts +++ b/src/hum/client.ts @@ -5,11 +5,9 @@ * always knew, but behind it there is no vendor — only the thrum, * and whatever hive al-Kimyawi has chosen to kindle. * - * The inversion is total. Where once Iksir asked a foreign runtime - * which jalasat existed, it now answers that question itself: the - * sijill was always the truth, and the runtime was only ever - * repeating it back. Sessions are minted here, held here, and - * carried across turns by the nestId the worker returns on + * Jalasat are Iksir's own. The sijill is the truth of which exist; + * nothing is asked of the nest about them. They are minted here, held + * here, and carried across turns by the nestId the worker returns on * session-ready — surrendered again as `resume` on the next prompt, * so the cell rehydrates its full prior context. * @@ -34,7 +32,7 @@ import type { HadathHum, JalsatHum, TasmimIksir } from "../types.ts"; /** How long a blocking prompt waits before it is abandoned. */ const MUHLAT_IFTIRADIYYA_MS = 30_000; -/** What Iksir remembers of a jalsa the runtime no longer remembers for it. */ +/** What Iksir remembers of a jalsa. Nothing else remembers it. */ interface HalatJalsa { id: string; huwiyyatWasfa: string; @@ -198,10 +196,9 @@ export class AmilHum { /** * The ruqya a murshid is summoned under. * - * OpenCode kept these as "agents" in its own config dir and attached them - * by name. Nothing does that now, so Iksir carries its own incantations: - * the ruqya is read from the prompts/ archive and sent as systemPrompt. - * Read once, then held — a murshid's identity does not change mid-work. + * Read from the prompts/ archive and sent as the systemPrompt, since + * a murshid arrives unnamed otherwise. Held after the first read — an + * identity does not change mid-work. */ #ruqya(ism: string): string | undefined { const mahfuz = this.#ruqan.get(ism); @@ -533,8 +530,8 @@ export class AmilHum { } /** - * The stream of ahdath. Never ends on its own — the strand reconnects - * beneath it, so unlike the old SSE loop there is nothing to resubscribe. + * The stream of ahdath. Never ends on its own; the strand reconnects + * beneath it, so there is nothing here to resubscribe. */ async *subscribeToEvents(signal?: AbortSignal): AsyncGenerator { while (!signal?.aborted) { diff --git a/src/main.ts b/src/main.ts index 7ee665d..29e39bc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -814,11 +814,10 @@ export async function abda(opts: { check?: boolean } = {}): Promise { const amil = createAmilHum(config, alat.adawat()); /** - * A nida arrives from the nest. The instruments are unchanged — each still - * inscribes its hadath into the sijill, and Munaffidh still drains that - * table on its heartbeat. Only the carriage changed: no server, no stdio, - * no polling for the call itself. The journal stays the record; the thrum - * is merely the road. + * A nida arrives from the nest, is worked, and its natija returns on the + * same strand — the cell stays parked until it does. Each instrument + * inscribes its own hadath as it goes, which Munaffidh drains on its + * heartbeat. The sijill is the record; the thrum is merely the road. */ amil.alaNida(async (nida) => { const natija = await alat.naffidh(nida.name, nida.args); From db0bf2b5f3b07ef118d63e024d9df4e374f4b3b1 Mon Sep 17 00:00:00 2001 From: Adil Shaikh Date: Mon, 24 Aug 2026 15:25:00 +0000 Subject: [PATCH 5/7] =?UTF-8?q?docs:=20complete=20the=20sacred=20hierarchy?= =?UTF-8?q?=20=E2=80=94=20Murshidun=20and=20Sani?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tree showed only the Khuddām, then dropped to the Buwtaqa, so Iksīr read as servants with nothing being guided. The Murshidun appeared in the prose but never in the diagram. They are not Khuddām. The split is lifetime: the Khuddām are eternal, one of each, and are Iksīr itself; the Murshidun are summoned per wasfa, are many, and perish when the work is done. A Sani hangs below each. The distinction matters more now that a Murshid is a cell burning in someone else's nest rather than a session Iksīr owns. Also updates ARABICIZED_TYPES.md for the renamed types. --- README.md | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 9a5b0a6..1f71161 100644 --- a/README.md +++ b/README.md @@ -74,21 +74,36 @@ Iksīr transmutes raw **wasfa** (وصفة - formulae) into perfected **dhahab** Kimyawi (كيميائي - You, the Human Alchemist) | Iksīr (إكسير - The Elixir) - | (Served by the Khuddām - خدّام - Sacred Servants) - |- Munadi منادي - calls forth the workers - |- Katib كاتب - inscribes all transformations - |- Arraf عرّاف - divines intent from utterances - |- Saail سائل - divines truth from questions - |- Mumayyiz مميّز - separates dhahab from khabath - |- Raqib رقيب - guards against fasad (corruption) - |- Hayat حياة - keeps vigil, performs the night rites - '- Munaffidh منفذ - executes the transmutation + | + |- Khuddām (خدّام - Sacred Servants) + | Eternal, bound to the workshop. One of each. They are Iksīr. + | + | |- Munadi منادي - calls forth the workers + | |- Katib كاتب - inscribes all transformations + | |- Arraf عرّاف - divines intent from utterances + | |- Saail سائل - divines truth from questions + | |- Mumayyiz مميّز - separates dhahab from khabath + | |- Raqib رقيب - guards against fasad (corruption) + | |- Hayat حياة - keeps vigil, performs the night rites + | '- Munaffidh منفذ - executes the transmutation + | + '- Murshidun (مرشدون - The Guides) + Summoned per wasfa, many, but only one at the flame. They perish + when the work is done. Iksīr does not contain them; it tends them. + Each dwells in its own inā', on its own branch, and never touches + the source directly. + | + '- Sani (صانع - The Craftsman) + Summoned by a Murshid for one waṣfa. Inscribes the runūz. + Returns to the void when the work is complete. | Buwtaqa (بوتقة - The Crucible) | Athanor (أثانور - The Sacred Furnace) ``` +The Khuddām are Iksīr's own flesh. The Murshidun are not — they are cells burning in a nest al-Kimyawi has kindled, reached across the thrum. Iksīr summons them, guards the single flame among them, and carries their words to you; what they are made of was never Iksīr's to decide. + The **Ālāt al-Iksīr** (آلات الإكسير - instruments of the Elixir) are forged within: transmutation rites, formula inscription, essence decanting. They are advertised to the nest in Iksīr's own hello, and a summons returns to them by name. Additional instruments may be consecrated through the sijill. ## The Sacred Laws From c86e7c7ad99c9e3b1e426c93ae2b0806fe249d10 Mon Sep 17 00:00:00 2001 From: Adil Shaikh Date: Mon, 24 Aug 2026 16:48:01 +0000 Subject: [PATCH 6/7] fix: the sijill is the truth of which vessels exist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restoring state asked the amil whether each persisted jalsa still existed. That check made sense against OpenCode, which owned the sessions. It does not now: AmilHum answers from an in-memory map that is empty at boot, so every row was skipped as "no longer exists" and katib restored nothing on any restart. The rows survived, orphaned and unreachable, and the next mention of a ticket minted a fresh vessel on a fresh branch. The source of truth moved and the permission check stayed behind. This removes it — hammalaHala restores every row unconditionally, and hands each back to the amil via istaadaJalsa, which until now had no caller. nestId rides in the existing hala_mufassala JSON beside activePRs, so a resumed murshid carries its resume token and wakes remembering its work. No schema migration needed. Two more checks of the same kind go with it: - wajadaAwKhalaqa asked the amil to confirm a session katib itself tracked, and deleted its own entry when the amil said no. - the title-search recovery path scanned the amil's map for sessions katib had already registered, and rebuilt the branch name from scratch rather than the persisted one. Dead, and wrong if it ever fired. Raqib no longer claims a compaction it cannot confirm. summarizeSession is fire-and-forget over the wire and always returns true, so "Auto- compacted session X" was announced whether or not anything happened. It now records the count when it asks, checks on a later heartbeat whether the vessel actually shrank, and tells al-Kimyawi once when it did not — which is also the honest outcome when a curation legitimately trims nothing. Adds a restart regression test: a vessel lit under one amil is restored, with its nestId, by a second amil that has never heard of it. Verified to fail with the old existence check in place. 144 tests pass. --- src/daemon/arraf.ts | 6 +- src/daemon/katib.ts | 175 +++++++++++++++----------------------------- src/daemon/raqib.ts | 61 ++++++++++----- src/test-helpers.ts | 19 +++++ tests/smoke.test.ts | 31 ++++++++ 5 files changed, 152 insertions(+), 140 deletions(-) diff --git a/src/daemon/arraf.ts b/src/daemon/arraf.ts index 90ec67a..aebb53b 100644 --- a/src/daemon/arraf.ts +++ b/src/daemon/arraf.ts @@ -452,11 +452,7 @@ ${Arraf.TAWJIHAT_NIZAM_NIYYA}`; * If the vessel has gone cold it is relit. */ async wajadaJalsatNiyya(): Promise { - if (this.#huwiyyatJalsatNiyya) { - const jalsa = await this.#amil.jalabJalsa(this.#huwiyyatJalsatNiyya); - if (jalsa) return this.#huwiyyatJalsatNiyya; - this.#huwiyyatJalsatNiyya = null; - } + if (this.#huwiyyatJalsatNiyya) return this.#huwiyyatJalsatNiyya; const jalsa = await this.#amil.khalaqaJalsa( "iksir-arraf", diff --git a/src/daemon/katib.ts b/src/daemon/katib.ts index 34bd34d..0c8ae96 100644 --- a/src/daemon/katib.ts +++ b/src/daemon/katib.ts @@ -119,53 +119,14 @@ export class MudirJalasat { const faailSabiq = this.#murshidFaailId; /** Is this vessel already lit? */ - let session = this.#murshidSessions.get(identifier); - if (session) { - /** Verify the vessel still breathes in the nest */ - const existing = await this.#amil.jalabJalsa(session.id); - if (existing) { - await logger.akhbar("session-manager", `Resuming tracked murshid session for ${identifier}`, { - sessionId: session.id, - }); - this.#murshidFaailId = identifier; - await this.takkadMinQanat(session); - return { session, jadida: false, mustarjaa: true, faailSabiq }; - } - await logger.haDHHir("session-manager", `Tracked session ${session.id} no longer exists in the nest`); - this.#murshidSessions.delete(identifier); - } - - /** - * Step 2: Check the nest for existing murshid session with matching title - * This handles cases where state wasn't persisted (crash, restart without save, etc.) - */ - const existingSession = await this.#bahathaAnJalsatMurshid(identifier); - if (existingSession) { - await logger.akhbar("session-manager", `Found existing murshid session in the nest for ${identifier}`, { - sessionId: existingSession.id, + const mawjuda = this.#murshidSessions.get(identifier); + if (mawjuda) { + await logger.akhbar("session-manager", `Resuming tracked murshid session for ${identifier}`, { + sessionId: mawjuda.id, }); - - session = { - id: existingSession.id, - huwiyya: identifier, - unwan: existingSession.title, - naw: type, - far: wallidIsmFar(identifier, type, undefined, existingSession.title), - hala: "fail", - unshiaFi: existingSession.createdAt.toISOString(), - akhirRisalaFi: existingSession.lastMessageAt.toISOString(), - activePRs: [], - channels: this.#messenger.hammalQanawatLilJalsa(identifier), - }; - - this.#murshidSessions.set(identifier, session); this.#murshidFaailId = identifier; - - await this.hafizaHala(); - - await this.takkadMinQanat(session); - - return { session, jadida: false, mustarjaa: true, faailSabiq }; + await this.takkadMinQanat(mawjuda); + return { session: mawjuda, jadida: false, mustarjaa: true, faailSabiq }; } await logger.akhbar("session-manager", `Creating new murshid session for ${identifier}`); @@ -178,7 +139,7 @@ export class MudirJalasat { return null; } - session = { + const session: JalsatMurshid = { id: openCodeSession.id, huwiyya: identifier, unwan: title, @@ -203,44 +164,6 @@ export class MudirJalasat { return { session, jadida: true, mustarjaa: false, faailSabiq }; } - /** - * Find an existing murshid session in the nest by searching titles - */ - async #bahathaAnJalsatMurshid(epicId: string): Promise<{ - id: string; - title: string; - createdAt: Date; - lastMessageAt: Date; - } | null> { - const sessions = await this.#amil.listSessions(); - const pattern = `[Murshid] ${epicId}:`; - - /** Find sessions matching the pattern, sorted by most recent */ - const matches = sessions - .filter((s) => s.title.includes(pattern)) - .sort((a, b) => b.lastMessageAt.getTime() - a.lastMessageAt.getTime()); - - if (matches.length === 0) { - return null; - } - - /** Return the most recent one */ - const match = matches[0]; - - if (matches.length > 1) { - await logger.haDHHir("session-manager", `Found ${matches.length} murshid sessions for ${epicId}, using most recent`, { - sessionIds: matches.map((m) => m.id), - }); - } - - return { - id: match.id, - title: match.title, - createdAt: match.createdAt, - lastMessageAt: match.lastMessageAt, - }; - } - /** * Get the active murshid session */ @@ -674,6 +597,12 @@ Call pm_read_diary for full decision history with reasoning. akhirRisalaFi: session.akhirRisalaFi, halaMufassala: { activePRs: session.activePRs || [], + /** + * The worker's own handle for this vessel. Without it a + * restarted murshid resumes nothing and wakes with no memory + * of its own work. + */ + nestId: this.#amil.huwiyyatUsh(session.id), }, }); @@ -693,8 +622,16 @@ Call pm_read_diary for full decision history with reasoning. } /** - * Load and tahaqqaq session state from SQLite - * Validates that sessions still exist in the nest before using them + * Restore the vessels from the sijill. + * + * The sijill is the truth of which jalasat exist. Nothing is asked of the + * nest here — a vessel is not less real because no cell happens to be + * burning in it, and a worker that exits between turns (claude `-p` does) + * would make every restart look like a graveyard. + * + * Each restored vessel is handed back to the amil with the worker's + * nestId, so the next hathth carries a resume and the murshid wakes + * remembering its own work. */ async hammalaHala(): Promise { try { @@ -705,38 +642,44 @@ Call pm_read_diary for full decision history with reasoning. return; } - /** Validate murshid sessions still exist in the nest */ const murshidunṢalihun: JalsatMurshid[] = []; for (const dbSession of dbSessions) { - const exists = await this.#amil.jalabJalsa(dbSession.id); - if (exists) { - /** Parse metadata */ - const metadata = JSON.parse(dbSession.hala_mufassala || "{}") as { - activePRs?: RisalaMutaba[]; - }; - - /** Hydrate qanawat from the sijill */ - const channels = this.#messenger.hammalQanawatLilJalsa(dbSession.huwiyya); - - const session: JalsatMurshid = { - id: dbSession.id, - huwiyya: dbSession.huwiyya, - unwan: dbSession.unwan ?? "", - naw: dbSession.naw as NawMurshid, - hala: dbSession.hala as JalsatMurshid["hala"], - far: dbSession.far ?? "", - illa: dbSession.illa ?? undefined, - unshiaFi: dbSession.unshia_fi, - akhirRisalaFi: dbSession.akhir_risala_fi ?? "", - channels, - activePRs: metadata.activePRs ?? [], - }; - - murshidunṢalihun.push(session); - await logger.akhbar("session-manager", `Restored murshid session for ${session.huwiyya}`); - } else { - await logger.haDHHir("session-manager", `Murshid session ${dbSession.id} no longer exists, skipping`); - } + const metadata = JSON.parse(dbSession.hala_mufassala || "{}") as { + activePRs?: RisalaMutaba[]; + nestId?: string; + }; + + /** Hydrate qanawat from the sijill */ + const channels = this.#messenger.hammalQanawatLilJalsa(dbSession.huwiyya); + + const session: JalsatMurshid = { + id: dbSession.id, + huwiyya: dbSession.huwiyya, + unwan: dbSession.unwan ?? "", + naw: dbSession.naw as NawMurshid, + hala: dbSession.hala as JalsatMurshid["hala"], + far: dbSession.far ?? "", + illa: dbSession.illa ?? undefined, + unshiaFi: dbSession.unshia_fi, + akhirRisalaFi: dbSession.akhir_risala_fi ?? "", + channels, + activePRs: metadata.activePRs ?? [], + }; + + this.#amil.istaadaJalsa({ + id: session.id, + projectId: "", + huwiyyatWasfa: session.huwiyya, + title: session.unwan, + status: "sakin", + createdAt: new Date(session.unshiaFi), + lastMessageAt: new Date(session.akhirRisalaFi || session.unshiaFi), + }, metadata.nestId); + + murshidunṢalihun.push(session); + await logger.akhbar("session-manager", `Restored murshid session for ${session.huwiyya}`, { + resumable: Boolean(metadata.nestId), + }); } this.istawradaHala({ diff --git a/src/daemon/raqib.ts b/src/daemon/raqib.ts index d84a66f..ceb44aa 100644 --- a/src/daemon/raqib.ts +++ b/src/daemon/raqib.ts @@ -49,8 +49,12 @@ interface RaqibDeps { /** The health record Raqib keeps for each vessel */ interface HalatSihhJalsa { - /** When Raqib last performed damj on this vessel */ + /** When Raqib last asked for damj on this vessel */ akhirDamjFi: number | null; + /** Risālāt counted at the moment damj was asked for */ + adadQablaDamj: number | null; + /** Has al-Kimyawi been told that a damj went unanswered? */ + ublighaAnDamjAqim: boolean; /** Has al-Kimyawi been alerted about this vessel's 'aliq state? */ ublighaAnAliq: boolean; /** Has Raqib already cut the thread on this vessel? */ @@ -233,33 +237,50 @@ export class Raqib { state: HalatSihhJalsa, now: number ): Promise { - if (state.akhirDamjFi && now - state.akhirDamjFi < TABREED_DAMJ_MS) { - return; - } - /** Count the risālāt within */ const counts = await this.#amil.jalabRisalaCount(sessionId); if (!counts) return; + /** + * Did the last damj take? Asking for one is not the same as getting one + * — the tone is accepted and echoed whether or not anything on the far + * side acts, and even a working curation may trim nothing when the whole + * vessel still sits inside the protected window. So Raqib measures rather + * than assumes, and says so once when the vessel did not shrink. + */ + if (state.akhirDamjFi !== null && state.adadQablaDamj !== null) { + if (counts.total < state.adadQablaDamj) { + state.adadQablaDamj = null; + state.ublighaAnDamjAqim = false; + } else if (!state.ublighaAnDamjAqim && now - state.akhirDamjFi >= TABREED_DAMJ_MS) { + state.ublighaAnDamjAqim = true; + await logger.haDHHir("health-monitor", `Damj had no effect on ${identifier}`, { + sessionId, + qabl: state.adadQablaDamj, + baad: counts.total, + }); + await this.#messenger.arsalaMunassaq("dispatch", + `Vessel **${identifier}** did not shrink after compaction ` + + `(${state.adadQablaDamj} → ${counts.total} messages). The nest may not ` + + `curate. Consider restarting this murshid before it grows incoherent.` + ); + } + } + + if (state.akhirDamjFi && now - state.akhirDamjFi < TABREED_DAMJ_MS) { + return; + } + if (counts.total >= HADD_DAMJ) { - await logger.akhbar("health-monitor", `Session ${identifier} has ${counts.total} messages, compacting`, { + await logger.akhbar("health-monitor", `Session ${identifier} has ${counts.total} messages, requesting damj`, { sessionId, threshold: HADD_DAMJ, }); - const success = await this.#amil.summarizeSession(sessionId); - - if (success) { - state.akhirDamjFi = now; - - await this.#messenger.arsalaMunassaq("dispatch", - `Auto-compacted session **${identifier}** (${counts.total} messages → summarized)` - ); - } else { - await logger.haDHHir("health-monitor", `Failed to compact session ${identifier}`, { - sessionId, - }); - } + await this.#amil.summarizeSession(sessionId); + state.akhirDamjFi = now; + state.adadQablaDamj = counts.total; + state.ublighaAnDamjAqim = false; } } @@ -271,6 +292,8 @@ export class Raqib { if (!state) { state = { akhirDamjFi: null, + adadQablaDamj: null, + ublighaAnDamjAqim: false, ublighaAnAliq: false, ulghiya: false, }; diff --git a/src/test-helpers.ts b/src/test-helpers.ts index 30841c5..bbe0fe2 100644 --- a/src/test-helpers.ts +++ b/src/test-helpers.ts @@ -82,6 +82,10 @@ export interface MockAmilHum { khalaqaJalsa(huwiyyatWasfa: string, title: string): Promise; jalabJalsa(sessionId: string): Promise; listSessions(): Promise>; + istaadaJalsa(jalsa: MockJalsatHum, nestId?: string): void; + huwiyyatUsh(sessionId: string): string | undefined; + /** Stand in for the worker reporting its handle on chi:"session-ready". */ + _reportNestId(sessionId: string, nestId: string): void; _calls: { mayyaza: string[]; @@ -120,6 +124,7 @@ export function mockAmilHum(overrides?: { }; const sessions = new Map(); + const nestIds = new Map(); let sessionCounter = 0; return { @@ -182,6 +187,20 @@ export function mockAmilHum(overrides?: { lastMessageAt: s.lastMessageAt, })); }, + + istaadaJalsa(jalsa, nestId) { + if (sessions.has(jalsa.id)) return; + sessions.set(jalsa.id, jalsa); + if (nestId) nestIds.set(jalsa.id, nestId); + }, + + huwiyyatUsh(sessionId) { + return nestIds.get(sessionId); + }, + + _reportNestId(sessionId, nestId) { + nestIds.set(sessionId, nestId); + }, }; } diff --git a/tests/smoke.test.ts b/tests/smoke.test.ts index ae5af65..f97d7d8 100644 --- a/tests/smoke.test.ts +++ b/tests/smoke.test.ts @@ -331,3 +331,34 @@ Deno.test("smoke: second murshid activation switches active session", async () = assertEquals(sessionManager.wajadaJalasatMurshid().length, 2); }); }); + + +Deno.test("smoke: vessels survive a restart, carrying their nestId", async () => { + await withTestRepo(async () => { + /** First life — light a vessel and let the worker name its handle. */ + const first = buildContext(); + await first.dispatcher.faaalLiRabitWasfa("TEAM-9001", "Rihla Baqiya", "https://linear.app/team/TEAM-9001"); + + const lit = first.sessionManager.jalabMurshid("TEAM-9001"); + assertEquals(lit !== null, true); + first.amil._reportNestId(lit!.id, "nest-handle-9001"); + await first.sessionManager.hafizaHala(); + + /** + * Second life — a fresh amil that has never heard of this vessel, which + * is exactly the state after a restart. The sijill is the only witness. + */ + const second = buildContext(); + assertEquals(second.sessionManager.wajadaJalasatMurshid().length, 0); + + await second.sessionManager.hammalaHala(); + + const restored = second.sessionManager.jalabMurshid("TEAM-9001"); + assertEquals(restored !== null, true); + assertEquals(restored!.id, lit!.id); + assertEquals(restored!.far, lit!.far); + + /** The amil must be able to resume, or the murshid wakes with no memory. */ + assertEquals(second.amil.huwiyyatUsh(lit!.id), "nest-handle-9001"); + }); +}); From 822dce6cf4dda62066808588d63b055ab160cae9 Mon Sep 17 00:00:00 2001 From: Adil Shaikh Date: Mon, 24 Aug 2026 17:00:08 +0000 Subject: [PATCH 7/7] fix: correct the instrument count and the socket-path note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified against a live humd (0.31.18, thrum 0.7.0): the hello is accepted, `bee.hid.registered` rather than `bee.hid.invalid`, and it counts forager.tools.registered count=23 Twenty-three, not twenty-four. The extra name in the old MCP file was the server's own, and that server is gone. Also withdraws an unfair note about WIRE.md. It documents an XDG_RUNTIME_DIR socket and I called it stale against the 0.32 source — but the running 0.31 daemon binds exactly where WIRE.md says. The default moved in 0.32; both are right for their version, which is precisely why the client asks runtime.json before falling back. --- src/hum/identity.ts | 2 +- src/hum/thrum.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/hum/identity.ts b/src/hum/identity.ts index 01b0cd9..410d260 100644 --- a/src/hum/identity.ts +++ b/src/hum/identity.ts @@ -9,7 +9,7 @@ * client_id changes with every reconnection; the hid does not. * Should the mark be absent or malformed, humd cannot recognize * a returning bee — and every reconnection leaves behind a ghost - * manifest, the tool count swelling by twenty-four each time until + * manifest, the tool count swelling by twenty-three each time until * the daemon is restarted. * * Mirrors hives/common/src/identity.rs byte-for-byte, so the seed diff --git a/src/hum/thrum.ts b/src/hum/thrum.ts index 41a2976..39edb33 100644 --- a/src/hum/thrum.ts +++ b/src/hum/thrum.ts @@ -73,10 +73,10 @@ function iqraMaalumatTashghil(): MaalumatTashghil | null { /** * Where humd listens, in the order a client must ask. * - * Mirrors hum_paths::thrum_sock_resolved — the rendezvous file wins over - * the default because humd may have bound somewhere else entirely. - * (WIRE.md still documents an XDG_RUNTIME_DIR path; the Rust source - * does not agree, and the Rust source is what binds the socket.) + * Mirrors hum_paths::thrum_sock_resolved. The rendezvous file wins over the + * default because the default has moved: humd bound under XDG_RUNTIME_DIR + * through 0.31, and under the state dir from 0.32. A live 0.31 daemon and + * a fresh 0.32 one disagree, and only runtime.json knows which is listening. */ export function masarThrum(sarih?: string): string { if (sarih) return sarih;