chapterhouse 0.1.5 → 0.2.0

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.
@@ -5,7 +5,7 @@ import { config, DEFAULT_MODEL } from "../config.js";
5
5
  import { loadMcpConfig } from "./mcp-config.js";
6
6
  import { getSkillDirectories } from "./skills.js";
7
7
  import { resetClient } from "./client.js";
8
- import { logConversation, getState, setState, deleteState, getCopilotSession, upsertCopilotSession, getTaskSessionKey, getDb } from "../store/db.js";
8
+ import { logConversation, getState, setState, deleteState, getCopilotSession, upsertCopilotSession, getTaskSessionKey, getDb, bumpProjectLastUsed } from "../store/db.js";
9
9
  import { maybeWriteEpisode } from "./episode-writer.js";
10
10
  import { getWikiSummary } from "../wiki/context.js";
11
11
  import { SESSIONS_DIR } from "../paths.js";
@@ -13,6 +13,8 @@ import { resolveModel } from "./router.js";
13
13
  import { loadAgents, ensureDefaultAgents, clearActiveTasks, getAgentRegistry, setActiveAgent, parseAtMention, buildAgentRoster, getActiveTasks, } from "./agents.js";
14
14
  import { normalizeProjectPath, setChannelProject } from "../squad/context.js";
15
15
  import { getSquadCoordinatorSystemMessage } from "../squad/charter.js";
16
+ import { childLogger } from "../util/logger.js";
17
+ const log = childLogger("orchestrator");
16
18
  /**
17
19
  * Permission handler for the orchestrator session.
18
20
  * Approves all tool requests so @chapterhouse has full access to all tools.
@@ -160,9 +162,9 @@ async function ensureClient() {
160
162
  return copilotClient;
161
163
  }
162
164
  if (!resetPromise) {
163
- console.log(`[chapterhouse] Client not connected (state: ${copilotClient?.getState() ?? "null"}), resetting…`);
165
+ log.info({ state: copilotClient?.getState() ?? "null" }, "Client not connected, resetting");
164
166
  resetPromise = resetClient().then((c) => {
165
- console.log(`[chapterhouse] Client reset successful, state: ${c.getState()}`);
167
+ log.info({ state: c.getState() }, "Client reset successful");
166
168
  copilotClient = c;
167
169
  return c;
168
170
  }).finally(() => { resetPromise = undefined; });
@@ -179,7 +181,7 @@ function startHealthCheck() {
179
181
  try {
180
182
  const state = copilotClient.getState();
181
183
  if (state !== "connected") {
182
- console.log(`[chapterhouse] Health check: client state is '${state}', resetting…`);
184
+ log.info({ state }, "Health check: client not connected, resetting");
183
185
  await ensureClient();
184
186
  // Session may need recovery after client reset
185
187
  sessionMap.clear();
@@ -187,7 +189,7 @@ function startHealthCheck() {
187
189
  }
188
190
  }
189
191
  catch (err) {
190
- console.error(`[chapterhouse] Health check error:`, err instanceof Error ? err.message : err);
192
+ log.error({ err: err instanceof Error ? err.message : err }, "Health check error");
191
193
  }
192
194
  }, HEALTH_CHECK_INTERVAL_MS);
193
195
  }
@@ -237,7 +239,7 @@ async function createOrResumeSession(sessionKey, projectRoot) {
237
239
  const savedSessionId = stored?.copilotSessionId ?? (sessionKey === "default" ? getState(ORCHESTRATOR_SESSION_KEY) : undefined);
238
240
  if (savedSessionId) {
239
241
  try {
240
- console.log(`[chapterhouse] Resuming session [${sessionKey}] ${savedSessionId.slice(0, 8)}…`);
242
+ log.info({ sessionKey, sessionId: savedSessionId.slice(0, 8) }, "Resuming session");
241
243
  const session = await client.resumeSession(savedSessionId, {
242
244
  model: config.copilotModel,
243
245
  configDir: SESSIONS_DIR,
@@ -249,19 +251,19 @@ async function createOrResumeSession(sessionKey, projectRoot) {
249
251
  onPermissionRequest: orchestratorPermissionHandler,
250
252
  infiniteSessions,
251
253
  });
252
- console.log(`[chapterhouse] Resumed session [${sessionKey}] successfully`);
254
+ log.info({ sessionKey }, "Session resumed successfully");
253
255
  upsertCopilotSession(sessionKey, isProjectSession ? "project" : "default", session.sessionId, projectRoot, config.copilotModel);
254
256
  sessionModelMap.set(sessionKey, config.copilotModel);
255
257
  return session;
256
258
  }
257
259
  catch (err) {
258
- console.log(`[chapterhouse] Could not resume session [${sessionKey}]: ${err instanceof Error ? err.message : err}. Creating new.`);
260
+ log.warn({ sessionKey, err: err instanceof Error ? err.message : err }, "Could not resume session, creating new");
259
261
  if (sessionKey === "default")
260
262
  deleteState(ORCHESTRATOR_SESSION_KEY);
261
263
  }
262
264
  }
263
265
  // Create a fresh session
264
- console.log(`[chapterhouse] Creating new session [${sessionKey}]`);
266
+ log.info({ sessionKey }, "Creating new session");
265
267
  const session = await client.createSession({
266
268
  model: config.copilotModel,
267
269
  configDir: SESSIONS_DIR,
@@ -273,7 +275,7 @@ async function createOrResumeSession(sessionKey, projectRoot) {
273
275
  onPermissionRequest: orchestratorPermissionHandler,
274
276
  infiniteSessions,
275
277
  });
276
- console.log(`[chapterhouse] Created session [${sessionKey}] ${session.sessionId.slice(0, 8)}…`);
278
+ log.info({ sessionKey, sessionId: session.sessionId.slice(0, 8) }, "Session created");
277
279
  upsertCopilotSession(sessionKey, isProjectSession ? "project" : "default", session.sessionId, projectRoot, config.copilotModel);
278
280
  // Backward compat: also persist the default session to the legacy state key
279
281
  if (sessionKey === "default")
@@ -287,30 +289,30 @@ export async function initOrchestrator(client) {
287
289
  // Initialize agent system
288
290
  ensureDefaultAgents();
289
291
  const agents = loadAgents();
290
- console.log(`[chapterhouse] Loaded ${agents.length} agent(s): ${agents.map((a) => `@${a.slug}`).join(", ") || "(none)"}`);
292
+ log.info({ count: agents.length, agents: agents.map((a) => `@${a.slug}`) }, "Agents loaded");
291
293
  // Validate configured model against available models
292
294
  try {
293
295
  const models = await client.listModels();
294
296
  const configured = config.copilotModel;
295
297
  const isAvailable = models.some((m) => m.id === configured);
296
298
  if (!isAvailable) {
297
- console.log(`[chapterhouse] ⚠️ Configured model '${configured}' is not available. Falling back to '${DEFAULT_MODEL}'.`);
299
+ log.warn({ configured, fallback: DEFAULT_MODEL }, "Configured model not available, falling back");
298
300
  config.copilotModel = DEFAULT_MODEL;
299
301
  }
300
302
  }
301
303
  catch (err) {
302
- console.log(`[chapterhouse] Could not validate model (will use '${config.copilotModel}' as-is): ${err instanceof Error ? err.message : err}`);
304
+ log.warn({ model: config.copilotModel, err: err instanceof Error ? err.message : err }, "Could not validate model, using as-is");
303
305
  }
304
- console.log(`[chapterhouse] Loading ${Object.keys(mcpServers).length} MCP server(s): ${Object.keys(mcpServers).join(", ") || "(none)"}`);
305
- console.log(`[chapterhouse] Skill directories: ${skillDirectories.join(", ") || "(none)"}`);
306
- console.log(`[chapterhouse] Persistent session mode — conversation history maintained by SDK`);
306
+ log.info({ mcpServerCount: Object.keys(mcpServers).length, mcpServers: Object.keys(mcpServers) }, "Loading MCP servers");
307
+ log.info({ skillDirectories }, "Skill directories");
308
+ log.info("Persistent session mode — conversation history maintained by SDK");
307
309
  startHealthCheck();
308
310
  // Eagerly create/resume the default orchestrator session
309
311
  try {
310
312
  await ensureOrchestratorSession("default");
311
313
  }
312
314
  catch (err) {
313
- console.error(`[chapterhouse] Failed to create initial session (will retry on first message):`, err instanceof Error ? err.message : err);
315
+ log.error({ err: err instanceof Error ? err.message : err }, "Failed to create initial session (will retry on first message)");
314
316
  }
315
317
  }
316
318
  /** How long to wait for the orchestrator to finish a turn (10 min). */
@@ -450,22 +452,22 @@ async function executeOnSession(sessionKey, prompt, callback, attachments, onAct
450
452
  // session and may have been (partially) processed. Return what we have.
451
453
  if (/timeout/i.test(msg)) {
452
454
  if (accumulated.length > 0) {
453
- console.log(`[chapterhouse] Timeout after ${ORCHESTRATOR_TIMEOUT_MS / 1000}s but have ${accumulated.length} chars — returning partial response`);
455
+ log.warn({ timeoutSec: ORCHESTRATOR_TIMEOUT_MS / 1000, charCount: accumulated.length }, "Timeout with partial response — returning partial");
454
456
  return accumulated;
455
457
  }
456
458
  // No text yet but tool calls ran — the session is working in the background
457
459
  // (e.g. delegate_to_agent dispatched). Don't error out.
458
460
  if (toolCallCount > 0) {
459
- console.log(`[chapterhouse] Timeout after ${ORCHESTRATOR_TIMEOUT_MS / 1000}s${toolCallCount} tool call(s) executed but no text yet. Session is still working.`);
461
+ log.warn({ timeoutSec: ORCHESTRATOR_TIMEOUT_MS / 1000, toolCallCount }, "Timeout — tool calls ran but no text yet, session still working");
460
462
  return "I'm still working on this — I've started processing but it's taking longer than expected. I'll send you the results when I'm done.";
461
463
  }
462
464
  // No text, no tool calls — the session is truly stuck
463
- console.log(`[chapterhouse] Timeout after ${ORCHESTRATOR_TIMEOUT_MS / 1000}s with no activity. Session may be stuck.`);
465
+ log.warn({ timeoutSec: ORCHESTRATOR_TIMEOUT_MS / 1000 }, "Timeout with no activity session may be stuck");
464
466
  return "Sorry, that request timed out before I could start working on it. Try again or break it into smaller pieces?";
465
467
  }
466
468
  // If the session is broken, invalidate it so it's recreated on next attempt
467
469
  if (/closed|destroy|disposed|invalid|expired|not found/i.test(msg)) {
468
- console.log(`[chapterhouse] Session [${sessionKey}] appears dead, will recreate: ${msg}`);
470
+ log.warn({ sessionKey, msg }, "Session appears dead, will recreate");
469
471
  sessionMap.delete(sessionKey);
470
472
  sessionModelMap.delete(sessionKey);
471
473
  if (sessionKey === "default")
@@ -493,7 +495,7 @@ async function executeOnSession(sessionKey, prompt, callback, attachments, onAct
493
495
  async function processQueue() {
494
496
  if (processing) {
495
497
  if (messageQueue.length > 0) {
496
- console.log(`[chapterhouse] Message queued (${messageQueue.length} waiting orchestrator is busy)`);
498
+ log.debug({ queueLength: messageQueue.length }, "Message queued, orchestrator is busy");
497
499
  }
498
500
  return;
499
501
  }
@@ -515,17 +517,17 @@ async function processQueue() {
515
517
  const currentModel = sessionModelMap.get(sessionKey) ?? config.copilotModel;
516
518
  const routeResult = await resolveModel(item.prompt, currentModel, recentTiers);
517
519
  if (routeResult.switched) {
518
- console.log(`[chapterhouse] Auto: switching to ${routeResult.model} (${routeResult.overrideName || routeResult.tier})`);
520
+ log.info({ model: routeResult.model, tier: routeResult.overrideName || routeResult.tier }, "Auto-routing: switching model");
519
521
  config.copilotModel = routeResult.model;
520
522
  const existingSession = sessionMap.get(sessionKey);
521
523
  if (existingSession) {
522
524
  try {
523
525
  await existingSession.setModel(routeResult.model);
524
526
  sessionModelMap.set(sessionKey, routeResult.model);
525
- console.log(`[chapterhouse] Model switched in-place via setModel() for [${sessionKey}]`);
527
+ log.info({ sessionKey }, "Model switched in-place");
526
528
  }
527
529
  catch (err) {
528
- console.log(`[chapterhouse] setModel() failed for [${sessionKey}], will recreate: ${err instanceof Error ? err.message : err}`);
530
+ log.warn({ sessionKey, err: err instanceof Error ? err.message : err }, "setModel() failed, will recreate session");
529
531
  sessionMap.delete(sessionKey);
530
532
  if (sessionKey === "default")
531
533
  deleteState(ORCHESTRATOR_SESSION_KEY);
@@ -570,6 +572,8 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
570
572
  sessionKey = "project:" + normalizeProjectPath(source.projectPath);
571
573
  // Keep the legacy channel-project map in sync for tools that read it
572
574
  setChannelProject(source.connectionId, normalizeProjectPath(source.projectPath));
575
+ // Bump last-used timestamp so sidebar can sort by real activity
576
+ bumpProjectLastUsed(normalizeProjectPath(source.projectPath));
573
577
  }
574
578
  else if (source.type === "background" && source.sessionKey) {
575
579
  sessionKey = source.sessionKey;
@@ -621,7 +625,7 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
621
625
  // the user reply path.
622
626
  if (copilotClient) {
623
627
  maybeWriteEpisode(copilotClient).catch((err) => {
624
- console.error("[chapterhouse] Episode write failed (non-fatal):", err instanceof Error ? err.message : err);
628
+ log.error({ err: err instanceof Error ? err.message : err }, "Episode write failed (non-fatal)");
625
629
  });
626
630
  }
627
631
  return;
@@ -634,7 +638,7 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
634
638
  }
635
639
  if (isRecoverableError(err) && attempt < MAX_RETRIES) {
636
640
  const delay = RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)];
637
- console.error(`[chapterhouse] Recoverable error: ${msg}. Retry ${attempt + 1}/${MAX_RETRIES} after ${delay}ms…`);
641
+ log.warn({ msg, attempt: attempt + 1, maxRetries: MAX_RETRIES, delayMs: delay }, "Recoverable error, retrying");
638
642
  await sleep(delay);
639
643
  // Reset client before retry in case the connection is stale
640
644
  try {
@@ -643,7 +647,7 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
643
647
  catch { /* will fail again on next attempt */ }
644
648
  continue;
645
649
  }
646
- console.error(`[chapterhouse] Error processing message: ${msg}`);
650
+ log.error({ msg }, "Error processing message");
647
651
  callback(`Error: ${msg}`, true);
648
652
  return;
649
653
  }
@@ -663,11 +667,11 @@ export async function cancelCurrentMessage() {
663
667
  if (activeSession && currentCallback) {
664
668
  try {
665
669
  await activeSession.abort();
666
- console.log(`[chapterhouse] Aborted in-flight request on [${currentProcessingSessionKey}]`);
670
+ log.info({ sessionKey: currentProcessingSessionKey }, "Aborted in-flight request");
667
671
  return true;
668
672
  }
669
673
  catch (err) {
670
- console.error(`[chapterhouse] Abort failed:`, err instanceof Error ? err.message : err);
674
+ log.error({ err: err instanceof Error ? err.message : err }, "Abort failed");
671
675
  }
672
676
  }
673
677
  return drained > 0;
@@ -704,7 +708,7 @@ export async function shutdownAgents() {
704
708
  await session.disconnect();
705
709
  }
706
710
  catch (err) {
707
- console.error(`[orchestrator] Error disconnecting session "${key}" during shutdown:`, err instanceof Error ? err.message : err);
711
+ log.error({ sessionKey: key, err: err instanceof Error ? err.message : err }, "Error disconnecting session during shutdown");
708
712
  }
709
713
  }
710
714
  sessionMap.clear();
@@ -182,6 +182,7 @@ async function loadOrchestratorModule(t, overrides = {}) {
182
182
  all: () => [],
183
183
  }),
184
184
  }),
185
+ bumpProjectLastUsed: (_projectRoot) => { },
185
186
  },
186
187
  });
187
188
  t.mock.module("./episode-writer.js", {
@@ -1,5 +1,7 @@
1
1
  import { getState, setState } from "../store/db.js";
2
2
  import { classifyWithLLM } from "./classifier.js";
3
+ import { childLogger } from "../util/logger.js";
4
+ const log = childLogger("router");
3
5
  // ---------------------------------------------------------------------------
4
6
  // Default configuration
5
7
  // ---------------------------------------------------------------------------
@@ -96,12 +98,12 @@ async function classifyMessage(prompt, recentTiers, client) {
96
98
  if (client) {
97
99
  const tier = await classifyWithLLM(client, text);
98
100
  if (tier) {
99
- console.log(`[chapterhouse] Classifier: ${tier}`);
101
+ log.debug({ tier }, "LLM classifier result");
100
102
  return tier;
101
103
  }
102
104
  }
103
105
  // Fallback — standard is always safe
104
- console.log(`[chapterhouse] Classifier (fallback): standard`);
106
+ log.debug({ tier: "standard" }, "Classifier fallback: using standard tier");
105
107
  return "standard";
106
108
  }
107
109
  // ---------------------------------------------------------------------------
@@ -22,6 +22,8 @@ import { getChannelProject } from "../squad/context.js";
22
22
  import { findSquadAgent } from "../squad/registry.js";
23
23
  import { buildSquadSystemPrefix } from "../squad/charter.js";
24
24
  import { mirrorDecisionToWiki, syncDecisionsFileToWiki } from "../squad/mirror.js";
25
+ import { childLogger } from "../util/logger.js";
26
+ const log = childLogger("tools");
25
27
  function getCategoryDir(category) {
26
28
  const map = {
27
29
  person: "people",
@@ -223,12 +225,12 @@ export function createTools(deps) {
223
225
  // all 89+ entries, not just this task-completion entry.
224
226
  syncDecisionsFileToWiki(projectRoot).then(syncResult => {
225
227
  if (syncResult) {
226
- console.log(`[squad] Post-task decisions sync: ${syncResult.entriesSynced} entries → ${syncResult.wikiPath}`);
228
+ log.info({ entriesSynced: syncResult.entriesSynced, wikiPath: syncResult.wikiPath }, "Post-task squad decisions synced to wiki");
227
229
  }
228
230
  }).catch(() => { });
229
231
  }
230
232
  catch (mirrorErr) {
231
- console.error("[tools] Failed to mirror squad decision to wiki (non-fatal):", mirrorErr instanceof Error ? mirrorErr.message : mirrorErr);
233
+ log.error({ err: mirrorErr instanceof Error ? mirrorErr.message : mirrorErr }, "Failed to mirror squad decision to wiki (non-fatal)");
232
234
  }
233
235
  }
234
236
  }
@@ -689,7 +691,7 @@ export function createTools(deps) {
689
691
  await switchSessionModel(args.model_id);
690
692
  }
691
693
  catch (err) {
692
- console.log(`[chapterhouse] setModel() failed during switch_model (will apply on next session): ${err instanceof Error ? err.message : err}`);
694
+ log.warn({ err: err instanceof Error ? err.message : err }, "setModel() failed during switch_model, will apply on next session");
693
695
  }
694
696
  // Disable router when manually switching — user has explicit preference
695
697
  if (getRouterConfig().enabled) {
@@ -1169,7 +1171,7 @@ export function createTools(deps) {
1169
1171
  // Schedule restart after returning the response
1170
1172
  setTimeout(() => {
1171
1173
  restartDaemon().catch((err) => {
1172
- console.error("[chapterhouse] Restart failed:", err);
1174
+ log.error({ err: err instanceof Error ? err.message : err }, "Restart failed");
1173
1175
  });
1174
1176
  }, 1000);
1175
1177
  return `Restarting Chapterhouse${reason}. I'll be back in a few seconds.`;