chapterhouse 0.3.26 → 0.4.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 (52) hide show
  1. package/dist/api/server.js +12 -0
  2. package/dist/api/server.test.js +39 -0
  3. package/dist/config.js +70 -0
  4. package/dist/config.test.js +109 -0
  5. package/dist/copilot/agents.js +27 -4
  6. package/dist/copilot/agents.test.js +7 -0
  7. package/dist/copilot/oneshot.js +54 -0
  8. package/dist/copilot/orchestrator.js +227 -3
  9. package/dist/copilot/orchestrator.test.js +372 -0
  10. package/dist/copilot/system-message.js +4 -0
  11. package/dist/copilot/system-message.test.js +24 -0
  12. package/dist/copilot/tools.agent.test.js +23 -0
  13. package/dist/copilot/tools.js +350 -4
  14. package/dist/copilot/tools.memory.test.js +248 -0
  15. package/dist/copilot/turn-event-log-env.test.js +19 -0
  16. package/dist/copilot/turn-event-log.js +22 -23
  17. package/dist/copilot/turn-event-log.test.js +61 -2
  18. package/dist/memory/active-scope.js +69 -0
  19. package/dist/memory/active-scope.test.js +76 -0
  20. package/dist/memory/checkpoint-prompt.js +71 -0
  21. package/dist/memory/checkpoint.js +257 -0
  22. package/dist/memory/checkpoint.test.js +255 -0
  23. package/dist/memory/decisions.js +53 -0
  24. package/dist/memory/decisions.test.js +92 -0
  25. package/dist/memory/entities.js +59 -0
  26. package/dist/memory/entities.test.js +65 -0
  27. package/dist/memory/eot.js +219 -0
  28. package/dist/memory/eot.test.js +263 -0
  29. package/dist/memory/hot-tier.js +187 -0
  30. package/dist/memory/hot-tier.test.js +197 -0
  31. package/dist/memory/housekeeping.js +352 -0
  32. package/dist/memory/housekeeping.test.js +280 -0
  33. package/dist/memory/inbox.js +73 -0
  34. package/dist/memory/index.js +11 -0
  35. package/dist/memory/observations.js +46 -0
  36. package/dist/memory/observations.test.js +86 -0
  37. package/dist/memory/recall.js +197 -0
  38. package/dist/memory/recall.test.js +196 -0
  39. package/dist/memory/scopes.js +89 -0
  40. package/dist/memory/scopes.test.js +201 -0
  41. package/dist/memory/tiering.js +193 -0
  42. package/dist/memory/types.js +2 -0
  43. package/dist/paths.js +7 -1
  44. package/dist/store/db.js +412 -8
  45. package/dist/store/db.test.js +83 -0
  46. package/dist/test/setup-env.js +16 -0
  47. package/dist/test/setup-env.test.js +4 -0
  48. package/package.json +1 -1
  49. package/web/dist/assets/{index-BRPJa1DK.js → index-DmYLALt0.js} +70 -70
  50. package/web/dist/assets/index-DmYLALt0.js.map +1 -0
  51. package/web/dist/index.html +1 -1
  52. package/web/dist/assets/index-BRPJa1DK.js.map +0 -1
@@ -7,7 +7,7 @@ import { homedir } from "os";
7
7
  import { listSkills, createSkill, removeSkill } from "./skills.js";
8
8
  import { config, persistModel } from "../config.js";
9
9
  import { agentEventBus } from "./agent-event-bus.js";
10
- import { getCurrentSourceChannel, getCurrentActivityCallback, getCurrentActiveProjectRules, getCurrentAuthenticatedUser, getLastAuthenticatedUser, getCurrentAuthorizationHeader, getCurrentSessionKey, switchSessionModel, } from "./orchestrator.js";
10
+ import { getCurrentSourceChannel, getCurrentActivityCallback, getCurrentActiveProjectRules, getCurrentAuthenticatedUser, getLastAuthenticatedUser, getCurrentAuthorizationHeader, getCurrentSessionKey, maybeScheduleScopeChangeCheckpoint, resetCheckpointSessionState, switchSessionModel, } from "./orchestrator.js";
11
11
  import { getRouterConfig, updateRouterConfig } from "./router.js";
12
12
  import { ensureWikiStructure, readPage, writePage, deletePage, writeRawSource, assertPagePath } from "../wiki/fs.js";
13
13
  import { searchIndex, addToIndex, removeFromIndex, buildIndexEntryForPage } from "../wiki/index-manager.js";
@@ -20,7 +20,8 @@ import { loadTaxonomy } from "../wiki/taxonomy.js";
20
20
  import { getCategoryDir, topicPagePath, slugify, entityCategories, FLAT_CATEGORIES } from "../wiki/topic-structure.js";
21
21
  import { withWikiWrite } from "../wiki/lock.js";
22
22
  import { readWikiPage, teamWikiSync } from "../wiki/team-sync.js";
23
- import { getAgentRegistry, getAgent, createEphemeralAgentSession, getAgentSessionStatus, getTask, registerTask, completeTask, failTask, createAgentFile, removeAgentFile, loadAgents, } from "./agents.js";
23
+ import { getAgentRegistry, getAgent, createEphemeralAgentSession, getAgentSessionStatus, getTask, registerTask, completeTask, failTask, createTaskId, createAgentFile, removeAgentFile, loadAgents, } from "./agents.js";
24
+ import * as agentsModule from "./agents.js";
24
25
  import { detectProjectRuleWarnings } from "./project-rule-warnings.js";
25
26
  import { renderDelegatedProjectRulesPreamble } from "./project-rules-injection.js";
26
27
  import { adoGetOkrs, adoOkrSummary, adoUpdateKr } from "../integrations/ado-skill.js";
@@ -28,6 +29,7 @@ import { TeamsNotifier } from "../integrations/teams-notify.js";
28
29
  import { TeamPushClient } from "../integrations/team-push.js";
29
30
  import { OKRMapper, parseOKRPageContent } from "./okr-mapper.js";
30
31
  import { childLogger } from "../util/logger.js";
32
+ import { getActiveScope as getMemoryActiveScope, getScope as getMemoryScope, inferScopeFromText, demoteToCold, demoteToWarm, queueMemoryProposal, recall as recallMemory, recordDecision, recordObservation, runHousekeeping, setActiveScope as setMemoryActiveScope, promoteToHot, upsertEntity, } from "../memory/index.js";
31
33
  const log = childLogger("tools");
32
34
  /** Escape a string for safe inclusion as a single-line YAML scalar value. */
33
35
  function yamlEscape(value) {
@@ -56,6 +58,81 @@ function isTimeoutError(err) {
56
58
  function hasAdoPat() {
57
59
  return (process.env.ADO_PAT?.trim() || config.adoPat).length > 0;
58
60
  }
61
+ function requireOrchestratorMemoryWrite() {
62
+ const agentSlug = agentsModule.getCurrentToolAgentSlug?.();
63
+ if (agentSlug && agentSlug !== "chapterhouse") {
64
+ return "Memory writes are orchestrator-only. Use memory_propose instead.";
65
+ }
66
+ return null;
67
+ }
68
+ function resolveMemoryScopeForWrite(explicitScope, content) {
69
+ const explicit = explicitScope ? getMemoryScope(explicitScope) : undefined;
70
+ if (explicitScope && !explicit) {
71
+ throw new Error(`Unknown memory scope '${explicitScope}'.`);
72
+ }
73
+ if (explicit) {
74
+ return { scopeId: explicit.id, scopeSlug: explicit.slug };
75
+ }
76
+ const active = getMemoryActiveScope();
77
+ if (active) {
78
+ return { scopeId: active.id, scopeSlug: active.slug };
79
+ }
80
+ const inferred = inferScopeFromText(content);
81
+ if (inferred) {
82
+ const inferredScope = getMemoryScope(inferred.scope_id);
83
+ if (inferredScope) {
84
+ return { scopeId: inferredScope.id, scopeSlug: inferredScope.slug };
85
+ }
86
+ }
87
+ const validScopes = getDb().prepare(`
88
+ SELECT slug
89
+ FROM mem_scopes
90
+ WHERE active = 1
91
+ ORDER BY slug
92
+ `).all();
93
+ const activeScope = getMemoryActiveScope()?.slug ?? "none";
94
+ throw new Error(`No scope inferred. Active scope: ${activeScope}. Valid scopes: [${validScopes.map((row) => row.slug).join(", ")}]. `
95
+ + "Set active scope with memory_set_scope or pass scope explicitly.");
96
+ }
97
+ const observationProposalPayloadSchema = z.object({
98
+ content: z.string(),
99
+ entity_id: z.number().int().positive().optional(),
100
+ source: z.string().optional(),
101
+ });
102
+ const decisionProposalPayloadSchema = z.object({
103
+ title: z.string(),
104
+ rationale: z.string().optional(),
105
+ decided_at: z.string().optional(),
106
+ });
107
+ const entityProposalPayloadSchema = z.object({
108
+ name: z.string(),
109
+ kind: z.string(),
110
+ summary: z.string().optional(),
111
+ });
112
+ const memoryProposeArgsSchema = z.object({
113
+ kind: z.enum(["observation", "decision", "entity"]),
114
+ scope_slug: z.string().optional(),
115
+ payload: z.record(z.string(), z.unknown()),
116
+ confidence: z.number().min(0).max(1).optional(),
117
+ reason: z.string().optional(),
118
+ }).superRefine((value, context) => {
119
+ const schema = value.kind === "observation"
120
+ ? observationProposalPayloadSchema
121
+ : value.kind === "decision"
122
+ ? decisionProposalPayloadSchema
123
+ : entityProposalPayloadSchema;
124
+ const parsed = schema.safeParse(value.payload);
125
+ if (!parsed.success) {
126
+ for (const issue of parsed.error.issues) {
127
+ context.addIssue({
128
+ code: z.ZodIssueCode.custom,
129
+ message: issue.message,
130
+ path: ["payload", ...issue.path],
131
+ });
132
+ }
133
+ }
134
+ });
135
+ const memoryTierTableSchema = z.enum(["observation", "decision", "entity"]);
59
136
  function getCurrentQuarter(now = new Date()) {
60
137
  return `${now.getUTCFullYear()}-Q${Math.floor(now.getUTCMonth() / 3) + 1}`;
61
138
  }
@@ -110,16 +187,17 @@ export function createTools(deps) {
110
187
  return `Agent '${args.agent_name}' not found. Available agents: ${available}`;
111
188
  }
112
189
  const delegatedSlug = agent.slug;
190
+ const taskId = createTaskId();
113
191
  let session;
114
192
  try {
115
193
  const allTools = createTools(deps);
116
- session = await createEphemeralAgentSession(agent.slug, deps.client, allTools, args.model_override);
194
+ session = await createEphemeralAgentSession(agent.slug, deps.client, allTools, args.model_override, undefined, taskId);
117
195
  }
118
196
  catch (err) {
119
197
  const msg = err instanceof Error ? err.message : String(err);
120
198
  return `Failed to create session for @${delegatedSlug}: ${msg}`;
121
199
  }
122
- const task = registerTask(delegatedSlug, args.summary, getCurrentSourceChannel());
200
+ const task = registerTask(delegatedSlug, args.summary, getCurrentSourceChannel(), taskId);
123
201
  const activeProjectRules = getCurrentActiveProjectRules();
124
202
  const warningLines = activeProjectRules
125
203
  ? detectProjectRuleWarnings(args.task, activeProjectRules.rules.hard)
@@ -689,6 +767,274 @@ export function createTools(deps) {
689
767
  return `Auto-routing disabled. Using fixed model: ${config.copilotModel}`;
690
768
  },
691
769
  }),
770
+ defineTool("memory_remember", {
771
+ description: "Write scoped agent memory (observation or decision) into the SQLite memory store. " +
772
+ "Use this for the new agent-memory system, not the legacy wiki-backed remember tool.",
773
+ parameters: z.object({
774
+ content: z.string().describe("Observation content or decision rationale."),
775
+ scope: z.string().optional().describe("Optional memory scope slug. Defaults to active scope, then keyword inference."),
776
+ kind: z.enum(["observation", "decision"]).optional().describe("Memory entry kind. Defaults to observation."),
777
+ entity_name: z.string().optional().describe("Optional entity name to attach. Auto-upserts the entity if provided."),
778
+ entity_kind: z.string().optional().describe("Required when entity_name is provided."),
779
+ title: z.string().optional().describe("Required for decision entries."),
780
+ decided_at: z.string().optional().describe("Decision date. Defaults to today."),
781
+ tier: z.enum(["hot", "warm", "cold"]).optional().describe("Storage tier. Defaults to warm."),
782
+ }),
783
+ handler: async (args) => {
784
+ const denied = requireOrchestratorMemoryWrite();
785
+ if (denied)
786
+ return denied;
787
+ if (args.entity_name && !args.entity_kind) {
788
+ return "entity_kind is required when entity_name is provided.";
789
+ }
790
+ try {
791
+ const { scopeId, scopeSlug } = resolveMemoryScopeForWrite(args.scope, args.content);
792
+ const kind = args.kind ?? "observation";
793
+ const entity = args.entity_name
794
+ ? upsertEntity({
795
+ scope_id: scopeId,
796
+ kind: args.entity_kind,
797
+ name: args.entity_name,
798
+ tier: args.tier ?? "warm",
799
+ })
800
+ : undefined;
801
+ if (kind === "decision") {
802
+ if (!args.title) {
803
+ return "title is required when kind='decision'.";
804
+ }
805
+ const decision = recordDecision({
806
+ scope_id: scopeId,
807
+ entity_id: entity?.id,
808
+ title: args.title,
809
+ rationale: args.content,
810
+ decided_at: args.decided_at ?? new Date().toISOString().slice(0, 10),
811
+ tier: args.tier ?? "warm",
812
+ });
813
+ return {
814
+ ok: true,
815
+ id: decision.id,
816
+ scope: scopeSlug,
817
+ kind,
818
+ entity_id: entity?.id,
819
+ };
820
+ }
821
+ const observation = recordObservation({
822
+ scope_id: scopeId,
823
+ entity_id: entity?.id,
824
+ content: args.content,
825
+ source: `agent:${agentsModule.getCurrentToolAgentSlug?.() ?? "chapterhouse"}`,
826
+ tier: args.tier ?? "warm",
827
+ });
828
+ return {
829
+ ok: true,
830
+ id: observation.id,
831
+ scope: scopeSlug,
832
+ kind,
833
+ entity_id: entity?.id,
834
+ };
835
+ }
836
+ catch (err) {
837
+ return err instanceof Error ? err.message : String(err);
838
+ }
839
+ },
840
+ }),
841
+ defineTool("memory_propose", {
842
+ description: "Queue a proposed scoped memory item for orchestrator review at end-of-task. " +
843
+ "Available to all agents; writes land in mem_inbox as pending proposals.",
844
+ parameters: memoryProposeArgsSchema,
845
+ handler: async (args) => {
846
+ try {
847
+ const parsedArgs = memoryProposeArgsSchema.parse(args);
848
+ const payload = parsedArgs.kind === "observation"
849
+ ? observationProposalPayloadSchema.parse(parsedArgs.payload)
850
+ : parsedArgs.kind === "decision"
851
+ ? decisionProposalPayloadSchema.parse(parsedArgs.payload)
852
+ : entityProposalPayloadSchema.parse(parsedArgs.payload);
853
+ const proposal = queueMemoryProposal({
854
+ kind: parsedArgs.kind,
855
+ scopeSlug: parsedArgs.scope_slug,
856
+ payload,
857
+ confidence: parsedArgs.confidence ?? 0.5,
858
+ reason: parsedArgs.reason,
859
+ sourceAgent: agentsModule.getCurrentToolAgentSlug?.() ?? "chapterhouse",
860
+ sourceTaskId: agentsModule.getCurrentToolTaskId?.(),
861
+ });
862
+ return {
863
+ proposal_id: proposal.id,
864
+ status: "queued",
865
+ };
866
+ }
867
+ catch (err) {
868
+ if (err instanceof z.ZodError) {
869
+ return err.issues.map((issue) => issue.message).join("; ");
870
+ }
871
+ return err instanceof Error ? err.message : String(err);
872
+ }
873
+ },
874
+ }),
875
+ defineTool("memory_recall", {
876
+ description: "Search scoped agent memory with FTS-backed recall. Use this for the new agent-memory store, not the wiki-backed recall tool.",
877
+ parameters: z.object({
878
+ query: z.string().describe("Query text to search for."),
879
+ scope: z.string().optional().describe("Optional scope slug. Defaults to the active scope when available."),
880
+ kinds: z.array(z.enum(["observation", "decision", "entity"])).optional()
881
+ .describe("Optional filter for memory entry kinds."),
882
+ limit: z.number().int().positive().optional().describe("Maximum number of ranked hits to return. Defaults to 10."),
883
+ includeSuperseded: z.boolean().optional().describe("Include superseded observations and decisions. Defaults to false."),
884
+ includeArchived: z.boolean().optional().describe("Include archived observations and decisions. Defaults to false."),
885
+ includeCold: z.boolean().optional().describe("Include cold-tier rows. Defaults to false."),
886
+ }),
887
+ handler: async (args) => {
888
+ const requestedScope = args.scope ? getMemoryScope(args.scope) : undefined;
889
+ if (args.scope && !requestedScope) {
890
+ return `Unknown memory scope '${args.scope}'.`;
891
+ }
892
+ const result = recallMemory({
893
+ query: args.query,
894
+ scope_id: requestedScope?.id,
895
+ kinds: args.kinds,
896
+ limit: args.limit ?? 10,
897
+ includeSuperseded: args.includeSuperseded,
898
+ includeArchived: args.includeArchived,
899
+ includeCold: args.includeCold,
900
+ });
901
+ return {
902
+ active_scope: result.activeScope
903
+ ? { slug: result.activeScope.slug, title: result.activeScope.title }
904
+ : null,
905
+ hot_tier: result.hotTier,
906
+ hits: result.hits.map((hit) => ({
907
+ kind: hit.kind,
908
+ id: hit.id,
909
+ scope: hit.scope,
910
+ content: hit.content,
911
+ decided_at: hit.decidedAt,
912
+ score: hit.score,
913
+ snippet: hit.snippet,
914
+ })),
915
+ };
916
+ },
917
+ }),
918
+ defineTool("memory_housekeep", {
919
+ description: "Run the scoped agent-memory housekeeping pipeline. Orchestrator-only write-tier maintenance tool.",
920
+ parameters: z.object({
921
+ scope_slug: z.string().optional().describe("Optional scope slug. Defaults to the active scope."),
922
+ all_scopes: z.boolean().optional().describe("Run scoped passes for all active scopes instead of one scope."),
923
+ passes: z.array(z.string()).optional().describe("Optional pass names: dedup_observations, dedup_decisions, orphan_cleanup, decay, compact_inbox."),
924
+ }),
925
+ handler: async (args) => {
926
+ const denied = requireOrchestratorMemoryWrite();
927
+ if (denied)
928
+ return denied;
929
+ if (args.scope_slug && args.all_scopes) {
930
+ return "Pass either scope_slug or all_scopes, not both.";
931
+ }
932
+ try {
933
+ const requestedScope = args.scope_slug ? getMemoryScope(args.scope_slug) : undefined;
934
+ if (args.scope_slug && !requestedScope) {
935
+ return `Unknown memory scope '${args.scope_slug}'.`;
936
+ }
937
+ const result = runHousekeeping({
938
+ scopeIds: requestedScope ? [requestedScope.id] : undefined,
939
+ allScopes: args.all_scopes,
940
+ passes: args.passes,
941
+ });
942
+ return {
943
+ ok: result.summaries.every((summary) => summary.errors.length === 0),
944
+ scope_ids: result.scopeIds,
945
+ total_examined: result.totalExamined,
946
+ total_modified: result.totalModified,
947
+ duration_ms: result.durationMs,
948
+ summaries: result.summaries,
949
+ };
950
+ }
951
+ catch (err) {
952
+ return err instanceof Error ? err.message : String(err);
953
+ }
954
+ },
955
+ }),
956
+ defineTool("memory_promote", {
957
+ description: "Promote a memory row to the hot tier. Orchestrator-only manual override.",
958
+ parameters: z.object({
959
+ table: memoryTierTableSchema,
960
+ id: z.number().int().positive(),
961
+ reason: z.string().min(1),
962
+ }),
963
+ handler: async (args) => {
964
+ const denied = requireOrchestratorMemoryWrite();
965
+ if (denied)
966
+ return denied;
967
+ try {
968
+ promoteToHot(args.table, args.id, args.reason);
969
+ return { ok: true, table: args.table, id: args.id, tier: "hot" };
970
+ }
971
+ catch (err) {
972
+ return err instanceof Error ? err.message : String(err);
973
+ }
974
+ },
975
+ }),
976
+ defineTool("memory_demote", {
977
+ description: "Demote a memory row to warm or cold tier. Orchestrator-only manual override.",
978
+ parameters: z.object({
979
+ table: memoryTierTableSchema,
980
+ id: z.number().int().positive(),
981
+ reason: z.string().min(1),
982
+ tier: z.enum(["warm", "cold"]).optional().describe("Target demotion tier. Defaults to warm."),
983
+ }),
984
+ handler: async (args) => {
985
+ const denied = requireOrchestratorMemoryWrite();
986
+ if (denied)
987
+ return denied;
988
+ try {
989
+ if (args.tier === "cold") {
990
+ demoteToCold(args.table, args.id, args.reason);
991
+ return { ok: true, table: args.table, id: args.id, tier: "cold" };
992
+ }
993
+ demoteToWarm(args.table, args.id, args.reason);
994
+ return { ok: true, table: args.table, id: args.id, tier: "warm" };
995
+ }
996
+ catch (err) {
997
+ return err instanceof Error ? err.message : String(err);
998
+ }
999
+ },
1000
+ }),
1001
+ defineTool("memory_set_scope", {
1002
+ description: "Set or clear the active scope for the agent-memory system. This affects default routing for memory_remember and memory_recall.",
1003
+ parameters: z.object({
1004
+ slug: z.string().nullable().describe("Scope slug to activate, or null to clear the active scope."),
1005
+ }),
1006
+ handler: async (args) => {
1007
+ const denied = requireOrchestratorMemoryWrite();
1008
+ if (denied)
1009
+ return denied;
1010
+ try {
1011
+ const previousScope = getMemoryActiveScope();
1012
+ const nextScope = args.slug === null ? null : (getMemoryScope(args.slug) ?? null);
1013
+ if (args.slug !== null && !nextScope) {
1014
+ return `Unknown memory scope '${args.slug}'.`;
1015
+ }
1016
+ const previousSlug = previousScope?.slug ?? null;
1017
+ const nextSlug = nextScope?.slug ?? null;
1018
+ const didChange = previousSlug !== nextSlug;
1019
+ const sessionKey = getCurrentSessionKey();
1020
+ if (didChange) {
1021
+ maybeScheduleScopeChangeCheckpoint(sessionKey, previousScope, nextScope);
1022
+ }
1023
+ const activeScope = setMemoryActiveScope(args.slug);
1024
+ if (didChange) {
1025
+ resetCheckpointSessionState(sessionKey);
1026
+ }
1027
+ return {
1028
+ active_scope: activeScope
1029
+ ? { slug: activeScope.slug, title: activeScope.title }
1030
+ : null,
1031
+ };
1032
+ }
1033
+ catch (err) {
1034
+ return err instanceof Error ? err.message : String(err);
1035
+ }
1036
+ },
1037
+ }),
692
1038
  // ----- Wiki-backed memory facades (preserve existing remember/recall/forget UX) -----
693
1039
  defineTool("remember", {
694
1040
  description: "Save a fact, preference, or detail to the wiki. Routes to topic pages automatically. " +
@@ -0,0 +1,248 @@
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 loadModules() {
7
+ const nonce = `${Date.now()}-${Math.random()}`;
8
+ const toolsModule = await import(new URL(`./tools.js?case=${nonce}`, import.meta.url).href);
9
+ const agentsModule = await import(new URL("./agents.js", import.meta.url).href);
10
+ const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
11
+ return { toolsModule, agentsModule, dbModule };
12
+ }
13
+ function findTool(tools, name) {
14
+ const tool = tools.find((entry) => entry.name === name);
15
+ assert.ok(tool, `${name} tool should be registered`);
16
+ return tool;
17
+ }
18
+ test.beforeEach(() => {
19
+ process.env.CHAPTERHOUSE_HOME = mkdtempSync(join(tmpdir(), "chapterhouse-tools-memory-"));
20
+ });
21
+ test.afterEach(async () => {
22
+ const home = process.env.CHAPTERHOUSE_HOME;
23
+ if (home) {
24
+ const dbModule = await import("../store/db.js");
25
+ dbModule.closeDb();
26
+ rmSync(home, { recursive: true, force: true });
27
+ }
28
+ });
29
+ test("memory tools remember, recall, set scope, and enforce orchestrator-only writes", async () => {
30
+ const { toolsModule, agentsModule, dbModule } = await loadModules();
31
+ const tools = toolsModule.createTools({
32
+ client: { async listModels() { return []; } },
33
+ onAgentTaskComplete: () => { },
34
+ });
35
+ const memoryRemember = findTool(tools, "memory_remember");
36
+ const memoryRecall = findTool(tools, "memory_recall");
37
+ const memorySetScope = findTool(tools, "memory_set_scope");
38
+ const bindToolsToAgent = agentsModule.bindToolsToAgent;
39
+ assert.equal(typeof bindToolsToAgent, "function", "bindToolsToAgent should be exported");
40
+ const chapterhouseTools = bindToolsToAgent("chapterhouse", tools);
41
+ const coderTools = bindToolsToAgent("coder", tools);
42
+ const chapterhouseRemember = findTool(chapterhouseTools, "memory_remember");
43
+ const chapterhouseRecall = findTool(chapterhouseTools, "memory_recall");
44
+ const chapterhouseSetScope = findTool(chapterhouseTools, "memory_set_scope");
45
+ const remembered = await chapterhouseRemember.handler({
46
+ content: "Use SQLite FTS5 to recall scoped memory entries.",
47
+ scope: "chapterhouse",
48
+ kind: "observation",
49
+ }, {});
50
+ assert.equal(typeof remembered, "object");
51
+ assert.equal(remembered.ok, true);
52
+ const db = dbModule.getDb();
53
+ const row = db.prepare(`
54
+ SELECT id, content
55
+ FROM mem_observations
56
+ WHERE content = ?
57
+ `).get("Use SQLite FTS5 to recall scoped memory entries.");
58
+ assert.ok(row, "memory_remember should write an observation row");
59
+ const ftsHits = db.prepare(`
60
+ SELECT rowid
61
+ FROM mem_observations_fts
62
+ WHERE mem_observations_fts MATCH 'SQLite'
63
+ `).all();
64
+ assert.equal(ftsHits.some((hit) => hit.rowid === row.id), true);
65
+ const recalled = await chapterhouseRecall.handler({
66
+ query: "SQLite FTS5 scoped memory",
67
+ scope: "chapterhouse",
68
+ limit: 10,
69
+ }, {});
70
+ assert.equal(typeof recalled, "object");
71
+ assert.equal((recalled.hits ?? []).some((hit) => hit.id === row.id), true);
72
+ const scopeResult = await chapterhouseSetScope.handler({ slug: "chapterhouse" }, {});
73
+ assert.equal(scopeResult.active_scope?.slug, "chapterhouse");
74
+ const implicitScopeRemember = await chapterhouseRemember.handler({
75
+ content: "Implicit active-scope writes should route to chapterhouse.",
76
+ kind: "observation",
77
+ }, {});
78
+ assert.equal(implicitScopeRemember.ok, true);
79
+ const coderVisibleTools = agentsModule.filterToolsForAgent({
80
+ slug: "coder",
81
+ name: "Coder",
82
+ description: "Software engineer",
83
+ model: "gpt-5.4",
84
+ systemMessage: "test",
85
+ }, tools);
86
+ assert.equal(coderVisibleTools.some((tool) => tool.name === "memory_remember"), false);
87
+ assert.equal(coderVisibleTools.some((tool) => tool.name === "memory_set_scope"), false);
88
+ assert.equal(coderVisibleTools.some((tool) => tool.name === "memory_recall"), true);
89
+ const coderRemember = findTool(coderTools, "memory_remember");
90
+ const rejected = await coderRemember.handler({
91
+ content: "Subagents should not be able to write memory directly.",
92
+ scope: "chapterhouse",
93
+ kind: "observation",
94
+ }, {});
95
+ assert.match(String(rejected), /orchestrator-only|memory_propose/i);
96
+ const coderRecall = findTool(coderVisibleTools, "memory_recall");
97
+ const coderRecalled = await coderRecall.handler({
98
+ query: "SQLite",
99
+ scope: "chapterhouse",
100
+ limit: 10,
101
+ }, {});
102
+ assert.equal(typeof coderRecalled, "object");
103
+ assert.equal((coderRecalled.hits ?? []).some((hit) => hit.id === row.id), true);
104
+ assert.ok(memoryRemember && memoryRecall && memorySetScope);
105
+ });
106
+ test("memory_propose queues pending proposals, defaults scope from the active scope, and captures delegated task context", async () => {
107
+ const { toolsModule, agentsModule, dbModule } = await loadModules();
108
+ const tools = toolsModule.createTools({
109
+ client: { async listModels() { return []; } },
110
+ onAgentTaskComplete: () => { },
111
+ });
112
+ const bindToolsToAgent = agentsModule.bindToolsToAgent;
113
+ assert.equal(typeof bindToolsToAgent, "function", "bindToolsToAgent should be exported");
114
+ const chapterhouseTools = bindToolsToAgent("chapterhouse", tools);
115
+ const coderTools = bindToolsToAgent("coder", tools, "task-propose-001");
116
+ const memorySetScope = findTool(chapterhouseTools, "memory_set_scope");
117
+ const memoryPropose = findTool(coderTools, "memory_propose");
118
+ await memorySetScope.handler({ slug: "chapterhouse" }, {});
119
+ const proposed = await memoryPropose.handler({
120
+ kind: "observation",
121
+ payload: {
122
+ content: "Subagents can queue durable observations for orchestrator review.",
123
+ source: "final task summary",
124
+ },
125
+ confidence: 0.9,
126
+ reason: "The user explicitly asked for the new proposal path.",
127
+ }, {});
128
+ assert.equal(proposed.status, "queued");
129
+ assert.equal(typeof proposed.proposal_id, "number");
130
+ const db = dbModule.getDb();
131
+ const row = db.prepare(`
132
+ SELECT kind, status, source_agent, source_task_id, payload
133
+ FROM mem_inbox
134
+ WHERE id = ?
135
+ `).get(proposed.proposal_id);
136
+ assert.ok(row, "memory_propose should insert a mem_inbox row");
137
+ assert.equal(row.kind, "memory_proposal");
138
+ assert.equal(row.status, "pending");
139
+ assert.equal(row.source_agent, "coder");
140
+ assert.equal(row.source_task_id, "task-propose-001");
141
+ const payload = JSON.parse(row.payload);
142
+ assert.equal(payload.kind, "observation");
143
+ assert.equal(payload.scope_slug, "chapterhouse");
144
+ assert.equal(payload.confidence, 0.9);
145
+ assert.equal(payload.reason, "The user explicitly asked for the new proposal path.");
146
+ assert.equal(payload.payload.content, "Subagents can queue durable observations for orchestrator review.");
147
+ });
148
+ test("memory_propose rejects invalid proposal kinds", async () => {
149
+ const { toolsModule } = await loadModules();
150
+ const tools = toolsModule.createTools({
151
+ client: { async listModels() { return []; } },
152
+ onAgentTaskComplete: () => { },
153
+ });
154
+ const memoryPropose = findTool(tools, "memory_propose");
155
+ const result = await memoryPropose.handler({
156
+ kind: "pattern",
157
+ payload: { content: "invalid kind" },
158
+ }, {});
159
+ assert.match(String(result), /observation|decision|entity/i);
160
+ });
161
+ test("memory_housekeep is orchestrator-only and returns housekeeping summaries", async () => {
162
+ const { toolsModule, agentsModule, dbModule } = await loadModules();
163
+ const tools = toolsModule.createTools({
164
+ client: { async listModels() { return []; } },
165
+ onAgentTaskComplete: () => { },
166
+ });
167
+ const bindToolsToAgent = agentsModule.bindToolsToAgent;
168
+ assert.equal(typeof bindToolsToAgent, "function", "bindToolsToAgent should be exported");
169
+ const chapterhouseTools = bindToolsToAgent("chapterhouse", tools);
170
+ const coderTools = bindToolsToAgent("coder", tools);
171
+ const memorySetScope = findTool(chapterhouseTools, "memory_set_scope");
172
+ const memoryRemember = findTool(chapterhouseTools, "memory_remember");
173
+ const memoryHousekeep = findTool(chapterhouseTools, "memory_housekeep");
174
+ const coderHousekeep = findTool(coderTools, "memory_housekeep");
175
+ await memorySetScope.handler({ slug: "chapterhouse" }, {});
176
+ const remembered = await memoryRemember.handler({
177
+ content: "Very old low-confidence memory should be archived by housekeeping.",
178
+ scope: "chapterhouse",
179
+ kind: "observation",
180
+ }, {});
181
+ const observationId = remembered.id;
182
+ const db = dbModule.getDb();
183
+ db.prepare(`
184
+ UPDATE mem_observations
185
+ SET confidence = 0.1, created_at = datetime('now', '-31 days')
186
+ WHERE id = ?
187
+ `).run(observationId);
188
+ const denied = await coderHousekeep.handler({ passes: ["decay"] }, {});
189
+ assert.match(String(denied), /orchestrator-only/i);
190
+ const visibleToCoder = agentsModule.filterToolsForAgent({
191
+ slug: "coder",
192
+ name: "Coder",
193
+ description: "Software engineer",
194
+ model: "gpt-5.4",
195
+ systemMessage: "test",
196
+ }, tools);
197
+ assert.equal(visibleToCoder.some((tool) => tool.name === "memory_housekeep"), false);
198
+ const result = await memoryHousekeep.handler({
199
+ scope_slug: "chapterhouse",
200
+ passes: ["decay"],
201
+ }, {});
202
+ assert.equal(result.ok, true);
203
+ assert.deepEqual(result.scope_ids.length, 1);
204
+ assert.equal(result.summaries[0]?.pass, "decayPass");
205
+ assert.equal(result.summaries[0]?.modified, 1);
206
+ });
207
+ test("memory_promote and memory_demote are orchestrator-only manual tier controls", async () => {
208
+ const { toolsModule, agentsModule, dbModule } = await loadModules();
209
+ const tools = toolsModule.createTools({
210
+ client: { async listModels() { return []; } },
211
+ onAgentTaskComplete: () => { },
212
+ });
213
+ const bindToolsToAgent = agentsModule.bindToolsToAgent;
214
+ assert.equal(typeof bindToolsToAgent, "function", "bindToolsToAgent should be exported");
215
+ const chapterhouseTools = bindToolsToAgent("chapterhouse", tools);
216
+ const coderTools = bindToolsToAgent("coder", tools);
217
+ const memoryRemember = findTool(chapterhouseTools, "memory_remember");
218
+ const memoryPromote = findTool(chapterhouseTools, "memory_promote");
219
+ const memoryDemote = findTool(chapterhouseTools, "memory_demote");
220
+ const coderPromote = findTool(coderTools, "memory_promote");
221
+ const coderDemote = findTool(coderTools, "memory_demote");
222
+ const remembered = await memoryRemember.handler({
223
+ content: "Manual tier controls should change this observation.",
224
+ scope: "chapterhouse",
225
+ kind: "observation",
226
+ }, {});
227
+ const observationId = remembered.id;
228
+ const db = dbModule.getDb();
229
+ assert.equal(db.prepare(`SELECT tier FROM mem_observations WHERE id = ?`).get(observationId).tier, "warm");
230
+ const deniedPromote = await coderPromote.handler({ table: "observation", id: observationId, reason: "test" }, {});
231
+ const deniedDemote = await coderDemote.handler({ table: "observation", id: observationId, reason: "test" }, {});
232
+ assert.match(String(deniedPromote), /orchestrator-only/i);
233
+ assert.match(String(deniedDemote), /orchestrator-only/i);
234
+ assert.equal(agentsModule.filterToolsForAgent({
235
+ slug: "coder",
236
+ name: "Coder",
237
+ description: "Software engineer",
238
+ model: "gpt-5.4",
239
+ systemMessage: "test",
240
+ }, tools).some((tool) => tool.name === "memory_promote" || tool.name === "memory_demote"), false);
241
+ const promoted = await memoryPromote.handler({ table: "observation", id: observationId, reason: "actively relevant" }, {});
242
+ assert.equal(promoted.ok, true);
243
+ assert.equal(db.prepare(`SELECT tier FROM mem_observations WHERE id = ?`).get(observationId).tier, "hot");
244
+ const demoted = await memoryDemote.handler({ table: "observation", id: observationId, reason: "no longer active", tier: "cold" }, {});
245
+ assert.equal(demoted.ok, true);
246
+ assert.equal(db.prepare(`SELECT tier FROM mem_observations WHERE id = ?`).get(observationId).tier, "cold");
247
+ });
248
+ //# sourceMappingURL=tools.memory.test.js.map
@@ -0,0 +1,19 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ test("SESSION_BUFFER_CAPACITY respects CHAPTERHOUSE_SSE_BUFFER_CAPACITY", async () => {
4
+ const previous = process.env.CHAPTERHOUSE_SSE_BUFFER_CAPACITY;
5
+ process.env.CHAPTERHOUSE_SSE_BUFFER_CAPACITY = "3";
6
+ try {
7
+ const module = await import(`./turn-event-log.js?capacity=${Date.now()}`);
8
+ assert.equal(module.SESSION_BUFFER_CAPACITY, 3);
9
+ }
10
+ finally {
11
+ if (previous === undefined) {
12
+ delete process.env.CHAPTERHOUSE_SSE_BUFFER_CAPACITY;
13
+ }
14
+ else {
15
+ process.env.CHAPTERHOUSE_SSE_BUFFER_CAPACITY = previous;
16
+ }
17
+ }
18
+ });
19
+ //# sourceMappingURL=turn-event-log-env.test.js.map