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.
- package/LICENSE +23 -0
- package/README.md +363 -0
- package/agents/chapterhouse.agent.md +40 -0
- package/agents/coder.agent.md +38 -0
- package/agents/designer.agent.md +43 -0
- package/agents/general-purpose.agent.md +30 -0
- package/dist/api/auth.js +159 -0
- package/dist/api/auth.test.js +463 -0
- package/dist/api/errors.js +95 -0
- package/dist/api/errors.test.js +89 -0
- package/dist/api/rate-limit.js +85 -0
- package/dist/api/server-runtime.js +62 -0
- package/dist/api/server.js +651 -0
- package/dist/api/server.test.js +385 -0
- package/dist/api/sse.integration.test.js +270 -0
- package/dist/api/sse.js +7 -0
- package/dist/api/team.js +196 -0
- package/dist/api/team.test.js +466 -0
- package/dist/cli.js +102 -0
- package/dist/config.js +299 -0
- package/dist/config.phase3.test.js +20 -0
- package/dist/config.test.js +148 -0
- package/dist/copilot/agents.js +447 -0
- package/dist/copilot/agents.squad.test.js +72 -0
- package/dist/copilot/classifier.js +72 -0
- package/dist/copilot/client.js +32 -0
- package/dist/copilot/client.test.js +100 -0
- package/dist/copilot/episode-writer.js +219 -0
- package/dist/copilot/episode-writer.test.js +41 -0
- package/dist/copilot/mcp-config.js +22 -0
- package/dist/copilot/okr-mapper.js +196 -0
- package/dist/copilot/okr-mapper.test.js +114 -0
- package/dist/copilot/orchestrator.js +685 -0
- package/dist/copilot/orchestrator.test.js +523 -0
- package/dist/copilot/router.js +142 -0
- package/dist/copilot/router.test.js +119 -0
- package/dist/copilot/skills.js +125 -0
- package/dist/copilot/standup.js +138 -0
- package/dist/copilot/standup.test.js +132 -0
- package/dist/copilot/system-message.js +143 -0
- package/dist/copilot/system-message.test.js +17 -0
- package/dist/copilot/tools.js +1212 -0
- package/dist/copilot/tools.okr.test.js +260 -0
- package/dist/copilot/tools.squad.test.js +168 -0
- package/dist/daemon.js +235 -0
- package/dist/home-path.js +12 -0
- package/dist/home-path.test.js +11 -0
- package/dist/integrations/ado-analytics.js +178 -0
- package/dist/integrations/ado-analytics.test.js +284 -0
- package/dist/integrations/ado-client.js +227 -0
- package/dist/integrations/ado-client.test.js +176 -0
- package/dist/integrations/ado-schema.js +25 -0
- package/dist/integrations/ado-schema.test.js +39 -0
- package/dist/integrations/ado-skill.js +55 -0
- package/dist/integrations/report-generator.js +114 -0
- package/dist/integrations/report-generator.test.js +62 -0
- package/dist/integrations/team-push.js +144 -0
- package/dist/integrations/team-push.test.js +178 -0
- package/dist/integrations/teams-notify.js +108 -0
- package/dist/integrations/teams-notify.test.js +135 -0
- package/dist/paths.js +41 -0
- package/dist/setup.js +149 -0
- package/dist/shutdown-signals.js +13 -0
- package/dist/shutdown-signals.test.js +33 -0
- package/dist/squad/charter.js +108 -0
- package/dist/squad/charter.test.js +89 -0
- package/dist/squad/context.js +48 -0
- package/dist/squad/context.test.js +59 -0
- package/dist/squad/discovery.js +280 -0
- package/dist/squad/discovery.test.js +93 -0
- package/dist/squad/index.js +7 -0
- package/dist/squad/mirror.js +81 -0
- package/dist/squad/mirror.scheduler.js +78 -0
- package/dist/squad/mirror.scheduler.test.js +197 -0
- package/dist/squad/mirror.test.js +172 -0
- package/dist/squad/registry.js +162 -0
- package/dist/squad/registry.test.js +31 -0
- package/dist/squad/squad-coordinator-system-message.test.js +190 -0
- package/dist/squad/squad-session-routing.test.js +260 -0
- package/dist/squad/types.js +4 -0
- package/dist/status.js +25 -0
- package/dist/status.test.js +22 -0
- package/dist/store/db.js +290 -0
- package/dist/store/db.test.js +126 -0
- package/dist/store/squad-sessions.test.js +341 -0
- package/dist/test/setup-env.js +3 -0
- package/dist/update.js +112 -0
- package/dist/update.test.js +25 -0
- package/dist/wiki/context.js +138 -0
- package/dist/wiki/fs.js +195 -0
- package/dist/wiki/fs.test.js +39 -0
- package/dist/wiki/index-manager.js +359 -0
- package/dist/wiki/index-manager.test.js +129 -0
- package/dist/wiki/lock.js +26 -0
- package/dist/wiki/lock.test.js +30 -0
- package/dist/wiki/log-manager.js +20 -0
- package/dist/wiki/migrate.js +306 -0
- package/dist/wiki/okr.test.js +101 -0
- package/dist/wiki/path-utils.js +4 -0
- package/dist/wiki/path-utils.test.js +8 -0
- package/dist/wiki/seed-team-wiki.js +296 -0
- package/dist/wiki/seed-team-wiki.test.js +69 -0
- package/dist/wiki/team-sync.js +212 -0
- package/dist/wiki/team-sync.test.js +185 -0
- package/dist/wiki/templates/okr.js +98 -0
- package/package.json +72 -0
- package/skills/.gitkeep +0 -0
- package/skills/find-skills/SKILL.md +161 -0
- package/skills/find-skills/_meta.json +4 -0
- package/skills/frontend-design/LICENSE.txt +177 -0
- package/skills/frontend-design/SKILL.md +42 -0
- package/skills/squad/SKILL.md +76 -0
- package/web/dist/assets/index-D-e7K-fT.css +10 -0
- package/web/dist/assets/index-DAg9IrpO.js +142 -0
- package/web/dist/assets/index-DAg9IrpO.js.map +1 -0
- package/web/dist/chapterhouse-icon.png +0 -0
- package/web/dist/chapterhouse-icon.svg +42 -0
- package/web/dist/chapterhouse-logo.svg +46 -0
- package/web/dist/index.html +15 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
test("getClient creates and caches a started Copilot client", async (t) => {
|
|
4
|
+
const instances = [];
|
|
5
|
+
class FakeCopilotClient {
|
|
6
|
+
options;
|
|
7
|
+
startCalls = 0;
|
|
8
|
+
stopCalls = 0;
|
|
9
|
+
constructor(options) {
|
|
10
|
+
this.options = options;
|
|
11
|
+
instances.push(this);
|
|
12
|
+
}
|
|
13
|
+
async start() {
|
|
14
|
+
this.startCalls++;
|
|
15
|
+
}
|
|
16
|
+
async stop() {
|
|
17
|
+
this.stopCalls++;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
t.mock.module("@github/copilot-sdk", {
|
|
21
|
+
namedExports: { CopilotClient: FakeCopilotClient },
|
|
22
|
+
});
|
|
23
|
+
t.mock.module("../config.js", {
|
|
24
|
+
namedExports: {
|
|
25
|
+
config: { copilotAuthToken: "ghu_test_token" },
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
const clientModule = await import(new URL(`./client.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
|
|
29
|
+
const first = await clientModule.getClient();
|
|
30
|
+
const second = await clientModule.getClient();
|
|
31
|
+
assert.strictEqual(first, second);
|
|
32
|
+
assert.equal(instances.length, 1);
|
|
33
|
+
assert.deepEqual(instances[0]?.options, {
|
|
34
|
+
autoStart: true,
|
|
35
|
+
autoRestart: true,
|
|
36
|
+
gitHubToken: "ghu_test_token",
|
|
37
|
+
});
|
|
38
|
+
assert.equal(instances[0]?.startCalls, 1);
|
|
39
|
+
});
|
|
40
|
+
test("resetClient stops the current client best-effort and replaces it", async (t) => {
|
|
41
|
+
let stopFailures = 1;
|
|
42
|
+
const instances = [];
|
|
43
|
+
class FakeCopilotClient {
|
|
44
|
+
stopCalls = 0;
|
|
45
|
+
constructor(_options) {
|
|
46
|
+
instances.push(this);
|
|
47
|
+
}
|
|
48
|
+
async start() { }
|
|
49
|
+
async stop() {
|
|
50
|
+
this.stopCalls++;
|
|
51
|
+
if (stopFailures > 0) {
|
|
52
|
+
stopFailures--;
|
|
53
|
+
throw new Error("stop failed");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
t.mock.module("@github/copilot-sdk", {
|
|
58
|
+
namedExports: { CopilotClient: FakeCopilotClient },
|
|
59
|
+
});
|
|
60
|
+
t.mock.module("../config.js", {
|
|
61
|
+
namedExports: {
|
|
62
|
+
config: { copilotAuthToken: "" },
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
const clientModule = await import(new URL(`./client.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
|
|
66
|
+
const first = await clientModule.getClient();
|
|
67
|
+
const second = await clientModule.resetClient();
|
|
68
|
+
assert.notStrictEqual(first, second);
|
|
69
|
+
assert.equal(instances.length, 2);
|
|
70
|
+
assert.equal(instances[0]?.stopCalls, 1);
|
|
71
|
+
});
|
|
72
|
+
test("stopClient tears down the cached client so the next getClient call recreates it", async (t) => {
|
|
73
|
+
const instances = [];
|
|
74
|
+
class FakeCopilotClient {
|
|
75
|
+
stopCalls = 0;
|
|
76
|
+
constructor(_options) {
|
|
77
|
+
instances.push(this);
|
|
78
|
+
}
|
|
79
|
+
async start() { }
|
|
80
|
+
async stop() {
|
|
81
|
+
this.stopCalls++;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
t.mock.module("@github/copilot-sdk", {
|
|
85
|
+
namedExports: { CopilotClient: FakeCopilotClient },
|
|
86
|
+
});
|
|
87
|
+
t.mock.module("../config.js", {
|
|
88
|
+
namedExports: {
|
|
89
|
+
config: { copilotAuthToken: "" },
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
const clientModule = await import(new URL(`./client.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
|
|
93
|
+
const first = await clientModule.getClient();
|
|
94
|
+
await clientModule.stopClient();
|
|
95
|
+
const second = await clientModule.getClient();
|
|
96
|
+
assert.notStrictEqual(first, second);
|
|
97
|
+
assert.equal(instances.length, 2);
|
|
98
|
+
assert.equal(instances[0]?.stopCalls, 1);
|
|
99
|
+
});
|
|
100
|
+
//# sourceMappingURL=client.test.js.map
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Episode writer — deterministic conversation summary backstop
|
|
3
|
+
// Generates daily wiki pages from conversation_log entries.
|
|
4
|
+
// Runs asynchronously after responses — never blocks the user.
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
import { existsSync } from "fs";
|
|
7
|
+
import { join } from "path";
|
|
8
|
+
import { approveAll } from "@github/copilot-sdk";
|
|
9
|
+
import { getDb, getState, setState } from "../store/db.js";
|
|
10
|
+
import { ensureWikiStructure, getWikiDir, readPage, writePage } from "../wiki/fs.js";
|
|
11
|
+
import { addToIndex } from "../wiki/index-manager.js";
|
|
12
|
+
import { appendLog } from "../wiki/log-manager.js";
|
|
13
|
+
import { withWikiWrite } from "../wiki/lock.js";
|
|
14
|
+
import { setBusy, setIdle } from "../status.js";
|
|
15
|
+
const EPISODE_MODEL = "gpt-4.1";
|
|
16
|
+
const EPISODE_TIMEOUT_MS = 30_000;
|
|
17
|
+
const MIN_TURNS_FOR_SUMMARY = 10;
|
|
18
|
+
const MIN_MINUTES_BETWEEN_SUMMARIES = 30;
|
|
19
|
+
const MAX_TURNS_PER_SUMMARY = 200;
|
|
20
|
+
const LAST_SUMMARIZED_KEY = "last_episode_log_id";
|
|
21
|
+
const LAST_SUMMARY_TIME_KEY = "last_episode_time";
|
|
22
|
+
const IN_PROGRESS_KEY = "episode_in_progress_range";
|
|
23
|
+
export const SYSTEM_PROMPT = `You are a conversation summarizer for an AI assistant called Chapterhouse. You receive conversation log entries and produce a concise, structured summary.
|
|
24
|
+
|
|
25
|
+
Output format — markdown with YAML frontmatter:
|
|
26
|
+
- Title: "Conversations on YYYY-MM-DD"
|
|
27
|
+
- Key topics discussed (as bullet points)
|
|
28
|
+
- Decisions made
|
|
29
|
+
- Action items or follow-ups
|
|
30
|
+
- Do NOT invent or suggest wiki page links. Only include a cross-references section if you are referencing pages that were explicitly mentioned in this conversation as already existing. If in doubt, omit the cross-references section entirely.
|
|
31
|
+
|
|
32
|
+
Be concise but capture all important information. Include names, specifics, and context — not vague summaries. Write in third person ("the user asked about...", "Chapterhouse suggested...").`;
|
|
33
|
+
let episodeSession;
|
|
34
|
+
let episodeClient;
|
|
35
|
+
// Single-flight guard: only one episode write can run at a time, and a session
|
|
36
|
+
// crash-recovery on startup will resume any in-progress range marked durably.
|
|
37
|
+
let inFlight;
|
|
38
|
+
async function ensureSession(client) {
|
|
39
|
+
if (episodeSession && episodeClient === client) {
|
|
40
|
+
return episodeSession;
|
|
41
|
+
}
|
|
42
|
+
if (episodeSession) {
|
|
43
|
+
episodeSession.destroy().catch(() => { });
|
|
44
|
+
episodeSession = undefined;
|
|
45
|
+
}
|
|
46
|
+
episodeSession = await client.createSession({
|
|
47
|
+
model: EPISODE_MODEL,
|
|
48
|
+
streaming: false,
|
|
49
|
+
systemMessage: { content: SYSTEM_PROMPT },
|
|
50
|
+
onPermissionRequest: approveAll,
|
|
51
|
+
});
|
|
52
|
+
episodeClient = client;
|
|
53
|
+
return episodeSession;
|
|
54
|
+
}
|
|
55
|
+
export function stripDeadWikiLinks(content, wikiRoot) {
|
|
56
|
+
const sanitized = content.replace(/\[\[([^\]]+)\]\]/g, (match, rawLinkText) => {
|
|
57
|
+
const linkText = rawLinkText.trim().replace(/\.md$/i, "");
|
|
58
|
+
const slugified = linkText
|
|
59
|
+
.split("/")
|
|
60
|
+
.map((segment) => segment.trim().toLowerCase().replace(/\s+/g, "-"))
|
|
61
|
+
.join("/");
|
|
62
|
+
const candidates = [
|
|
63
|
+
join(wikiRoot, `${linkText}.md`),
|
|
64
|
+
join(wikiRoot, `${slugified}.md`),
|
|
65
|
+
join(wikiRoot, "pages", `${linkText}.md`),
|
|
66
|
+
join(wikiRoot, "pages", `${slugified}.md`),
|
|
67
|
+
];
|
|
68
|
+
return candidates.some((candidate) => existsSync(candidate)) ? match : linkText;
|
|
69
|
+
});
|
|
70
|
+
return stripEmptyCrossReferencesSections(sanitized);
|
|
71
|
+
}
|
|
72
|
+
function stripEmptyCrossReferencesSections(content) {
|
|
73
|
+
return content
|
|
74
|
+
.replace(/(^|\n)\*\*Cross-references:\*\*\s*\n([\s\S]*?)(?=\n(?:\*\*[^*\n]+:\*\*|#{1,6}\s|---\s*$)|$)/gi, (match, prefix, body) => (/\[\[[^\]]+\]\]/.test(body) ? match : prefix))
|
|
75
|
+
.replace(/(^|\n)#{1,6}\s*Cross[- ]references\s*\n([\s\S]*?)(?=\n(?:\*\*[^*\n]+:\*\*|#{1,6}\s|---\s*$)|$)/gi, (match, prefix, body) => (/\[\[[^\]]+\]\]/.test(body) ? match : prefix))
|
|
76
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
77
|
+
.trim();
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Check if a conversation summary is due, and if so, generate one.
|
|
81
|
+
* Call this after delivering a response — it runs asynchronously.
|
|
82
|
+
*
|
|
83
|
+
* Single-flight: overlapping calls coalesce into the in-flight promise so
|
|
84
|
+
* we never summarize the same log range twice. The "in-progress" range is
|
|
85
|
+
* recorded BEFORE the LLM call so a crash mid-summary lets us recover or
|
|
86
|
+
* skip rather than silently re-summarize on next boot.
|
|
87
|
+
*/
|
|
88
|
+
export function maybeWriteEpisode(client) {
|
|
89
|
+
if (inFlight)
|
|
90
|
+
return inFlight;
|
|
91
|
+
inFlight = runEpisode(client).finally(() => { inFlight = undefined; });
|
|
92
|
+
return inFlight;
|
|
93
|
+
}
|
|
94
|
+
async function runEpisode(client) {
|
|
95
|
+
let dreaming = false;
|
|
96
|
+
try {
|
|
97
|
+
const db = getDb();
|
|
98
|
+
const lastId = parseInt(getState(LAST_SUMMARIZED_KEY) || "0", 10);
|
|
99
|
+
const lastTime = parseInt(getState(LAST_SUMMARY_TIME_KEY) || "0", 10);
|
|
100
|
+
const now = Date.now();
|
|
101
|
+
// Check time gate
|
|
102
|
+
if (now - lastTime < MIN_MINUTES_BETWEEN_SUMMARIES * 60 * 1000)
|
|
103
|
+
return;
|
|
104
|
+
// If a previous run crashed mid-write, the in-progress marker tells us
|
|
105
|
+
// which range was being summarized. Skip past it on the next attempt
|
|
106
|
+
// so we don't repeat work.
|
|
107
|
+
const prevRange = getState(IN_PROGRESS_KEY);
|
|
108
|
+
let effectiveLastId = lastId;
|
|
109
|
+
if (prevRange) {
|
|
110
|
+
const m = prevRange.match(/^(\d+):(\d+)$/);
|
|
111
|
+
if (m) {
|
|
112
|
+
const prevEnd = parseInt(m[2], 10);
|
|
113
|
+
if (prevEnd > effectiveLastId)
|
|
114
|
+
effectiveLastId = prevEnd;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// Get unsummarized turns (windowed to bound LLM input cost)
|
|
118
|
+
const rows = db.prepare(`SELECT id, role, content, source, ts FROM conversation_log WHERE id > ? ORDER BY id ASC LIMIT ?`).all(effectiveLastId, MAX_TURNS_PER_SUMMARY);
|
|
119
|
+
if (rows.length < MIN_TURNS_FOR_SUMMARY)
|
|
120
|
+
return;
|
|
121
|
+
const startId = rows[0].id;
|
|
122
|
+
const endId = rows[rows.length - 1].id;
|
|
123
|
+
// Durably record the range we're about to summarize, BEFORE any LLM call
|
|
124
|
+
// or page write. If we crash, the next run will skip past endId.
|
|
125
|
+
setState(IN_PROGRESS_KEY, `${startId}:${endId}`);
|
|
126
|
+
// Format conversation for summarization
|
|
127
|
+
const transcript = rows.map((r) => {
|
|
128
|
+
const tag = r.role === "user" ? `[${r.source}] User` : r.role === "system" ? `[system]` : "Chapterhouse";
|
|
129
|
+
const content = r.content.length > 500 ? r.content.slice(0, 500) + "…" : r.content;
|
|
130
|
+
return `${tag} (${r.ts}): ${content}`;
|
|
131
|
+
}).join("\n");
|
|
132
|
+
setBusy("dreaming", "Consolidating memories...");
|
|
133
|
+
dreaming = true;
|
|
134
|
+
const session = await ensureSession(client);
|
|
135
|
+
const result = await session.sendAndWait({ prompt: `Summarize this conversation:\n\n${transcript}` }, EPISODE_TIMEOUT_MS);
|
|
136
|
+
const generatedSummary = result?.data?.content || "";
|
|
137
|
+
const summary = stripDeadWikiLinks(generatedSummary, getWikiDir());
|
|
138
|
+
if (!summary || summary.length < 50) {
|
|
139
|
+
// Nothing useful — clear the in-progress marker so we can retry later.
|
|
140
|
+
setState(IN_PROGRESS_KEY, "");
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
// All page+index+state writes go through the wiki write lock so they
|
|
144
|
+
// can't interleave with remember/forget/wiki_update calls.
|
|
145
|
+
await withWikiWrite(async () => {
|
|
146
|
+
ensureWikiStructure();
|
|
147
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
148
|
+
const pagePath = `pages/conversations/${today}.md`;
|
|
149
|
+
const existing = readPage(pagePath);
|
|
150
|
+
// Idempotency marker: bail out if this exact id-range has already been written.
|
|
151
|
+
const rangeMarker = `<!-- episode-range:${startId}-${endId} -->`;
|
|
152
|
+
if (existing && existing.includes(rangeMarker)) {
|
|
153
|
+
// Already persisted; just advance the checkpoint.
|
|
154
|
+
setState(LAST_SUMMARIZED_KEY, String(endId));
|
|
155
|
+
setState(LAST_SUMMARY_TIME_KEY, String(now));
|
|
156
|
+
setState(IN_PROGRESS_KEY, "");
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (existing) {
|
|
160
|
+
const updated = existing.replace(/^(---[\s\S]*?updated:\s*)[\d-]+/m, `$1${today}`);
|
|
161
|
+
writePage(pagePath, updated.trimEnd() + `\n\n---\n\n${rangeMarker}\n${summary}\n`);
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
const page = [
|
|
165
|
+
"---",
|
|
166
|
+
`title: Conversations on ${today}`,
|
|
167
|
+
`tags: [conversation, episode]`,
|
|
168
|
+
`created: ${today}`,
|
|
169
|
+
`updated: ${today}`,
|
|
170
|
+
"related: []",
|
|
171
|
+
"---",
|
|
172
|
+
"",
|
|
173
|
+
`# Conversations on ${today}`,
|
|
174
|
+
"",
|
|
175
|
+
rangeMarker,
|
|
176
|
+
"",
|
|
177
|
+
summary,
|
|
178
|
+
"",
|
|
179
|
+
].join("\n");
|
|
180
|
+
writePage(pagePath, page);
|
|
181
|
+
}
|
|
182
|
+
addToIndex({
|
|
183
|
+
path: pagePath,
|
|
184
|
+
title: `Conversations on ${today}`,
|
|
185
|
+
summary: `Daily conversation summary for ${today}`,
|
|
186
|
+
section: "Conversations",
|
|
187
|
+
tags: ["conversation", "episode"],
|
|
188
|
+
updated: today,
|
|
189
|
+
});
|
|
190
|
+
appendLog("update", `episode-writer: summarized ${rows.length} turns (ids ${startId}-${endId}) → ${pagePath}`);
|
|
191
|
+
// Advance checkpoint and clear the in-progress marker atomically (within the lock).
|
|
192
|
+
setState(LAST_SUMMARIZED_KEY, String(endId));
|
|
193
|
+
setState(LAST_SUMMARY_TIME_KEY, String(now));
|
|
194
|
+
setState(IN_PROGRESS_KEY, "");
|
|
195
|
+
});
|
|
196
|
+
console.log(`[chapterhouse] Episode writer: summarized ${rows.length} turns (ids ${startId}-${endId}) → pages/conversations/${new Date().toISOString().slice(0, 10)}.md`);
|
|
197
|
+
}
|
|
198
|
+
catch (err) {
|
|
199
|
+
console.log(`[chapterhouse] Episode writer error (non-fatal): ${err instanceof Error ? err.message : err}`);
|
|
200
|
+
if (episodeSession) {
|
|
201
|
+
episodeSession.destroy().catch(() => { });
|
|
202
|
+
episodeSession = undefined;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
if (dreaming) {
|
|
207
|
+
setIdle();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/** Tear down the episode writer session. */
|
|
212
|
+
export function stopEpisodeWriter() {
|
|
213
|
+
if (episodeSession) {
|
|
214
|
+
episodeSession.destroy().catch(() => { });
|
|
215
|
+
episodeSession = undefined;
|
|
216
|
+
episodeClient = undefined;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
//# sourceMappingURL=episode-writer.js.map
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import * as episodeWriter from "./episode-writer.js";
|
|
7
|
+
const tmpRoot = mkdtempSync(join(tmpdir(), "chapterhouse-episode-writer-"));
|
|
8
|
+
test.after(() => {
|
|
9
|
+
rmSync(tmpRoot, { recursive: true, force: true });
|
|
10
|
+
});
|
|
11
|
+
test("SYSTEM_PROMPT forbids invented wiki links", () => {
|
|
12
|
+
assert.match(episodeWriter.SYSTEM_PROMPT, /Do NOT invent or suggest wiki page links\./);
|
|
13
|
+
assert.match(episodeWriter.SYSTEM_PROMPT, /Only include a cross-references section if you are referencing pages that were explicitly mentioned in this conversation as already existing\./);
|
|
14
|
+
});
|
|
15
|
+
test("stripDeadWikiLinks removes missing wiki links and empty cross-references sections", () => {
|
|
16
|
+
const wikiRoot = join(tmpRoot, "wiki");
|
|
17
|
+
mkdirSync(join(wikiRoot, "pages"), { recursive: true });
|
|
18
|
+
writeFileSync(join(wikiRoot, "pages", "existing-page.md"), "# Existing\n");
|
|
19
|
+
const summary = [
|
|
20
|
+
"## Summary",
|
|
21
|
+
"",
|
|
22
|
+
"**Cross-references:**",
|
|
23
|
+
"- [[Existing Page]]",
|
|
24
|
+
"- [[Missing Page]]",
|
|
25
|
+
"",
|
|
26
|
+
].join("\n");
|
|
27
|
+
const sanitized = episodeWriter.stripDeadWikiLinks(summary, wikiRoot);
|
|
28
|
+
assert.match(sanitized, /\[\[Existing Page\]\]/);
|
|
29
|
+
assert.doesNotMatch(sanitized, /\[\[Missing Page\]\]/);
|
|
30
|
+
assert.match(sanitized, /- Missing Page/);
|
|
31
|
+
assert.match(sanitized, /\*\*Cross-references:\*\*/);
|
|
32
|
+
const withoutLiveLinks = episodeWriter.stripDeadWikiLinks([
|
|
33
|
+
"## Summary",
|
|
34
|
+
"",
|
|
35
|
+
"**Cross-references:**",
|
|
36
|
+
"- [[Missing Page]]",
|
|
37
|
+
"",
|
|
38
|
+
].join("\n"), wikiRoot);
|
|
39
|
+
assert.doesNotMatch(withoutLiveLinks, /\*\*Cross-references:\*\*/);
|
|
40
|
+
});
|
|
41
|
+
//# sourceMappingURL=episode-writer.test.js.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { readFileSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
/**
|
|
5
|
+
* Load MCP server configs from ~/.copilot/mcp-config.json.
|
|
6
|
+
* Returns an empty record if the file doesn't exist or is invalid.
|
|
7
|
+
*/
|
|
8
|
+
export function loadMcpConfig() {
|
|
9
|
+
const configPath = join(homedir(), ".copilot", "mcp-config.json");
|
|
10
|
+
try {
|
|
11
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
12
|
+
const parsed = JSON.parse(raw);
|
|
13
|
+
if (parsed.mcpServers && typeof parsed.mcpServers === "object") {
|
|
14
|
+
return parsed.mcpServers;
|
|
15
|
+
}
|
|
16
|
+
return {};
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=mcp-config.js.map
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
const STOP_WORDS = new Set([
|
|
5
|
+
"about", "after", "against", "been", "from", "into", "just", "that", "their", "them",
|
|
6
|
+
"then", "they", "this", "today", "work", "worked", "yesterday",
|
|
7
|
+
]);
|
|
8
|
+
const MIN_HISTORY_CONFIDENCE = 2;
|
|
9
|
+
export class OKRMapper {
|
|
10
|
+
teamWikiSync;
|
|
11
|
+
_adoClient;
|
|
12
|
+
learnedMappings;
|
|
13
|
+
constructor(teamWikiSync, _adoClient) {
|
|
14
|
+
this.teamWikiSync = teamWikiSync;
|
|
15
|
+
this._adoClient = _adoClient;
|
|
16
|
+
this.learnedMappings = loadLearnedMappings();
|
|
17
|
+
}
|
|
18
|
+
recordConfirmedMapping(activity, krId) {
|
|
19
|
+
const now = new Date().toISOString();
|
|
20
|
+
for (const token of tokenize(activity)) {
|
|
21
|
+
const existing = this.learnedMappings[token];
|
|
22
|
+
this.learnedMappings[token] = {
|
|
23
|
+
krId,
|
|
24
|
+
confirmedCount: existing?.krId === krId ? existing.confirmedCount + 1 : 1,
|
|
25
|
+
lastUsed: now,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
saveLearnedMappings(this.learnedMappings);
|
|
29
|
+
}
|
|
30
|
+
async findMatchingKRs(activity, period) {
|
|
31
|
+
const okrPage = await this.teamWikiSync.fetchPage(`pages/okrs/${period ?? getCurrentQuarter()}.md`);
|
|
32
|
+
if (!okrPage) {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
const activityTokens = tokenize(activity);
|
|
36
|
+
return parseOKRPageContent(okrPage)
|
|
37
|
+
.map((kr) => {
|
|
38
|
+
const score = scoreMatch(activityTokens, kr, this.learnedMappings);
|
|
39
|
+
const historyBacked = hasHistoricalMatch(activityTokens, kr.krId, this.learnedMappings);
|
|
40
|
+
return {
|
|
41
|
+
krId: kr.krId,
|
|
42
|
+
title: kr.title,
|
|
43
|
+
objectiveTitle: kr.objectiveTitle,
|
|
44
|
+
confidence: historyBacked ? "high" : scoreToConfidence(score),
|
|
45
|
+
score,
|
|
46
|
+
};
|
|
47
|
+
})
|
|
48
|
+
.filter((kr) => kr.score > 0)
|
|
49
|
+
.sort((a, b) => b.score - a.score)
|
|
50
|
+
.slice(0, 3)
|
|
51
|
+
.map(({ score: _score, ...match }) => match);
|
|
52
|
+
}
|
|
53
|
+
formatUpdatePrompt(activity, matches) {
|
|
54
|
+
if (matches.length === 0) {
|
|
55
|
+
return `You mentioned: "${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.`;
|
|
56
|
+
}
|
|
57
|
+
const [best, ...rest] = matches;
|
|
58
|
+
if (!best) {
|
|
59
|
+
return `You mentioned: "${activity}". Tell me the KR id and delta (0-100), and I'll log it.`;
|
|
60
|
+
}
|
|
61
|
+
if (rest.length === 0) {
|
|
62
|
+
return `You mentioned: "${activity}". This looks like it maps to ${best.krId} (${best.title}). Shall I log this as progress? If so, what's the delta (0-100)?`;
|
|
63
|
+
}
|
|
64
|
+
const alternatives = rest.map((match) => `${match.krId} (${match.title}, ${match.confidence} confidence)`).join("; ");
|
|
65
|
+
return `You mentioned: "${activity}". The best match looks like ${best.krId} (${best.title}). I also found ${alternatives}. Which KR should I log this against, and what's the delta (0-100)?`;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export function parseOKRPageContent(content) {
|
|
69
|
+
const lines = content.split("\n");
|
|
70
|
+
const keyResults = [];
|
|
71
|
+
let currentObjectiveTitle = "";
|
|
72
|
+
let currentKeyResult;
|
|
73
|
+
for (const line of lines) {
|
|
74
|
+
const objectiveMatch = line.match(/^##\s+[^:]+:\s+(.+)$/);
|
|
75
|
+
if (objectiveMatch) {
|
|
76
|
+
currentObjectiveTitle = objectiveMatch[1]?.trim() ?? "";
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const keyResultMatch = line.match(/^###\s+([^:]+):\s+(.+)$/);
|
|
80
|
+
if (keyResultMatch) {
|
|
81
|
+
currentKeyResult = {
|
|
82
|
+
krId: keyResultMatch[1]?.trim() ?? "",
|
|
83
|
+
title: keyResultMatch[2]?.trim() ?? "",
|
|
84
|
+
objectiveTitle: currentObjectiveTitle,
|
|
85
|
+
confidence: "low",
|
|
86
|
+
owner: "",
|
|
87
|
+
};
|
|
88
|
+
keyResults.push(currentKeyResult);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (!currentKeyResult) {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const ownerMatch = line.match(/^- \*\*Owner\*\*:\s+(.+)$/);
|
|
95
|
+
if (ownerMatch) {
|
|
96
|
+
currentKeyResult.owner = ownerMatch[1]?.trim() ?? "";
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const targetMatch = line.match(/^- \*\*Target\*\*:\s+([0-9.]+)\s*(.*)$/);
|
|
100
|
+
if (targetMatch) {
|
|
101
|
+
currentKeyResult.targetValue = Number(targetMatch[1]);
|
|
102
|
+
currentKeyResult.unit = targetMatch[2]?.trim() || currentKeyResult.unit;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const currentMatch = line.match(/^- \*\*Current\*\*:\s+([0-9.]+)\s*(.*)$/);
|
|
106
|
+
if (currentMatch) {
|
|
107
|
+
currentKeyResult.currentValue = Number(currentMatch[1]);
|
|
108
|
+
currentKeyResult.unit = currentMatch[2]?.trim() || currentKeyResult.unit;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const unitMatch = line.match(/^- \*\*Unit\*\*:\s+(.+)$/);
|
|
112
|
+
if (unitMatch) {
|
|
113
|
+
currentKeyResult.unit = unitMatch[1]?.trim() ?? currentKeyResult.unit;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return keyResults.filter((kr) => kr.krId.length > 0 && kr.title.length > 0);
|
|
117
|
+
}
|
|
118
|
+
function getCurrentQuarter(now = new Date()) {
|
|
119
|
+
return `${now.getUTCFullYear()}-Q${Math.floor(now.getUTCMonth() / 3) + 1}`;
|
|
120
|
+
}
|
|
121
|
+
function tokenize(value) {
|
|
122
|
+
return [...new Set(value
|
|
123
|
+
.toLowerCase()
|
|
124
|
+
.split(/[^a-z0-9]+/)
|
|
125
|
+
.map((token) => token.trim())
|
|
126
|
+
.filter((token) => token.length >= 3)
|
|
127
|
+
.map((token) => token.replace(/(ing|ed|es|s)$/i, ""))
|
|
128
|
+
.filter((token) => token.length >= 3)
|
|
129
|
+
.filter((token) => !STOP_WORDS.has(token))
|
|
130
|
+
.filter(Boolean))];
|
|
131
|
+
}
|
|
132
|
+
function scoreMatch(activityTokens, keyResult, learnedMappings) {
|
|
133
|
+
const titleTokens = new Set(tokenize(keyResult.title));
|
|
134
|
+
const objectiveTokens = new Set(tokenize(keyResult.objectiveTitle));
|
|
135
|
+
let score = 0;
|
|
136
|
+
for (const token of activityTokens) {
|
|
137
|
+
if (titleTokens.has(token)) {
|
|
138
|
+
score += 2;
|
|
139
|
+
}
|
|
140
|
+
if (objectiveTokens.has(token)) {
|
|
141
|
+
score += 1;
|
|
142
|
+
}
|
|
143
|
+
const learned = learnedMappings[token];
|
|
144
|
+
if (learned?.krId === keyResult.krId && learned.confirmedCount >= MIN_HISTORY_CONFIDENCE) {
|
|
145
|
+
score += Math.max(3, learned.confirmedCount * 2);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return score;
|
|
149
|
+
}
|
|
150
|
+
function hasHistoricalMatch(activityTokens, krId, learnedMappings) {
|
|
151
|
+
return activityTokens.some((token) => {
|
|
152
|
+
const learned = learnedMappings[token];
|
|
153
|
+
return learned?.krId === krId && learned.confirmedCount >= MIN_HISTORY_CONFIDENCE;
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
function scoreToConfidence(score) {
|
|
157
|
+
if (score >= 4) {
|
|
158
|
+
return "high";
|
|
159
|
+
}
|
|
160
|
+
if (score >= 2) {
|
|
161
|
+
return "medium";
|
|
162
|
+
}
|
|
163
|
+
return "low";
|
|
164
|
+
}
|
|
165
|
+
function loadLearnedMappings() {
|
|
166
|
+
const okrMemoryPath = getOkrMemoryPath();
|
|
167
|
+
if (!existsSync(okrMemoryPath)) {
|
|
168
|
+
return {};
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
const parsed = JSON.parse(readFileSync(okrMemoryPath, "utf-8"));
|
|
172
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
return {};
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function saveLearnedMappings(store) {
|
|
179
|
+
const wikiDir = getWikiDir();
|
|
180
|
+
mkdirSync(wikiDir, { recursive: true });
|
|
181
|
+
writeFileSync(join(wikiDir, ".okr-memory.json"), `${JSON.stringify(store, null, 2)}\n`);
|
|
182
|
+
}
|
|
183
|
+
function getWikiDir() {
|
|
184
|
+
const configuredHome = process.env.CHAPTERHOUSE_HOME?.trim();
|
|
185
|
+
if (!configuredHome) {
|
|
186
|
+
return join(homedir(), ".chapterhouse", "wiki");
|
|
187
|
+
}
|
|
188
|
+
const chapterhouseHome = configuredHome.endsWith(".chapterhouse")
|
|
189
|
+
? configuredHome
|
|
190
|
+
: join(configuredHome, ".chapterhouse");
|
|
191
|
+
return join(chapterhouseHome, "wiki");
|
|
192
|
+
}
|
|
193
|
+
function getOkrMemoryPath() {
|
|
194
|
+
return join(getWikiDir(), ".okr-memory.json");
|
|
195
|
+
}
|
|
196
|
+
//# sourceMappingURL=okr-mapper.js.map
|