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.
- package/dist/api/server.js +12 -0
- package/dist/api/server.test.js +39 -0
- package/dist/config.js +70 -0
- package/dist/config.test.js +109 -0
- package/dist/copilot/agents.js +27 -4
- package/dist/copilot/agents.test.js +7 -0
- package/dist/copilot/oneshot.js +54 -0
- package/dist/copilot/orchestrator.js +227 -3
- package/dist/copilot/orchestrator.test.js +372 -0
- package/dist/copilot/system-message.js +4 -0
- package/dist/copilot/system-message.test.js +24 -0
- package/dist/copilot/tools.agent.test.js +23 -0
- package/dist/copilot/tools.js +350 -4
- package/dist/copilot/tools.memory.test.js +248 -0
- package/dist/copilot/turn-event-log-env.test.js +19 -0
- package/dist/copilot/turn-event-log.js +22 -23
- package/dist/copilot/turn-event-log.test.js +61 -2
- package/dist/memory/active-scope.js +69 -0
- package/dist/memory/active-scope.test.js +76 -0
- package/dist/memory/checkpoint-prompt.js +71 -0
- package/dist/memory/checkpoint.js +257 -0
- package/dist/memory/checkpoint.test.js +255 -0
- package/dist/memory/decisions.js +53 -0
- package/dist/memory/decisions.test.js +92 -0
- package/dist/memory/entities.js +59 -0
- package/dist/memory/entities.test.js +65 -0
- package/dist/memory/eot.js +219 -0
- package/dist/memory/eot.test.js +263 -0
- package/dist/memory/hot-tier.js +187 -0
- package/dist/memory/hot-tier.test.js +197 -0
- package/dist/memory/housekeeping.js +352 -0
- package/dist/memory/housekeeping.test.js +280 -0
- package/dist/memory/inbox.js +73 -0
- package/dist/memory/index.js +11 -0
- package/dist/memory/observations.js +46 -0
- package/dist/memory/observations.test.js +86 -0
- package/dist/memory/recall.js +197 -0
- package/dist/memory/recall.test.js +196 -0
- package/dist/memory/scopes.js +89 -0
- package/dist/memory/scopes.test.js +201 -0
- package/dist/memory/tiering.js +193 -0
- package/dist/memory/types.js +2 -0
- package/dist/paths.js +7 -1
- package/dist/store/db.js +412 -8
- package/dist/store/db.test.js +83 -0
- package/dist/test/setup-env.js +16 -0
- package/dist/test/setup-env.test.js +4 -0
- package/package.json +1 -1
- package/web/dist/assets/{index-BRPJa1DK.js → index-DmYLALt0.js} +70 -70
- package/web/dist/assets/index-DmYLALt0.js.map +1 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-BRPJa1DK.js.map +0 -1
package/dist/copilot/tools.js
CHANGED
|
@@ -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
|