chapterhouse 0.7.0 → 0.8.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.
Files changed (72) hide show
  1. package/agents/korg.agent.md +65 -0
  2. package/dist/api/korg.js +34 -0
  3. package/dist/api/korg.test.js +42 -0
  4. package/dist/api/server.js +238 -2
  5. package/dist/api/server.test.js +199 -0
  6. package/dist/config.js +28 -0
  7. package/dist/config.test.js +20 -0
  8. package/dist/copilot/agents.js +3 -4
  9. package/dist/copilot/agents.test.js +12 -1
  10. package/dist/copilot/orchestrator.js +12 -1
  11. package/dist/copilot/orchestrator.test.js +3 -7
  12. package/dist/copilot/system-message.js +11 -10
  13. package/dist/copilot/system-message.test.js +6 -1
  14. package/dist/copilot/tools.js +184 -376
  15. package/dist/copilot/tools.memory.test.js +32 -0
  16. package/dist/copilot/tools.wiki.test.js +53 -59
  17. package/dist/daemon.js +9 -0
  18. package/dist/memory/decisions.js +6 -5
  19. package/dist/memory/entities.js +20 -9
  20. package/dist/memory/hooks.js +151 -0
  21. package/dist/memory/hooks.test.js +325 -0
  22. package/dist/memory/hot-tier.js +37 -0
  23. package/dist/memory/hot-tier.test.js +30 -0
  24. package/dist/memory/housekeeping-scheduler.js +35 -0
  25. package/dist/memory/housekeeping-scheduler.test.js +50 -0
  26. package/dist/memory/inbox.js +10 -0
  27. package/dist/memory/index.js +3 -1
  28. package/dist/memory/migration.js +244 -0
  29. package/dist/memory/migration.test.js +100 -0
  30. package/dist/memory/reflect.js +273 -0
  31. package/dist/memory/reflect.test.js +254 -0
  32. package/dist/store/db.js +119 -4
  33. package/dist/store/db.test.js +19 -1
  34. package/dist/test/setup-env.js +1 -0
  35. package/dist/wiki/consolidation.js +641 -0
  36. package/dist/wiki/consolidation.test.js +140 -0
  37. package/dist/wiki/frontmatter.js +48 -0
  38. package/dist/wiki/frontmatter.test.js +42 -0
  39. package/dist/wiki/index-manager.js +246 -330
  40. package/dist/wiki/index-manager.test.js +138 -145
  41. package/dist/wiki/ingest.js +347 -0
  42. package/dist/wiki/ingest.test.js +111 -0
  43. package/dist/wiki/links.js +151 -0
  44. package/dist/wiki/links.test.js +176 -0
  45. package/dist/wiki/migrate-topics.test.js +16 -6
  46. package/dist/wiki/scheduler.js +118 -0
  47. package/dist/wiki/scheduler.test.js +64 -0
  48. package/dist/wiki/timeline.js +51 -0
  49. package/dist/wiki/timeline.test.js +65 -0
  50. package/dist/wiki/topic-structure.js +1 -1
  51. package/package.json +1 -1
  52. package/skills/pkb-ideas/SKILL.md +78 -0
  53. package/skills/pkb-ideas/_meta.json +4 -0
  54. package/skills/pkb-org/SKILL.md +82 -0
  55. package/skills/pkb-org/_meta.json +4 -0
  56. package/skills/pkb-people/SKILL.md +74 -0
  57. package/skills/pkb-people/_meta.json +4 -0
  58. package/skills/pkb-research/SKILL.md +83 -0
  59. package/skills/pkb-research/_meta.json +4 -0
  60. package/skills/pkb-source/SKILL.md +38 -0
  61. package/skills/pkb-source/_meta.json +4 -0
  62. package/skills/wiki-conventions/SKILL.md +5 -5
  63. package/web/dist/assets/{index-DuKYxMIR.css → index-5kz9aRU9.css} +1 -1
  64. package/web/dist/assets/{index-DytB69KC.js → index-BbX9RKf3.js} +91 -89
  65. package/web/dist/assets/index-BbX9RKf3.js.map +1 -0
  66. package/web/dist/index.html +2 -2
  67. package/dist/wiki/context.js +0 -138
  68. package/dist/wiki/fix.js +0 -335
  69. package/dist/wiki/fix.test.js +0 -350
  70. package/dist/wiki/lint.js +0 -451
  71. package/dist/wiki/lint.test.js +0 -329
  72. package/web/dist/assets/index-DytB69KC.js.map +0 -1
@@ -10,15 +10,15 @@ import { ModeContext } from "../mode-context.js";
10
10
  import { agentEventBus } from "./agent-event-bus.js";
11
11
  import { getCurrentSourceChannel, getCurrentActivityCallback, getCurrentActiveProjectRules, getCurrentAuthenticatedUser, getLastAuthenticatedUser, getCurrentAuthorizationHeader, getCurrentSessionKey, sendToAgentSession, invalidateOrchestratorSession, maybeScheduleScopeChangeCheckpoint, resetCheckpointSessionState, switchSessionModel, } from "./orchestrator.js";
12
12
  import { getRouterConfig, updateRouterConfig } from "./router.js";
13
- import { ensureWikiStructure, readPage, writePage, deletePage, writeRawSource, assertPagePath } from "../wiki/fs.js";
14
- import { searchIndex, addToIndex, removeFromIndex, buildIndexEntryForPage } from "../wiki/index-manager.js";
15
- import { validateWikiFrontmatter } from "../wiki/frontmatter.js";
16
- import { fixWiki } from "../wiki/fix.js";
17
- import { lintWiki, renderWikiLintReport } from "../wiki/lint.js";
18
- import { rebuildIndexFromPages } from "../wiki/index-manager.js";
13
+ import { ensureWikiStructure, writePage, assertPagePath } from "../wiki/fs.js";
14
+ import { searchIndex, addToIndex, buildIndexEntryForPage, } from "../wiki/index-manager.js";
15
+ import { traverse as wikiTraverse } from "../wiki/links.js";
16
+ import { validateWikiFrontmatter, validateAndBackfillFrontmatter } from "../wiki/frontmatter.js";
17
+ import { appendTimeline } from "../wiki/timeline.js";
18
+ import { ingestSource, detectSourceType } from "../wiki/ingest.js";
19
19
  import { appendLog } from "../wiki/log-manager.js";
20
20
  import { loadTaxonomy } from "../wiki/taxonomy.js";
21
- import { getCategoryDir, topicPagePath, slugify, entityCategories, FLAT_CATEGORIES } from "../wiki/topic-structure.js";
21
+ import { topicPagePath } from "../wiki/topic-structure.js";
22
22
  import { withWikiWrite } from "../wiki/lock.js";
23
23
  import { readWikiPage, teamWikiSync } from "../wiki/team-sync.js";
24
24
  import { getAgentRegistry, getAgent, createEphemeralAgentSession, getAgentSessionStatus, getTask, registerTask, completeTask, failTask, createTaskId, createAgentFile, removeAgentFile, loadAgents, } from "./agents.js";
@@ -30,7 +30,7 @@ import { TeamsNotifier } from "../integrations/teams-notify.js";
30
30
  import { TeamPushClient } from "../integrations/team-push.js";
31
31
  import { OKRMapper, parseOKRPageContent } from "./okr-mapper.js";
32
32
  import { childLogger } from "../util/logger.js";
33
- import { getActiveScope as getMemoryActiveScope, createScope as createMemoryScope, getScope as getMemoryScope, inferScopeFromText, completeActionItem, demoteToCold, demoteToWarm, dropActionItem, queueMemoryProposal, recall as recallMemory, listActionItems, recordDecision, recordActionItem, recordObservation, runHousekeeping, setActiveScope as setMemoryActiveScope, snoozeActionItem, promoteToHot, upsertEntity, } from "../memory/index.js";
33
+ import { getActiveScope as getMemoryActiveScope, createScope as createMemoryScope, getScope as getMemoryScope, inferScopeFromText, completeActionItem, demoteToCold, demoteToWarm, dropActionItem, queueMemoryProposal, recall as recallMemory, listActionItems, recordDecision, recordActionItem, recordObservation, runHousekeeping, reflectOnScope, reflectAllScopes, setActiveScope as setMemoryActiveScope, snoozeActionItem, promoteToHot, upsertEntity, hookDispatcher, } from "../memory/index.js";
34
34
  const log = childLogger("tools");
35
35
  const modeContext = new ModeContext(config);
36
36
  /** Escape a string for safe inclusion as a single-line YAML scalar value. */
@@ -944,6 +944,17 @@ export function createTools(deps) {
944
944
  decided_at: args.decided_at ?? new Date().toISOString().slice(0, 10),
945
945
  tier: args.tier ?? "warm",
946
946
  });
947
+ if (entity) {
948
+ appendTimeline(topicPagePath(entity.kind, entity.name), `${decision.title}\n\n${decision.rationale}`);
949
+ }
950
+ hookDispatcher.dispatch("memory:decision", {
951
+ id: decision.id,
952
+ scope_id: scopeId,
953
+ title: decision.title,
954
+ rationale: decision.rationale,
955
+ }).catch((err) => {
956
+ log.error({ err }, "memory.hooks.decision.dispatch_error");
957
+ });
947
958
  return {
948
959
  ok: true,
949
960
  id: decision.id,
@@ -1041,6 +1052,13 @@ export function createTools(deps) {
1041
1052
  description: parsed.data.description ?? "",
1042
1053
  keywords: [parsed.data.slug],
1043
1054
  });
1055
+ hookDispatcher.dispatch("scope:created", {
1056
+ slug: scope.slug,
1057
+ title: scope.title,
1058
+ description: scope.description,
1059
+ }).catch((err) => {
1060
+ log.error({ err }, "memory.hooks.scope_created.dispatch_error");
1061
+ });
1044
1062
  return {
1045
1063
  ok: true,
1046
1064
  scope: {
@@ -1289,6 +1307,51 @@ export function createTools(deps) {
1289
1307
  }
1290
1308
  },
1291
1309
  }),
1310
+ defineTool("memory_reflect", {
1311
+ description: "Synthesize durable scoped memory patterns from repeated observations. Orchestrator-only reflect tool.",
1312
+ parameters: z.object({
1313
+ scope: z.string().optional(),
1314
+ }),
1315
+ handler: async (args) => {
1316
+ const denied = requireOrchestratorMemoryWrite();
1317
+ if (denied)
1318
+ return denied;
1319
+ try {
1320
+ if (args.scope) {
1321
+ const requestedScope = getMemoryScope(args.scope);
1322
+ if (!requestedScope) {
1323
+ return `Unknown memory scope '${args.scope}'.`;
1324
+ }
1325
+ const result = await reflectOnScope(args.scope, getDb());
1326
+ return {
1327
+ ok: true,
1328
+ scope: args.scope,
1329
+ patterns_created: result.patternsCreated,
1330
+ patterns_updated: result.patternsUpdated,
1331
+ contradictions_found: result.contradictionsFound,
1332
+ };
1333
+ }
1334
+ const results = await reflectAllScopes(getDb());
1335
+ const totals = Object.values(results).reduce((acc, result) => ({
1336
+ patterns_created: acc.patterns_created + result.patternsCreated,
1337
+ patterns_updated: acc.patterns_updated + result.patternsUpdated,
1338
+ contradictions_found: acc.contradictions_found + result.contradictionsFound,
1339
+ }), {
1340
+ patterns_created: 0,
1341
+ patterns_updated: 0,
1342
+ contradictions_found: 0,
1343
+ });
1344
+ return {
1345
+ ok: true,
1346
+ scope: "all",
1347
+ ...totals,
1348
+ };
1349
+ }
1350
+ catch (err) {
1351
+ return err instanceof Error ? err.message : String(err);
1352
+ }
1353
+ },
1354
+ }),
1292
1355
  defineTool("memory_promote", {
1293
1356
  description: "Promote a memory row to the hot tier. Orchestrator-only manual override.",
1294
1357
  parameters: z.object({
@@ -1372,273 +1435,21 @@ export function createTools(deps) {
1372
1435
  }
1373
1436
  },
1374
1437
  }),
1375
- // ----- Wiki-backed memory facades (preserve existing remember/recall/forget UX) -----
1438
+ // ----- Removed legacy wiki facades kept as compatibility stubs -----
1376
1439
  defineTool("remember", {
1377
- description: "Save a fact, preference, or detail to the wiki. Routes to topic pages automatically. " +
1378
- "Use for discrete facts ('The team prefers dark mode', 'Project uses Vercel'). " +
1379
- "Entity categories (project/person/org/tool/topic/area) are filed under pages/<category>/<topic>/index.md; " +
1380
- "pass `entity` for those, and `facet` to file under a sub-page like pages/projects/chapterhouse/feature-ideas.md. " +
1381
- "A `decision` is always recorded against an entity: pass both `about` (which entity category) and `entity`; " +
1382
- "it lands at pages/<about>/<entity>/decisions.md. " +
1383
- "For richer knowledge pages, use wiki_update instead.",
1384
- parameters: z.object({
1385
- category: z.enum(["preference", "fact", "project", "person", "routine", "decision", "org", "tool", "topic", "area"])
1386
- .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)."),
1387
- content: z.string().describe("The thing to remember — a concise, self-contained statement"),
1388
- 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>/…"),
1389
- about: z.enum(["project", "person", "org", "tool", "topic", "area"]).optional()
1390
- .describe("Required only when category='decision': which entity category the decision concerns. The decision is filed at pages/<about>/<entity>/decisions.md."),
1391
- 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.)"),
1392
- related: z.array(z.string()).optional().describe("Wiki page paths this connects to, for cross-referencing"),
1393
- }),
1394
- handler: async (args) => {
1395
- return withWikiWrite(async () => {
1396
- ensureWikiStructure();
1397
- const now = new Date().toISOString().slice(0, 10);
1398
- const titleCase = (s) => s.split(/[-_\s]+/).filter(Boolean).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
1399
- // Routing: code-authoritative slugification and page lookup.
1400
- let pagePath;
1401
- let title;
1402
- let routedCategoryDir; // the directory under pages/ this ends up in
1403
- if (args.category === "decision") {
1404
- if (!args.about || !args.entity) {
1405
- 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.`;
1406
- }
1407
- routedCategoryDir = getCategoryDir(args.about);
1408
- if (!entityCategories().includes(routedCategoryDir)) {
1409
- return `'${args.about}' isn't a known entity category. Use one of: ${entityCategories().join(", ")}.`;
1410
- }
1411
- pagePath = topicPagePath(args.about, args.entity, "decisions");
1412
- const existingMatch = searchIndex(args.entity, 5).find((p) => p.path === pagePath);
1413
- title = existingMatch ? existingMatch.title : `${titleCase(args.entity)} — Decisions`;
1414
- }
1415
- else {
1416
- routedCategoryDir = getCategoryDir(args.category);
1417
- const isFlat = FLAT_CATEGORIES.includes(routedCategoryDir);
1418
- if (!isFlat) {
1419
- if (!args.entity) {
1420
- 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".`;
1421
- }
1422
- const facet = args.facet ? slugify(args.facet) : "index";
1423
- pagePath = topicPagePath(args.category, args.entity, facet);
1424
- // Reuse an existing page if the index already has one at (or matching) this path.
1425
- const existingPages = searchIndex(args.entity, 5);
1426
- const existingMatch = existingPages.find((p) => p.path === pagePath ||
1427
- (facet === "index" && p.title.toLowerCase() === args.entity.toLowerCase() && p.path.startsWith(`pages/${routedCategoryDir}/`)));
1428
- if (existingMatch) {
1429
- pagePath = existingMatch.path;
1430
- title = existingMatch.title;
1431
- }
1432
- else {
1433
- const topicTitle = titleCase(args.entity);
1434
- title = facet === "index" ? topicTitle : `${topicTitle} — ${titleCase(facet)}`;
1435
- }
1436
- }
1437
- else {
1438
- pagePath = `pages/${routedCategoryDir}.md`;
1439
- title = titleCase(routedCategoryDir);
1440
- }
1441
- }
1442
- // Defense-in-depth: pagePath is constructed from controlled parts but
1443
- // assertPagePath also enforces the topic-structure rules.
1444
- assertPagePath(pagePath);
1445
- const existing = readPage(pagePath);
1446
- if (existing) {
1447
- const updated = existing.replace(/^(---[\s\S]*?updated:\s*)[\d-]+/m, `$1${now}`);
1448
- writePage(pagePath, updated.trimEnd() + `\n- ${args.content} _(${now})_\n`);
1449
- }
1450
- else {
1451
- const tags = [args.category];
1452
- if (args.entity)
1453
- tags.push(args.entity.toLowerCase());
1454
- const safeTags = tags.map(yamlListItem).join(", ");
1455
- const safeRelated = (args.related || []).map(yamlListItem).join(", ");
1456
- const page = [
1457
- "---",
1458
- `title: ${yamlEscape(title)}`,
1459
- `tags: [${safeTags}]`,
1460
- `created: ${now}`,
1461
- `updated: ${now}`,
1462
- `related: [${safeRelated}]`,
1463
- "---",
1464
- "",
1465
- `# ${title}`,
1466
- "",
1467
- `- ${args.content} _(${now})_`,
1468
- "",
1469
- ].join("\n");
1470
- writePage(pagePath, page);
1471
- }
1472
- // Rebuild the index entry from the page on disk so summary/tags/updated
1473
- // stay in sync rather than being clobbered by the latest bullet.
1474
- const rebuilt = buildIndexEntryForPage(pagePath, {
1475
- title,
1476
- section: "Knowledge",
1477
- tags: [args.category, ...(args.entity ? [args.entity.toLowerCase()] : [])],
1478
- updated: now,
1479
- // Keep existing summary if present; otherwise use the new content.
1480
- summary: indexSafe(args.content).slice(0, 120),
1481
- });
1482
- if (rebuilt)
1483
- addToIndex(rebuilt);
1484
- appendLog("update", `remember (${args.category}${args.entity ? `, ${args.entity}` : ""}): ${indexSafe(args.content).slice(0, 80)}`);
1485
- const relatedHint = args.related?.length
1486
- ? ` Related pages that may need updating: ${args.related.join(", ")}`
1487
- : "";
1488
- return `Remembered in ${pagePath}: "${args.content}"${relatedHint}`;
1489
- });
1490
- },
1440
+ description: "REMOVED: Use wiki_update or memory_remember instead.",
1441
+ parameters: z.object({}).passthrough(),
1442
+ handler: async () => "This tool has been removed. Use wiki_update to write to wiki pages, or memory_remember for agent memory.",
1491
1443
  }),
1492
1444
  defineTool("recall", {
1493
- description: "Search the wiki for stored knowledge. Returns matching page summaries from the index. " +
1494
- "Use wiki_read to drill into specific pages for deeper context. " +
1495
- "Use when you need to look up something the user told you, or when asked 'do you remember...?'",
1496
- parameters: z.object({
1497
- keyword: z.string().optional().describe("Search term to match against wiki pages"),
1498
- category: z.enum(["preference", "fact", "project", "person", "routine", "decision", "org", "tool", "topic", "area"]).optional()
1499
- .describe("Optional: filter by category"),
1500
- }),
1501
- handler: async (args) => {
1502
- ensureWikiStructure();
1503
- const query = [args.keyword, args.category].filter(Boolean).join(" ");
1504
- const matches = searchIndex(query || "", 10);
1505
- if (matches.length === 0) {
1506
- 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.";
1507
- }
1508
- const sections = [];
1509
- for (const match of matches) {
1510
- const content = readPage(match.path);
1511
- if (!content)
1512
- continue;
1513
- // Extract updated date from frontmatter
1514
- const updatedMatch = content.match(/^updated:\s*(.+)$/m);
1515
- const updated = updatedMatch ? ` (updated: ${updatedMatch[1].trim()})` : "";
1516
- const body = content.replace(/^---[\s\S]*?---\s*/, "").trim();
1517
- const trimmed = body.length > 800 ? body.slice(0, 800) + "…" : body;
1518
- sections.push(`**${match.title}** (${match.path})${updated}:\n${trimmed}`);
1519
- }
1520
- return sections.length > 0
1521
- ? `Found ${matches.length} wiki page(s):\n\n${sections.join("\n\n")}`
1522
- : "No matching content found.";
1523
- },
1445
+ description: "REMOVED: Use wiki_search or memory_recall instead.",
1446
+ parameters: z.object({}).passthrough(),
1447
+ handler: async () => "This tool has been removed. Use wiki_search to search wiki pages, or memory_recall for agent memory.",
1524
1448
  }),
1525
1449
  defineTool("forget", {
1526
- description: "Remove content from the wiki. Three modes: (1) page_path + content removes matching bullet lines, " +
1527
- "(2) page_path + revision replaces a section with corrected content, " +
1528
- "(3) page_path alone deletes the entire page.",
1529
- parameters: z.object({
1530
- page_path: z.string().describe("Wiki page path to modify or delete"),
1531
- content: z.string().optional().describe("Specific text to match and remove (line-removal mode)"),
1532
- revision: z.string().optional().describe("Replacement content for a section (section-rewrite mode)"),
1533
- section_heading: z.string().optional().describe("The heading of the section to replace (used with revision)"),
1534
- }),
1535
- handler: async (args) => {
1536
- return withWikiWrite(async () => {
1537
- // Defense: only allow modifying real pages, never index.md / log.md / sources/.
1538
- assertPagePath(args.page_path);
1539
- // Delete entire page
1540
- if (!args.content && !args.revision) {
1541
- const page = readPage(args.page_path);
1542
- if (!page)
1543
- return `Page ${args.page_path} not found.`;
1544
- deletePage(args.page_path);
1545
- removeFromIndex(args.page_path);
1546
- appendLog("delete", `forget: deleted page ${args.page_path}`);
1547
- return `Deleted page ${args.page_path} and removed from index.`;
1548
- }
1549
- // Line-removal mode: remove bullet lines that match content.
1550
- // Precision rules: prefer a single exact match (whole bullet body equals
1551
- // the search text). If no exact match, fall back to substring match —
1552
- // but if the substring would match >1 bullets, refuse and report so the
1553
- // caller can disambiguate. This prevents "forget CST" from nuking every
1554
- // bullet that happens to mention CST.
1555
- if (args.content) {
1556
- const page = readPage(args.page_path);
1557
- if (!page)
1558
- return `Page ${args.page_path} not found.`;
1559
- const search = args.content.trim();
1560
- const lines = page.split("\n");
1561
- const isBullet = (l) => /^\s*[-*]\s+/.test(l);
1562
- const bulletText = (l) => l.replace(/^\s*[-*]\s+/, "").replace(/\s*_\(\d{4}-\d{2}-\d{2}\)_\s*$/, "").trim();
1563
- // Pass 1: exact-bullet match (case-insensitive).
1564
- const exactMatches = [];
1565
- for (let i = 0; i < lines.length; i++) {
1566
- if (isBullet(lines[i]) && bulletText(lines[i]).toLowerCase() === search.toLowerCase()) {
1567
- exactMatches.push(i);
1568
- }
1569
- }
1570
- let toRemove;
1571
- if (exactMatches.length > 0) {
1572
- toRemove = new Set(exactMatches);
1573
- }
1574
- else {
1575
- // Pass 2: substring match — but require precision.
1576
- const subMatches = [];
1577
- for (let i = 0; i < lines.length; i++) {
1578
- if (isBullet(lines[i]) && lines[i].toLowerCase().includes(search.toLowerCase())) {
1579
- subMatches.push(i);
1580
- }
1581
- }
1582
- if (subMatches.length === 0) {
1583
- return `No matching bullet points found in ${args.page_path}.`;
1584
- }
1585
- if (subMatches.length > 1) {
1586
- const preview = subMatches.slice(0, 5)
1587
- .map((i) => ` • ${lines[i].trim()}`).join("\n");
1588
- 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}`;
1589
- }
1590
- toRemove = new Set(subMatches);
1591
- }
1592
- const updatedLines = lines.filter((_, i) => !toRemove.has(i));
1593
- // Bump frontmatter `updated:` so the index reflects the change.
1594
- const today = new Date().toISOString().slice(0, 10);
1595
- let updated = updatedLines.join("\n").replace(/^(---[\s\S]*?updated:\s*)[\d-]+/m, `$1${today}`);
1596
- writePage(args.page_path, updated);
1597
- // Refresh the corresponding index entry from the page so the index
1598
- // doesn't keep advertising forgotten content.
1599
- const rebuilt = buildIndexEntryForPage(args.page_path, { updated: today });
1600
- if (rebuilt)
1601
- addToIndex(rebuilt);
1602
- appendLog("update", `forget: removed ${toRemove.size} line(s) matching "${indexSafe(search).slice(0, 60)}" from ${args.page_path}`);
1603
- return `Removed ${toRemove.size} line(s) from ${args.page_path}.`;
1604
- }
1605
- // Section-rewrite mode: replace a section with revised content
1606
- if (args.revision) {
1607
- const page = readPage(args.page_path);
1608
- if (!page)
1609
- return `Page ${args.page_path} not found.`;
1610
- if (args.section_heading) {
1611
- const headingPattern = new RegExp(`(^#{1,6}\\s*${args.section_heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$)`, "m");
1612
- const headingMatch = page.match(headingPattern);
1613
- if (!headingMatch || headingMatch.index === undefined) {
1614
- return `Section "${args.section_heading}" not found in ${args.page_path}.`;
1615
- }
1616
- const sectionStart = headingMatch.index;
1617
- const level = (headingMatch[1].match(/^#+/) || ["#"])[0].length;
1618
- const nextHeading = page.slice(sectionStart + headingMatch[0].length)
1619
- .search(new RegExp(`^#{1,${level}}\\s`, "m"));
1620
- const sectionEnd = nextHeading === -1
1621
- ? page.length
1622
- : sectionStart + headingMatch[0].length + nextHeading;
1623
- const updated = page.slice(0, sectionStart) + args.revision + "\n" + page.slice(sectionEnd);
1624
- writePage(args.page_path, updated);
1625
- }
1626
- else {
1627
- // Replace entire body (keep frontmatter)
1628
- const fmMatch = page.match(/^---[\s\S]*?---\s*/);
1629
- const frontmatter = fmMatch ? fmMatch[0] : "";
1630
- writePage(args.page_path, frontmatter + args.revision + "\n");
1631
- }
1632
- const today = new Date().toISOString().slice(0, 10);
1633
- const rebuilt = buildIndexEntryForPage(args.page_path, { updated: today });
1634
- if (rebuilt)
1635
- addToIndex(rebuilt);
1636
- appendLog("update", `forget: revised section in ${args.page_path}`);
1637
- return `Revised content in ${args.page_path}.`;
1638
- }
1639
- return "Nothing to do — provide content (line-removal) or revision (section-rewrite).";
1640
- });
1641
- },
1450
+ description: "REMOVED: Use wiki_update instead.",
1451
+ parameters: z.object({}).passthrough(),
1452
+ handler: async () => "This tool has been removed. Use wiki_update to modify wiki pages.",
1642
1453
  }),
1643
1454
  // ----- New wiki tools -----
1644
1455
  defineTool("wiki_search", {
@@ -1686,10 +1497,15 @@ export function createTools(deps) {
1686
1497
  parameters: wikiUpdateArgsSchema,
1687
1498
  handler: async (args) => {
1688
1499
  try {
1689
- const parsedArgs = wikiUpdateArgsSchema.parse(args);
1500
+ let parsedArgs = wikiUpdateArgsSchema.parse(args);
1690
1501
  return await withWikiWrite(async () => {
1691
1502
  ensureWikiStructure();
1692
1503
  assertPagePath(parsedArgs.path);
1504
+ // Backfill missing frontmatter fields before validation
1505
+ const backfilled = validateAndBackfillFrontmatter(parsedArgs.path, parsedArgs.content);
1506
+ if (backfilled.changed) {
1507
+ parsedArgs = { ...parsedArgs, content: backfilled.content };
1508
+ }
1693
1509
  const validation = validateWikiFrontmatter(parsedArgs.content, {
1694
1510
  allowedTags: loadTaxonomy(),
1695
1511
  });
@@ -1728,127 +1544,119 @@ export function createTools(deps) {
1728
1544
  },
1729
1545
  }),
1730
1546
  defineTool("wiki_ingest", {
1731
- description: "Ingest a source into the wiki. Saves the raw content as an immutable source document, " +
1732
- "then returns it so you can create wiki pages from it. Supports URLs (fetches the page) " +
1733
- "or raw text passed directly. For local files, read the file yourself and pass content as text.",
1547
+ description: "REMOVED: Use wiki_ingest_source instead.",
1548
+ parameters: z.object({}).passthrough(),
1549
+ handler: async () => "This tool has been removed. Use wiki_ingest_source instead.",
1550
+ }),
1551
+ defineTool("wiki_lint", {
1552
+ description: "REMOVED: Wiki health checks are no longer needed.",
1553
+ parameters: z.object({}).passthrough(),
1554
+ handler: async () => "This tool has been removed. Wiki health checks are no longer needed with SQLite-backed storage.",
1555
+ }),
1556
+ defineTool("wiki_rebuild_index", {
1557
+ description: "REMOVED: The wiki index is maintained automatically.",
1558
+ parameters: z.object({}).passthrough(),
1559
+ handler: async () => "This tool has been removed. The wiki index is now maintained automatically via SQLite FTS5.",
1560
+ }),
1561
+ defineTool("wiki_traverse", {
1562
+ description: "Walk the wiki entity graph from a starting page. Returns pages connected by typed links. " +
1563
+ "Use to discover related knowledge, trace dependencies, find who works on a project, etc. " +
1564
+ "Depth 1 returns direct neighbors; depth 2-3 expands further (max 3). " +
1565
+ "Optionally filter by link_type: references, implements, supersedes, member_of, works_on, decided_by, depends_on, attended, follow_up.",
1734
1566
  parameters: z.object({
1735
- type: z.enum(["url", "text"]).describe("Source type: 'url' to fetch a web page, 'text' for raw content"),
1736
- source: z.string().describe("URL or raw text content"),
1737
- name: z.string().optional().describe("Name for the source (auto-generated if omitted)"),
1567
+ page: z.string().describe("Wiki page path to traverse from (e.g. 'pages/topics/rust/index.md')"),
1568
+ link_type: z.string().optional().describe("Filter by link type (e.g. 'references', 'implements')"),
1569
+ depth: z.number().int().min(1).max(3).optional().describe("Traversal depth (1–3, default 1)"),
1738
1570
  }),
1739
1571
  handler: async (args) => {
1740
1572
  ensureWikiStructure();
1741
- let content;
1742
- let sourceName;
1743
- if (args.type === "url") {
1744
- // Validate URL scheme
1745
- let parsedUrl;
1746
- try {
1747
- parsedUrl = new URL(args.source);
1748
- }
1749
- catch {
1750
- return "Invalid URL format.";
1751
- }
1752
- if (!["http:", "https:"].includes(parsedUrl.protocol)) {
1753
- return "Only http and https URLs are supported.";
1754
- }
1755
- // Block private/internal addresses
1756
- const host = parsedUrl.hostname.toLowerCase();
1757
- if (host === "localhost" || host === "127.0.0.1" || host === "::1" ||
1758
- host.startsWith("10.") || host.startsWith("192.168.") ||
1759
- host.startsWith("169.254.") || host === "metadata.google.internal") {
1760
- return "Cannot fetch internal/private URLs.";
1761
- }
1762
- try {
1763
- const res = await fetch(args.source);
1764
- if (!res.ok) {
1765
- return `Fetch failed: ${res.status} ${res.statusText}`;
1766
- }
1767
- content = await res.text();
1768
- // Strip HTML tags for a rough markdown conversion
1769
- content = content.replace(/<script[\s\S]*?<\/script>/gi, "")
1770
- .replace(/<style[\s\S]*?<\/style>/gi, "")
1771
- .replace(/<[^>]+>/g, " ")
1772
- .replace(/\s{2,}/g, " ")
1773
- .trim();
1774
- }
1775
- catch (err) {
1776
- return `Failed to fetch URL: ${err instanceof Error ? err.message : err}`;
1777
- }
1778
- sourceName = args.name || parsedUrl.hostname + "-" + Date.now();
1779
- }
1780
- else {
1781
- content = args.source;
1782
- sourceName = args.name || "text-" + Date.now();
1783
- }
1784
- const fileName = `${new Date().toISOString().slice(0, 10)}-${sourceName}.md`;
1785
- await withWikiWrite(async () => {
1786
- writeRawSource(fileName, content);
1787
- appendLog("ingest", `Ingested ${args.type}: ${indexSafe(sourceName)} (${content.length} chars)`);
1788
- });
1789
- // Return the content so the LLM can create wiki pages from it
1790
- const preview = content.length > 3000 ? content.slice(0, 3000) + "\n\n…(truncated)" : content;
1791
- return `Source saved as sources/${fileName} (${content.length} chars).\n\n` +
1792
- "Now create wiki pages from this content using wiki_update. " +
1793
- "Update existing pages and the index as needed.\n\n" +
1794
- `--- Source content ---\n${preview}`;
1573
+ const results = wikiTraverse(args.page, args.link_type, args.depth ?? 1);
1574
+ if (results.length === 0)
1575
+ return `No linked pages found for: ${args.page}`;
1576
+ const lines = results.map((r) => `• [depth ${r.depth}] ${r.direction === "outbound" ? "→" : "←"} ${r.page} (${r.link_type})`);
1577
+ return `Found ${results.length} linked page(s):\n${lines.join("\n")}`;
1795
1578
  },
1796
1579
  }),
1797
- defineTool("wiki_lint", {
1798
- description: "Health-check the wiki. Looks for: orphan pages (not in index), index entries pointing " +
1799
- "to missing pages, and pages with no cross-references. Returns a report.",
1800
- parameters: z.object({}),
1801
- handler: async () => {
1802
- ensureWikiStructure();
1803
- const report = lintWiki();
1804
- const orphanCount = report.issues.filter((issue) => issue.rule === "orphan-page").length;
1805
- const missingCount = report.issues.filter((issue) => issue.rule === "missing-page").length;
1806
- appendLog("lint", `${orphanCount} orphans, ${missingCount} missing`);
1807
- return renderWikiLintReport(report);
1808
- },
1580
+ defineTool("wiki_fix", {
1581
+ description: "REMOVED: Use wiki_update instead.",
1582
+ parameters: z.object({}).passthrough(),
1583
+ handler: async () => "This tool has been removed. Use wiki_update to correct wiki pages.",
1809
1584
  }),
1810
- defineTool("wiki_rebuild_index", {
1811
- description: "Rebuild the wiki index.md from the pages on disk. Use when the index is " +
1812
- "corrupted, out of sync with pages, or after manual edits to the wiki. " +
1813
- "Safe to run anytime it preserves section assignments where possible.",
1814
- parameters: z.object({}),
1815
- handler: async () => {
1816
- return withWikiWrite(async () => {
1817
- const { rebuildIndexFromPages } = await import("../wiki/index-manager.js");
1818
- const entries = rebuildIndexFromPages();
1819
- appendLog("rebuild-index", `wiki_rebuild_index: rebuilt ${entries.length} entries from pages on disk`);
1820
- return `Rebuilt index with ${entries.length} entries.`;
1821
- });
1585
+ defineTool("wiki_append_timeline", {
1586
+ description: "Append an entry to the '## Timeline' section of a wiki page. " +
1587
+ "Creates the section (and the page itself) if absent. " +
1588
+ "Timeline is append-onlyexisting entries are never modified. " +
1589
+ "Use for recording events, source ingestion, and interaction history.",
1590
+ parameters: z.object({
1591
+ page: z.string().describe("Relative path from wiki root (e.g. 'pages/people/alice/index.md')"),
1592
+ entry: z.string().describe("Markdown text for the timeline entry"),
1593
+ source_id: z.string().optional().describe("Optional reference to a wiki_sources id"),
1594
+ }),
1595
+ handler: async (args) => {
1596
+ ensureWikiStructure();
1597
+ try {
1598
+ const entry = args.source_id
1599
+ ? `${args.entry}\n\n_Source: ${args.source_id}_`
1600
+ : args.entry;
1601
+ return await withWikiWrite(async () => {
1602
+ appendTimeline(args.page, entry);
1603
+ return `Timeline entry appended to ${args.page}`;
1604
+ });
1605
+ }
1606
+ catch (err) {
1607
+ log.error({ err: err instanceof Error ? err.message : err, page: args.page }, "wiki_append_timeline failed");
1608
+ return { error: err instanceof Error ? err.message : "wiki_append_timeline failed" };
1609
+ }
1822
1610
  },
1823
1611
  }),
1824
- defineTool("wiki_fix", {
1825
- description: "Auto-repair common wiki inconsistencies. Defaults to dry-run preview mode and returns a per-file report " +
1826
- "plus unified diff. Supports frontmatter backfill, taxonomy tag normalization, and autostub marking.",
1612
+ defineTool("wiki_ingest_source", {
1613
+ description: "Ingest an external source (URL, PDF, Git repo, or raw text) into the PKB. " +
1614
+ "Fetches and parses the content, extracts entities with LLM, creates/updates wiki pages, " +
1615
+ "and writes timeline entries. Idempotent: re-ingesting the same source returns the existing result. " +
1616
+ "Type is auto-detected if omitted.",
1827
1617
  parameters: z.object({
1828
- dry_run: z.boolean().optional().describe("Preview only when true (default). Set false to apply fixes."),
1829
- fixes: z.array(z.enum(["frontmatter-backfill", "tag-normalize", "autostub-mark"])).optional()
1830
- .describe("Optional subset of fixes to apply. Defaults to all fixes."),
1831
- path_glob: z.string().optional().describe("Optional glob to limit which wiki page paths are considered."),
1618
+ source: z.string().describe("URL, file path, git repo URL, or raw text content to ingest"),
1619
+ type: z.enum(["url", "pdf", "repo", "text"]).optional().describe("Source type. Auto-detected if omitted: http(s) URL → url/repo, .pdf path → pdf, else text"),
1620
+ topic: z.string().optional().describe("Optional hint for entity extraction focus"),
1621
+ session_id: z.string().optional().describe("Optional research session id to persist in wiki_sources"),
1622
+ session_name: z.string().optional().describe("Optional human-readable research session name"),
1832
1623
  }),
1833
1624
  handler: async (args) => {
1834
1625
  ensureWikiStructure();
1835
- const dryRun = args.dry_run ?? true;
1836
- const runFixer = () => {
1837
- const report = fixWiki({
1838
- dryRun,
1839
- fixes: args.fixes,
1840
- pathGlob: args.path_glob,
1841
- logAction: dryRun ? undefined : (type, path) => appendLog(type, path),
1626
+ try {
1627
+ const sourceType = args.type ?? detectSourceType(args.source);
1628
+ const result = await ingestSource(args.source, sourceType, args.topic, {
1629
+ sessionId: args.session_id,
1630
+ sessionName: args.session_name,
1842
1631
  });
1843
- if (!dryRun && report.changedFiles > 0) {
1844
- rebuildIndexFromPages();
1632
+ if (result.already_existed) {
1633
+ return (`Source already ingested (id: ${result.source_id}).\n` +
1634
+ `Pages previously updated: ${result.pages_updated.length > 0 ? result.pages_updated.join(", ") : "none"}`);
1845
1635
  }
1846
- return report;
1847
- };
1848
- if (dryRun) {
1849
- return runFixer();
1636
+ const lines = [
1637
+ `✅ Ingested source (id: ${result.source_id})`,
1638
+ ];
1639
+ if (result.pages_created.length > 0) {
1640
+ lines.push(`📄 Pages created (${result.pages_created.length}): ${result.pages_created.join(", ")}`);
1641
+ }
1642
+ if (result.pages_updated.length > 0) {
1643
+ lines.push(`✏️ Pages updated (${result.pages_updated.length}): ${result.pages_updated.join(", ")}`);
1644
+ }
1645
+ if (result.entities.length > 0) {
1646
+ lines.push(`🔍 Entities extracted (${result.entities.length}):`);
1647
+ for (const e of result.entities) {
1648
+ lines.push(` • ${e.name} (${e.type}) → ${e.path}`);
1649
+ }
1650
+ }
1651
+ else {
1652
+ lines.push("🔍 No entities extracted.");
1653
+ }
1654
+ return lines.join("\n");
1655
+ }
1656
+ catch (err) {
1657
+ log.error({ err: err instanceof Error ? err.message : err }, "wiki_ingest_source failed");
1658
+ return { error: err instanceof Error ? err.message : "Ingestion failed" };
1850
1659
  }
1851
- return withWikiWrite(runFixer);
1852
1660
  },
1853
1661
  }),
1854
1662
  defineTool("restart_chapterhouse", {