chapterhouse 0.13.1 → 0.14.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 (116) hide show
  1. package/dist/api/route-coverage.test.js +1 -3
  2. package/dist/api/server.js +0 -2
  3. package/dist/api/server.test.js +0 -281
  4. package/dist/config.js +3 -85
  5. package/dist/config.test.js +5 -123
  6. package/dist/copilot/agents.js +13 -10
  7. package/dist/copilot/agents.test.js +10 -11
  8. package/dist/copilot/memory-coordinator.js +12 -227
  9. package/dist/copilot/memory-coordinator.test.js +31 -250
  10. package/dist/copilot/orchestrator.js +8 -66
  11. package/dist/copilot/orchestrator.test.js +9 -467
  12. package/dist/copilot/skills.js +15 -1
  13. package/dist/copilot/system-message.js +9 -15
  14. package/dist/copilot/system-message.test.js +9 -22
  15. package/dist/copilot/tools/index.js +3 -3
  16. package/dist/copilot/tools-deps.js +1 -1
  17. package/dist/copilot/tools.agent.test.js +6 -0
  18. package/dist/copilot/tools.inventory.test.js +1 -14
  19. package/dist/daemon.js +7 -9
  20. package/dist/memory/assets.js +33 -0
  21. package/dist/memory/domains.js +58 -0
  22. package/dist/memory/domains.test.js +47 -0
  23. package/dist/memory/git.js +66 -0
  24. package/dist/memory/git.test.js +32 -0
  25. package/dist/memory/history.js +19 -0
  26. package/dist/memory/hottier.js +32 -0
  27. package/dist/memory/hottier.test.js +33 -0
  28. package/dist/memory/index.js +5 -13
  29. package/dist/memory/instructions.js +17 -0
  30. package/dist/memory/manager.js +84 -0
  31. package/dist/memory/markdown.js +78 -0
  32. package/dist/memory/markdown.test.js +42 -0
  33. package/dist/memory/mutex.js +18 -0
  34. package/dist/memory/path-guard.js +26 -0
  35. package/dist/memory/path-guard.test.js +27 -0
  36. package/dist/memory/paths.js +12 -0
  37. package/dist/memory/reconcile.js +75 -0
  38. package/dist/memory/reconcile.test.js +50 -0
  39. package/dist/memory/scaffold.js +37 -0
  40. package/dist/memory/scaffold.test.js +52 -0
  41. package/dist/memory/tools/commit-wrapper.js +32 -0
  42. package/dist/memory/tools/domains.js +73 -0
  43. package/dist/memory/tools/domains.test.js +66 -0
  44. package/dist/memory/tools/git.js +52 -0
  45. package/dist/memory/tools/index.js +25 -0
  46. package/dist/memory/tools/read.js +101 -0
  47. package/dist/memory/tools/read.test.js +69 -0
  48. package/dist/memory/tools/search.js +103 -0
  49. package/dist/memory/tools/search.test.js +63 -0
  50. package/dist/memory/tools/sessions.js +45 -0
  51. package/dist/memory/tools/sessions.test.js +74 -0
  52. package/dist/memory/tools/shared.js +7 -0
  53. package/dist/memory/tools/write.js +116 -0
  54. package/dist/memory/tools/write.test.js +107 -0
  55. package/dist/memory/walk.js +39 -0
  56. package/dist/store/repositories/sessions.js +40 -0
  57. package/dist/wiki/consolidation.js +3 -31
  58. package/dist/wiki/consolidation.test.js +0 -19
  59. package/package.json +1 -1
  60. package/skills/system/evolve/SKILL.md +131 -0
  61. package/skills/system/foresight/SKILL.md +116 -0
  62. package/skills/system/history/SKILL.md +58 -0
  63. package/skills/system/housekeeping/SKILL.md +185 -0
  64. package/skills/system/reflect/SKILL.md +214 -0
  65. package/skills/system/scenario/SKILL.md +198 -0
  66. package/skills/system/setup/SKILL.md +113 -0
  67. package/web/dist/assets/{WikiEdit-CGRxNazp.js → WikiEdit-BTsiBfbC.js} +2 -2
  68. package/web/dist/assets/{WikiEdit-CGRxNazp.js.map → WikiEdit-BTsiBfbC.js.map} +1 -1
  69. package/web/dist/assets/{WikiGraph-eVWNhZS3.js → WikiGraph-COOZbUeH.js} +2 -2
  70. package/web/dist/assets/{WikiGraph-eVWNhZS3.js.map → WikiGraph-COOZbUeH.js.map} +1 -1
  71. package/web/dist/assets/{index-gAvLNEvJ.js → index-aCcfpaLM.js} +101 -101
  72. package/web/dist/assets/index-aCcfpaLM.js.map +1 -0
  73. package/web/dist/index.html +1 -1
  74. package/dist/api/routes/memory.js +0 -475
  75. package/dist/api/routes/memory.test.js +0 -108
  76. package/dist/copilot/tools/memory.js +0 -678
  77. package/dist/copilot/tools.memory.test.js +0 -590
  78. package/dist/memory/action-items.js +0 -100
  79. package/dist/memory/action-items.test.js +0 -83
  80. package/dist/memory/active-scope.js +0 -78
  81. package/dist/memory/active-scope.test.js +0 -80
  82. package/dist/memory/checkpoint-prompt.js +0 -71
  83. package/dist/memory/checkpoint.js +0 -274
  84. package/dist/memory/checkpoint.test.js +0 -275
  85. package/dist/memory/decisions.js +0 -54
  86. package/dist/memory/decisions.test.js +0 -92
  87. package/dist/memory/entities.js +0 -70
  88. package/dist/memory/entities.test.js +0 -65
  89. package/dist/memory/eot.js +0 -459
  90. package/dist/memory/eot.test.js +0 -949
  91. package/dist/memory/hooks.js +0 -149
  92. package/dist/memory/hooks.test.js +0 -325
  93. package/dist/memory/hot-tier.js +0 -283
  94. package/dist/memory/hot-tier.test.js +0 -275
  95. package/dist/memory/housekeeping-scheduler.js +0 -187
  96. package/dist/memory/housekeeping-scheduler.test.js +0 -236
  97. package/dist/memory/housekeeping.js +0 -497
  98. package/dist/memory/housekeeping.test.js +0 -410
  99. package/dist/memory/inbox.js +0 -83
  100. package/dist/memory/inbox.test.js +0 -178
  101. package/dist/memory/migration.js +0 -244
  102. package/dist/memory/migration.test.js +0 -108
  103. package/dist/memory/observations.js +0 -46
  104. package/dist/memory/observations.test.js +0 -86
  105. package/dist/memory/recall.js +0 -269
  106. package/dist/memory/recall.test.js +0 -265
  107. package/dist/memory/reflect.js +0 -273
  108. package/dist/memory/reflect.test.js +0 -256
  109. package/dist/memory/scope-lock.js +0 -26
  110. package/dist/memory/scope-lock.test.js +0 -118
  111. package/dist/memory/scopes.js +0 -89
  112. package/dist/memory/scopes.test.js +0 -176
  113. package/dist/memory/tiering.js +0 -223
  114. package/dist/memory/tiering.test.js +0 -323
  115. package/dist/memory/types.js +0 -2
  116. package/web/dist/assets/index-gAvLNEvJ.js.map +0 -1
@@ -0,0 +1,84 @@
1
+ // MemoryManager owns the memory subsystem: directory layout, the write-lock,
2
+ // startup scaffolding, the hot-tier block, and the conversation-history source.
3
+ // Ported from chgo's manager.go, with graceful degradation when git is absent.
4
+ import { homedir } from "os";
5
+ import { join } from "path";
6
+ import { Mutex } from "./mutex.js";
7
+ import { newMemoryPaths } from "./paths.js";
8
+ import { gitAvailable } from "./git.js";
9
+ import { scaffold } from "./scaffold.js";
10
+ import { hotTier } from "./hottier.js";
11
+ import { memorySystemInstructions } from "./instructions.js";
12
+ import { ConversationLogSessionSource } from "./history.js";
13
+ import { childLogger } from "../util/logger.js";
14
+ /**
15
+ * Resolves the Chapterhouse data directory. Inlined here (rather than imported
16
+ * from ../paths.js) so the memory manager stays usable when test suites mock
17
+ * the paths module with a partial export set.
18
+ */
19
+ function resolveDataDir() {
20
+ const configured = process.env.CHAPTERHOUSE_HOME?.trim();
21
+ if (!configured)
22
+ return join(homedir(), ".chapterhouse");
23
+ return configured.endsWith(".chapterhouse") ? configured : join(configured, ".chapterhouse");
24
+ }
25
+ const log = childLogger("memory");
26
+ export class MemoryManager {
27
+ paths;
28
+ /** Serializes mutating operations across concurrent agent sessions. */
29
+ mutex = new Mutex();
30
+ sessionSource = new ConversationLogSessionSource();
31
+ ready = false;
32
+ disabled = false;
33
+ constructor(dataDir) {
34
+ this.paths = newMemoryPaths(dataDir);
35
+ }
36
+ /** Installs a conversation-history provider (overrides the default). */
37
+ setSessionSource(src) {
38
+ this.sessionSource = src;
39
+ }
40
+ getSessionSource() {
41
+ return this.sessionSource;
42
+ }
43
+ /** True once the memory tree is scaffolded and usable. */
44
+ isReady() {
45
+ return this.ready && !this.disabled;
46
+ }
47
+ /**
48
+ * Startup entry point: verifies git, then scaffolds and reconciles the tree.
49
+ * When git is unavailable the memory subsystem is disabled for this run
50
+ * rather than crashing the daemon.
51
+ */
52
+ async ensureReady() {
53
+ if (!gitAvailable()) {
54
+ this.disabled = true;
55
+ log.error("git not found on PATH — the memory system requires git; memory is disabled for this run");
56
+ return;
57
+ }
58
+ await scaffold(this.paths);
59
+ this.ready = true;
60
+ }
61
+ /** The always-loaded hot-tier block, or "" when memory is unavailable. */
62
+ hotTier() {
63
+ if (!this.isReady())
64
+ return "";
65
+ return hotTier(this.paths.memoryRoot);
66
+ }
67
+ /** The memory operating instructions for the system message. */
68
+ systemInstructions() {
69
+ return memorySystemInstructions();
70
+ }
71
+ }
72
+ let singleton;
73
+ /** Returns the process-wide MemoryManager, rooted at the Chapterhouse home. */
74
+ export function getMemoryManager() {
75
+ if (!singleton) {
76
+ singleton = new MemoryManager(resolveDataDir());
77
+ }
78
+ return singleton;
79
+ }
80
+ /** Resets the singleton — for tests only. */
81
+ export function resetMemoryManagerForTests() {
82
+ singleton = undefined;
83
+ }
84
+ //# sourceMappingURL=manager.js.map
@@ -0,0 +1,78 @@
1
+ // Pure markdown helpers for memory files: L0 headers, section extraction, and
2
+ // line ranges. Ported from helpers in chgo's tools_read.go / tools_write.go.
3
+ /** Returns the text inside the first `<!-- L0: ... -->` comment, or "". */
4
+ export function extractL0(content) {
5
+ for (const line of content.split("\n")) {
6
+ const t = line.trim();
7
+ if (t.startsWith("<!-- L0:")) {
8
+ let inner = t.slice("<!-- L0:".length).trim();
9
+ if (inner.endsWith("-->"))
10
+ inner = inner.slice(0, -"-->".length);
11
+ return inner.trim();
12
+ }
13
+ }
14
+ return "";
15
+ }
16
+ /** Reports whether content has an `<!-- L0: -->` line within the first 4 lines. */
17
+ export function hasL0Header(content) {
18
+ const lines = content.split("\n");
19
+ for (let i = 0; i < lines.length && i <= 3; i++) {
20
+ if (lines[i].includes("<!-- L0:"))
21
+ return true;
22
+ }
23
+ return false;
24
+ }
25
+ /**
26
+ * Returns lines [start, end] (1-based, inclusive). A zero or out-of-range
27
+ * bound is treated as the file edge.
28
+ */
29
+ export function extractLines(content, start, end) {
30
+ const lines = content.split("\n");
31
+ let s = start;
32
+ let e = end;
33
+ if (s < 1)
34
+ s = 1;
35
+ if (e < 1 || e > lines.length)
36
+ e = lines.length;
37
+ if (s > lines.length)
38
+ return "";
39
+ return lines.slice(s - 1, e).join("\n");
40
+ }
41
+ /** Counts the leading `#` characters of a trimmed heading line. */
42
+ function headingLevel(trimmed) {
43
+ return (trimmed.match(/^#+/)?.[0].length) ?? 0;
44
+ }
45
+ /**
46
+ * Returns a heading line plus its body, up to the next heading at the same or
47
+ * higher level. Heading match is a case-insensitive substring.
48
+ */
49
+ export function extractSection(content, heading) {
50
+ const lines = content.split("\n");
51
+ const want = heading.trim().toLowerCase();
52
+ let startIdx = -1;
53
+ let startLevel = 0;
54
+ for (let i = 0; i < lines.length; i++) {
55
+ const t = lines[i].trim();
56
+ if (!t.startsWith("#"))
57
+ continue;
58
+ const level = headingLevel(t);
59
+ const text = t.replace(/^#+/, "").trim().toLowerCase();
60
+ if (text.includes(want)) {
61
+ startIdx = i;
62
+ startLevel = level;
63
+ break;
64
+ }
65
+ }
66
+ if (startIdx < 0)
67
+ return `(section "${heading}" not found)`;
68
+ let end = lines.length;
69
+ for (let i = startIdx + 1; i < lines.length; i++) {
70
+ const t = lines[i].trim();
71
+ if (t.startsWith("#") && headingLevel(t) <= startLevel) {
72
+ end = i;
73
+ break;
74
+ }
75
+ }
76
+ return lines.slice(startIdx, end).join("\n");
77
+ }
78
+ //# sourceMappingURL=markdown.js.map
@@ -0,0 +1,42 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { extractL0, extractLines, extractSection, hasL0Header } from "./markdown.js";
4
+ test("extractL0 returns the summary text", () => {
5
+ assert.equal(extractL0("<!-- L0: a quick summary -->\n# Title"), "a quick summary");
6
+ });
7
+ test("extractL0 returns empty string when absent", () => {
8
+ assert.equal(extractL0("# Title\nbody"), "");
9
+ });
10
+ test("extractL0 finds an L0 header beyond line 1", () => {
11
+ assert.equal(extractL0("# Title\n<!-- L0: second line -->\n"), "second line");
12
+ });
13
+ test("hasL0Header is true within the first 4 lines, false beyond", () => {
14
+ assert.equal(hasL0Header("<!-- L0: x -->\n"), true);
15
+ assert.equal(hasL0Header("a\nb\nc\n<!-- L0: x -->"), true);
16
+ assert.equal(hasL0Header("a\nb\nc\nd\n<!-- L0: x -->"), false);
17
+ assert.equal(hasL0Header("# Title only"), false);
18
+ });
19
+ test("extractLines returns a 1-based inclusive range", () => {
20
+ const content = "one\ntwo\nthree\nfour";
21
+ assert.equal(extractLines(content, 2, 3), "two\nthree");
22
+ });
23
+ test("extractLines treats zero bounds as file edges", () => {
24
+ const content = "one\ntwo\nthree";
25
+ assert.equal(extractLines(content, 0, 0), content);
26
+ assert.equal(extractLines(content, 2, 0), "two\nthree");
27
+ });
28
+ test("extractLines returns empty when start is past the end", () => {
29
+ assert.equal(extractLines("one\ntwo", 9, 0), "");
30
+ });
31
+ test("extractSection extracts a heading and its body", () => {
32
+ const content = "# Top\n## Current State\nbody line\n## Next\nother";
33
+ assert.equal(extractSection(content, "current state"), "## Current State\nbody line");
34
+ });
35
+ test("extractSection stops at a same-or-higher level heading", () => {
36
+ const content = "## A\n### A.1\nnested\n## B\ndone";
37
+ assert.equal(extractSection(content, "A"), "## A\n### A.1\nnested");
38
+ });
39
+ test("extractSection reports a missing section", () => {
40
+ assert.equal(extractSection("# Top\nbody", "nowhere"), '(section "nowhere" not found)');
41
+ });
42
+ //# sourceMappingURL=markdown.test.js.map
@@ -0,0 +1,18 @@
1
+ // A minimal promise-chain mutex. Serializes mutating memory operations across
2
+ // concurrent agent sessions that share one memory tree — the TypeScript
3
+ // analogue of chgo's sync.Mutex.
4
+ export class Mutex {
5
+ tail = Promise.resolve();
6
+ /** Acquires the lock; resolves with a release function. */
7
+ async acquire() {
8
+ let release;
9
+ const next = new Promise((resolve) => {
10
+ release = resolve;
11
+ });
12
+ const previous = this.tail;
13
+ this.tail = previous.then(() => next);
14
+ await previous;
15
+ return release;
16
+ }
17
+ }
18
+ //# sourceMappingURL=mutex.js.map
@@ -0,0 +1,26 @@
1
+ // Path-traversal protection for memory tools. Resolves a caller-supplied path
2
+ // against the memory root, tolerating a leading "memory/" and rejecting any
3
+ // path that escapes the root. Ported from chgo's tool.go resolveMemoryPath.
4
+ import { isAbsolute, join, normalize, sep } from "path";
5
+ /**
6
+ * Resolves `rel` against `root`. Throws if the path is empty, absolute, or
7
+ * escapes the memory root.
8
+ */
9
+ export function resolveMemoryPath(root, rel) {
10
+ const trimmed = (rel ?? "").trim();
11
+ if (trimmed === "")
12
+ throw new Error("path is required");
13
+ let p = trimmed.split("\\").join("/");
14
+ if (p.startsWith("memory/"))
15
+ p = p.slice("memory/".length);
16
+ if (isAbsolute(p)) {
17
+ throw new Error(`path must be relative to the memory root: ${rel}`);
18
+ }
19
+ const abs = join(root, p);
20
+ const rootClean = normalize(root).replace(new RegExp(`\\${sep}+$`), "");
21
+ if (abs !== rootClean && !abs.startsWith(rootClean + sep)) {
22
+ throw new Error(`path "${rel}" escapes the memory root`);
23
+ }
24
+ return abs;
25
+ }
26
+ //# sourceMappingURL=path-guard.js.map
@@ -0,0 +1,27 @@
1
+ import assert from "node:assert/strict";
2
+ import { join } from "node:path";
3
+ import test from "node:test";
4
+ import { resolveMemoryPath } from "./path-guard.js";
5
+ const root = "/tmp/chapterhouse-memory-root";
6
+ test("resolveMemoryPath resolves a relative path under the root", () => {
7
+ assert.equal(resolveMemoryPath(root, "personal/hot-memory.md"), join(root, "personal/hot-memory.md"));
8
+ });
9
+ test("resolveMemoryPath tolerates a leading memory/ prefix", () => {
10
+ assert.equal(resolveMemoryPath(root, "memory/personal/entities.md"), join(root, "personal/entities.md"));
11
+ });
12
+ test("resolveMemoryPath keeps interior .. inside the root", () => {
13
+ assert.equal(resolveMemoryPath(root, "cog-meta/x/../patterns.md"), join(root, "cog-meta/patterns.md"));
14
+ });
15
+ test("resolveMemoryPath allows nested domain paths", () => {
16
+ assert.equal(resolveMemoryPath(root, "work/acme/observations.md"), join(root, "work/acme/observations.md"));
17
+ });
18
+ test("resolveMemoryPath rejects an empty path", () => {
19
+ assert.throws(() => resolveMemoryPath(root, " "), /path is required/);
20
+ });
21
+ test("resolveMemoryPath rejects an absolute path", () => {
22
+ assert.throws(() => resolveMemoryPath(root, "/etc/passwd"), /must be relative/);
23
+ });
24
+ test("resolveMemoryPath rejects a path that escapes the root", () => {
25
+ assert.throws(() => resolveMemoryPath(root, "../../etc/passwd"), /escapes the memory root/);
26
+ });
27
+ //# sourceMappingURL=path-guard.test.js.map
@@ -0,0 +1,12 @@
1
+ // Directory layout for the file-based memory subsystem, derived from the
2
+ // Chapterhouse data directory. Ported from chgo's internal/memory/paths.go.
3
+ import { join } from "path";
4
+ /** Derives the memory directory layout from the data directory. */
5
+ export function newMemoryPaths(dataDir) {
6
+ return {
7
+ dataDir,
8
+ memoryRoot: join(dataDir, "memory"),
9
+ skillsDomains: join(dataDir, "skills", "domains"),
10
+ };
11
+ }
12
+ //# sourceMappingURL=paths.js.map
@@ -0,0 +1,75 @@
1
+ // Reconciles the filesystem with the domain manifest: creates each domain's
2
+ // directory and starter files, regenerates each non-system domain's skill, and
3
+ // prunes orphan generated skills. Ported from chgo's reconcile.go + skills.go.
4
+ import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "fs";
5
+ import { join } from "path";
6
+ import { domainSkillTemplate, readAsset } from "./assets.js";
7
+ /** Files that have a dedicated starter template; others use the generic one. */
8
+ const NAMED_STARTERS = new Set(["hot-memory", "observations", "action-items", "entities"]);
9
+ /**
10
+ * Makes the filesystem match the manifest. Existing memory files are never
11
+ * overwritten; domain skills are always regenerated; orphan skills are pruned.
12
+ */
13
+ export function reconcile(paths, manifest) {
14
+ const tmpl = domainSkillTemplate();
15
+ const keep = new Set();
16
+ for (const domain of manifest.domains) {
17
+ reconcileDomainFiles(paths, domain);
18
+ if (domain.type === "system")
19
+ continue;
20
+ keep.add(domain.id);
21
+ writeDomainSkill(paths, tmpl, domain);
22
+ }
23
+ pruneOrphanSkills(paths, keep);
24
+ }
25
+ /** Creates the domain directory and any missing starter files. */
26
+ function reconcileDomainFiles(paths, domain) {
27
+ const domainDir = join(paths.memoryRoot, ...domain.path.split("/"));
28
+ mkdirSync(domainDir, { recursive: true });
29
+ for (const file of domain.files) {
30
+ const dest = join(domainDir, `${file}.md`);
31
+ if (existsSync(dest))
32
+ continue;
33
+ writeFileSync(dest, renderStarter(file, domain.label), "utf8");
34
+ }
35
+ }
36
+ /** Regenerates the domain's SKILL.md from the template. */
37
+ function writeDomainSkill(paths, tmpl, domain) {
38
+ const skillDir = join(paths.skillsDomains, domain.id);
39
+ mkdirSync(skillDir, { recursive: true });
40
+ writeFileSync(join(skillDir, "SKILL.md"), renderDomainSkill(tmpl, domain), "utf8");
41
+ }
42
+ /**
43
+ * Removes generated skill directories with no matching non-system domain.
44
+ * Memory directories are never pruned.
45
+ */
46
+ function pruneOrphanSkills(paths, keep) {
47
+ let entries;
48
+ try {
49
+ entries = readdirSync(paths.skillsDomains, { withFileTypes: true });
50
+ }
51
+ catch {
52
+ return; // skills/domains/ does not exist yet
53
+ }
54
+ for (const entry of entries) {
55
+ if (entry.isDirectory() && !keep.has(entry.name)) {
56
+ rmSync(join(paths.skillsDomains, entry.name), { recursive: true, force: true });
57
+ }
58
+ }
59
+ }
60
+ /** Renders the starter template for a domain file. */
61
+ export function renderStarter(file, label) {
62
+ const name = NAMED_STARTERS.has(file) ? file : "generic";
63
+ const tmpl = readAsset(`templates/${name}.md`);
64
+ return tmpl.split("{{LABEL}}").join(label).split("{{FILE}}").join(file);
65
+ }
66
+ /** Substitutes domain fields into the per-domain skill template. */
67
+ export function renderDomainSkill(tmpl, domain) {
68
+ return tmpl
69
+ .split("{{ID}}").join(domain.id)
70
+ .split("{{LABEL}}").join(domain.label)
71
+ .split("{{PATH}}").join(domain.path)
72
+ .split("{{TRIGGER_LINE}}").join(domain.triggers.join(", "))
73
+ .split("{{FILES}}").join(domain.files.join(", "));
74
+ }
75
+ //# sourceMappingURL=reconcile.js.map
@@ -0,0 +1,50 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import test from "node:test";
5
+ import { gitAvailable } from "./git.js";
6
+ import { MemoryManager } from "./manager.js";
7
+ import { parseManifest } from "./domains.js";
8
+ import { reconcile } from "./reconcile.js";
9
+ const sandbox = join(process.cwd(), ".test-work", `mem-reconcile-${process.pid}`);
10
+ const acme = {
11
+ id: "acme",
12
+ path: "work/acme",
13
+ type: "work",
14
+ label: "Acme work",
15
+ triggers: ["acme"],
16
+ files: ["hot-memory", "observations"],
17
+ };
18
+ test.beforeEach(() => {
19
+ rmSync(sandbox, { recursive: true, force: true });
20
+ mkdirSync(sandbox, { recursive: true });
21
+ });
22
+ test.after(() => rmSync(sandbox, { recursive: true, force: true }));
23
+ test("reconcile creates a new domain's directory, files, and skill", async () => {
24
+ if (!gitAvailable())
25
+ return;
26
+ const mgr = new MemoryManager(sandbox);
27
+ await mgr.ensureReady();
28
+ const manifest = parseManifest(readFileSync(join(mgr.paths.memoryRoot, "domains.yml"), "utf8"));
29
+ manifest.domains.push(acme);
30
+ reconcile(mgr.paths, manifest);
31
+ assert.ok(existsSync(join(mgr.paths.memoryRoot, "work/acme/hot-memory.md")));
32
+ assert.ok(existsSync(join(mgr.paths.memoryRoot, "work/acme/observations.md")));
33
+ assert.ok(existsSync(join(mgr.paths.skillsDomains, "acme", "SKILL.md")));
34
+ });
35
+ test("reconcile prunes an orphan skill but keeps the memory directory", async () => {
36
+ if (!gitAvailable())
37
+ return;
38
+ const mgr = new MemoryManager(sandbox);
39
+ await mgr.ensureReady();
40
+ const withAcme = parseManifest(readFileSync(join(mgr.paths.memoryRoot, "domains.yml"), "utf8"));
41
+ withAcme.domains.push(acme);
42
+ reconcile(mgr.paths, withAcme);
43
+ assert.ok(existsSync(join(mgr.paths.skillsDomains, "acme", "SKILL.md")));
44
+ // Reconcile again without acme — its skill is pruned, its memory is kept.
45
+ const withoutAcme = parseManifest(readFileSync(join(mgr.paths.memoryRoot, "domains.yml"), "utf8"));
46
+ reconcile(mgr.paths, withoutAcme);
47
+ assert.ok(!existsSync(join(mgr.paths.skillsDomains, "acme")), "orphan skill pruned");
48
+ assert.ok(existsSync(join(mgr.paths.memoryRoot, "work/acme/hot-memory.md")), "memory kept");
49
+ });
50
+ //# sourceMappingURL=reconcile.test.js.map
@@ -0,0 +1,37 @@
1
+ // Builds and maintains the memory tree. On first run it copies the seed files,
2
+ // initializes a git repo, and reconciles the manifest. On every subsequent run
3
+ // it reconciles only — so manifest edits take effect — but never overwrites
4
+ // files. Ported from chgo's scaffold.go.
5
+ import { existsSync, mkdirSync, readFileSync } from "fs";
6
+ import { join } from "path";
7
+ import { copySeedTree } from "./assets.js";
8
+ import { parseManifest } from "./domains.js";
9
+ import { reconcile } from "./reconcile.js";
10
+ import { gitCommitAll, gitInit } from "./git.js";
11
+ import { childLogger } from "../util/logger.js";
12
+ const log = childLogger("memory");
13
+ /** Scaffolds (when fresh) or reconciles (when existing) the memory tree. */
14
+ export async function scaffold(paths) {
15
+ const fresh = !existsSync(paths.memoryRoot);
16
+ if (fresh) {
17
+ log.info({ root: paths.memoryRoot }, "scaffolding fresh memory tree");
18
+ }
19
+ mkdirSync(paths.memoryRoot, { recursive: true });
20
+ copySeedTree(paths.memoryRoot);
21
+ mkdirSync(join(paths.memoryRoot, "cog-meta", "scenarios"), { recursive: true });
22
+ const manifestData = readFileSync(join(paths.memoryRoot, "domains.yml"), "utf8");
23
+ try {
24
+ const manifest = parseManifest(manifestData);
25
+ reconcile(paths, manifest);
26
+ }
27
+ catch (err) {
28
+ if (fresh)
29
+ throw err;
30
+ log.warn({ err }, "memory: domains.yml malformed, skipping reconcile");
31
+ }
32
+ if (fresh) {
33
+ await gitInit(paths.memoryRoot);
34
+ }
35
+ await gitCommitAll(paths.memoryRoot, "chore: scaffold memory");
36
+ }
37
+ //# sourceMappingURL=scaffold.js.map
@@ -0,0 +1,52 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import test from "node:test";
5
+ import { gitAvailable } from "./git.js";
6
+ import { MemoryManager } from "./manager.js";
7
+ const sandbox = join(process.cwd(), ".test-work", `mem-scaffold-${process.pid}`);
8
+ test.beforeEach(() => {
9
+ rmSync(sandbox, { recursive: true, force: true });
10
+ mkdirSync(sandbox, { recursive: true });
11
+ });
12
+ test.after(() => rmSync(sandbox, { recursive: true, force: true }));
13
+ test("ensureReady scaffolds a fresh memory tree", async () => {
14
+ if (!gitAvailable())
15
+ return;
16
+ const mgr = new MemoryManager(sandbox);
17
+ await mgr.ensureReady();
18
+ assert.equal(mgr.isReady(), true);
19
+ const root = mgr.paths.memoryRoot;
20
+ for (const rel of [
21
+ "domains.yml",
22
+ "hot-memory.md",
23
+ "glacier/index.md",
24
+ "cog-meta/patterns.md",
25
+ "cog-meta/reflect-cursor.md",
26
+ ]) {
27
+ assert.ok(existsSync(join(root, rel)), `expected ${rel}`);
28
+ }
29
+ assert.ok(existsSync(join(root, "cog-meta", "scenarios")), "cog-meta/scenarios dir");
30
+ assert.ok(existsSync(join(root, ".git")), ".git repo");
31
+ // The personal domain is generated from the manifest.
32
+ for (const rel of ["personal/hot-memory.md", "personal/observations.md", "personal/entities.md"]) {
33
+ assert.ok(existsSync(join(root, rel)), `expected ${rel}`);
34
+ }
35
+ // personal gets a generated skill; the system domain cog-meta does not.
36
+ assert.ok(existsSync(join(mgr.paths.skillsDomains, "personal", "SKILL.md")), "personal skill");
37
+ assert.ok(!existsSync(join(mgr.paths.skillsDomains, "cog-meta")), "cog-meta must not get a skill");
38
+ });
39
+ test("scaffold is idempotent and never overwrites edited files", async () => {
40
+ if (!gitAvailable())
41
+ return;
42
+ const first = new MemoryManager(sandbox);
43
+ await first.ensureReady();
44
+ const hot = join(first.paths.memoryRoot, "hot-memory.md");
45
+ writeFileSync(hot, "<!-- L0: edited -->\nhand-edited content");
46
+ const second = new MemoryManager(sandbox);
47
+ await second.ensureReady();
48
+ assert.equal(readFileSync(hot, "utf8"), "<!-- L0: edited -->\nhand-edited content");
49
+ const manifest = readFileSync(join(second.paths.memoryRoot, "domains.yml"), "utf8");
50
+ assert.equal(manifest.match(/id: personal/g)?.length, 1, "no duplicate domains");
51
+ });
52
+ //# sourceMappingURL=scaffold.test.js.map
@@ -0,0 +1,32 @@
1
+ // Write-lock + auto-commit wrapper for mutating memory tools. Every memory
2
+ // write goes through withMemoryWrite so the tree stays consistent and every
3
+ // change is committed to git.
4
+ import { gitCommitAll } from "../git.js";
5
+ /**
6
+ * Runs `fn` under the memory write-lock, then commits the tree with `message`.
7
+ */
8
+ export async function withMemoryWrite(manager, message, fn) {
9
+ const release = await manager.mutex.acquire();
10
+ try {
11
+ const result = await fn();
12
+ await gitCommitAll(manager.paths.memoryRoot, message);
13
+ return result;
14
+ }
15
+ finally {
16
+ release();
17
+ }
18
+ }
19
+ /**
20
+ * Runs `fn` under the memory write-lock without an automatic commit — for
21
+ * operations that commit themselves with a custom message.
22
+ */
23
+ export async function withMemoryLock(manager, fn) {
24
+ const release = await manager.mutex.acquire();
25
+ try {
26
+ return await fn();
27
+ }
28
+ finally {
29
+ release();
30
+ }
31
+ }
32
+ //# sourceMappingURL=commit-wrapper.js.map
@@ -0,0 +1,73 @@
1
+ // Domain tools: cog_domains, cog_domain_create. Ported from chgo's tools_domains.go.
2
+ import { defineTool } from "@github/copilot-sdk";
3
+ import { z } from "zod";
4
+ import { readFileSync, writeFileSync } from "fs";
5
+ import { join } from "path";
6
+ import { DOMAIN_TYPES, defaultFilesForType, findDomain, marshalManifest, parseManifest, } from "../domains.js";
7
+ import { reconcile } from "../reconcile.js";
8
+ import { gitCommitAll } from "../git.js";
9
+ import { withMemoryLock } from "./commit-wrapper.js";
10
+ import { toolError } from "./shared.js";
11
+ export function createDomainTools(manager) {
12
+ const root = manager.paths.memoryRoot;
13
+ const manifestPath = join(root, "domains.yml");
14
+ return [
15
+ defineTool("cog_domains", {
16
+ description: "Return the domain manifest (memory/domains.yml).",
17
+ parameters: z.object({}),
18
+ handler: async () => {
19
+ try {
20
+ return readFileSync(manifestPath, "utf8");
21
+ }
22
+ catch (err) {
23
+ return toolError(err);
24
+ }
25
+ },
26
+ }),
27
+ defineTool("cog_domain_create", {
28
+ description: "Add a domain: append it to domains.yml, create its memory directory and " +
29
+ "starter files, generate its skill, and commit. Used by the setup skill.",
30
+ parameters: z.object({
31
+ id: z.string().describe("Unique slug, e.g. acme"),
32
+ path: z.string().describe("Memory path, may be nested, e.g. work/acme"),
33
+ type: z.string().describe("personal | work | side-project | system"),
34
+ label: z.string().describe("One-line description"),
35
+ triggers: z.array(z.string()).optional().describe("Routing keywords for this domain"),
36
+ files: z.array(z.string()).optional().describe("Optional: memory files to create; defaults by type"),
37
+ }),
38
+ handler: async (args) => {
39
+ try {
40
+ if (!args.id || !args.path || !args.type) {
41
+ return "Error: id, path, and type are required";
42
+ }
43
+ if (!DOMAIN_TYPES.includes(args.type)) {
44
+ return `Error: invalid type "${args.type}"`;
45
+ }
46
+ return await withMemoryLock(manager, async () => {
47
+ const manifest = parseManifest(readFileSync(manifestPath, "utf8"));
48
+ if (findDomain(manifest, args.id)) {
49
+ return `Error: domain "${args.id}" already exists`;
50
+ }
51
+ const domain = {
52
+ id: args.id,
53
+ path: args.path,
54
+ type: args.type,
55
+ label: args.label,
56
+ triggers: args.triggers ?? [],
57
+ files: args.files && args.files.length > 0 ? args.files : defaultFilesForType(args.type),
58
+ };
59
+ manifest.domains.push(domain);
60
+ writeFileSync(manifestPath, marshalManifest(manifest), "utf8");
61
+ reconcile(manager.paths, manifest);
62
+ await gitCommitAll(root, `feat: add domain ${args.id}`);
63
+ return `created domain "${args.id}" at memory/${args.path} with skill ${args.id}`;
64
+ });
65
+ }
66
+ catch (err) {
67
+ return toolError(err);
68
+ }
69
+ },
70
+ }),
71
+ ];
72
+ }
73
+ //# sourceMappingURL=domains.js.map