chapterhouse 0.4.2 → 0.5.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/bellonda.agent.md +11 -0
- package/agents/hwi-noree.agent.md +12 -0
- package/dist/api/server.js +39 -2
- package/dist/api/server.test.js +20 -0
- package/dist/api/turn-sse.integration.test.js +12 -0
- package/dist/copilot/agents.js +16 -4
- package/dist/copilot/agents.test.js +43 -1
- package/dist/copilot/orchestrator.js +173 -32
- package/dist/copilot/orchestrator.test.js +236 -20
- package/dist/copilot/session-manager.js +11 -2
- package/dist/copilot/session-manager.test.js +25 -0
- package/dist/copilot/tools.agent.test.js +52 -4
- package/dist/copilot/tools.js +265 -18
- package/dist/copilot/tools.memory.test.js +175 -2
- package/dist/daemon.js +6 -0
- package/dist/memory/action-items.js +100 -0
- package/dist/memory/action-items.test.js +83 -0
- package/dist/memory/active-scope.js +9 -0
- package/dist/memory/eot.js +28 -3
- package/dist/memory/eot.test.js +108 -0
- package/dist/memory/hot-tier.js +60 -1
- package/dist/memory/hot-tier.test.js +38 -0
- package/dist/memory/housekeeping-scheduler.js +152 -0
- package/dist/memory/housekeeping-scheduler.test.js +187 -0
- package/dist/memory/index.js +2 -1
- package/dist/memory/recall.js +59 -0
- package/dist/memory/recall.test.js +27 -0
- package/dist/memory/tiering.js +33 -3
- package/dist/store/db.js +130 -17
- package/dist/store/db.test.js +61 -5
- package/package.json +1 -1
- package/web/dist/assets/{index-B_cCSHan.js → index-BfHqP3-C.js} +87 -87
- package/web/dist/assets/{index-B_cCSHan.js.map → index-BfHqP3-C.js.map} +1 -1
- package/web/dist/assets/index-_O6AoWOS.css +10 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-DhY5yWmC.css +0 -10
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { getDb } from "../store/db.js";
|
|
2
|
+
function toActionItem(row) {
|
|
3
|
+
return {
|
|
4
|
+
id: row.id,
|
|
5
|
+
scopeId: row.scope_id,
|
|
6
|
+
entityId: row.entity_id ?? undefined,
|
|
7
|
+
title: row.title,
|
|
8
|
+
detail: row.detail ?? undefined,
|
|
9
|
+
status: row.status,
|
|
10
|
+
dueAt: row.due_at ?? undefined,
|
|
11
|
+
snoozeUntil: row.snooze_until ?? undefined,
|
|
12
|
+
source: row.source ?? undefined,
|
|
13
|
+
tier: row.tier,
|
|
14
|
+
tierPinnedAt: row.tier_pinned_at ?? undefined,
|
|
15
|
+
tierReason: row.tier_reason ?? undefined,
|
|
16
|
+
lastRecalledAt: row.last_recalled_at ?? undefined,
|
|
17
|
+
createdAt: row.created_at,
|
|
18
|
+
updatedAt: row.updated_at,
|
|
19
|
+
resolvedAt: row.resolved_at ?? undefined,
|
|
20
|
+
resolutionReason: row.resolution_reason ?? undefined,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const ACTION_ITEM_COLUMNS = `
|
|
24
|
+
id, scope_id, entity_id, title, detail, status, due_at, snooze_until, source,
|
|
25
|
+
created_at, updated_at, resolved_at, resolution_reason, tier, tier_pinned_at,
|
|
26
|
+
tier_reason, last_recalled_at
|
|
27
|
+
`;
|
|
28
|
+
export function recordActionItem(input) {
|
|
29
|
+
const result = getDb().prepare(`
|
|
30
|
+
INSERT INTO mem_action_items (
|
|
31
|
+
scope_id, entity_id, title, detail, due_at, source, tier, created_at, updated_at
|
|
32
|
+
)
|
|
33
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
|
34
|
+
`).run(input.scope_id, input.entity_id ?? null, input.title, input.detail ?? null, input.due_at ?? null, input.source ?? null, input.tier ?? "warm");
|
|
35
|
+
return getActionItem(Number(result.lastInsertRowid));
|
|
36
|
+
}
|
|
37
|
+
export function getActionItem(id) {
|
|
38
|
+
const row = getDb().prepare(`
|
|
39
|
+
SELECT ${ACTION_ITEM_COLUMNS}
|
|
40
|
+
FROM mem_action_items
|
|
41
|
+
WHERE id = ?
|
|
42
|
+
`).get(id);
|
|
43
|
+
return row ? toActionItem(row) : undefined;
|
|
44
|
+
}
|
|
45
|
+
export function listActionItems(input = {}) {
|
|
46
|
+
const rows = getDb().prepare(`
|
|
47
|
+
SELECT ${ACTION_ITEM_COLUMNS}
|
|
48
|
+
FROM mem_action_items
|
|
49
|
+
WHERE (? IS NULL OR scope_id = ?)
|
|
50
|
+
AND (
|
|
51
|
+
? IS NOT NULL
|
|
52
|
+
OR status = 'open'
|
|
53
|
+
OR (status = 'snoozed' AND snooze_until IS NOT NULL AND datetime(snooze_until) <= datetime('now'))
|
|
54
|
+
)
|
|
55
|
+
AND (? IS NULL OR status = ?)
|
|
56
|
+
AND (? IS NULL OR due_at IS NOT NULL AND datetime(due_at) <= datetime(?))
|
|
57
|
+
AND (? = 1 OR tier != 'cold')
|
|
58
|
+
ORDER BY
|
|
59
|
+
CASE WHEN due_at IS NULL THEN 1 ELSE 0 END ASC,
|
|
60
|
+
datetime(due_at) ASC,
|
|
61
|
+
datetime(created_at) DESC,
|
|
62
|
+
id DESC
|
|
63
|
+
LIMIT ? OFFSET ?
|
|
64
|
+
`).all(input.scope_id ?? null, input.scope_id ?? null, input.status ?? null, input.status ?? null, input.status ?? null, input.due_before ?? null, input.due_before ?? null, input.includeArchived ? 1 : 0, input.limit ?? 50, input.offset ?? 0);
|
|
65
|
+
return rows.map(toActionItem);
|
|
66
|
+
}
|
|
67
|
+
function resolveActionItem(id, status, reason) {
|
|
68
|
+
const result = getDb().prepare(`
|
|
69
|
+
UPDATE mem_action_items
|
|
70
|
+
SET status = ?,
|
|
71
|
+
resolved_at = CURRENT_TIMESTAMP,
|
|
72
|
+
resolution_reason = ?,
|
|
73
|
+
updated_at = CURRENT_TIMESTAMP
|
|
74
|
+
WHERE id = ?
|
|
75
|
+
`).run(status, reason ?? null, id);
|
|
76
|
+
if (result.changes === 0) {
|
|
77
|
+
throw new Error(`Unknown action item id '${id}'.`);
|
|
78
|
+
}
|
|
79
|
+
return getActionItem(id);
|
|
80
|
+
}
|
|
81
|
+
export function completeActionItem(id, resolutionReason) {
|
|
82
|
+
return resolveActionItem(id, "done", resolutionReason);
|
|
83
|
+
}
|
|
84
|
+
export function dropActionItem(id, reason) {
|
|
85
|
+
return resolveActionItem(id, "dropped", reason);
|
|
86
|
+
}
|
|
87
|
+
export function snoozeActionItem(id, snoozeUntil) {
|
|
88
|
+
const result = getDb().prepare(`
|
|
89
|
+
UPDATE mem_action_items
|
|
90
|
+
SET status = 'snoozed',
|
|
91
|
+
snooze_until = ?,
|
|
92
|
+
updated_at = CURRENT_TIMESTAMP
|
|
93
|
+
WHERE id = ?
|
|
94
|
+
`).run(snoozeUntil, id);
|
|
95
|
+
if (result.changes === 0) {
|
|
96
|
+
throw new Error(`Unknown action item id '${id}'.`);
|
|
97
|
+
}
|
|
98
|
+
return getActionItem(id);
|
|
99
|
+
}
|
|
100
|
+
//# sourceMappingURL=action-items.js.map
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdirSync, rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
const repoRoot = process.cwd();
|
|
6
|
+
const sandboxRoot = join(repoRoot, ".test-work", `memory-action-items-${process.pid}`);
|
|
7
|
+
const chapterhouseHome = join(sandboxRoot, ".chapterhouse");
|
|
8
|
+
process.env.CHAPTERHOUSE_HOME = sandboxRoot;
|
|
9
|
+
function resetSandbox() {
|
|
10
|
+
mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
|
|
11
|
+
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
12
|
+
mkdirSync(chapterhouseHome, { recursive: true });
|
|
13
|
+
}
|
|
14
|
+
async function loadModules() {
|
|
15
|
+
const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
|
|
16
|
+
const memoryModule = await import(new URL("./index.js", import.meta.url).href);
|
|
17
|
+
return { dbModule, memoryModule };
|
|
18
|
+
}
|
|
19
|
+
function getFunction(module, name) {
|
|
20
|
+
const value = module[name];
|
|
21
|
+
assert.equal(typeof value, "function", `expected ${name} to be exported`);
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
test.beforeEach(async () => {
|
|
25
|
+
process.env.CHAPTERHOUSE_HOME = sandboxRoot;
|
|
26
|
+
const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
|
|
27
|
+
dbModule.closeDb();
|
|
28
|
+
resetSandbox();
|
|
29
|
+
});
|
|
30
|
+
test.after(async () => {
|
|
31
|
+
const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
|
|
32
|
+
dbModule.closeDb();
|
|
33
|
+
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
34
|
+
});
|
|
35
|
+
test("action items round-trip through add, list, and complete with done hidden by default", async () => {
|
|
36
|
+
const { dbModule, memoryModule } = await loadModules();
|
|
37
|
+
dbModule.getDb();
|
|
38
|
+
const getScope = getFunction(memoryModule, "getScope");
|
|
39
|
+
const recordActionItem = getFunction(memoryModule, "recordActionItem");
|
|
40
|
+
const listActionItems = getFunction(memoryModule, "listActionItems");
|
|
41
|
+
const completeActionItem = getFunction(memoryModule, "completeActionItem");
|
|
42
|
+
const chapterhouse = getScope("chapterhouse");
|
|
43
|
+
assert.ok(chapterhouse);
|
|
44
|
+
const added = recordActionItem({
|
|
45
|
+
scope_id: chapterhouse.id,
|
|
46
|
+
title: "Migrate feature ideas into memory",
|
|
47
|
+
detail: "Move feature-ideas.md into mem_action_items once the schema exists.",
|
|
48
|
+
due_at: "2026-05-15T12:00:00.000Z",
|
|
49
|
+
source: "test",
|
|
50
|
+
});
|
|
51
|
+
assert.equal(added.status, "open");
|
|
52
|
+
assert.equal(added.title, "Migrate feature ideas into memory");
|
|
53
|
+
assert.equal(added.detail, "Move feature-ideas.md into mem_action_items once the schema exists.");
|
|
54
|
+
assert.equal(added.dueAt, "2026-05-15T12:00:00.000Z");
|
|
55
|
+
assert.deepEqual(listActionItems({ scope_id: chapterhouse.id }).map((item) => item.id), [added.id]);
|
|
56
|
+
const completed = completeActionItem(added.id, "Migrated successfully.");
|
|
57
|
+
assert.equal(completed.status, "done");
|
|
58
|
+
assert.ok(completed.resolvedAt);
|
|
59
|
+
assert.deepEqual(listActionItems({ scope_id: chapterhouse.id }).map((item) => item.id), []);
|
|
60
|
+
assert.deepEqual(listActionItems({ scope_id: chapterhouse.id, status: "done" }).map((item) => item.id), [added.id]);
|
|
61
|
+
});
|
|
62
|
+
test("snoozed action items are hidden by default until snooze_until passes", async () => {
|
|
63
|
+
const { dbModule, memoryModule } = await loadModules();
|
|
64
|
+
dbModule.getDb();
|
|
65
|
+
const getScope = getFunction(memoryModule, "getScope");
|
|
66
|
+
const recordActionItem = getFunction(memoryModule, "recordActionItem");
|
|
67
|
+
const snoozeActionItem = getFunction(memoryModule, "snoozeActionItem");
|
|
68
|
+
const listActionItems = getFunction(memoryModule, "listActionItems");
|
|
69
|
+
const chapterhouse = getScope("chapterhouse");
|
|
70
|
+
assert.ok(chapterhouse);
|
|
71
|
+
const future = recordActionItem({ scope_id: chapterhouse.id, title: "Remind infra later", source: "test" });
|
|
72
|
+
const expired = recordActionItem({ scope_id: chapterhouse.id, title: "Reappears now", source: "test" });
|
|
73
|
+
assert.equal(snoozeActionItem(future.id, "2999-01-01T00:00:00.000Z").status, "snoozed");
|
|
74
|
+
assert.equal(snoozeActionItem(expired.id, "2000-01-01T00:00:00.000Z").status, "snoozed");
|
|
75
|
+
const defaults = listActionItems({ scope_id: chapterhouse.id });
|
|
76
|
+
assert.equal(defaults.some((item) => item.id === future.id), false);
|
|
77
|
+
assert.equal(defaults.some((item) => item.id === expired.id), true);
|
|
78
|
+
assert.deepEqual(listActionItems({ scope_id: chapterhouse.id, status: "snoozed" }).map((item) => item.id).sort(), [
|
|
79
|
+
expired.id,
|
|
80
|
+
future.id,
|
|
81
|
+
].sort());
|
|
82
|
+
});
|
|
83
|
+
//# sourceMappingURL=action-items.test.js.map
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
1
2
|
import { getDb } from "../store/db.js";
|
|
2
3
|
import { getScope, listScopes } from "./scopes.js";
|
|
3
4
|
const ACTIVE_SCOPE_KEY = "current_scope_slug";
|
|
5
|
+
const activeScopeOverride = new AsyncLocalStorage();
|
|
4
6
|
function setSetting(key, value) {
|
|
5
7
|
getDb().prepare(`
|
|
6
8
|
INSERT INTO mem_settings (key, value)
|
|
@@ -20,6 +22,10 @@ function clearSetting(key) {
|
|
|
20
22
|
getDb().prepare(`DELETE FROM mem_settings WHERE key = ?`).run(key);
|
|
21
23
|
}
|
|
22
24
|
export function getActiveScope() {
|
|
25
|
+
const override = activeScopeOverride.getStore();
|
|
26
|
+
if (override !== undefined) {
|
|
27
|
+
return override === null ? null : (getScope(override) ?? null);
|
|
28
|
+
}
|
|
23
29
|
const slug = getSetting(ACTIVE_SCOPE_KEY);
|
|
24
30
|
if (!slug)
|
|
25
31
|
return null;
|
|
@@ -30,6 +36,9 @@ export function getActiveScope() {
|
|
|
30
36
|
}
|
|
31
37
|
return scope;
|
|
32
38
|
}
|
|
39
|
+
export function withActiveScope(slug, fn) {
|
|
40
|
+
return activeScopeOverride.run(slug, fn);
|
|
41
|
+
}
|
|
33
42
|
export function setActiveScope(slug) {
|
|
34
43
|
if (slug === null) {
|
|
35
44
|
clearSetting(ACTIVE_SCOPE_KEY);
|
package/dist/memory/eot.js
CHANGED
|
@@ -5,6 +5,7 @@ import { recordDecision } from "./decisions.js";
|
|
|
5
5
|
import { upsertEntity } from "./entities.js";
|
|
6
6
|
import { listPendingMemoryProposalsForTask, resolveInboxItem } from "./inbox.js";
|
|
7
7
|
import { recordObservation } from "./observations.js";
|
|
8
|
+
import { recordActionItem } from "./action-items.js";
|
|
8
9
|
import { getScope } from "./scopes.js";
|
|
9
10
|
const log = childLogger("memory.eot");
|
|
10
11
|
function isEndOfTaskHookEnabled() {
|
|
@@ -30,6 +31,8 @@ function buildReviewerSystemPrompt() {
|
|
|
30
31
|
"Optionally extract additional implicit durable memories from the task summary.",
|
|
31
32
|
"Return JSON only with keys: decisions, implicit_memories.",
|
|
32
33
|
"Each decision must include proposal_id, decision, reason.",
|
|
34
|
+
"Supported kinds are observation, decision, entity, and action_item.",
|
|
35
|
+
"Entity payloads must include name and entity_kind.",
|
|
33
36
|
"Each implicit memory must include kind, scope_slug, payload, and may include confidence/reason.",
|
|
34
37
|
].join("\n");
|
|
35
38
|
}
|
|
@@ -48,7 +51,10 @@ function parseEnvelope(raw) {
|
|
|
48
51
|
if (!parsed || typeof parsed !== "object") {
|
|
49
52
|
throw new Error("Invalid memory proposal payload.");
|
|
50
53
|
}
|
|
51
|
-
if (parsed.kind !== "observation"
|
|
54
|
+
if (parsed.kind !== "observation"
|
|
55
|
+
&& parsed.kind !== "decision"
|
|
56
|
+
&& parsed.kind !== "entity"
|
|
57
|
+
&& parsed.kind !== "action_item") {
|
|
52
58
|
throw new Error("Invalid proposal kind.");
|
|
53
59
|
}
|
|
54
60
|
if (!parsed.payload || typeof parsed.payload !== "object") {
|
|
@@ -93,7 +99,10 @@ function parseReviewerResponse(raw) {
|
|
|
93
99
|
return [];
|
|
94
100
|
}
|
|
95
101
|
const candidate = entry;
|
|
96
|
-
if (candidate.kind !== "observation"
|
|
102
|
+
if (candidate.kind !== "observation"
|
|
103
|
+
&& candidate.kind !== "decision"
|
|
104
|
+
&& candidate.kind !== "entity"
|
|
105
|
+
&& candidate.kind !== "action_item") {
|
|
97
106
|
return [];
|
|
98
107
|
}
|
|
99
108
|
if (typeof candidate.scope_slug !== "string" || !candidate.payload || typeof candidate.payload !== "object") {
|
|
@@ -136,10 +145,26 @@ function rememberAcceptedMemory(kind, scopeSlug, payload, source, confidence) {
|
|
|
136
145
|
});
|
|
137
146
|
return;
|
|
138
147
|
}
|
|
148
|
+
if (kind === "action_item") {
|
|
149
|
+
const actionItem = payload;
|
|
150
|
+
recordActionItem({
|
|
151
|
+
scope_id: scope.id,
|
|
152
|
+
entity_id: actionItem.entity_id,
|
|
153
|
+
title: actionItem.title,
|
|
154
|
+
detail: actionItem.detail,
|
|
155
|
+
due_at: actionItem.due_at,
|
|
156
|
+
source: actionItem.source ?? source,
|
|
157
|
+
});
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
139
160
|
const entity = payload;
|
|
161
|
+
const entityKind = entity.entity_kind ?? entity.kind;
|
|
162
|
+
if (!entityKind) {
|
|
163
|
+
throw new Error("Entity proposal payload requires entity_kind.");
|
|
164
|
+
}
|
|
140
165
|
upsertEntity({
|
|
141
166
|
scope_id: scope.id,
|
|
142
|
-
kind:
|
|
167
|
+
kind: entityKind,
|
|
143
168
|
name: entity.name,
|
|
144
169
|
summary: entity.summary,
|
|
145
170
|
confidence,
|
package/dist/memory/eot.test.js
CHANGED
|
@@ -155,6 +155,85 @@ test("runEndOfTaskMemoryHook accepts matching proposals, rejects others from the
|
|
|
155
155
|
auto_accept: true,
|
|
156
156
|
}]);
|
|
157
157
|
});
|
|
158
|
+
test("runEndOfTaskMemoryHook accepts action_item proposals into mem_action_items", async () => {
|
|
159
|
+
const { dbModule, memoryModule, eotModule } = await loadModules("action-item-accept");
|
|
160
|
+
const db = dbModule.getDb();
|
|
161
|
+
const getScope = getFunction(memoryModule, "getScope");
|
|
162
|
+
const listActionItems = getFunction(memoryModule, "listActionItems");
|
|
163
|
+
const runEndOfTaskMemoryHook = getFunction(eotModule, "runEndOfTaskMemoryHook");
|
|
164
|
+
const chapterhouse = getScope("chapterhouse");
|
|
165
|
+
assert.ok(chapterhouse);
|
|
166
|
+
const inserted = db.prepare(`
|
|
167
|
+
INSERT INTO mem_inbox (scope_id, kind, payload, source_agent, source_task_id, status)
|
|
168
|
+
VALUES (?, 'memory_proposal', ?, 'coder', 'task-eot-action-item', 'pending')
|
|
169
|
+
`).run(chapterhouse.id, JSON.stringify({
|
|
170
|
+
kind: "action_item",
|
|
171
|
+
payload: {
|
|
172
|
+
title: "Migrate feature ideas",
|
|
173
|
+
detail: "Move feature-ideas.md into mem_action_items.",
|
|
174
|
+
source: "subagent_proposal",
|
|
175
|
+
},
|
|
176
|
+
confidence: 0.9,
|
|
177
|
+
}));
|
|
178
|
+
await runEndOfTaskMemoryHook({
|
|
179
|
+
taskId: "task-eot-action-item",
|
|
180
|
+
finalResult: "Completed and proposed a follow-up action item.",
|
|
181
|
+
copilotClient: {},
|
|
182
|
+
callLLM: async () => JSON.stringify({
|
|
183
|
+
decisions: [{
|
|
184
|
+
proposal_id: Number(inserted.lastInsertRowid),
|
|
185
|
+
decision: "accept",
|
|
186
|
+
reason: "Concrete follow-up.",
|
|
187
|
+
}],
|
|
188
|
+
implicit_memories: [],
|
|
189
|
+
}),
|
|
190
|
+
});
|
|
191
|
+
const actionItems = listActionItems({ scope_id: chapterhouse.id });
|
|
192
|
+
assert.equal(actionItems.some((item) => item.title === "Migrate feature ideas"
|
|
193
|
+
&& item.detail === "Move feature-ideas.md into mem_action_items."), true);
|
|
194
|
+
const inbox = db.prepare(`SELECT status FROM mem_inbox WHERE id = ?`).get(Number(inserted.lastInsertRowid));
|
|
195
|
+
assert.equal(inbox.status, "accepted");
|
|
196
|
+
});
|
|
197
|
+
test("runEndOfTaskMemoryHook accepts entity proposals with entity_kind into mem_entities", async () => {
|
|
198
|
+
const { dbModule, memoryModule, eotModule } = await loadModules("entity-accept");
|
|
199
|
+
const db = dbModule.getDb();
|
|
200
|
+
const getScope = getFunction(memoryModule, "getScope");
|
|
201
|
+
const listEntities = getFunction(memoryModule, "listEntities");
|
|
202
|
+
const runEndOfTaskMemoryHook = getFunction(eotModule, "runEndOfTaskMemoryHook");
|
|
203
|
+
const chapterhouse = getScope("chapterhouse");
|
|
204
|
+
assert.ok(chapterhouse);
|
|
205
|
+
const inserted = db.prepare(`
|
|
206
|
+
INSERT INTO mem_inbox (scope_id, kind, payload, source_agent, source_task_id, status)
|
|
207
|
+
VALUES (?, 'memory_proposal', ?, 'coder', 'task-eot-entity', 'pending')
|
|
208
|
+
`).run(chapterhouse.id, JSON.stringify({
|
|
209
|
+
kind: "entity",
|
|
210
|
+
payload: {
|
|
211
|
+
name: "truenas",
|
|
212
|
+
entity_kind: "host",
|
|
213
|
+
summary: "NAS host used by Bellonda.",
|
|
214
|
+
},
|
|
215
|
+
confidence: 0.9,
|
|
216
|
+
}));
|
|
217
|
+
await runEndOfTaskMemoryHook({
|
|
218
|
+
taskId: "task-eot-entity",
|
|
219
|
+
finalResult: "Completed and proposed a durable host entity.",
|
|
220
|
+
copilotClient: {},
|
|
221
|
+
callLLM: async () => JSON.stringify({
|
|
222
|
+
decisions: [{
|
|
223
|
+
proposal_id: Number(inserted.lastInsertRowid),
|
|
224
|
+
decision: "accept",
|
|
225
|
+
reason: "Durable entity.",
|
|
226
|
+
}],
|
|
227
|
+
implicit_memories: [],
|
|
228
|
+
}),
|
|
229
|
+
});
|
|
230
|
+
const entities = listEntities({ scope_id: chapterhouse.id, kind: "host" });
|
|
231
|
+
assert.equal(entities.some((entity) => entity.name === "truenas"
|
|
232
|
+
&& entity.kind === "host"
|
|
233
|
+
&& entity.summary === "NAS host used by Bellonda."), true);
|
|
234
|
+
const inbox = db.prepare(`SELECT status FROM mem_inbox WHERE id = ?`).get(Number(inserted.lastInsertRowid));
|
|
235
|
+
assert.equal(inbox.status, "accepted");
|
|
236
|
+
});
|
|
158
237
|
test("runEndOfTaskMemoryHook leaves reviewed proposals pending when CHAPTERHOUSE_MEMORY_AUTO_ACCEPT=0", async () => {
|
|
159
238
|
process.env.CHAPTERHOUSE_MEMORY_AUTO_ACCEPT = "0";
|
|
160
239
|
const { dbModule, memoryModule, eotModule } = await loadModules("pending");
|
|
@@ -260,4 +339,33 @@ test("runEndOfTaskMemoryHook can persist implicit extracted memories that were n
|
|
|
260
339
|
auto_accept: true,
|
|
261
340
|
}]);
|
|
262
341
|
});
|
|
342
|
+
test("runEndOfTaskMemoryHook accepts implicit entity memories with entity_kind", async () => {
|
|
343
|
+
const { memoryModule, eotModule } = await loadModules("implicit-entity");
|
|
344
|
+
const getScope = getFunction(memoryModule, "getScope");
|
|
345
|
+
const listEntities = getFunction(memoryModule, "listEntities");
|
|
346
|
+
const runEndOfTaskMemoryHook = getFunction(eotModule, "runEndOfTaskMemoryHook");
|
|
347
|
+
const chapterhouse = getScope("chapterhouse");
|
|
348
|
+
assert.ok(chapterhouse, "chapterhouse scope should be seeded");
|
|
349
|
+
await runEndOfTaskMemoryHook({
|
|
350
|
+
taskId: "task-eot-implicit-entity",
|
|
351
|
+
finalResult: "The subagent discovered a durable host entity while finishing the task.",
|
|
352
|
+
copilotClient: {},
|
|
353
|
+
callLLM: async () => JSON.stringify({
|
|
354
|
+
decisions: [],
|
|
355
|
+
implicit_memories: [{
|
|
356
|
+
kind: "entity",
|
|
357
|
+
scope_slug: "chapterhouse",
|
|
358
|
+
payload: {
|
|
359
|
+
name: "synology",
|
|
360
|
+
entity_kind: "host",
|
|
361
|
+
summary: "NAS host used by Bellonda.",
|
|
362
|
+
},
|
|
363
|
+
}],
|
|
364
|
+
}),
|
|
365
|
+
});
|
|
366
|
+
const entities = listEntities({ scope_id: chapterhouse.id, kind: "host" });
|
|
367
|
+
assert.equal(entities.some((entity) => entity.name === "synology"
|
|
368
|
+
&& entity.kind === "host"
|
|
369
|
+
&& entity.summary === "NAS host used by Bellonda."), true);
|
|
370
|
+
});
|
|
263
371
|
//# sourceMappingURL=eot.test.js.map
|
package/dist/memory/hot-tier.js
CHANGED
|
@@ -2,6 +2,7 @@ import { getDb } from "../store/db.js";
|
|
|
2
2
|
import { getActiveScope } from "./active-scope.js";
|
|
3
3
|
import { getScope } from "./scopes.js";
|
|
4
4
|
const HOT_TIER_LIMIT = 30;
|
|
5
|
+
const HOT_TIER_ACTION_ITEM_LIMIT = 10;
|
|
5
6
|
function toEntity(row) {
|
|
6
7
|
return {
|
|
7
8
|
id: row.id,
|
|
@@ -44,6 +45,27 @@ function toDecision(row) {
|
|
|
44
45
|
createdAt: row.created_at,
|
|
45
46
|
};
|
|
46
47
|
}
|
|
48
|
+
function toActionItem(row) {
|
|
49
|
+
return {
|
|
50
|
+
id: row.id,
|
|
51
|
+
scopeId: row.scope_id,
|
|
52
|
+
entityId: row.entity_id ?? undefined,
|
|
53
|
+
title: row.title,
|
|
54
|
+
detail: row.detail ?? undefined,
|
|
55
|
+
status: row.status,
|
|
56
|
+
dueAt: row.due_at ?? undefined,
|
|
57
|
+
snoozeUntil: row.snooze_until ?? undefined,
|
|
58
|
+
source: row.source ?? undefined,
|
|
59
|
+
tier: row.tier,
|
|
60
|
+
tierPinnedAt: row.tier_pinned_at ?? undefined,
|
|
61
|
+
tierReason: row.tier_reason ?? undefined,
|
|
62
|
+
lastRecalledAt: row.last_recalled_at ?? undefined,
|
|
63
|
+
createdAt: row.created_at,
|
|
64
|
+
updatedAt: row.updated_at,
|
|
65
|
+
resolvedAt: row.resolved_at ?? undefined,
|
|
66
|
+
resolutionReason: row.resolution_reason ?? undefined,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
47
69
|
function escapeXmlText(value) {
|
|
48
70
|
return value
|
|
49
71
|
.replaceAll("&", "&")
|
|
@@ -108,6 +130,27 @@ function loadHotDecisions(scopeId, options) {
|
|
|
108
130
|
`).all(scopeId, options.includeSuperseded ? 1 : 0, options.includeArchived ? 1 : 0, HOT_TIER_LIMIT);
|
|
109
131
|
return rows.map((row) => ({ ...toDecision(row), sortKey: row.decided_at }));
|
|
110
132
|
}
|
|
133
|
+
function loadOpenActionItems(scopeId) {
|
|
134
|
+
const rows = getDb().prepare(`
|
|
135
|
+
SELECT
|
|
136
|
+
id, scope_id, entity_id, title, detail, status, due_at, snooze_until, source,
|
|
137
|
+
created_at, updated_at, resolved_at, resolution_reason, tier, tier_pinned_at,
|
|
138
|
+
tier_reason, last_recalled_at
|
|
139
|
+
FROM mem_action_items
|
|
140
|
+
WHERE scope_id = ?
|
|
141
|
+
AND (
|
|
142
|
+
status = 'open'
|
|
143
|
+
OR (status = 'snoozed' AND snooze_until IS NOT NULL AND datetime(snooze_until) <= datetime('now'))
|
|
144
|
+
)
|
|
145
|
+
ORDER BY
|
|
146
|
+
CASE WHEN due_at IS NULL THEN 1 ELSE 0 END ASC,
|
|
147
|
+
datetime(due_at) ASC,
|
|
148
|
+
datetime(created_at) DESC,
|
|
149
|
+
id DESC
|
|
150
|
+
LIMIT ?
|
|
151
|
+
`).all(scopeId, HOT_TIER_ACTION_ITEM_LIMIT);
|
|
152
|
+
return rows.map(toActionItem);
|
|
153
|
+
}
|
|
111
154
|
export function getHotTierEntries(scope_id, options = {}) {
|
|
112
155
|
const scope = getHotTierScope(scope_id);
|
|
113
156
|
if (!scope) {
|
|
@@ -116,6 +159,7 @@ export function getHotTierEntries(scope_id, options = {}) {
|
|
|
116
159
|
entities: [],
|
|
117
160
|
observations: [],
|
|
118
161
|
decisions: [],
|
|
162
|
+
actionItems: [],
|
|
119
163
|
};
|
|
120
164
|
}
|
|
121
165
|
const merged = [
|
|
@@ -130,13 +174,17 @@ export function getHotTierEntries(scope_id, options = {}) {
|
|
|
130
174
|
entities: merged.filter((entry) => entry.type === "entity"),
|
|
131
175
|
observations: merged.filter((entry) => entry.type === "observation"),
|
|
132
176
|
decisions: merged.filter((entry) => entry.type === "decision"),
|
|
177
|
+
actionItems: loadOpenActionItems(scope.id),
|
|
133
178
|
};
|
|
134
179
|
}
|
|
135
180
|
export function renderHotTierXML(entries) {
|
|
136
181
|
if (!entries.scope) {
|
|
137
182
|
return "";
|
|
138
183
|
}
|
|
139
|
-
if (entries.entities.length === 0
|
|
184
|
+
if (entries.entities.length === 0
|
|
185
|
+
&& entries.observations.length === 0
|
|
186
|
+
&& entries.decisions.length === 0
|
|
187
|
+
&& entries.actionItems.length === 0) {
|
|
140
188
|
return "";
|
|
141
189
|
}
|
|
142
190
|
const observationsByEntity = new Map();
|
|
@@ -174,6 +222,17 @@ export function renderHotTierXML(entries) {
|
|
|
174
222
|
const truncated = truncateObservation(observation.content);
|
|
175
223
|
lines.push(` <observation id="observation-${observation.id}" tier="${escapeXmlAttr(observation.tier)}" confidence="${observation.confidence}" created_at="${escapeXmlAttr(observation.createdAt)}"${truncated.truncated ? ` truncated="true"` : ""}>`, ` ${escapeXmlText(truncated.content)}`, " </observation>");
|
|
176
224
|
}
|
|
225
|
+
if (entries.actionItems.length > 0) {
|
|
226
|
+
lines.push(" <action_items>");
|
|
227
|
+
for (const item of entries.actionItems) {
|
|
228
|
+
lines.push(` <action_item id="action-item-${item.id}" status="${escapeXmlAttr(item.status)}" created_at="${escapeXmlAttr(item.createdAt)}"${item.dueAt ? ` due_at="${escapeXmlAttr(item.dueAt)}"` : ""}>`, ` <title>${escapeXmlText(item.title)}</title>`);
|
|
229
|
+
if (item.detail) {
|
|
230
|
+
lines.push(` <detail>${escapeXmlText(item.detail)}</detail>`);
|
|
231
|
+
}
|
|
232
|
+
lines.push(" </action_item>");
|
|
233
|
+
}
|
|
234
|
+
lines.push(" </action_items>");
|
|
235
|
+
}
|
|
177
236
|
lines.push("</memory_context>");
|
|
178
237
|
return `${lines.join("\n")}\n`;
|
|
179
238
|
}
|
|
@@ -41,6 +41,7 @@ test("renderHotTierForActiveScope returns an empty string when no active scope i
|
|
|
41
41
|
entities: [],
|
|
42
42
|
observations: [],
|
|
43
43
|
decisions: [],
|
|
44
|
+
actionItems: [],
|
|
44
45
|
});
|
|
45
46
|
});
|
|
46
47
|
test("renderHotTierXML escapes content, merges recency across stores, and enforces the 30-entry cap", async () => {
|
|
@@ -130,6 +131,43 @@ test("active-scope hot-tier queries do not leak rows from other scopes", async (
|
|
|
130
131
|
assert.match(xml, /Chapterhouse hot entry/);
|
|
131
132
|
assert.doesNotMatch(xml, /Team-only hot entry/);
|
|
132
133
|
});
|
|
134
|
+
test("renderHotTierXML includes open active-scope action items in a bounded action_items block", async () => {
|
|
135
|
+
const { dbModule, memoryModule, hotTierModule } = await loadModules();
|
|
136
|
+
dbModule.getDb();
|
|
137
|
+
const getScope = getFunction(memoryModule, "getScope");
|
|
138
|
+
const recordActionItem = getFunction(memoryModule, "recordActionItem");
|
|
139
|
+
const chapterhouse = getScope("chapterhouse");
|
|
140
|
+
const team = getScope("team");
|
|
141
|
+
assert.ok(chapterhouse);
|
|
142
|
+
assert.ok(team);
|
|
143
|
+
const urgent = recordActionItem({
|
|
144
|
+
scope_id: chapterhouse.id,
|
|
145
|
+
title: "Migrate <feature ideas>",
|
|
146
|
+
detail: "Move feature-ideas.md into memory & keep source links.",
|
|
147
|
+
due_at: "2026-05-14T12:00:00.000Z",
|
|
148
|
+
source: "test",
|
|
149
|
+
});
|
|
150
|
+
recordActionItem({
|
|
151
|
+
scope_id: chapterhouse.id,
|
|
152
|
+
title: "Undated backlog item",
|
|
153
|
+
detail: "Should appear after dated items.",
|
|
154
|
+
source: "test",
|
|
155
|
+
});
|
|
156
|
+
recordActionItem({
|
|
157
|
+
scope_id: team.id,
|
|
158
|
+
title: "Other scope action",
|
|
159
|
+
source: "test",
|
|
160
|
+
});
|
|
161
|
+
const entries = hotTierModule.getHotTierEntries(chapterhouse.id);
|
|
162
|
+
const xml = hotTierModule.renderHotTierXML(entries);
|
|
163
|
+
assert.equal(entries.actionItems.length, 2);
|
|
164
|
+
assert.match(xml, /<action_items>/);
|
|
165
|
+
assert.match(xml, new RegExp(`<action_item[^>]*id="action-item-${urgent.id}"[^>]*status="open"`));
|
|
166
|
+
assert.match(xml, /<title>Migrate <feature ideas><\/title>/);
|
|
167
|
+
assert.match(xml, /<detail>Move feature-ideas\.md into memory & keep source links\.<\/detail>/);
|
|
168
|
+
assert.ok(xml.indexOf("Migrate <feature ideas>") < xml.indexOf("Undated backlog item"));
|
|
169
|
+
assert.doesNotMatch(xml, /Other scope action/);
|
|
170
|
+
});
|
|
133
171
|
test("hot-tier entries exclude superseded and archived observations and decisions by default with opt-in inclusion", async () => {
|
|
134
172
|
const { dbModule, memoryModule, hotTierModule } = await loadModules();
|
|
135
173
|
const db = dbModule.getDb();
|