chapterhouse 0.6.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.
- package/agents/korg.agent.md +65 -0
- package/dist/api/agent-edit-access.js +11 -0
- package/dist/api/agents.api.test.js +48 -0
- package/dist/api/korg.js +34 -0
- package/dist/api/korg.test.js +42 -0
- package/dist/api/server.js +420 -13
- package/dist/api/server.test.js +533 -3
- package/dist/config.js +28 -0
- package/dist/config.test.js +20 -0
- package/dist/copilot/agent-event-bus.js +1 -0
- package/dist/copilot/agents.js +117 -50
- package/dist/copilot/agents.mcp-servers.test.js +87 -0
- package/dist/copilot/agents.parse.test.js +69 -0
- package/dist/copilot/agents.test.js +137 -2
- package/dist/copilot/orchestrator.js +62 -13
- package/dist/copilot/orchestrator.test.js +130 -8
- package/dist/copilot/session-manager.js +34 -0
- package/dist/copilot/system-message.js +11 -10
- package/dist/copilot/system-message.test.js +6 -1
- package/dist/copilot/tools.js +184 -376
- package/dist/copilot/tools.memory.test.js +32 -0
- package/dist/copilot/tools.wiki.test.js +53 -59
- package/dist/daemon.js +9 -0
- package/dist/memory/decisions.js +6 -5
- package/dist/memory/entities.js +20 -9
- package/dist/memory/hooks.js +151 -0
- package/dist/memory/hooks.test.js +325 -0
- package/dist/memory/hot-tier.js +37 -0
- package/dist/memory/hot-tier.test.js +30 -0
- package/dist/memory/housekeeping-scheduler.js +35 -0
- package/dist/memory/housekeeping-scheduler.test.js +50 -0
- package/dist/memory/inbox.js +10 -0
- package/dist/memory/index.js +3 -1
- package/dist/memory/migration.js +244 -0
- package/dist/memory/migration.test.js +100 -0
- package/dist/memory/reflect.js +273 -0
- package/dist/memory/reflect.test.js +254 -0
- package/dist/store/db.js +119 -4
- package/dist/store/db.test.js +19 -1
- package/dist/test/setup-env.js +3 -1
- package/dist/test/setup-env.test.js +8 -1
- package/dist/wiki/consolidation.js +641 -0
- package/dist/wiki/consolidation.test.js +140 -0
- package/dist/wiki/frontmatter.js +48 -0
- package/dist/wiki/frontmatter.test.js +42 -0
- package/dist/wiki/index-manager.js +246 -330
- package/dist/wiki/index-manager.test.js +138 -145
- package/dist/wiki/ingest.js +347 -0
- package/dist/wiki/ingest.test.js +111 -0
- package/dist/wiki/links.js +151 -0
- package/dist/wiki/links.test.js +176 -0
- package/dist/wiki/migrate-topics.test.js +16 -6
- package/dist/wiki/scheduler.js +118 -0
- package/dist/wiki/scheduler.test.js +64 -0
- package/dist/wiki/timeline.js +51 -0
- package/dist/wiki/timeline.test.js +65 -0
- package/dist/wiki/topic-structure.js +1 -1
- package/package.json +3 -1
- package/skills/pkb-ideas/SKILL.md +78 -0
- package/skills/pkb-ideas/_meta.json +4 -0
- package/skills/pkb-org/SKILL.md +82 -0
- package/skills/pkb-org/_meta.json +4 -0
- package/skills/pkb-people/SKILL.md +74 -0
- package/skills/pkb-people/_meta.json +4 -0
- package/skills/pkb-research/SKILL.md +83 -0
- package/skills/pkb-research/_meta.json +4 -0
- package/skills/pkb-source/SKILL.md +38 -0
- package/skills/pkb-source/_meta.json +4 -0
- package/skills/wiki-conventions/SKILL.md +5 -5
- package/web/dist/assets/index-5kz9aRU9.css +10 -0
- package/web/dist/assets/{index-B5oDsQ5y.js → index-BbX9RKf3.js} +101 -99
- package/web/dist/assets/index-BbX9RKf3.js.map +1 -0
- package/web/dist/index.html +2 -2
- package/dist/wiki/context.js +0 -138
- package/dist/wiki/fix.js +0 -335
- package/dist/wiki/fix.test.js +0 -350
- package/dist/wiki/lint.js +0 -451
- package/dist/wiki/lint.test.js +0 -329
- package/web/dist/assets/index-B5oDsQ5y.js.map +0 -1
- package/web/dist/assets/index-DknKAtDS.css +0 -10
package/dist/copilot/tools.js
CHANGED
|
@@ -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,
|
|
14
|
-
import { searchIndex, addToIndex,
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
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 {
|
|
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
|
-
// -----
|
|
1438
|
+
// ----- Removed legacy wiki facades kept as compatibility stubs -----
|
|
1376
1439
|
defineTool("remember", {
|
|
1377
|
-
description: "
|
|
1378
|
-
|
|
1379
|
-
|
|
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: "
|
|
1494
|
-
|
|
1495
|
-
|
|
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: "
|
|
1527
|
-
|
|
1528
|
-
|
|
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
|
-
|
|
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: "
|
|
1732
|
-
|
|
1733
|
-
|
|
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
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
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
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
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("
|
|
1798
|
-
description: "
|
|
1799
|
-
|
|
1800
|
-
|
|
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("
|
|
1811
|
-
description: "
|
|
1812
|
-
"
|
|
1813
|
-
"
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
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-only — existing 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("
|
|
1825
|
-
description: "
|
|
1826
|
-
"
|
|
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
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
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
|
-
|
|
1836
|
-
|
|
1837
|
-
const
|
|
1838
|
-
|
|
1839
|
-
|
|
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 (
|
|
1844
|
-
|
|
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
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
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", {
|