chapterhouse 0.3.14 → 0.3.16
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/dist/config.js +12 -1
- package/dist/copilot/system-message.js +7 -0
- package/dist/copilot/tools.js +81 -76
- package/dist/copilot/tools.wiki.test.js +143 -0
- package/dist/daemon.js +6 -0
- package/dist/wiki/frontmatter.js +148 -0
- package/dist/wiki/frontmatter.test.js +109 -0
- package/dist/wiki/fs.js +8 -3
- package/dist/wiki/fs.test.js +3 -2
- package/dist/wiki/index-manager.js +106 -49
- package/dist/wiki/index-manager.test.js +22 -3
- package/dist/wiki/lint.js +424 -0
- package/dist/wiki/lint.test.js +260 -0
- package/dist/wiki/log-manager.js +52 -9
- package/dist/wiki/log-manager.test.js +47 -0
- package/dist/wiki/migrate-topics.js +132 -0
- package/dist/wiki/migrate-topics.test.js +57 -0
- package/dist/wiki/taxonomy.js +73 -0
- package/dist/wiki/taxonomy.test.js +70 -0
- package/dist/wiki/topic-structure.js +167 -0
- package/dist/wiki/topic-structure.test.js +74 -0
- package/package.json +1 -1
- package/web/dist/assets/{index-BlIWCM11.js → index-BYuMgJ36.js} +61 -61
- package/web/dist/assets/index-BYuMgJ36.js.map +1 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-BlIWCM11.js.map +0 -1
package/dist/config.js
CHANGED
|
@@ -3,7 +3,7 @@ import { z } from "zod";
|
|
|
3
3
|
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
4
4
|
import { API_TOKEN_PATH, ENV_PATH, ensureChapterhouseHome } from "./paths.js";
|
|
5
5
|
export const DISABLE_DOTENV_ENV_VAR = "CHAPTERHOUSE_DISABLE_DOTENV";
|
|
6
|
-
const DEFAULT_WORKER_TIMEOUT_MS =
|
|
6
|
+
const DEFAULT_WORKER_TIMEOUT_MS = 900_000;
|
|
7
7
|
const BOOLEAN_ENV_PATTERN = /^(true|false)$/;
|
|
8
8
|
function loadRuntimeEnv() {
|
|
9
9
|
if (process.env[DISABLE_DOTENV_ENV_VAR] === "1") {
|
|
@@ -37,6 +37,7 @@ const configSchema = z.object({
|
|
|
37
37
|
TEAM_CHAPTERHOUSE_TOKEN: z.string().optional(),
|
|
38
38
|
TEAM_WIKI_CACHE_TTL_MINUTES: z.string().optional(),
|
|
39
39
|
TEAM_WIKI_PATHS: z.string().optional(),
|
|
40
|
+
WIKI_ENTITY_CATEGORIES: z.string().optional(),
|
|
40
41
|
TEAMS_WEBHOOK_URL: z.string().optional(),
|
|
41
42
|
TEAMS_NOTIFICATIONS_ENABLED: z.string().optional(),
|
|
42
43
|
COPILOT_TOKEN: z.string().optional(),
|
|
@@ -51,6 +52,7 @@ const configSchema = z.object({
|
|
|
51
52
|
export const DEFAULT_MODEL = "claude-sonnet-4.6";
|
|
52
53
|
export const DEFAULT_TEAM_WIKI_CACHE_TTL_MINUTES = 60;
|
|
53
54
|
export const DEFAULT_TEAM_WIKI_PATHS = ["pages/team", "pages/okrs", "pages/kpis", "pages/shared"];
|
|
55
|
+
export const DEFAULT_ENTITY_CATEGORIES = ["projects", "people", "orgs", "tools", "topics", "areas"];
|
|
54
56
|
export const DEFAULT_STANDUP_TIME = "09:00";
|
|
55
57
|
export const DEFAULT_ADO_ORG = "";
|
|
56
58
|
export const DEFAULT_ADO_PROJECT = "";
|
|
@@ -128,6 +130,13 @@ function parseTeamWikiPaths(rawValue) {
|
|
|
128
130
|
.filter(Boolean);
|
|
129
131
|
return paths.length > 0 ? paths : [...DEFAULT_TEAM_WIKI_PATHS];
|
|
130
132
|
}
|
|
133
|
+
function parseEntityCategories(rawValue) {
|
|
134
|
+
const cats = (rawValue || DEFAULT_ENTITY_CATEGORIES.join(","))
|
|
135
|
+
.split(",")
|
|
136
|
+
.map((value) => value.trim().toLowerCase().replace(/[^a-z0-9-]/g, ""))
|
|
137
|
+
.filter(Boolean);
|
|
138
|
+
return cats.length > 0 ? [...new Set(cats)] : [...DEFAULT_ENTITY_CATEGORIES];
|
|
139
|
+
}
|
|
131
140
|
function resolveConfiguredApiToken(envToken, { apiTokenPath = API_TOKEN_PATH, exists = existsSync, readFile = readFileSync, } = {}) {
|
|
132
141
|
const trimmedEnvToken = envToken?.trim();
|
|
133
142
|
if (trimmedEnvToken) {
|
|
@@ -213,6 +222,7 @@ export function parseRuntimeConfig(env, options = {}) {
|
|
|
213
222
|
teamChapterhouseToken: raw.TEAM_CHAPTERHOUSE_TOKEN?.trim() || "",
|
|
214
223
|
teamWikiCacheTtlMinutes: parsedTeamWikiCacheTtlMinutes,
|
|
215
224
|
teamWikiPaths: parseTeamWikiPaths(raw.TEAM_WIKI_PATHS),
|
|
225
|
+
wikiEntityCategories: parseEntityCategories(raw.WIKI_ENTITY_CATEGORIES),
|
|
216
226
|
teamsWebhookUrl,
|
|
217
227
|
teamsNotificationsEnabled,
|
|
218
228
|
apiRateLimitWindowMs,
|
|
@@ -253,6 +263,7 @@ export const config = {
|
|
|
253
263
|
teamChapterhouseToken: runtimeConfig.teamChapterhouseToken,
|
|
254
264
|
teamWikiCacheTtlMinutes: runtimeConfig.teamWikiCacheTtlMinutes,
|
|
255
265
|
teamWikiPaths: runtimeConfig.teamWikiPaths,
|
|
266
|
+
wikiEntityCategories: runtimeConfig.wikiEntityCategories,
|
|
256
267
|
teamsWebhookUrl: runtimeConfig.teamsWebhookUrl,
|
|
257
268
|
teamsNotificationsEnabled: runtimeConfig.teamsNotificationsEnabled,
|
|
258
269
|
apiRateLimitWindowMs: runtimeConfig.apiRateLimitWindowMs,
|
|
@@ -115,6 +115,13 @@ You can delegate **multiple tasks simultaneously**. Different agents can work in
|
|
|
115
115
|
|
|
116
116
|
**Past conversations**: Daily conversation summaries are auto-written to \`pages/conversations/YYYY-MM-DD.md\`. When the user references something from earlier ("what did we decide about X", "remember when we…", "the thing we discussed yesterday"), call \`wiki_search\` (or \`recall\`) — don't guess from your own context, since older turns may have been compacted out.
|
|
117
117
|
|
|
118
|
+
**Wiki structure** — the wiki enforces a topic layout, so put things in the right place:
|
|
119
|
+
- **Entity categories** (\`projects\`, \`people\`, \`orgs\`, \`tools\`, \`topics\`, \`areas\`): every named thing gets its own directory \`pages/<category>/<topic-slug>/\`. The topic's overview lives at \`pages/<category>/<topic-slug>/index.md\`; related sub-pages ("facets") go alongside it, e.g. \`pages/projects/chapterhouse/decisions.md\`, \`pages/projects/chapterhouse/feature-ideas.md\`. Exactly one topic level deep; lowercase-slug names only.
|
|
120
|
+
- **Flat categories** (\`preferences\`, \`facts\`, \`routines\`): a single file each, e.g. \`pages/preferences.md\`.
|
|
121
|
+
- **Decisions** are always recorded against an entity, never as a standalone page: a decision about a project goes to \`pages/projects/<topic>/decisions.md\`. With \`remember\`, pass \`category: "decision"\` plus \`about\` (which entity category) and \`entity\`.
|
|
122
|
+
- With \`remember\`, pass \`entity\` for entity categories (and \`facet\` to target a sub-page). With \`wiki_update\`, give the full canonical path — bad paths are rejected with a suggested correction; just retry with the suggestion.
|
|
123
|
+
- If the structure ever looks wrong, call \`wiki_rebuild_index\` to regenerate the index from disk.
|
|
124
|
+
|
|
118
125
|
**Learning workflow**: When the user asks you to do something you don't have a skill for:
|
|
119
126
|
1. **Search skills.sh first**: Use the find-skills skill to search for existing community skills.
|
|
120
127
|
2. **Present what you found**: Tell the user the skill name, what it does, and its security status.
|
package/dist/copilot/tools.js
CHANGED
|
@@ -8,9 +8,13 @@ import { listSkills, createSkill, removeSkill } from "./skills.js";
|
|
|
8
8
|
import { config, persistModel } from "../config.js";
|
|
9
9
|
import { getCurrentSourceChannel, getCurrentActivityCallback, getCurrentAuthenticatedUser, getLastAuthenticatedUser, getCurrentAuthorizationHeader, getCurrentSessionKey, switchSessionModel, } from "./orchestrator.js";
|
|
10
10
|
import { getRouterConfig, updateRouterConfig } from "./router.js";
|
|
11
|
-
import { ensureWikiStructure, readPage, writePage, deletePage,
|
|
12
|
-
import { searchIndex, addToIndex, removeFromIndex,
|
|
11
|
+
import { ensureWikiStructure, readPage, writePage, deletePage, writeRawSource, assertPagePath } from "../wiki/fs.js";
|
|
12
|
+
import { searchIndex, addToIndex, removeFromIndex, buildIndexEntryForPage } from "../wiki/index-manager.js";
|
|
13
|
+
import { validateWikiFrontmatter } from "../wiki/frontmatter.js";
|
|
14
|
+
import { lintWiki, renderWikiLintReport } from "../wiki/lint.js";
|
|
13
15
|
import { appendLog } from "../wiki/log-manager.js";
|
|
16
|
+
import { loadTaxonomy } from "../wiki/taxonomy.js";
|
|
17
|
+
import { getCategoryDir, topicPagePath, slugify, entityCategories, FLAT_CATEGORIES } from "../wiki/topic-structure.js";
|
|
14
18
|
import { withWikiWrite } from "../wiki/lock.js";
|
|
15
19
|
import { readWikiPage, teamWikiSync } from "../wiki/team-sync.js";
|
|
16
20
|
import { getAgentRegistry, getAgent, createEphemeralAgentSession, getAgentSessionStatus, getTask, registerTask, completeTask, failTask, createAgentFile, removeAgentFile, loadAgents, } from "./agents.js";
|
|
@@ -20,17 +24,6 @@ import { TeamPushClient } from "../integrations/team-push.js";
|
|
|
20
24
|
import { OKRMapper, parseOKRPageContent } from "./okr-mapper.js";
|
|
21
25
|
import { childLogger } from "../util/logger.js";
|
|
22
26
|
const log = childLogger("tools");
|
|
23
|
-
function getCategoryDir(category) {
|
|
24
|
-
const map = {
|
|
25
|
-
person: "people",
|
|
26
|
-
project: "projects",
|
|
27
|
-
preference: "preferences",
|
|
28
|
-
fact: "facts",
|
|
29
|
-
routine: "routines",
|
|
30
|
-
decision: "decisions",
|
|
31
|
-
};
|
|
32
|
-
return map[category] || category;
|
|
33
|
-
}
|
|
34
27
|
/** Escape a string for safe inclusion as a single-line YAML scalar value. */
|
|
35
28
|
function yamlEscape(value) {
|
|
36
29
|
// Always quote and escape backslashes, double quotes, and newlines.
|
|
@@ -650,55 +643,73 @@ export function createTools(deps) {
|
|
|
650
643
|
}),
|
|
651
644
|
// ----- Wiki-backed memory facades (preserve existing remember/recall/forget UX) -----
|
|
652
645
|
defineTool("remember", {
|
|
653
|
-
description: "Save a fact, preference, or detail to the wiki. Routes to
|
|
646
|
+
description: "Save a fact, preference, or detail to the wiki. Routes to topic pages automatically. " +
|
|
654
647
|
"Use for discrete facts ('The team prefers dark mode', 'Project uses Vercel'). " +
|
|
648
|
+
"Entity categories (project/person/org/tool/topic/area) are filed under pages/<category>/<topic>/index.md; " +
|
|
649
|
+
"pass `entity` for those, and `facet` to file under a sub-page like pages/projects/chapterhouse/feature-ideas.md. " +
|
|
650
|
+
"A `decision` is always recorded against an entity: pass both `about` (which entity category) and `entity`; " +
|
|
651
|
+
"it lands at pages/<about>/<entity>/decisions.md. " +
|
|
655
652
|
"For richer knowledge pages, use wiki_update instead.",
|
|
656
653
|
parameters: z.object({
|
|
657
|
-
category: z.enum(["preference", "fact", "project", "person", "routine", "decision"])
|
|
658
|
-
.describe("Category
|
|
654
|
+
category: z.enum(["preference", "fact", "project", "person", "routine", "decision", "org", "tool", "topic", "area"])
|
|
655
|
+
.describe("Category. Entity categories (need `entity`): project (codebase/repo), person (people), org (companies/teams), tool (software/technologies), topic (knowledge areas), area (areas of responsibility). decision (a choice made — needs `about` + `entity`). Flat categories: preference (likes/dislikes/settings), fact (general knowledge), routine (schedules/habits)."),
|
|
659
656
|
content: z.string().describe("The thing to remember — a concise, self-contained statement"),
|
|
660
|
-
entity: z.string().optional().describe("
|
|
657
|
+
entity: z.string().optional().describe("Required for entity categories and for decisions: the specific topic this is about (e.g. 'chapterhouse', 'vercel', 'Brian'). Routes to pages/<category>/<topic-slug>/…"),
|
|
658
|
+
about: z.enum(["project", "person", "org", "tool", "topic", "area"]).optional()
|
|
659
|
+
.describe("Required only when category='decision': which entity category the decision concerns. The decision is filed at pages/<about>/<entity>/decisions.md."),
|
|
660
|
+
facet: z.string().optional().describe("Optional sub-page within the topic directory (e.g. 'feature-ideas', 'architecture'). Only meaningful with an entity category + `entity`. Defaults to the topic's index page. (For decisions use `about` instead.)"),
|
|
661
661
|
related: z.array(z.string()).optional().describe("Wiki page paths this connects to, for cross-referencing"),
|
|
662
662
|
}),
|
|
663
663
|
handler: async (args) => {
|
|
664
664
|
return withWikiWrite(async () => {
|
|
665
665
|
ensureWikiStructure();
|
|
666
666
|
const now = new Date().toISOString().slice(0, 10);
|
|
667
|
-
|
|
667
|
+
const titleCase = (s) => s.split(/[-_\s]+/).filter(Boolean).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
|
|
668
|
+
// Routing: code-authoritative slugification and page lookup.
|
|
668
669
|
let pagePath;
|
|
669
670
|
let title;
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
// Check for existing page with fuzzy match before creating new
|
|
675
|
-
const existingPages = searchIndex(args.entity, 5);
|
|
676
|
-
const existingMatch = existingPages.find((p) => {
|
|
677
|
-
const pSlug = p.path.split("/").pop()?.replace(".md", "") || "";
|
|
678
|
-
return pSlug === slug || p.title.toLowerCase() === args.entity.toLowerCase();
|
|
679
|
-
});
|
|
680
|
-
if (existingMatch) {
|
|
681
|
-
pagePath = existingMatch.path;
|
|
682
|
-
title = existingMatch.title;
|
|
671
|
+
let routedCategoryDir; // the directory under pages/ this ends up in
|
|
672
|
+
if (args.category === "decision") {
|
|
673
|
+
if (!args.about || !args.entity) {
|
|
674
|
+
return `A decision needs both 'about' (the entity category it concerns — one of: ${entityCategories().join(", ")}) and 'entity' (the specific one, e.g. "chapterhouse"). It will be filed at pages/<about>/<entity>/decisions.md.`;
|
|
683
675
|
}
|
|
684
|
-
|
|
685
|
-
|
|
676
|
+
routedCategoryDir = getCategoryDir(args.about);
|
|
677
|
+
if (!entityCategories().includes(routedCategoryDir)) {
|
|
678
|
+
return `'${args.about}' isn't a known entity category. Use one of: ${entityCategories().join(", ")}.`;
|
|
686
679
|
}
|
|
680
|
+
pagePath = topicPagePath(args.about, args.entity, "decisions");
|
|
681
|
+
const existingMatch = searchIndex(args.entity, 5).find((p) => p.path === pagePath);
|
|
682
|
+
title = existingMatch ? existingMatch.title : `${titleCase(args.entity)} — Decisions`;
|
|
687
683
|
}
|
|
688
684
|
else {
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
685
|
+
routedCategoryDir = getCategoryDir(args.category);
|
|
686
|
+
const isFlat = FLAT_CATEGORIES.includes(routedCategoryDir);
|
|
687
|
+
if (!isFlat) {
|
|
688
|
+
if (!args.entity) {
|
|
689
|
+
return `The '${args.category}' category needs an 'entity' (the specific ${args.category} this is about) so it can be filed under pages/${routedCategoryDir}/<topic>/. Re-call remember with an entity, e.g. entity: "chapterhouse".`;
|
|
690
|
+
}
|
|
691
|
+
const facet = args.facet ? slugify(args.facet) : "index";
|
|
692
|
+
pagePath = topicPagePath(args.category, args.entity, facet);
|
|
693
|
+
// Reuse an existing page if the index already has one at (or matching) this path.
|
|
694
|
+
const existingPages = searchIndex(args.entity, 5);
|
|
695
|
+
const existingMatch = existingPages.find((p) => p.path === pagePath ||
|
|
696
|
+
(facet === "index" && p.title.toLowerCase() === args.entity.toLowerCase() && p.path.startsWith(`pages/${routedCategoryDir}/`)));
|
|
697
|
+
if (existingMatch) {
|
|
698
|
+
pagePath = existingMatch.path;
|
|
699
|
+
title = existingMatch.title;
|
|
700
|
+
}
|
|
701
|
+
else {
|
|
702
|
+
const topicTitle = titleCase(args.entity);
|
|
703
|
+
title = facet === "index" ? topicTitle : `${topicTitle} — ${titleCase(facet)}`;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
else {
|
|
707
|
+
pagePath = `pages/${routedCategoryDir}.md`;
|
|
708
|
+
title = titleCase(routedCategoryDir);
|
|
709
|
+
}
|
|
699
710
|
}
|
|
700
711
|
// Defense-in-depth: pagePath is constructed from controlled parts but
|
|
701
|
-
// assertPagePath
|
|
712
|
+
// assertPagePath also enforces the topic-structure rules.
|
|
702
713
|
assertPagePath(pagePath);
|
|
703
714
|
const existing = readPage(pagePath);
|
|
704
715
|
if (existing) {
|
|
@@ -753,7 +764,7 @@ export function createTools(deps) {
|
|
|
753
764
|
"Use when you need to look up something the user told you, or when asked 'do you remember...?'",
|
|
754
765
|
parameters: z.object({
|
|
755
766
|
keyword: z.string().optional().describe("Search term to match against wiki pages"),
|
|
756
|
-
category: z.enum(["preference", "fact", "project", "person", "routine", "decision"]).optional()
|
|
767
|
+
category: z.enum(["preference", "fact", "project", "person", "routine", "decision", "org", "tool", "topic", "area"]).optional()
|
|
757
768
|
.describe("Optional: filter by category"),
|
|
758
769
|
}),
|
|
759
770
|
handler: async (args) => {
|
|
@@ -916,9 +927,12 @@ export function createTools(deps) {
|
|
|
916
927
|
}),
|
|
917
928
|
defineTool("wiki_read", {
|
|
918
929
|
description: "Read a specific wiki page by path. Use after wiki_search to read full page content. " +
|
|
919
|
-
"Paths are relative to the wiki root (
|
|
930
|
+
"Paths are relative to the wiki root. Layout: entity categories (projects, people, orgs, tools, " +
|
|
931
|
+
"topics, areas) live at 'pages/<category>/<topic>/index.md' (the topic overview) plus optional " +
|
|
932
|
+
"'pages/<category>/<topic>/<facet>.md' sub-pages; flat categories at 'pages/<category>.md' " +
|
|
933
|
+
"(preferences, facts, routines, decisions); daily summaries at 'pages/conversations/YYYY-MM-DD.md'.",
|
|
920
934
|
parameters: z.object({
|
|
921
|
-
path: z.string().describe("Path to the wiki page (e.g. 'pages/people/brian.md', 'index.md')"),
|
|
935
|
+
path: z.string().describe("Path to the wiki page (e.g. 'pages/people/brian/index.md', 'pages/projects/chapterhouse/decisions.md', 'index.md')"),
|
|
922
936
|
}),
|
|
923
937
|
handler: async (args) => {
|
|
924
938
|
ensureWikiStructure();
|
|
@@ -932,9 +946,14 @@ export function createTools(deps) {
|
|
|
932
946
|
description: "Create or update a wiki page. You provide the full page content (markdown with optional " +
|
|
933
947
|
"YAML frontmatter). The page will be written to disk and the index updated. Use this for " +
|
|
934
948
|
"rich knowledge pages, entity pages, synthesis documents — anything more structured than " +
|
|
935
|
-
"a quick 'remember' call. After creating/updating a page, the index is automatically updated."
|
|
949
|
+
"a quick 'remember' call. After creating/updating a page, the index is automatically updated. " +
|
|
950
|
+
"PATH RULES: entity-category pages MUST be 'pages/<category>/<topic-slug>/<page>.md' where " +
|
|
951
|
+
"category is one of projects, people, orgs, tools, topics, areas, '<page>' is 'index' for the " +
|
|
952
|
+
"topic overview or a facet name (e.g. 'decisions', 'feature-ideas') — exactly one topic level, " +
|
|
953
|
+
"lowercase slugs only. Flat-category pages MUST be 'pages/<category>.md' (preferences, facts, " +
|
|
954
|
+
"routines, decisions). Bad paths are rejected with a suggested correction.",
|
|
936
955
|
parameters: z.object({
|
|
937
|
-
path: z.string().describe("Page path relative to wiki root (e.g. 'pages/projects/
|
|
956
|
+
path: z.string().describe("Page path relative to wiki root (e.g. 'pages/projects/chapterhouse/index.md', 'pages/projects/chapterhouse/decisions.md', 'pages/people/brian/index.md')"),
|
|
938
957
|
title: z.string().describe("Page title for the index"),
|
|
939
958
|
summary: z.string().describe("One-line summary for the index"),
|
|
940
959
|
section: z.string().optional().describe("Index section (default: 'Knowledge')"),
|
|
@@ -944,20 +963,20 @@ export function createTools(deps) {
|
|
|
944
963
|
return withWikiWrite(async () => {
|
|
945
964
|
ensureWikiStructure();
|
|
946
965
|
assertPagePath(args.path);
|
|
966
|
+
const validation = validateWikiFrontmatter(args.content, {
|
|
967
|
+
allowedTags: loadTaxonomy(),
|
|
968
|
+
});
|
|
969
|
+
if (!validation.valid) {
|
|
970
|
+
throw new Error(validation.errors.map((error) => error.message).join("\n\n"));
|
|
971
|
+
}
|
|
947
972
|
writePage(args.path, args.content);
|
|
948
|
-
// Rebuild from disk so the index summary/tags/updated reflect the actual page
|
|
949
|
-
// but prefer caller-supplied title/summary/section as overrides.
|
|
973
|
+
// Rebuild from disk so the index summary/tags/updated reflect the actual page.
|
|
950
974
|
const today = new Date().toISOString().slice(0, 10);
|
|
951
975
|
const rebuilt = buildIndexEntryForPage(args.path, {
|
|
952
|
-
title: args.title,
|
|
953
|
-
summary: indexSafe(args.summary).slice(0, 160),
|
|
954
976
|
section: args.section || "Knowledge",
|
|
955
977
|
updated: today,
|
|
956
978
|
});
|
|
957
979
|
if (rebuilt) {
|
|
958
|
-
// Overrides win even if the page frontmatter says otherwise.
|
|
959
|
-
rebuilt.title = args.title;
|
|
960
|
-
rebuilt.summary = indexSafe(args.summary).slice(0, 160);
|
|
961
980
|
rebuilt.section = args.section || "Knowledge";
|
|
962
981
|
addToIndex(rebuilt);
|
|
963
982
|
}
|
|
@@ -1048,25 +1067,11 @@ export function createTools(deps) {
|
|
|
1048
1067
|
parameters: z.object({}),
|
|
1049
1068
|
handler: async () => {
|
|
1050
1069
|
ensureWikiStructure();
|
|
1051
|
-
const
|
|
1052
|
-
const
|
|
1053
|
-
const
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
const missing = indexEntries.filter((e) => !readPage(e.path));
|
|
1057
|
-
const report = [`Wiki health report (${pages.length} pages, ${sources.length} sources):`];
|
|
1058
|
-
if (orphans.length > 0) {
|
|
1059
|
-
report.push(`\n**Orphan pages** (not in index):\n${orphans.map((p) => `- ${p}`).join("\n")}`);
|
|
1060
|
-
}
|
|
1061
|
-
if (missing.length > 0) {
|
|
1062
|
-
report.push(`\n**Missing pages** (in index but not on disk):\n${missing.map((e) => `- ${e.path}: ${e.title}`).join("\n")}`);
|
|
1063
|
-
}
|
|
1064
|
-
if (orphans.length === 0 && missing.length === 0) {
|
|
1065
|
-
report.push("\n✅ No issues found. Index and pages are in sync.");
|
|
1066
|
-
}
|
|
1067
|
-
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.`);
|
|
1068
|
-
appendLog("lint", `${orphans.length} orphans, ${missing.length} missing`);
|
|
1069
|
-
return report.join("\n");
|
|
1070
|
+
const report = lintWiki();
|
|
1071
|
+
const orphanCount = report.issues.filter((issue) => issue.rule === "orphan-page").length;
|
|
1072
|
+
const missingCount = report.issues.filter((issue) => issue.rule === "missing-page").length;
|
|
1073
|
+
appendLog("lint", `${orphanCount} orphans, ${missingCount} missing`);
|
|
1074
|
+
return renderWikiLintReport(report);
|
|
1070
1075
|
},
|
|
1071
1076
|
}),
|
|
1072
1077
|
defineTool("wiki_rebuild_index", {
|
|
@@ -1078,7 +1083,7 @@ export function createTools(deps) {
|
|
|
1078
1083
|
return withWikiWrite(async () => {
|
|
1079
1084
|
const { rebuildIndexFromPages } = await import("../wiki/index-manager.js");
|
|
1080
1085
|
const entries = rebuildIndexFromPages();
|
|
1081
|
-
appendLog("
|
|
1086
|
+
appendLog("rebuild-index", `wiki_rebuild_index: rebuilt ${entries.length} entries from pages on disk`);
|
|
1082
1087
|
return `Rebuilt index with ${entries.length} entries.`;
|
|
1083
1088
|
});
|
|
1084
1089
|
},
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
async function loadToolsModule() {
|
|
7
|
+
return await import(new URL(`./tools.js?cachebust=${Date.now()}-${Math.random()}`, import.meta.url).href);
|
|
8
|
+
}
|
|
9
|
+
async function readWikiArtifacts() {
|
|
10
|
+
const nonce = `${Date.now()}-${Math.random()}`;
|
|
11
|
+
const wikiFs = await import(new URL(`../wiki/fs.js?case=${nonce}`, import.meta.url).href);
|
|
12
|
+
return wikiFs;
|
|
13
|
+
}
|
|
14
|
+
test.beforeEach(() => {
|
|
15
|
+
process.env.CHAPTERHOUSE_HOME = mkdtempSync(join(tmpdir(), "chapterhouse-tools-wiki-"));
|
|
16
|
+
process.env.CHAPTERHOUSE_AGENT_NAME = "tools-test-agent";
|
|
17
|
+
});
|
|
18
|
+
test.afterEach(async () => {
|
|
19
|
+
const home = process.env.CHAPTERHOUSE_HOME;
|
|
20
|
+
if (home) {
|
|
21
|
+
const dbModule = await import("../store/db.js");
|
|
22
|
+
dbModule.closeDb();
|
|
23
|
+
rmSync(home, { recursive: true, force: true });
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
test("wiki_update rejects content without required frontmatter", async () => {
|
|
27
|
+
const toolsModule = await loadToolsModule();
|
|
28
|
+
const tools = toolsModule.createTools({
|
|
29
|
+
client: { async listModels() { return []; } },
|
|
30
|
+
onAgentTaskComplete: () => { },
|
|
31
|
+
});
|
|
32
|
+
const tool = tools.find((entry) => entry.name === "wiki_update");
|
|
33
|
+
assert.ok(tool);
|
|
34
|
+
await assert.rejects(tool.handler({
|
|
35
|
+
path: "pages/shared/chapterhouse.md",
|
|
36
|
+
title: "Chapterhouse",
|
|
37
|
+
summary: "Runtime notes",
|
|
38
|
+
content: "# Chapterhouse\n\nRuntime notes.\n",
|
|
39
|
+
}), /Wiki page frontmatter violates the required shape/i);
|
|
40
|
+
});
|
|
41
|
+
test("wiki_update rejects malformed summaries and unknown tags", async () => {
|
|
42
|
+
const toolsModule = await loadToolsModule();
|
|
43
|
+
const tools = toolsModule.createTools({
|
|
44
|
+
client: { async listModels() { return []; } },
|
|
45
|
+
onAgentTaskComplete: () => { },
|
|
46
|
+
});
|
|
47
|
+
const tool = tools.find((entry) => entry.name === "wiki_update");
|
|
48
|
+
assert.ok(tool);
|
|
49
|
+
await assert.rejects(tool.handler({
|
|
50
|
+
path: "pages/shared/chapterhouse.md",
|
|
51
|
+
title: "Chapterhouse",
|
|
52
|
+
summary: "Runtime notes",
|
|
53
|
+
content: `---
|
|
54
|
+
title: Chapterhouse
|
|
55
|
+
summary: **Runtime notes**
|
|
56
|
+
tags: [engineering, made-up-tag]
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
# Chapterhouse
|
|
60
|
+
|
|
61
|
+
Runtime notes.
|
|
62
|
+
`,
|
|
63
|
+
}), /Add it to `pages\/_meta\/taxonomy\.md` first\./);
|
|
64
|
+
});
|
|
65
|
+
test("wiki_update accepts valid frontmatter and refreshes the index entry", async () => {
|
|
66
|
+
const toolsModule = await loadToolsModule();
|
|
67
|
+
const tools = toolsModule.createTools({
|
|
68
|
+
client: { async listModels() { return []; } },
|
|
69
|
+
onAgentTaskComplete: () => { },
|
|
70
|
+
});
|
|
71
|
+
const tool = tools.find((entry) => entry.name === "wiki_update");
|
|
72
|
+
assert.ok(tool);
|
|
73
|
+
const result = await tool.handler({
|
|
74
|
+
path: "pages/shared/chapterhouse.md",
|
|
75
|
+
title: "Chapterhouse",
|
|
76
|
+
summary: "Runtime notes",
|
|
77
|
+
content: `---
|
|
78
|
+
title: Chapterhouse
|
|
79
|
+
summary: Runtime notes
|
|
80
|
+
tags: [engineering]
|
|
81
|
+
autostub: true
|
|
82
|
+
confidence: low
|
|
83
|
+
contested: true
|
|
84
|
+
contradictions: [pages/shared/other.md]
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
# Chapterhouse
|
|
88
|
+
|
|
89
|
+
Runtime notes.
|
|
90
|
+
`,
|
|
91
|
+
});
|
|
92
|
+
assert.equal(result, "Wiki page updated: Chapterhouse (pages/shared/chapterhouse.md)");
|
|
93
|
+
const wikiFs = await readWikiArtifacts();
|
|
94
|
+
assert.match(wikiFs.readPage("pages/shared/chapterhouse.md") ?? "", /summary: Runtime notes/);
|
|
95
|
+
assert.match(wikiFs.readIndexFile(), /\[Chapterhouse\]\(pages\/shared\/chapterhouse\.md\) — Runtime notes/);
|
|
96
|
+
});
|
|
97
|
+
test("wiki tools append audit entries to pages/_meta/log.md", async () => {
|
|
98
|
+
const toolsModule = await loadToolsModule();
|
|
99
|
+
const tools = toolsModule.createTools({
|
|
100
|
+
client: { async listModels() { return []; } },
|
|
101
|
+
onAgentTaskComplete: () => { },
|
|
102
|
+
});
|
|
103
|
+
const wikiUpdate = tools.find((entry) => entry.name === "wiki_update");
|
|
104
|
+
const wikiIngest = tools.find((entry) => entry.name === "wiki_ingest");
|
|
105
|
+
const wikiLint = tools.find((entry) => entry.name === "wiki_lint");
|
|
106
|
+
const wikiRebuildIndex = tools.find((entry) => entry.name === "wiki_rebuild_index");
|
|
107
|
+
const forget = tools.find((entry) => entry.name === "forget");
|
|
108
|
+
assert.ok(wikiUpdate && wikiIngest && wikiLint && wikiRebuildIndex && forget);
|
|
109
|
+
await wikiUpdate.handler({
|
|
110
|
+
path: "pages/shared/chapterhouse.md",
|
|
111
|
+
title: "Chapterhouse",
|
|
112
|
+
summary: "Runtime notes",
|
|
113
|
+
content: `---
|
|
114
|
+
title: Chapterhouse
|
|
115
|
+
summary: Runtime notes
|
|
116
|
+
updated: 2026-05-12
|
|
117
|
+
tags: [engineering]
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
# Chapterhouse
|
|
121
|
+
|
|
122
|
+
## Overview
|
|
123
|
+
|
|
124
|
+
Runtime notes with enough content to avoid incidental lint noise in the audit-log test.
|
|
125
|
+
`,
|
|
126
|
+
});
|
|
127
|
+
await wikiIngest.handler({
|
|
128
|
+
type: "text",
|
|
129
|
+
source: "Source content for the wiki ingest audit log test.",
|
|
130
|
+
name: "audit-log-source",
|
|
131
|
+
});
|
|
132
|
+
await wikiLint.handler({});
|
|
133
|
+
await wikiRebuildIndex.handler({});
|
|
134
|
+
await forget.handler({ page_path: "pages/shared/chapterhouse.md" });
|
|
135
|
+
const wikiFs = await readWikiArtifacts();
|
|
136
|
+
const log = wikiFs.readLogFile();
|
|
137
|
+
assert.match(log, /update \| wiki_update: Chapterhouse \(pages\/shared\/chapterhouse\.md\) \| tools-test-agent/);
|
|
138
|
+
assert.match(log, /ingest \| Ingested text: audit-log-source \(\d+ chars\) \| tools-test-agent/);
|
|
139
|
+
assert.match(log, /lint \| .* \| tools-test-agent/);
|
|
140
|
+
assert.match(log, /rebuild-index \| wiki_rebuild_index: rebuilt \d+ entries from pages on disk \| tools-test-agent/);
|
|
141
|
+
assert.match(log, /delete \| forget: deleted page pages\/shared\/chapterhouse\.md \| tools-test-agent/);
|
|
142
|
+
});
|
|
143
|
+
//# sourceMappingURL=tools.wiki.test.js.map
|
package/dist/daemon.js
CHANGED
|
@@ -11,6 +11,7 @@ import { checkForUpdate } from "./update.js";
|
|
|
11
11
|
import { ensureWikiStructure } from "./wiki/fs.js";
|
|
12
12
|
import { seedTeamWiki } from "./wiki/seed-team-wiki.js";
|
|
13
13
|
import { shouldMigrate, migrateMemoriesToWiki, shouldReorganize, reorganizeWiki } from "./wiki/migrate.js";
|
|
14
|
+
import { shouldEnforceTopics, enforceTopicStructure } from "./wiki/migrate-topics.js";
|
|
14
15
|
import { SESSIONS_DIR } from "./paths.js";
|
|
15
16
|
import { getDisplayHost } from "./api/server-runtime.js";
|
|
16
17
|
import { StandupScheduler } from "./copilot/standup.js";
|
|
@@ -113,6 +114,11 @@ async function main() {
|
|
|
113
114
|
const count = reorganizeWiki();
|
|
114
115
|
log.info({ count }, "Created entity pages during reorganization");
|
|
115
116
|
}
|
|
117
|
+
if (shouldEnforceTopics()) {
|
|
118
|
+
log.info("Enforcing wiki topic-directory structure");
|
|
119
|
+
const moved = enforceTopicStructure();
|
|
120
|
+
log.info({ moved }, "Topic-structure migration complete");
|
|
121
|
+
}
|
|
116
122
|
// Prune orphaned session folders older than 7 days
|
|
117
123
|
pruneOldSessions();
|
|
118
124
|
// One-time deprecation note for legacy Telegram users (v1 → v2)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
const FRONTMATTER_RE = /^---\s*\n([\s\S]*?)\n---\s*\n?/;
|
|
2
|
+
const SUMMARY_MARKDOWN_RE = /(\*\*|__|[_*`~]|^\s*#+\s|\[[^\]]+\]\([^)]+\)|!\[[^\]]*\]\([^)]+\)|^\s*>)/m;
|
|
3
|
+
const FRONTMATTER_TEMPLATE = `---\ntitle: <title>\nsummary: <plain-text one-line summary, max 200 chars>\nupdated: YYYY-MM-DD\ntags: []\nrelated: []\n---`;
|
|
4
|
+
export function parseWikiFrontmatter(content) {
|
|
5
|
+
const match = content.match(FRONTMATTER_RE);
|
|
6
|
+
if (!match) {
|
|
7
|
+
return {
|
|
8
|
+
parsed: { metadata: {} },
|
|
9
|
+
body: content,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
const parsed = { metadata: {} };
|
|
13
|
+
for (const line of match[1].split("\n")) {
|
|
14
|
+
const idx = line.indexOf(":");
|
|
15
|
+
if (idx <= 0)
|
|
16
|
+
continue;
|
|
17
|
+
const key = line.slice(0, idx).trim();
|
|
18
|
+
const rawValue = line.slice(idx + 1).trim();
|
|
19
|
+
const value = parseValue(rawValue);
|
|
20
|
+
switch (key) {
|
|
21
|
+
case "title":
|
|
22
|
+
case "summary":
|
|
23
|
+
case "updated":
|
|
24
|
+
if (typeof value === "string")
|
|
25
|
+
parsed[key] = value;
|
|
26
|
+
else
|
|
27
|
+
parsed.metadata[key] = value;
|
|
28
|
+
break;
|
|
29
|
+
case "tags":
|
|
30
|
+
case "contradictions":
|
|
31
|
+
case "related":
|
|
32
|
+
if (Array.isArray(value))
|
|
33
|
+
parsed[key] = value;
|
|
34
|
+
else
|
|
35
|
+
parsed.metadata[key] = value;
|
|
36
|
+
break;
|
|
37
|
+
case "autostub":
|
|
38
|
+
case "contested":
|
|
39
|
+
if (typeof value === "boolean")
|
|
40
|
+
parsed[key] = value;
|
|
41
|
+
else
|
|
42
|
+
parsed.metadata[key] = value;
|
|
43
|
+
break;
|
|
44
|
+
case "confidence":
|
|
45
|
+
if (value === "high" || value === "medium" || value === "low") {
|
|
46
|
+
parsed.confidence = value;
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
parsed.metadata[key] = value;
|
|
50
|
+
}
|
|
51
|
+
break;
|
|
52
|
+
default:
|
|
53
|
+
parsed.metadata[key] = value;
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
parsed,
|
|
59
|
+
body: content.slice(match[0].length),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
export function hasWikiFrontmatter(content) {
|
|
63
|
+
return FRONTMATTER_RE.test(content);
|
|
64
|
+
}
|
|
65
|
+
export function validateWikiFrontmatter(content, options = {}) {
|
|
66
|
+
const errors = [];
|
|
67
|
+
const { parsed } = parseWikiFrontmatter(content);
|
|
68
|
+
if (!hasWikiFrontmatter(content)) {
|
|
69
|
+
errors.push({
|
|
70
|
+
rule: "missing-frontmatter",
|
|
71
|
+
message: "Wiki page frontmatter violates the required shape: missing YAML frontmatter. Use:\n" + FRONTMATTER_TEMPLATE,
|
|
72
|
+
});
|
|
73
|
+
return { valid: false, errors };
|
|
74
|
+
}
|
|
75
|
+
if (!parsed.title?.trim()) {
|
|
76
|
+
errors.push({
|
|
77
|
+
rule: "missing-title",
|
|
78
|
+
field: "title",
|
|
79
|
+
message: formatFrontmatterMessage("missing 'title'"),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
if (!parsed.summary?.trim()) {
|
|
83
|
+
errors.push({
|
|
84
|
+
rule: "missing-summary",
|
|
85
|
+
field: "summary",
|
|
86
|
+
message: formatFrontmatterMessage("missing 'summary'"),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
const summary = parsed.summary.trim();
|
|
91
|
+
if (summary.length > 200 ||
|
|
92
|
+
summary.includes("\n") ||
|
|
93
|
+
summary.includes("\r") ||
|
|
94
|
+
SUMMARY_MARKDOWN_RE.test(summary)) {
|
|
95
|
+
errors.push({
|
|
96
|
+
rule: "invalid-summary",
|
|
97
|
+
field: "summary",
|
|
98
|
+
message: formatFrontmatterMessage("invalid 'summary' (use plain text, one line, max 200 chars)"),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
for (const field of ["updated", "tags", "autostub", "confidence", "contested", "contradictions", "related"]) {
|
|
103
|
+
if (field in parsed.metadata) {
|
|
104
|
+
errors.push({
|
|
105
|
+
rule: "invalid-field-type",
|
|
106
|
+
field,
|
|
107
|
+
message: formatFrontmatterMessage(`invalid '${field}' type`),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (options.allowedTags && parsed.tags) {
|
|
112
|
+
const allowed = new Set(options.allowedTags.map((tag) => tag.toLowerCase()));
|
|
113
|
+
for (const tag of parsed.tags) {
|
|
114
|
+
if (!allowed.has(tag.toLowerCase())) {
|
|
115
|
+
errors.push({
|
|
116
|
+
rule: "unknown-tag",
|
|
117
|
+
field: "tags",
|
|
118
|
+
message: formatFrontmatterMessage(`unknown tag '${tag}'. Add it to \`pages/_meta/taxonomy.md\` first.`),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
valid: errors.length === 0,
|
|
125
|
+
errors,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function formatFrontmatterMessage(reason) {
|
|
129
|
+
return `Wiki page frontmatter violates the required shape: ${reason}. Use:\n${FRONTMATTER_TEMPLATE}`;
|
|
130
|
+
}
|
|
131
|
+
function parseValue(rawValue) {
|
|
132
|
+
if (rawValue.startsWith("[") && rawValue.endsWith("]")) {
|
|
133
|
+
return rawValue
|
|
134
|
+
.slice(1, -1)
|
|
135
|
+
.split(",")
|
|
136
|
+
.map((item) => stripQuotes(item.trim()))
|
|
137
|
+
.filter(Boolean);
|
|
138
|
+
}
|
|
139
|
+
if (rawValue === "true")
|
|
140
|
+
return true;
|
|
141
|
+
if (rawValue === "false")
|
|
142
|
+
return false;
|
|
143
|
+
return stripQuotes(rawValue);
|
|
144
|
+
}
|
|
145
|
+
function stripQuotes(value) {
|
|
146
|
+
return value.replace(/^['"]|['"]$/g, "");
|
|
147
|
+
}
|
|
148
|
+
//# sourceMappingURL=frontmatter.js.map
|