chapterhouse 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/LICENSE +23 -0
  2. package/README.md +363 -0
  3. package/agents/chapterhouse.agent.md +40 -0
  4. package/agents/coder.agent.md +38 -0
  5. package/agents/designer.agent.md +43 -0
  6. package/agents/general-purpose.agent.md +30 -0
  7. package/dist/api/auth.js +159 -0
  8. package/dist/api/auth.test.js +463 -0
  9. package/dist/api/errors.js +95 -0
  10. package/dist/api/errors.test.js +89 -0
  11. package/dist/api/rate-limit.js +85 -0
  12. package/dist/api/server-runtime.js +62 -0
  13. package/dist/api/server.js +651 -0
  14. package/dist/api/server.test.js +385 -0
  15. package/dist/api/sse.integration.test.js +270 -0
  16. package/dist/api/sse.js +7 -0
  17. package/dist/api/team.js +196 -0
  18. package/dist/api/team.test.js +466 -0
  19. package/dist/cli.js +102 -0
  20. package/dist/config.js +299 -0
  21. package/dist/config.phase3.test.js +20 -0
  22. package/dist/config.test.js +148 -0
  23. package/dist/copilot/agents.js +447 -0
  24. package/dist/copilot/agents.squad.test.js +72 -0
  25. package/dist/copilot/classifier.js +72 -0
  26. package/dist/copilot/client.js +32 -0
  27. package/dist/copilot/client.test.js +100 -0
  28. package/dist/copilot/episode-writer.js +219 -0
  29. package/dist/copilot/episode-writer.test.js +41 -0
  30. package/dist/copilot/mcp-config.js +22 -0
  31. package/dist/copilot/okr-mapper.js +196 -0
  32. package/dist/copilot/okr-mapper.test.js +114 -0
  33. package/dist/copilot/orchestrator.js +685 -0
  34. package/dist/copilot/orchestrator.test.js +523 -0
  35. package/dist/copilot/router.js +142 -0
  36. package/dist/copilot/router.test.js +119 -0
  37. package/dist/copilot/skills.js +125 -0
  38. package/dist/copilot/standup.js +138 -0
  39. package/dist/copilot/standup.test.js +132 -0
  40. package/dist/copilot/system-message.js +143 -0
  41. package/dist/copilot/system-message.test.js +17 -0
  42. package/dist/copilot/tools.js +1212 -0
  43. package/dist/copilot/tools.okr.test.js +260 -0
  44. package/dist/copilot/tools.squad.test.js +168 -0
  45. package/dist/daemon.js +235 -0
  46. package/dist/home-path.js +12 -0
  47. package/dist/home-path.test.js +11 -0
  48. package/dist/integrations/ado-analytics.js +178 -0
  49. package/dist/integrations/ado-analytics.test.js +284 -0
  50. package/dist/integrations/ado-client.js +227 -0
  51. package/dist/integrations/ado-client.test.js +176 -0
  52. package/dist/integrations/ado-schema.js +25 -0
  53. package/dist/integrations/ado-schema.test.js +39 -0
  54. package/dist/integrations/ado-skill.js +55 -0
  55. package/dist/integrations/report-generator.js +114 -0
  56. package/dist/integrations/report-generator.test.js +62 -0
  57. package/dist/integrations/team-push.js +144 -0
  58. package/dist/integrations/team-push.test.js +178 -0
  59. package/dist/integrations/teams-notify.js +108 -0
  60. package/dist/integrations/teams-notify.test.js +135 -0
  61. package/dist/paths.js +41 -0
  62. package/dist/setup.js +149 -0
  63. package/dist/shutdown-signals.js +13 -0
  64. package/dist/shutdown-signals.test.js +33 -0
  65. package/dist/squad/charter.js +108 -0
  66. package/dist/squad/charter.test.js +89 -0
  67. package/dist/squad/context.js +48 -0
  68. package/dist/squad/context.test.js +59 -0
  69. package/dist/squad/discovery.js +280 -0
  70. package/dist/squad/discovery.test.js +93 -0
  71. package/dist/squad/index.js +7 -0
  72. package/dist/squad/mirror.js +81 -0
  73. package/dist/squad/mirror.scheduler.js +78 -0
  74. package/dist/squad/mirror.scheduler.test.js +197 -0
  75. package/dist/squad/mirror.test.js +172 -0
  76. package/dist/squad/registry.js +162 -0
  77. package/dist/squad/registry.test.js +31 -0
  78. package/dist/squad/squad-coordinator-system-message.test.js +190 -0
  79. package/dist/squad/squad-session-routing.test.js +260 -0
  80. package/dist/squad/types.js +4 -0
  81. package/dist/status.js +25 -0
  82. package/dist/status.test.js +22 -0
  83. package/dist/store/db.js +290 -0
  84. package/dist/store/db.test.js +126 -0
  85. package/dist/store/squad-sessions.test.js +341 -0
  86. package/dist/test/setup-env.js +3 -0
  87. package/dist/update.js +112 -0
  88. package/dist/update.test.js +25 -0
  89. package/dist/wiki/context.js +138 -0
  90. package/dist/wiki/fs.js +195 -0
  91. package/dist/wiki/fs.test.js +39 -0
  92. package/dist/wiki/index-manager.js +359 -0
  93. package/dist/wiki/index-manager.test.js +129 -0
  94. package/dist/wiki/lock.js +26 -0
  95. package/dist/wiki/lock.test.js +30 -0
  96. package/dist/wiki/log-manager.js +20 -0
  97. package/dist/wiki/migrate.js +306 -0
  98. package/dist/wiki/okr.test.js +101 -0
  99. package/dist/wiki/path-utils.js +4 -0
  100. package/dist/wiki/path-utils.test.js +8 -0
  101. package/dist/wiki/seed-team-wiki.js +296 -0
  102. package/dist/wiki/seed-team-wiki.test.js +69 -0
  103. package/dist/wiki/team-sync.js +212 -0
  104. package/dist/wiki/team-sync.test.js +185 -0
  105. package/dist/wiki/templates/okr.js +98 -0
  106. package/package.json +72 -0
  107. package/skills/.gitkeep +0 -0
  108. package/skills/find-skills/SKILL.md +161 -0
  109. package/skills/find-skills/_meta.json +4 -0
  110. package/skills/frontend-design/LICENSE.txt +177 -0
  111. package/skills/frontend-design/SKILL.md +42 -0
  112. package/skills/squad/SKILL.md +76 -0
  113. package/web/dist/assets/index-D-e7K-fT.css +10 -0
  114. package/web/dist/assets/index-DAg9IrpO.js +142 -0
  115. package/web/dist/assets/index-DAg9IrpO.js.map +1 -0
  116. package/web/dist/chapterhouse-icon.png +0 -0
  117. package/web/dist/chapterhouse-icon.svg +42 -0
  118. package/web/dist/chapterhouse-logo.svg +46 -0
  119. package/web/dist/index.html +15 -0
@@ -0,0 +1,1212 @@
1
+ import { z } from "zod";
2
+ import { approveAll, defineTool } from "@github/copilot-sdk";
3
+ import { getDb } from "../store/db.js";
4
+ import { readdirSync, readFileSync } from "fs";
5
+ import { join } from "path";
6
+ import { homedir } from "os";
7
+ import { listSkills, createSkill, removeSkill } from "./skills.js";
8
+ import { config, persistModel } from "../config.js";
9
+ import { getCurrentSourceChannel, getCurrentActivityCallback, getCurrentAuthenticatedUser, getLastAuthenticatedUser, getCurrentAuthorizationHeader, getCurrentChannelKey, getCurrentSessionKey, switchSessionModel, } from "./orchestrator.js";
10
+ import { getRouterConfig, updateRouterConfig } from "./router.js";
11
+ import { ensureWikiStructure, readPage, writePage, deletePage, listPages, writeRawSource, listSources, assertPagePath } from "../wiki/fs.js";
12
+ import { searchIndex, addToIndex, removeFromIndex, parseIndex, buildIndexEntryForPage } from "../wiki/index-manager.js";
13
+ import { appendLog } from "../wiki/log-manager.js";
14
+ import { withWikiWrite } from "../wiki/lock.js";
15
+ import { readWikiPage, teamWikiSync } from "../wiki/team-sync.js";
16
+ import { getAgentRegistry, getAgent, createEphemeralAgentSession, createSquadAgentSession, getAgentSessionStatus, getTask, registerTask, completeTask, failTask, createAgentFile, removeAgentFile, loadAgents, } from "./agents.js";
17
+ import { adoGetOkrs, adoOkrSummary, adoUpdateKr } from "../integrations/ado-skill.js";
18
+ import { TeamsNotifier } from "../integrations/teams-notify.js";
19
+ import { TeamPushClient } from "../integrations/team-push.js";
20
+ import { OKRMapper, parseOKRPageContent } from "./okr-mapper.js";
21
+ import { getChannelProject } from "../squad/context.js";
22
+ import { findSquadAgent } from "../squad/registry.js";
23
+ import { buildSquadSystemPrefix } from "../squad/charter.js";
24
+ import { mirrorDecisionToWiki, syncDecisionsFileToWiki } from "../squad/mirror.js";
25
+ function getCategoryDir(category) {
26
+ const map = {
27
+ person: "people",
28
+ project: "projects",
29
+ preference: "preferences",
30
+ fact: "facts",
31
+ routine: "routines",
32
+ decision: "decisions",
33
+ };
34
+ return map[category] || category;
35
+ }
36
+ /** Escape a string for safe inclusion as a single-line YAML scalar value. */
37
+ function yamlEscape(value) {
38
+ // Always quote and escape backslashes, double quotes, and newlines.
39
+ const escaped = value
40
+ .replace(/\\/g, "\\\\")
41
+ .replace(/"/g, '\\"')
42
+ .replace(/\n/g, "\\n")
43
+ .replace(/\r/g, "\\r");
44
+ return `"${escaped}"`;
45
+ }
46
+ /** Escape a single token for use inside a YAML inline list `[a, b]`. */
47
+ function yamlListItem(value) {
48
+ // Restrict to a safe character set; replace anything else.
49
+ const safe = value.replace(/[^A-Za-z0-9_./-]/g, "-");
50
+ return safe || "untagged";
51
+ }
52
+ /** Sanitize a single line for safe inclusion as an index/log table entry. */
53
+ function indexSafe(text) {
54
+ return text.replace(/[\r\n|]/g, " ").trim();
55
+ }
56
+ function isTimeoutError(err) {
57
+ const msg = err instanceof Error ? err.message : String(err);
58
+ return /timeout|timed?\s*out/i.test(msg);
59
+ }
60
+ function hasAdoPat() {
61
+ return (process.env.ADO_PAT?.trim() || config.adoPat).length > 0;
62
+ }
63
+ function getCurrentQuarter(now = new Date()) {
64
+ return `${now.getUTCFullYear()}-Q${Math.floor(now.getUTCMonth() / 3) + 1}`;
65
+ }
66
+ function isSharedTeamWikiPath(path) {
67
+ return path.startsWith("pages/shared/");
68
+ }
69
+ export async function getMyOkrsSummary(options) {
70
+ const user = options.getCurrentUser();
71
+ if (!user) {
72
+ return "I don't have the current user identity for this session, so I can't filter your OKRs.";
73
+ }
74
+ const content = await options.createTeamPushClient().fetchOKRs(options.period);
75
+ const owned = parseOKRPageContent(content)
76
+ .filter((kr) => isOwnedByCurrentUser(kr.owner, user))
77
+ .sort((a, b) => a.krId.localeCompare(b.krId));
78
+ if (owned.length === 0) {
79
+ return `No current OKRs found for ${user.name}.`;
80
+ }
81
+ const lines = owned.map((kr) => {
82
+ const progress = Number.isFinite(kr.currentValue) && Number.isFinite(kr.targetValue)
83
+ ? ` — ${kr.currentValue}/${kr.targetValue}${kr.unit ? ` ${kr.unit}` : ""}`
84
+ : "";
85
+ return `• ${kr.krId}: ${kr.title} (${kr.objectiveTitle})${progress}`;
86
+ });
87
+ return `Current OKRs for ${user.name}:\n${lines.join("\n")}`;
88
+ }
89
+ export function createTools(deps) {
90
+ const getCurrentUser = deps.getCurrentUser ?? (() => getCurrentAuthenticatedUser() ?? getLastAuthenticatedUser());
91
+ const createTeamPushClient = deps.createTeamPushClient ?? (() => new TeamPushClient({
92
+ getAuthorizationHeader: getCurrentAuthorizationHeader,
93
+ getCurrentUser,
94
+ }));
95
+ const createOKRMapper = deps.createOKRMapper ?? (() => new OKRMapper(teamWikiSync));
96
+ return [
97
+ // ----- Agent Delegation Tools (for @chapterhouse) -----
98
+ defineTool("delegate_to_agent", {
99
+ description: "Delegate a task to a specialist agent. The task runs in the background — you'll be notified when it's done. " +
100
+ "Available agents: use show_agent_roster to see the roster. For @general-purpose, specify model_override based on task complexity.",
101
+ parameters: z.object({
102
+ agent_name: z.string().describe("Name or slug of the agent to delegate to (e.g. 'coder', 'designer', 'general-purpose')"),
103
+ task: z.string().describe("Detailed task description for the agent"),
104
+ summary: z.string().describe("Short human-readable summary of the task (under 80 chars, e.g. 'Fix login button styling')"),
105
+ model_override: z.string().optional().describe("Model override for agents with model 'auto' (e.g. 'gpt-4.1', 'claude-sonnet-4.6', 'claude-opus-4.6')"),
106
+ }),
107
+ handler: async (args) => {
108
+ const agent = getAgent(args.agent_name);
109
+ if (agent?.slug === "chapterhouse") {
110
+ return "Cannot delegate to yourself. Handle this directly or pick a specialist agent.";
111
+ }
112
+ const channelKey = getCurrentChannelKey() ?? "default";
113
+ const projectRoot = config.squadEnabled ? (getChannelProject(channelKey) ?? undefined) : undefined;
114
+ const squadDescriptor = (!agent && projectRoot)
115
+ ? (() => { try {
116
+ return findSquadAgent(projectRoot, args.agent_name);
117
+ }
118
+ catch {
119
+ return null;
120
+ } })()
121
+ : null;
122
+ if (!agent && !squadDescriptor) {
123
+ const available = getAgentRegistry().map((a) => a.slug).join(", ");
124
+ return `Agent '${args.agent_name}' not found. Available agents: ${available}`;
125
+ }
126
+ const delegatedSlug = agent?.slug ?? squadDescriptor?.slug ?? args.agent_name;
127
+ let session;
128
+ let isSquadAgent = false;
129
+ try {
130
+ const allTools = createTools(deps);
131
+ if (squadDescriptor && projectRoot) {
132
+ const squadContext = {
133
+ projectRoot,
134
+ squadDir: join(projectRoot, ".squad"),
135
+ teamDir: join(projectRoot, ".squad"),
136
+ personalDir: null,
137
+ mode: "local",
138
+ projectKey: null,
139
+ config: {},
140
+ agents: [squadDescriptor],
141
+ decisionsPath: join(projectRoot, ".squad", "decisions.md"),
142
+ loadedAt: new Date().toISOString(),
143
+ };
144
+ const charterPrefix = await buildSquadSystemPrefix(squadContext, squadDescriptor);
145
+ session = await createSquadAgentSession(delegatedSlug, deps.client, allTools, charterPrefix, args.model_override || squadDescriptor.modelPreference);
146
+ isSquadAgent = true;
147
+ }
148
+ else {
149
+ session = await createEphemeralAgentSession(agent.slug, deps.client, allTools, args.model_override);
150
+ }
151
+ }
152
+ catch (err) {
153
+ const msg = err instanceof Error ? err.message : String(err);
154
+ return `Failed to create session for @${delegatedSlug}: ${msg}`;
155
+ }
156
+ const task = registerTask(delegatedSlug, args.summary, getCurrentSourceChannel());
157
+ // Persist task to DB
158
+ const db = getDb();
159
+ db.prepare(`INSERT INTO agent_tasks (task_id, agent_slug, description, status, origin_channel, session_key) VALUES (?, ?, ?, 'running', ?, ?)`).run(task.taskId, delegatedSlug, args.summary, task.originChannel || null, getCurrentSessionKey());
160
+ // Capture the parent's activity callback so the child session can stream
161
+ // its events back to the originating SSE connection. This survives past
162
+ // the parent assistant turn — the child runs long after the parent's
163
+ // `executeOnSession` finishes.
164
+ const parentActivity = getCurrentActivityCallback();
165
+ const childUnsubs = [];
166
+ if (parentActivity) {
167
+ childUnsubs.push(session.on("assistant.reasoning_delta", (event) => {
168
+ parentActivity({
169
+ kind: "thinking_delta",
170
+ reasoningId: event.data.reasoningId,
171
+ deltaContent: event.data.deltaContent,
172
+ agentSlug: delegatedSlug,
173
+ });
174
+ }), session.on("tool.execution_start", (event) => {
175
+ const data = event.data;
176
+ parentActivity({
177
+ kind: "tool_start",
178
+ toolCallId: data.toolCallId,
179
+ toolName: data.toolName,
180
+ mcpServerName: data.mcpServerName,
181
+ arguments: data.arguments,
182
+ agentSlug: delegatedSlug,
183
+ });
184
+ }), session.on("tool.execution_complete", (event) => {
185
+ const data = event.data;
186
+ const result = data.result;
187
+ const resultPreview = typeof result?.content === "string" ? result.content.slice(0, 400) : undefined;
188
+ const detailedContent = typeof result?.detailedContent === "string"
189
+ ? result.detailedContent
190
+ : typeof result?.content === "string"
191
+ ? result.content
192
+ : undefined;
193
+ parentActivity({
194
+ kind: "tool_complete",
195
+ toolCallId: data.toolCallId,
196
+ success: data.success,
197
+ resultPreview,
198
+ detailedContent,
199
+ agentSlug: delegatedSlug,
200
+ });
201
+ }));
202
+ }
203
+ const timeoutMs = config.workerTimeoutMs;
204
+ // Non-blocking: dispatch and return immediately. Session is always destroyed after.
205
+ (async () => {
206
+ try {
207
+ const result = await session.sendAndWait({ prompt: args.task }, timeoutMs);
208
+ const output = result?.data?.content || "No response";
209
+ completeTask(task.taskId, output);
210
+ db.prepare(`UPDATE agent_tasks SET status = 'completed', result = ?, completed_at = CURRENT_TIMESTAMP WHERE task_id = ?`).run(output.slice(0, 10000), task.taskId);
211
+ deps.onAgentTaskComplete(task.taskId, delegatedSlug, output);
212
+ if (isSquadAgent && projectRoot && squadDescriptor) {
213
+ try {
214
+ const wikiDecisionPath = await mirrorDecisionToWiki({
215
+ taskId: task.taskId,
216
+ projectRoot,
217
+ squadAgentSlug: squadDescriptor.slug,
218
+ wikiDecisionPath: "",
219
+ }, args.summary, output);
220
+ db.prepare(`INSERT OR REPLACE INTO squad_task_links (task_id, project_root, squad_agent_slug, wiki_decision_path)
221
+ VALUES (?, ?, ?, ?)`).run(task.taskId, projectRoot, squadDescriptor.slug, wikiDecisionPath);
222
+ // Sync the full decisions.md file to the wiki so the page reflects
223
+ // all 89+ entries, not just this task-completion entry.
224
+ syncDecisionsFileToWiki(projectRoot).then(syncResult => {
225
+ if (syncResult) {
226
+ console.log(`[squad] Post-task decisions sync: ${syncResult.entriesSynced} entries → ${syncResult.wikiPath}`);
227
+ }
228
+ }).catch(() => { });
229
+ }
230
+ catch (mirrorErr) {
231
+ console.error("[tools] Failed to mirror squad decision to wiki (non-fatal):", mirrorErr instanceof Error ? mirrorErr.message : mirrorErr);
232
+ }
233
+ }
234
+ }
235
+ catch (err) {
236
+ const msg = err instanceof Error ? err.message : String(err);
237
+ failTask(task.taskId, msg);
238
+ db.prepare(`UPDATE agent_tasks SET status = 'error', result = ?, completed_at = CURRENT_TIMESTAMP WHERE task_id = ?`).run(msg, task.taskId);
239
+ deps.onAgentTaskComplete(task.taskId, delegatedSlug, `Error: ${msg}`);
240
+ }
241
+ finally {
242
+ for (const unsub of childUnsubs) {
243
+ try {
244
+ unsub();
245
+ }
246
+ catch { /* best effort */ }
247
+ }
248
+ session.destroy().catch(() => { });
249
+ }
250
+ })();
251
+ const model = (args.model_override && args.model_override.length > 0)
252
+ ? args.model_override
253
+ : squadDescriptor?.modelPreference || (agent?.model === "auto" ? "claude-sonnet-4.6" : agent?.model || "claude-sonnet-4.6");
254
+ return `Task delegated to @${delegatedSlug} (${model}). Task ID: ${task.taskId}. I'll notify you when it's done.`;
255
+ },
256
+ }),
257
+ defineTool("check_agent_status", {
258
+ description: "Check the status of an agent or a specific delegated task.",
259
+ parameters: z.object({
260
+ agent_name: z.string().optional().describe("Agent name/slug to check"),
261
+ task_id: z.string().optional().describe("Specific task ID to check"),
262
+ }),
263
+ handler: async (args) => {
264
+ if (args.task_id) {
265
+ const task = getTask(args.task_id);
266
+ if (!task)
267
+ return `Task '${args.task_id}' not found.`;
268
+ const elapsed = Math.round((Date.now() - task.startedAt) / 1000);
269
+ let info = `Task ${task.taskId} (@${task.agentSlug})\nStatus: ${task.status}\nDescription: ${task.description}\nElapsed: ${elapsed}s`;
270
+ if (task.result)
271
+ info += `\n\nResult:\n${task.result.slice(0, 2000)}`;
272
+ return info;
273
+ }
274
+ if (args.agent_name) {
275
+ const agent = getAgent(args.agent_name);
276
+ if (!agent)
277
+ return `Agent '${args.agent_name}' not found.`;
278
+ const status = getAgentSessionStatus(agent.slug);
279
+ let info = `@${agent.slug} (${agent.name})\nModel: ${agent.model}`;
280
+ if (status.tasks.length > 0) {
281
+ info += `\n\nActive tasks (${status.tasks.length}):`;
282
+ for (const t of status.tasks) {
283
+ info += `\n• ${t.taskId}: ${t.description} (${t.status})`;
284
+ }
285
+ }
286
+ return info;
287
+ }
288
+ // Show all agents
289
+ const agents = getAgentRegistry();
290
+ const lines = agents.map((a) => {
291
+ const status = getAgentSessionStatus(a.slug);
292
+ const runningTasks = status.tasks.filter((t) => t.status === "running");
293
+ const sessionBadge = runningTasks.length > 0 ? "●" : "○";
294
+ const taskInfo = runningTasks.length > 0 ? ` (${runningTasks.length} task(s) running)` : "";
295
+ return `${sessionBadge} @${a.slug} — ${a.description} [${a.model}]${taskInfo}`;
296
+ });
297
+ return `Agents (${agents.length}):\n${lines.join("\n")}`;
298
+ },
299
+ }),
300
+ defineTool("get_agent_result", {
301
+ description: "Get the result of a completed agent task.",
302
+ parameters: z.object({
303
+ task_id: z.string().describe("The task ID (from delegate_to_agent)"),
304
+ }),
305
+ handler: async (args) => {
306
+ const task = getTask(args.task_id);
307
+ if (!task) {
308
+ // Check DB for completed tasks that may have been cleared from memory
309
+ const db = getDb();
310
+ const row = db.prepare(`SELECT * FROM agent_tasks WHERE task_id = ?`).get(args.task_id);
311
+ if (!row)
312
+ return `Task '${args.task_id}' not found.`;
313
+ return `Task ${row.task_id} (@${row.agent_slug})\nStatus: ${row.status}\nDescription: ${row.description}\n\nResult:\n${row.result || "(no result)"}`;
314
+ }
315
+ if (task.status === "running") {
316
+ const elapsed = Math.round((Date.now() - task.startedAt) / 1000);
317
+ return `Task ${task.taskId} is still running (${elapsed}s elapsed).`;
318
+ }
319
+ return `Task ${task.taskId} (@${task.agentSlug}) — ${task.status}\n\nResult:\n${task.result || "(no result)"}`;
320
+ },
321
+ }),
322
+ defineTool("show_agent_roster", {
323
+ description: "List all registered agents with their name, model, status, and current tasks. When a squad project is active, also shows squad team members available via @mention.",
324
+ parameters: z.object({}),
325
+ handler: async () => {
326
+ const agents = getAgentRegistry();
327
+ const channelKey = getCurrentChannelKey() ?? "default";
328
+ const projectRoot = config.squadEnabled ? (getChannelProject(channelKey) ?? undefined) : undefined;
329
+ const chLines = agents.map((a) => {
330
+ const status = getAgentSessionStatus(a.slug);
331
+ const runningTasks = status.tasks.filter((t) => t.status === "running");
332
+ const badge = runningTasks.length > 0 ? "● working" : "○ idle";
333
+ const taskInfo = runningTasks.length > 0
334
+ ? `\n Tasks: ${runningTasks.map((t) => `${t.taskId}: ${t.description}`).join(", ")}`
335
+ : "";
336
+ return `• @${a.slug} (${a.name}) — ${a.model} — ${badge}${taskInfo}\n ${a.description}`;
337
+ });
338
+ let squadSection = "";
339
+ if (projectRoot) {
340
+ try {
341
+ const { renderProjectAgentRoster } = await import("../squad/registry.js");
342
+ const squadRoster = renderProjectAgentRoster(projectRoot);
343
+ if (squadRoster) {
344
+ squadSection = `\n\nSquad agents (project: ${projectRoot}):\n${squadRoster}`;
345
+ }
346
+ }
347
+ catch {
348
+ // squad unavailable — skip
349
+ }
350
+ }
351
+ else if (config.squadEnabled) {
352
+ squadSection = "\n\n(Squad is enabled — select a project context to see squad agents)";
353
+ }
354
+ if (chLines.length === 0 && !squadSection)
355
+ return "No agents registered.";
356
+ return `Registered agents (${chLines.length}):\n${chLines.join("\n")}${squadSection}`;
357
+ },
358
+ }),
359
+ defineTool("teams_notify", {
360
+ description: "Send a notification to the team Microsoft Teams channel",
361
+ parameters: z.object({
362
+ title: z.string().min(1).describe("Notification title"),
363
+ message: z.string().min(1).describe("Notification body"),
364
+ }),
365
+ handler: async (args) => {
366
+ const notifier = new TeamsNotifier();
367
+ const sent = await notifier.sendMessage(args.title, args.message);
368
+ return sent
369
+ ? "Sent notification to the team Microsoft Teams channel."
370
+ : "Teams notifications are disabled or TEAMS_WEBHOOK_URL is not configured.";
371
+ },
372
+ }),
373
+ defineTool("log_okr_progress", {
374
+ description: "Log progress on a team OKR key result. Use when the user mentions completing work, shipping features, or making progress on goals.",
375
+ parameters: z.object({
376
+ activity: z.string().min(1).describe("Human description of what was done"),
377
+ krId: z.string().optional().describe("Key result identifier"),
378
+ delta: z.number().finite().optional().describe("Progress delta in the range 0-100"),
379
+ notes: z.string().optional().describe("Optional notes about the work"),
380
+ }),
381
+ handler: async (args) => {
382
+ if (config.chapterhouseMode !== "personal") {
383
+ return "OKR progress logging is only available from personal Chapterhouse instances.";
384
+ }
385
+ const mapper = createOKRMapper();
386
+ if (!args.krId) {
387
+ const matches = await mapper.findMatchingKRs(args.activity);
388
+ return matches.length > 0
389
+ ? mapper.formatUpdatePrompt(args.activity, matches)
390
+ : `You mentioned: "${args.activity}". I couldn't confidently map that to a team key result yet. Tell me the KR id and delta (0-100), and I'll log it.`;
391
+ }
392
+ if (args.delta === undefined) {
393
+ return `I can log "${args.activity}" against ${args.krId}. What's the delta (0-100)?`;
394
+ }
395
+ const result = await createTeamPushClient().pushUpdate({
396
+ activity: args.activity,
397
+ krId: args.krId,
398
+ delta: args.delta,
399
+ notes: args.notes,
400
+ });
401
+ mapper.recordConfirmedMapping(args.activity, args.krId);
402
+ const deltaText = typeof result.entry?.delta === "number" ? ` (${result.entry.delta}% logged)` : "";
403
+ return `Logged OKR progress for ${args.krId}${deltaText}.`;
404
+ },
405
+ }),
406
+ defineTool("get_my_okrs", {
407
+ description: "Show the current OKR key results owned by this user",
408
+ parameters: z.object({
409
+ period: z.string().optional().describe("Optional OKR period in YYYY-QN format"),
410
+ }),
411
+ handler: async (args) => await getMyOkrsSummary({
412
+ createTeamPushClient,
413
+ getCurrentUser,
414
+ period: args.period,
415
+ }),
416
+ }),
417
+ defineTool("write_team_wiki", {
418
+ description: "Write or update a page in the shared team wiki",
419
+ parameters: z.object({
420
+ path: z.string().min(1).describe("Shared team wiki page path, starting with pages/shared/"),
421
+ content: z.string().describe("Full markdown content to write to the shared team wiki page"),
422
+ }),
423
+ handler: async (args) => {
424
+ if (!isSharedTeamWikiPath(args.path)) {
425
+ return 'Shared team wiki path must start with "pages/shared/".';
426
+ }
427
+ await createTeamPushClient().writePage(args.path, args.content);
428
+ return `Wrote shared team wiki page: ${args.path}`;
429
+ },
430
+ }),
431
+ defineTool("ado_get_okrs", {
432
+ description: "Get current OKR status from Azure DevOps for a given period (e.g. '2026-Q2')",
433
+ parameters: z.object({
434
+ period: z.string().optional().describe("Optional OKR period such as '2026-Q2'"),
435
+ }),
436
+ handler: async (args) => {
437
+ if (!hasAdoPat()) {
438
+ return "Azure DevOps OKR integration is not configured. Set ADO_PAT in ~/.chapterhouse/.env.";
439
+ }
440
+ return await adoGetOkrs(args.period);
441
+ },
442
+ }),
443
+ defineTool("ado_update_kr", {
444
+ description: "Update the current value of a Key Result in Azure DevOps",
445
+ parameters: z.object({
446
+ workItemId: z.number().int().positive().describe("Azure DevOps work item ID for the key result"),
447
+ currentValue: z.number().finite().describe("New current value for the key result"),
448
+ notes: z.string().optional().describe("Optional progress note to add as an ADO comment"),
449
+ }),
450
+ handler: async (args) => {
451
+ if (!hasAdoPat()) {
452
+ return "Azure DevOps OKR integration is not configured. Set ADO_PAT in ~/.chapterhouse/.env.";
453
+ }
454
+ return await adoUpdateKr(args.workItemId, args.currentValue, args.notes);
455
+ },
456
+ }),
457
+ defineTool("ado_okr_summary", {
458
+ description: "Get a full OKR summary including percent complete for all objectives",
459
+ parameters: z.object({
460
+ period: z.string().optional().describe("Optional OKR period such as '2026-Q2'"),
461
+ }),
462
+ handler: async (args) => {
463
+ if (!hasAdoPat()) {
464
+ return { error: "Azure DevOps OKR integration is not configured. Set ADO_PAT in ~/.chapterhouse/.env." };
465
+ }
466
+ return await adoOkrSummary(args.period);
467
+ },
468
+ }),
469
+ ...(hasAdoPat() ? [
470
+ defineTool("generate_okr_report", {
471
+ description: "Generate a monthly OKR report narrative for the team. Queries ADO for current KR values and drafts an executive summary.",
472
+ parameters: z.object({
473
+ period: z.string().optional().describe("Optional OKR period such as '2026-Q2'"),
474
+ }),
475
+ handler: async (args) => {
476
+ const generator = deps.createReportGenerator?.() ?? await (async () => {
477
+ const { ReportGenerator } = await import("../integrations/report-generator.js");
478
+ return new ReportGenerator();
479
+ })();
480
+ return await generator.generateMonthlyReport(args.period?.trim() || getCurrentQuarter());
481
+ },
482
+ }),
483
+ ] : []),
484
+ defineTool("hire_agent", {
485
+ description: "Create a new custom agent by writing an .agent.md file to ~/.chapterhouse/agents/. " +
486
+ "The agent will be available immediately after creation.",
487
+ parameters: z.object({
488
+ slug: z.string().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/).describe("Kebab-case identifier, e.g. 'data-analyst'"),
489
+ name: z.string().describe("Human-readable name"),
490
+ description: z.string().describe("One-line description of the agent's specialty"),
491
+ model: z.string().describe("Model to use (e.g. 'claude-sonnet-4.6', 'gpt-5.4', or 'auto')"),
492
+ system_prompt: z.string().describe("The agent's system prompt (markdown)"),
493
+ skills: z.array(z.string()).optional().describe("Skills to attach to this agent"),
494
+ tools: z.array(z.string()).optional().describe("Tool allowlist (omit for all execution tools)"),
495
+ }),
496
+ handler: async (args) => {
497
+ const err = createAgentFile(args.slug, args.name, args.description, args.model, args.system_prompt, args.skills, args.tools);
498
+ if (err)
499
+ return err;
500
+ // Reload registry
501
+ loadAgents();
502
+ return `Agent @${args.slug} created. It's ready for delegation.`;
503
+ },
504
+ }),
505
+ defineTool("fire_agent", {
506
+ description: "Remove a custom agent's .agent.md file and destroy its session. Cannot remove built-in agents.",
507
+ parameters: z.object({
508
+ slug: z.string().describe("The agent slug to remove"),
509
+ }),
510
+ handler: async (args) => {
511
+ const err = removeAgentFile(args.slug);
512
+ if (err)
513
+ return err;
514
+ loadAgents();
515
+ return `Agent @${args.slug} removed.`;
516
+ },
517
+ }),
518
+ defineTool("list_machine_sessions", {
519
+ description: "List ALL Copilot CLI sessions on this machine — including sessions started from VS Code, " +
520
+ "the terminal, or other tools. Shows session ID, summary, working directory. " +
521
+ "Use this when the user asks about existing sessions running on the machine. " +
522
+ "By default shows the 20 most recently active sessions.",
523
+ parameters: z.object({
524
+ cwd_filter: z.string().optional().describe("Optional: only show sessions whose working directory contains this string"),
525
+ limit: z.number().int().min(1).max(100).optional().describe("Chapterhouse sessions to return (default 20)"),
526
+ }),
527
+ handler: async (args) => {
528
+ const sessionStateDir = join(homedir(), ".copilot", "session-state");
529
+ const limit = args.limit || 20;
530
+ let entries = [];
531
+ try {
532
+ const dirs = readdirSync(sessionStateDir);
533
+ for (const dir of dirs) {
534
+ const yamlPath = join(sessionStateDir, dir, "workspace.yaml");
535
+ try {
536
+ const content = readFileSync(yamlPath, "utf-8");
537
+ const parsed = parseSimpleYaml(content);
538
+ if (args.cwd_filter && !parsed.cwd?.includes(args.cwd_filter))
539
+ continue;
540
+ entries.push({
541
+ id: parsed.id || dir,
542
+ cwd: parsed.cwd || "unknown",
543
+ summary: parsed.summary || "",
544
+ updatedAt: parsed.updated_at ? new Date(parsed.updated_at) : new Date(0),
545
+ });
546
+ }
547
+ catch {
548
+ // Skip dirs without valid workspace.yaml
549
+ }
550
+ }
551
+ }
552
+ catch (err) {
553
+ if (err instanceof Error && "code" in err && err.code === "ENOENT") {
554
+ return "No Copilot sessions found on this machine (session state directory does not exist yet).";
555
+ }
556
+ return "Could not read session state directory.";
557
+ }
558
+ // Sort by most recently updated
559
+ entries.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime());
560
+ entries = entries.slice(0, limit);
561
+ if (entries.length === 0) {
562
+ return "No Copilot sessions found on this machine.";
563
+ }
564
+ const lines = entries.map((s) => {
565
+ const age = formatAge(s.updatedAt);
566
+ const summary = s.summary ? ` — ${s.summary}` : "";
567
+ return `• ID: ${s.id}\n ${s.cwd} (${age})${summary}`;
568
+ });
569
+ return `Found ${entries.length} session(s) (most recent first):\n${lines.join("\n")}`;
570
+ },
571
+ }),
572
+ defineTool("attach_machine_session", {
573
+ description: "Attach to an existing Copilot CLI session on this machine (e.g. one started from VS Code or terminal). " +
574
+ "Resumes the session so you can observe or interact with it.",
575
+ parameters: z.object({
576
+ session_id: z.string().describe("The session ID to attach to (from list_machine_sessions)"),
577
+ name: z.string().describe("A short name to reference this session by, e.g. 'vscode-main'"),
578
+ }),
579
+ handler: async (args) => {
580
+ try {
581
+ const session = await deps.client.resumeSession(args.session_id, {
582
+ model: config.copilotModel,
583
+ onPermissionRequest: approveAll,
584
+ });
585
+ const db = getDb();
586
+ db.prepare(`INSERT OR REPLACE INTO agent_sessions (slug, copilot_session_id, model, status)
587
+ VALUES (?, ?, ?, 'idle')`).run(args.name, args.session_id, config.copilotModel);
588
+ return `Attached to session ${args.session_id.slice(0, 8)}… as '${args.name}'.`;
589
+ }
590
+ catch (err) {
591
+ const msg = err instanceof Error ? err.message : String(err);
592
+ return `Failed to attach to session: ${msg}`;
593
+ }
594
+ },
595
+ }),
596
+ defineTool("list_skills", {
597
+ description: "List all available skills that Chapterhouse knows. Skills are instruction documents that teach Chapterhouse " +
598
+ "how to use external tools and services (e.g. Gmail, browser automation, YouTube transcripts). " +
599
+ "Shows skill name, description, and whether it's a local or global skill.",
600
+ parameters: z.object({}),
601
+ handler: async () => {
602
+ const skills = listSkills();
603
+ if (skills.length === 0) {
604
+ return "No skills installed yet. Use learn_skill to teach me something new.";
605
+ }
606
+ const lines = skills.map((s) => `• ${s.name} (${s.source}) — ${s.description}`);
607
+ return `Available skills (${skills.length}):\n${lines.join("\n")}`;
608
+ },
609
+ }),
610
+ defineTool("learn_skill", {
611
+ description: "Teach Chapterhouse a new skill by creating a SKILL.md instruction file. Use this when the user asks Chapterhouse " +
612
+ "to do something it doesn't know how to do yet (e.g. 'check my email', 'search the web'). " +
613
+ "First, use a worker session to research what CLI tools are available on the system (run 'which', " +
614
+ "'--help', etc.), then create the skill with the instructions you've learned. " +
615
+ "The skill becomes available on the next message (no restart needed).",
616
+ parameters: z.object({
617
+ slug: z.string().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/).describe("Short kebab-case identifier for the skill, e.g. 'gmail', 'web-search'"),
618
+ name: z.string().refine(s => !s.includes('\n'), "must be single-line").describe("Human-readable name for the skill, e.g. 'Gmail', 'Web Search'"),
619
+ description: z.string().refine(s => !s.includes('\n'), "must be single-line").describe("One-line description of when to use this skill"),
620
+ instructions: z.string().describe("Markdown instructions for how to use the skill. Include: what CLI tool to use, " +
621
+ "common commands with examples, authentication steps if needed, tips and gotchas. " +
622
+ "This becomes the SKILL.md content body."),
623
+ }),
624
+ handler: async (args) => {
625
+ return createSkill(args.slug, args.name, args.description, args.instructions);
626
+ },
627
+ }),
628
+ defineTool("uninstall_skill", {
629
+ description: "Remove a skill from Chapterhouse's local skills directory (~/.chapterhouse/skills/). " +
630
+ "The skill will no longer be available on the next message. " +
631
+ "Only works for local skills — bundled and global skills cannot be removed this way.",
632
+ parameters: z.object({
633
+ slug: z.string().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/).describe("The kebab-case slug of the skill to remove, e.g. 'gmail', 'web-search'"),
634
+ }),
635
+ handler: async (args) => {
636
+ const result = removeSkill(args.slug);
637
+ return result.message;
638
+ },
639
+ }),
640
+ defineTool("list_models", {
641
+ description: "List all available Copilot models. Shows model id, name, and billing tier. " +
642
+ "Marks the currently active model. Use when the user asks what models are available " +
643
+ "or wants to know which model is in use.",
644
+ parameters: z.object({}),
645
+ handler: async () => {
646
+ try {
647
+ const models = await deps.client.listModels();
648
+ if (models.length === 0) {
649
+ return "No models available.";
650
+ }
651
+ const current = config.copilotModel;
652
+ const lines = models.map((m) => {
653
+ const active = m.id === current ? " ← active" : "";
654
+ const billing = m.billing ? ` (${m.billing.multiplier}x)` : "";
655
+ return `• ${m.id}${billing}${active}`;
656
+ });
657
+ return `Available models (${models.length}):\n${lines.join("\n")}\n\nCurrent: ${current}`;
658
+ }
659
+ catch (err) {
660
+ const msg = err instanceof Error ? err.message : String(err);
661
+ return `Failed to list models: ${msg}`;
662
+ }
663
+ },
664
+ }),
665
+ defineTool("switch_model", {
666
+ description: "Switch the Copilot model Chapterhouse uses for conversations. Takes effect on the next message. " +
667
+ "The change is persisted across restarts. Use when the user asks to change or switch models.",
668
+ parameters: z.object({
669
+ model_id: z.string().describe("The model id to switch to (from list_models)"),
670
+ }),
671
+ handler: async (args) => {
672
+ try {
673
+ const models = await deps.client.listModels();
674
+ const match = models.find((m) => m.id === args.model_id);
675
+ if (!match) {
676
+ const suggestions = models
677
+ .filter((m) => m.id.includes(args.model_id) || m.id.toLowerCase().includes(args.model_id.toLowerCase()))
678
+ .map((m) => m.id);
679
+ const hint = suggestions.length > 0
680
+ ? ` Did you mean: ${suggestions.join(", ")}?`
681
+ : " Use list_models to see available options.";
682
+ return `Model '${args.model_id}' not found.${hint}`;
683
+ }
684
+ const previous = config.copilotModel;
685
+ config.copilotModel = args.model_id;
686
+ persistModel(args.model_id);
687
+ // Apply model change to the live session immediately
688
+ try {
689
+ await switchSessionModel(args.model_id);
690
+ }
691
+ catch (err) {
692
+ console.log(`[chapterhouse] setModel() failed during switch_model (will apply on next session): ${err instanceof Error ? err.message : err}`);
693
+ }
694
+ // Disable router when manually switching — user has explicit preference
695
+ if (getRouterConfig().enabled) {
696
+ updateRouterConfig({ enabled: false });
697
+ return `Switched model from '${previous}' to '${args.model_id}'. Auto-routing disabled (use /auto or toggle_auto to re-enable).`;
698
+ }
699
+ return `Switched model from '${previous}' to '${args.model_id}'.`;
700
+ }
701
+ catch (err) {
702
+ const msg = err instanceof Error ? err.message : String(err);
703
+ return `Failed to switch model: ${msg}`;
704
+ }
705
+ },
706
+ }),
707
+ defineTool("toggle_auto", {
708
+ description: "Enable or disable automatic model routing (auto mode). When enabled, Chapterhouse automatically picks " +
709
+ "the best model (fast/standard/premium) for each message to save cost and optimize speed. " +
710
+ "Use when the user asks to turn auto-routing on or off.",
711
+ parameters: z.object({
712
+ enabled: z.boolean().describe("true to enable auto-routing, false to disable"),
713
+ }),
714
+ handler: async (args) => {
715
+ const updated = updateRouterConfig({ enabled: args.enabled });
716
+ if (args.enabled) {
717
+ const tiers = updated.tierModels;
718
+ return `Auto-routing enabled. Tier models:\n• fast: ${tiers.fast}\n• standard: ${tiers.standard}\n• premium: ${tiers.premium}\n\nMax will automatically pick the best model for each message.`;
719
+ }
720
+ return `Auto-routing disabled. Using fixed model: ${config.copilotModel}`;
721
+ },
722
+ }),
723
+ // ----- Wiki-backed memory facades (preserve existing remember/recall/forget UX) -----
724
+ defineTool("remember", {
725
+ description: "Save a fact, preference, or detail to the wiki. Routes to entity-specific pages automatically. " +
726
+ "Use for discrete facts ('The team prefers dark mode', 'Project uses Vercel'). " +
727
+ "For richer knowledge pages, use wiki_update instead.",
728
+ parameters: z.object({
729
+ category: z.enum(["preference", "fact", "project", "person", "routine", "decision"])
730
+ .describe("Category: preference (likes/dislikes/settings), fact (general knowledge), project (codebase/repo info), person (people info), routine (schedules/habits), decision (choices made)"),
731
+ content: z.string().describe("The thing to remember — a concise, self-contained statement"),
732
+ entity: z.string().optional().describe("The specific entity this is about (e.g. 'team', 'chapterhouse', 'vercel'). Routes to a dedicated entity page."),
733
+ related: z.array(z.string()).optional().describe("Wiki page paths this connects to, for cross-referencing"),
734
+ }),
735
+ handler: async (args) => {
736
+ return withWikiWrite(async () => {
737
+ ensureWikiStructure();
738
+ const now = new Date().toISOString().slice(0, 10);
739
+ // Entity routing: code-authoritative slugification and page lookup
740
+ let pagePath;
741
+ let title;
742
+ if (args.entity) {
743
+ const slug = args.entity.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
744
+ const categoryDir = getCategoryDir(args.category);
745
+ pagePath = `pages/${categoryDir}/${slug}.md`;
746
+ // Check for existing page with fuzzy match before creating new
747
+ const existingPages = searchIndex(args.entity, 5);
748
+ const existingMatch = existingPages.find((p) => {
749
+ const pSlug = p.path.split("/").pop()?.replace(".md", "") || "";
750
+ return pSlug === slug || p.title.toLowerCase() === args.entity.toLowerCase();
751
+ });
752
+ if (existingMatch) {
753
+ pagePath = existingMatch.path;
754
+ title = existingMatch.title;
755
+ }
756
+ else {
757
+ title = args.entity.split(/[-_\s]+/).map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
758
+ }
759
+ }
760
+ else {
761
+ const categoryMap = {
762
+ preference: "pages/preferences.md",
763
+ fact: "pages/facts.md",
764
+ project: "pages/projects.md",
765
+ person: "pages/people.md",
766
+ routine: "pages/routines.md",
767
+ decision: "pages/decisions.md",
768
+ };
769
+ pagePath = categoryMap[args.category] || `pages/${args.category}.md`;
770
+ title = args.category.charAt(0).toUpperCase() + args.category.slice(1);
771
+ }
772
+ // Defense-in-depth: pagePath is constructed from controlled parts but
773
+ // assertPagePath will catch any drift (e.g. an entity slug producing "..").
774
+ assertPagePath(pagePath);
775
+ const existing = readPage(pagePath);
776
+ if (existing) {
777
+ const updated = existing.replace(/^(---[\s\S]*?updated:\s*)[\d-]+/m, `$1${now}`);
778
+ writePage(pagePath, updated.trimEnd() + `\n- ${args.content} _(${now})_\n`);
779
+ }
780
+ else {
781
+ const tags = [args.category];
782
+ if (args.entity)
783
+ tags.push(args.entity.toLowerCase());
784
+ const safeTags = tags.map(yamlListItem).join(", ");
785
+ const safeRelated = (args.related || []).map(yamlListItem).join(", ");
786
+ const page = [
787
+ "---",
788
+ `title: ${yamlEscape(title)}`,
789
+ `tags: [${safeTags}]`,
790
+ `created: ${now}`,
791
+ `updated: ${now}`,
792
+ `related: [${safeRelated}]`,
793
+ "---",
794
+ "",
795
+ `# ${title}`,
796
+ "",
797
+ `- ${args.content} _(${now})_`,
798
+ "",
799
+ ].join("\n");
800
+ writePage(pagePath, page);
801
+ }
802
+ // Rebuild the index entry from the page on disk so summary/tags/updated
803
+ // stay in sync rather than being clobbered by the latest bullet.
804
+ const rebuilt = buildIndexEntryForPage(pagePath, {
805
+ title,
806
+ section: "Knowledge",
807
+ tags: [args.category, ...(args.entity ? [args.entity.toLowerCase()] : [])],
808
+ updated: now,
809
+ // Keep existing summary if present; otherwise use the new content.
810
+ summary: indexSafe(args.content).slice(0, 120),
811
+ });
812
+ if (rebuilt)
813
+ addToIndex(rebuilt);
814
+ appendLog("update", `remember (${args.category}${args.entity ? `, ${args.entity}` : ""}): ${indexSafe(args.content).slice(0, 80)}`);
815
+ const relatedHint = args.related?.length
816
+ ? ` Related pages that may need updating: ${args.related.join(", ")}`
817
+ : "";
818
+ return `Remembered in ${pagePath}: "${args.content}"${relatedHint}`;
819
+ });
820
+ },
821
+ }),
822
+ defineTool("recall", {
823
+ description: "Search the wiki for stored knowledge. Returns matching page summaries from the index. " +
824
+ "Use wiki_read to drill into specific pages for deeper context. " +
825
+ "Use when you need to look up something the user told you, or when asked 'do you remember...?'",
826
+ parameters: z.object({
827
+ keyword: z.string().optional().describe("Search term to match against wiki pages"),
828
+ category: z.enum(["preference", "fact", "project", "person", "routine", "decision"]).optional()
829
+ .describe("Optional: filter by category"),
830
+ }),
831
+ handler: async (args) => {
832
+ ensureWikiStructure();
833
+ const query = [args.keyword, args.category].filter(Boolean).join(" ");
834
+ const matches = searchIndex(query || "", 10);
835
+ if (matches.length === 0) {
836
+ return "No matching memories found in the wiki. The wiki is the single source of truth — if it's not here, I don't know it yet.";
837
+ }
838
+ const sections = [];
839
+ for (const match of matches) {
840
+ const content = readPage(match.path);
841
+ if (!content)
842
+ continue;
843
+ // Extract updated date from frontmatter
844
+ const updatedMatch = content.match(/^updated:\s*(.+)$/m);
845
+ const updated = updatedMatch ? ` (updated: ${updatedMatch[1].trim()})` : "";
846
+ const body = content.replace(/^---[\s\S]*?---\s*/, "").trim();
847
+ const trimmed = body.length > 800 ? body.slice(0, 800) + "…" : body;
848
+ sections.push(`**${match.title}** (${match.path})${updated}:\n${trimmed}`);
849
+ }
850
+ return sections.length > 0
851
+ ? `Found ${matches.length} wiki page(s):\n\n${sections.join("\n\n")}`
852
+ : "No matching content found.";
853
+ },
854
+ }),
855
+ defineTool("forget", {
856
+ description: "Remove content from the wiki. Three modes: (1) page_path + content removes matching bullet lines, " +
857
+ "(2) page_path + revision replaces a section with corrected content, " +
858
+ "(3) page_path alone deletes the entire page.",
859
+ parameters: z.object({
860
+ page_path: z.string().describe("Wiki page path to modify or delete"),
861
+ content: z.string().optional().describe("Specific text to match and remove (line-removal mode)"),
862
+ revision: z.string().optional().describe("Replacement content for a section (section-rewrite mode)"),
863
+ section_heading: z.string().optional().describe("The heading of the section to replace (used with revision)"),
864
+ }),
865
+ handler: async (args) => {
866
+ return withWikiWrite(async () => {
867
+ // Defense: only allow modifying real pages, never index.md / log.md / sources/.
868
+ assertPagePath(args.page_path);
869
+ // Delete entire page
870
+ if (!args.content && !args.revision) {
871
+ const page = readPage(args.page_path);
872
+ if (!page)
873
+ return `Page ${args.page_path} not found.`;
874
+ deletePage(args.page_path);
875
+ removeFromIndex(args.page_path);
876
+ appendLog("delete", `forget: deleted page ${args.page_path}`);
877
+ return `Deleted page ${args.page_path} and removed from index.`;
878
+ }
879
+ // Line-removal mode: remove bullet lines that match content.
880
+ // Precision rules: prefer a single exact match (whole bullet body equals
881
+ // the search text). If no exact match, fall back to substring match —
882
+ // but if the substring would match >1 bullets, refuse and report so the
883
+ // caller can disambiguate. This prevents "forget CST" from nuking every
884
+ // bullet that happens to mention CST.
885
+ if (args.content) {
886
+ const page = readPage(args.page_path);
887
+ if (!page)
888
+ return `Page ${args.page_path} not found.`;
889
+ const search = args.content.trim();
890
+ const lines = page.split("\n");
891
+ const isBullet = (l) => /^\s*[-*]\s+/.test(l);
892
+ const bulletText = (l) => l.replace(/^\s*[-*]\s+/, "").replace(/\s*_\(\d{4}-\d{2}-\d{2}\)_\s*$/, "").trim();
893
+ // Pass 1: exact-bullet match (case-insensitive).
894
+ const exactMatches = [];
895
+ for (let i = 0; i < lines.length; i++) {
896
+ if (isBullet(lines[i]) && bulletText(lines[i]).toLowerCase() === search.toLowerCase()) {
897
+ exactMatches.push(i);
898
+ }
899
+ }
900
+ let toRemove;
901
+ if (exactMatches.length > 0) {
902
+ toRemove = new Set(exactMatches);
903
+ }
904
+ else {
905
+ // Pass 2: substring match — but require precision.
906
+ const subMatches = [];
907
+ for (let i = 0; i < lines.length; i++) {
908
+ if (isBullet(lines[i]) && lines[i].toLowerCase().includes(search.toLowerCase())) {
909
+ subMatches.push(i);
910
+ }
911
+ }
912
+ if (subMatches.length === 0) {
913
+ return `No matching bullet points found in ${args.page_path}.`;
914
+ }
915
+ if (subMatches.length > 1) {
916
+ const preview = subMatches.slice(0, 5)
917
+ .map((i) => ` • ${lines[i].trim()}`).join("\n");
918
+ return `Refused: substring "${search}" matches ${subMatches.length} bullets in ${args.page_path}. Be more specific (paste the full bullet text), or call forget repeatedly with the exact bullet to remove. Matches:\n${preview}`;
919
+ }
920
+ toRemove = new Set(subMatches);
921
+ }
922
+ const updatedLines = lines.filter((_, i) => !toRemove.has(i));
923
+ // Bump frontmatter `updated:` so the index reflects the change.
924
+ const today = new Date().toISOString().slice(0, 10);
925
+ let updated = updatedLines.join("\n").replace(/^(---[\s\S]*?updated:\s*)[\d-]+/m, `$1${today}`);
926
+ writePage(args.page_path, updated);
927
+ // Refresh the corresponding index entry from the page so the index
928
+ // doesn't keep advertising forgotten content.
929
+ const rebuilt = buildIndexEntryForPage(args.page_path, { updated: today });
930
+ if (rebuilt)
931
+ addToIndex(rebuilt);
932
+ appendLog("update", `forget: removed ${toRemove.size} line(s) matching "${indexSafe(search).slice(0, 60)}" from ${args.page_path}`);
933
+ return `Removed ${toRemove.size} line(s) from ${args.page_path}.`;
934
+ }
935
+ // Section-rewrite mode: replace a section with revised content
936
+ if (args.revision) {
937
+ const page = readPage(args.page_path);
938
+ if (!page)
939
+ return `Page ${args.page_path} not found.`;
940
+ if (args.section_heading) {
941
+ const headingPattern = new RegExp(`(^#{1,6}\\s*${args.section_heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$)`, "m");
942
+ const headingMatch = page.match(headingPattern);
943
+ if (!headingMatch || headingMatch.index === undefined) {
944
+ return `Section "${args.section_heading}" not found in ${args.page_path}.`;
945
+ }
946
+ const sectionStart = headingMatch.index;
947
+ const level = (headingMatch[1].match(/^#+/) || ["#"])[0].length;
948
+ const nextHeading = page.slice(sectionStart + headingMatch[0].length)
949
+ .search(new RegExp(`^#{1,${level}}\\s`, "m"));
950
+ const sectionEnd = nextHeading === -1
951
+ ? page.length
952
+ : sectionStart + headingMatch[0].length + nextHeading;
953
+ const updated = page.slice(0, sectionStart) + args.revision + "\n" + page.slice(sectionEnd);
954
+ writePage(args.page_path, updated);
955
+ }
956
+ else {
957
+ // Replace entire body (keep frontmatter)
958
+ const fmMatch = page.match(/^---[\s\S]*?---\s*/);
959
+ const frontmatter = fmMatch ? fmMatch[0] : "";
960
+ writePage(args.page_path, frontmatter + args.revision + "\n");
961
+ }
962
+ const today = new Date().toISOString().slice(0, 10);
963
+ const rebuilt = buildIndexEntryForPage(args.page_path, { updated: today });
964
+ if (rebuilt)
965
+ addToIndex(rebuilt);
966
+ appendLog("update", `forget: revised section in ${args.page_path}`);
967
+ return `Revised content in ${args.page_path}.`;
968
+ }
969
+ return "Nothing to do — provide content (line-removal) or revision (section-rewrite).";
970
+ });
971
+ },
972
+ }),
973
+ // ----- New wiki tools -----
974
+ defineTool("wiki_search", {
975
+ description: "Search Chapterhouse's wiki knowledge base. Returns matching page titles, paths, and summaries " +
976
+ "from the wiki index. Use this to find relevant knowledge before answering questions.",
977
+ parameters: z.object({
978
+ query: z.string().describe("What to search for in the wiki"),
979
+ }),
980
+ handler: async (args) => {
981
+ ensureWikiStructure();
982
+ const matches = searchIndex(args.query, 10);
983
+ if (matches.length === 0)
984
+ return "No matching wiki pages found.";
985
+ const lines = matches.map((m) => `• [${m.title}](${m.path}) — ${m.summary}`);
986
+ return `Found ${matches.length} page(s):\n${lines.join("\n")}`;
987
+ },
988
+ }),
989
+ defineTool("wiki_read", {
990
+ description: "Read a specific wiki page by path. Use after wiki_search to read full page content. " +
991
+ "Paths are relative to the wiki root (e.g. 'pages/preferences.md', 'index.md').",
992
+ parameters: z.object({
993
+ path: z.string().describe("Path to the wiki page (e.g. 'pages/people/brian.md', 'index.md')"),
994
+ }),
995
+ handler: async (args) => {
996
+ ensureWikiStructure();
997
+ const content = await readWikiPage(args.path);
998
+ if (!content)
999
+ return `Page not found: ${args.path}`;
1000
+ return content;
1001
+ },
1002
+ }),
1003
+ defineTool("wiki_update", {
1004
+ description: "Create or update a wiki page. You provide the full page content (markdown with optional " +
1005
+ "YAML frontmatter). The page will be written to disk and the index updated. Use this for " +
1006
+ "rich knowledge pages, entity pages, synthesis documents — anything more structured than " +
1007
+ "a quick 'remember' call. After creating/updating a page, the index is automatically updated.",
1008
+ parameters: z.object({
1009
+ path: z.string().describe("Page path relative to wiki root (e.g. 'pages/projects/max.md')"),
1010
+ title: z.string().describe("Page title for the index"),
1011
+ summary: z.string().describe("One-line summary for the index"),
1012
+ section: z.string().optional().describe("Index section (default: 'Knowledge')"),
1013
+ content: z.string().describe("Full page content (markdown)"),
1014
+ }),
1015
+ handler: async (args) => {
1016
+ return withWikiWrite(async () => {
1017
+ ensureWikiStructure();
1018
+ assertPagePath(args.path);
1019
+ writePage(args.path, args.content);
1020
+ // Rebuild from disk so the index summary/tags/updated reflect the actual page,
1021
+ // but prefer caller-supplied title/summary/section as overrides.
1022
+ const today = new Date().toISOString().slice(0, 10);
1023
+ const rebuilt = buildIndexEntryForPage(args.path, {
1024
+ title: args.title,
1025
+ summary: indexSafe(args.summary).slice(0, 160),
1026
+ section: args.section || "Knowledge",
1027
+ updated: today,
1028
+ });
1029
+ if (rebuilt) {
1030
+ // Overrides win even if the page frontmatter says otherwise.
1031
+ rebuilt.title = args.title;
1032
+ rebuilt.summary = indexSafe(args.summary).slice(0, 160);
1033
+ rebuilt.section = args.section || "Knowledge";
1034
+ addToIndex(rebuilt);
1035
+ }
1036
+ else {
1037
+ addToIndex({
1038
+ path: args.path,
1039
+ title: args.title,
1040
+ summary: indexSafe(args.summary).slice(0, 160),
1041
+ section: args.section || "Knowledge",
1042
+ updated: today,
1043
+ });
1044
+ }
1045
+ appendLog("update", `wiki_update: ${indexSafe(args.title)} (${args.path})`);
1046
+ return `Wiki page updated: ${args.title} (${args.path})`;
1047
+ });
1048
+ },
1049
+ }),
1050
+ defineTool("wiki_ingest", {
1051
+ description: "Ingest a source into the wiki. Saves the raw content as an immutable source document, " +
1052
+ "then returns it so you can create wiki pages from it. Supports URLs (fetches the page) " +
1053
+ "or raw text passed directly. For local files, read the file yourself and pass content as text.",
1054
+ parameters: z.object({
1055
+ type: z.enum(["url", "text"]).describe("Source type: 'url' to fetch a web page, 'text' for raw content"),
1056
+ source: z.string().describe("URL or raw text content"),
1057
+ name: z.string().optional().describe("Name for the source (auto-generated if omitted)"),
1058
+ }),
1059
+ handler: async (args) => {
1060
+ ensureWikiStructure();
1061
+ let content;
1062
+ let sourceName;
1063
+ if (args.type === "url") {
1064
+ // Validate URL scheme
1065
+ let parsedUrl;
1066
+ try {
1067
+ parsedUrl = new URL(args.source);
1068
+ }
1069
+ catch {
1070
+ return "Invalid URL format.";
1071
+ }
1072
+ if (!["http:", "https:"].includes(parsedUrl.protocol)) {
1073
+ return "Only http and https URLs are supported.";
1074
+ }
1075
+ // Block private/internal addresses
1076
+ const host = parsedUrl.hostname.toLowerCase();
1077
+ if (host === "localhost" || host === "127.0.0.1" || host === "::1" ||
1078
+ host.startsWith("10.") || host.startsWith("192.168.") ||
1079
+ host.startsWith("169.254.") || host === "metadata.google.internal") {
1080
+ return "Cannot fetch internal/private URLs.";
1081
+ }
1082
+ try {
1083
+ const res = await fetch(args.source);
1084
+ if (!res.ok) {
1085
+ return `Fetch failed: ${res.status} ${res.statusText}`;
1086
+ }
1087
+ content = await res.text();
1088
+ // Strip HTML tags for a rough markdown conversion
1089
+ content = content.replace(/<script[\s\S]*?<\/script>/gi, "")
1090
+ .replace(/<style[\s\S]*?<\/style>/gi, "")
1091
+ .replace(/<[^>]+>/g, " ")
1092
+ .replace(/\s{2,}/g, " ")
1093
+ .trim();
1094
+ }
1095
+ catch (err) {
1096
+ return `Failed to fetch URL: ${err instanceof Error ? err.message : err}`;
1097
+ }
1098
+ sourceName = args.name || parsedUrl.hostname + "-" + Date.now();
1099
+ }
1100
+ else {
1101
+ content = args.source;
1102
+ sourceName = args.name || "text-" + Date.now();
1103
+ }
1104
+ const fileName = `${new Date().toISOString().slice(0, 10)}-${sourceName}.md`;
1105
+ await withWikiWrite(async () => {
1106
+ writeRawSource(fileName, content);
1107
+ appendLog("ingest", `Ingested ${args.type}: ${indexSafe(sourceName)} (${content.length} chars)`);
1108
+ });
1109
+ // Return the content so the LLM can create wiki pages from it
1110
+ const preview = content.length > 3000 ? content.slice(0, 3000) + "\n\n…(truncated)" : content;
1111
+ return `Source saved as sources/${fileName} (${content.length} chars).\n\n` +
1112
+ "Now create wiki pages from this content using wiki_update. " +
1113
+ "Update existing pages and the index as needed.\n\n" +
1114
+ `--- Source content ---\n${preview}`;
1115
+ },
1116
+ }),
1117
+ defineTool("wiki_lint", {
1118
+ description: "Health-check the wiki. Looks for: orphan pages (not in index), index entries pointing " +
1119
+ "to missing pages, and pages with no cross-references. Returns a report.",
1120
+ parameters: z.object({}),
1121
+ handler: async () => {
1122
+ ensureWikiStructure();
1123
+ const indexEntries = parseIndex();
1124
+ const pages = listPages();
1125
+ const sources = listSources();
1126
+ const indexPaths = new Set(indexEntries.map((e) => e.path));
1127
+ const orphans = pages.filter((p) => !indexPaths.has(p));
1128
+ const missing = indexEntries.filter((e) => !readPage(e.path));
1129
+ const report = [`Wiki health report (${pages.length} pages, ${sources.length} sources):`];
1130
+ if (orphans.length > 0) {
1131
+ report.push(`\n**Orphan pages** (not in index):\n${orphans.map((p) => `- ${p}`).join("\n")}`);
1132
+ }
1133
+ if (missing.length > 0) {
1134
+ report.push(`\n**Missing pages** (in index but not on disk):\n${missing.map((e) => `- ${e.path}: ${e.title}`).join("\n")}`);
1135
+ }
1136
+ if (orphans.length === 0 && missing.length === 0) {
1137
+ report.push("\n✅ No issues found. Index and pages are in sync.");
1138
+ }
1139
+ report.push(`\n**Suggestions**: Look for pages that should link to each other, topics mentioned but lacking their own page, and stale content that needs updating.`);
1140
+ appendLog("lint", `${orphans.length} orphans, ${missing.length} missing`);
1141
+ return report.join("\n");
1142
+ },
1143
+ }),
1144
+ defineTool("wiki_rebuild_index", {
1145
+ description: "Rebuild the wiki index.md from the pages on disk. Use when the index is " +
1146
+ "corrupted, out of sync with pages, or after manual edits to the wiki. " +
1147
+ "Safe to run anytime — it preserves section assignments where possible.",
1148
+ parameters: z.object({}),
1149
+ handler: async () => {
1150
+ return withWikiWrite(async () => {
1151
+ const { rebuildIndexFromPages } = await import("../wiki/index-manager.js");
1152
+ const entries = rebuildIndexFromPages();
1153
+ appendLog("lint", `wiki_rebuild_index: rebuilt ${entries.length} entries from pages on disk`);
1154
+ return `Rebuilt index with ${entries.length} entries.`;
1155
+ });
1156
+ },
1157
+ }),
1158
+ defineTool("restart_chapterhouse", {
1159
+ description: "Restart the Chapterhouse daemon process. Use when the user asks Chapterhouse to restart himself, " +
1160
+ "or when a restart is needed to pick up configuration changes. " +
1161
+ "Spawns a new process and exits the current one.",
1162
+ parameters: z.object({
1163
+ reason: z.string().optional().describe("Optional reason for the restart"),
1164
+ }),
1165
+ handler: async (args) => {
1166
+ const reason = args.reason ? ` (${args.reason})` : "";
1167
+ // Dynamic import to avoid circular dependency
1168
+ const { restartDaemon } = await import("../daemon.js");
1169
+ // Schedule restart after returning the response
1170
+ setTimeout(() => {
1171
+ restartDaemon().catch((err) => {
1172
+ console.error("[chapterhouse] Restart failed:", err);
1173
+ });
1174
+ }, 1000);
1175
+ return `Restarting Chapterhouse${reason}. I'll be back in a few seconds.`;
1176
+ },
1177
+ }),
1178
+ ];
1179
+ }
1180
+ function formatAge(date) {
1181
+ const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
1182
+ if (seconds < 60)
1183
+ return "just now";
1184
+ if (seconds < 3600)
1185
+ return `${Math.floor(seconds / 60)}m ago`;
1186
+ if (seconds < 86400)
1187
+ return `${Math.floor(seconds / 3600)}h ago`;
1188
+ return `${Math.floor(seconds / 86400)}d ago`;
1189
+ }
1190
+ function parseSimpleYaml(content) {
1191
+ const result = {};
1192
+ for (const line of content.split("\n")) {
1193
+ const idx = line.indexOf(": ");
1194
+ if (idx > 0) {
1195
+ const key = line.slice(0, idx).trim();
1196
+ const value = line.slice(idx + 2).trim();
1197
+ result[key] = value;
1198
+ }
1199
+ }
1200
+ return result;
1201
+ }
1202
+ function isOwnedByCurrentUser(owner, user) {
1203
+ const normalizedOwner = normalizeIdentity(owner);
1204
+ return [user.id, user.name, user.email]
1205
+ .map((value) => normalizeIdentity(value))
1206
+ .filter(Boolean)
1207
+ .includes(normalizedOwner);
1208
+ }
1209
+ function normalizeIdentity(value) {
1210
+ return (value ?? "").trim().toLowerCase();
1211
+ }
1212
+ //# sourceMappingURL=tools.js.map