@cleocode/adapters 2026.4.39 → 2026.4.40
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/cant-context.d.ts +130 -0
- package/dist/cant-context.d.ts.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +386 -199
- package/dist/index.js.map +4 -4
- package/dist/providers/claude-code/hooks.d.ts.map +1 -1
- package/dist/providers/claude-code/spawn.d.ts.map +1 -1
- package/dist/providers/opencode/spawn.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/cant-context.test.ts +248 -0
- package/src/cant-context.ts +379 -0
- package/src/index.ts +3 -0
- package/src/providers/claude-code/hooks.ts +12 -11
- package/src/providers/claude-code/spawn.ts +15 -1
- package/src/providers/opencode/spawn.ts +30 -11
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hooks.d.ts","sourceRoot":"","sources":["../../../src/providers/claude-code/hooks.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAMH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AA8C/D;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,qBAAa,sBAAuB,YAAW,mBAAmB;IAChE,kEAAkE;IAClE,OAAO,CAAC,UAAU,CAAS;IAE3B;;;;;;;;;;;OAWG;IACH,gBAAgB,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI;IAItD,+DAA+D;IAC/D,OAAO,CAAC,UAAU,CAAuB;IAEzC;;;;;;;;;;;;;;;;OAgBG;IACG,mBAAmB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;
|
|
1
|
+
{"version":3,"file":"hooks.d.ts","sourceRoot":"","sources":["../../../src/providers/claude-code/hooks.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAMH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AA8C/D;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,qBAAa,sBAAuB,YAAW,mBAAmB;IAChE,kEAAkE;IAClE,OAAO,CAAC,UAAU,CAAS;IAE3B;;;;;;;;;;;OAWG;IACH,gBAAgB,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI;IAItD,+DAA+D;IAC/D,OAAO,CAAC,UAAU,CAAuB;IAEzC;;;;;;;;;;;;;;;;OAgBG;IACG,mBAAmB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAuE5D;;;;;;;OAOG;IACG,qBAAqB,IAAI,OAAO,CAAC,IAAI,CAAC;IA2C5C;;OAEG;IACH,YAAY,IAAI,OAAO;IAIvB;;;;OAIG;IACH,aAAa,IAAI,MAAM,GAAG,IAAI;IAI9B;;;;;;;;OAQG;IACH,WAAW,IAAI,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAI/C;;;;;;;;;;OAUG;IACG,2BAA2B,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;IAStD;;;;;;;;;;OAUG;IACG,kBAAkB,IAAI,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC;IASnD;;;;;;;;;;OAUG;IACG,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IAW9D;;;;;;;;;;;;OAYG;IACG,aAAa,CAAC,UAAU,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;CA2DrF"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"spawn.d.ts","sourceRoot":"","sources":["../../../src/providers/claude-code/spawn.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAKH,OAAO,KAAK,EAAE,oBAAoB,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAY3F;;;;;;;;;;;;;;GAcG;AACH,qBAAa,uBAAwB,YAAW,oBAAoB;IAClE,mDAAmD;IACnD,OAAO,CAAC,UAAU,CAAqC;IAEvD;;;;OAIG;IACG,QAAQ,IAAI,OAAO,CAAC,OAAO,CAAC;IASlC;;;;;;;;OAQG;IACG,KAAK,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"spawn.d.ts","sourceRoot":"","sources":["../../../src/providers/claude-code/spawn.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAKH,OAAO,KAAK,EAAE,oBAAoB,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAY3F;;;;;;;;;;;;;;GAcG;AACH,qBAAa,uBAAwB,YAAW,oBAAoB;IAClE,mDAAmD;IACnD,OAAO,CAAC,UAAU,CAAqC;IAEvD;;;;OAIG;IACG,QAAQ,IAAI,OAAO,CAAC,OAAO,CAAC;IASlC;;;;;;;;OAQG;IACG,KAAK,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,WAAW,CAAC;IAqFxD;;;;;;;OAOG;IACG,WAAW,IAAI,OAAO,CAAC,WAAW,EAAE,CAAC;IAqB3C;;;;;;;OAOG;IACG,SAAS,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAWnD"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"spawn.d.ts","sourceRoot":"","sources":["../../../src/providers/opencode/spawn.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAMH,OAAO,KAAK,EAAE,oBAAoB,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAiB3F;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,0BAA0B,CAAC,WAAW,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM,CAY5F;
|
|
1
|
+
{"version":3,"file":"spawn.d.ts","sourceRoot":"","sources":["../../../src/providers/opencode/spawn.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAMH,OAAO,KAAK,EAAE,oBAAoB,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAiB3F;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,0BAA0B,CAAC,WAAW,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM,CAY5F;AA+CD;;;;;;;;;;;;;;GAcG;AACH,qBAAa,qBAAsB,YAAW,oBAAoB;IAChE,mDAAmD;IACnD,OAAO,CAAC,UAAU,CAAqC;IAEvD;;;;OAIG;IACG,QAAQ,IAAI,OAAO,CAAC,OAAO,CAAC;IASlC;;;;;;;;;OASG;IACG,KAAK,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,WAAW,CAAC;IA+ExD;;;;;;;OAOG;IACG,WAAW,IAAI,OAAO,CAAC,WAAW,EAAE,CAAC;IAqB3C;;;;;;;OAOG;IACG,SAAS,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAWnD"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cleocode/adapters",
|
|
3
|
-
"version": "2026.4.
|
|
3
|
+
"version": "2026.4.40",
|
|
4
4
|
"description": "Unified provider adapters for CLEO (Claude Code, OpenCode, Cursor)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
}
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@cleocode/caamp": "2026.4.
|
|
16
|
-
"@cleocode/contracts": "2026.4.
|
|
15
|
+
"@cleocode/caamp": "2026.4.40",
|
|
16
|
+
"@cleocode/contracts": "2026.4.40"
|
|
17
17
|
},
|
|
18
18
|
"license": "MIT",
|
|
19
19
|
"engines": {
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the shared CANT context builder.
|
|
3
|
+
*
|
|
4
|
+
* Tests cover:
|
|
5
|
+
* - discoverCantFiles: finds .cant files, handles missing dirs
|
|
6
|
+
* - resolveThreeTierPaths: XDG-compliant paths with env var overrides
|
|
7
|
+
* - discoverCantFilesMultiTier: 3-tier merge with override semantics
|
|
8
|
+
* - readMemoryBridge: reads file, handles missing/empty
|
|
9
|
+
* - buildMemoryBridgeBlock: wraps content in labeled section
|
|
10
|
+
* - buildMentalModelInjection: pure function, numbered list, empty input
|
|
11
|
+
* - buildCantEnrichedPrompt: full pipeline, fallback on failure
|
|
12
|
+
*
|
|
13
|
+
* @task T555
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { tmpdir } from 'node:os';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
buildCantEnrichedPrompt,
|
|
23
|
+
buildMemoryBridgeBlock,
|
|
24
|
+
buildMentalModelInjection,
|
|
25
|
+
discoverCantFiles,
|
|
26
|
+
discoverCantFilesMultiTier,
|
|
27
|
+
readMemoryBridge,
|
|
28
|
+
resolveThreeTierPaths,
|
|
29
|
+
} from '../cant-context.js';
|
|
30
|
+
|
|
31
|
+
let tempDir: string;
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
tempDir = join(tmpdir(), `cleo-cant-ctx-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`);
|
|
35
|
+
mkdirSync(tempDir, { recursive: true });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// discoverCantFiles
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
describe('discoverCantFiles', () => {
|
|
47
|
+
it('finds .cant files recursively', () => {
|
|
48
|
+
const cantDir = join(tempDir, 'cant');
|
|
49
|
+
mkdirSync(join(cantDir, 'agents'), { recursive: true });
|
|
50
|
+
writeFileSync(join(cantDir, 'team.cant'), 'team: test');
|
|
51
|
+
writeFileSync(join(cantDir, 'agents', 'worker.cant'), 'agent: worker');
|
|
52
|
+
writeFileSync(join(cantDir, 'agents', 'README.md'), 'ignored');
|
|
53
|
+
|
|
54
|
+
const files = discoverCantFiles(cantDir);
|
|
55
|
+
expect(files).toHaveLength(2);
|
|
56
|
+
expect(files.some((f) => f.endsWith('team.cant'))).toBe(true);
|
|
57
|
+
expect(files.some((f) => f.endsWith('worker.cant'))).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('returns empty array for non-existent directory', () => {
|
|
61
|
+
const files = discoverCantFiles(join(tempDir, 'does-not-exist'));
|
|
62
|
+
expect(files).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// resolveThreeTierPaths
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
describe('resolveThreeTierPaths', () => {
|
|
71
|
+
it('returns project tier pointing to .cleo/cant/', () => {
|
|
72
|
+
const paths = resolveThreeTierPaths('/my/project');
|
|
73
|
+
expect(paths.project).toBe('/my/project/.cleo/cant');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('respects XDG_DATA_HOME for global tier', () => {
|
|
77
|
+
const originalXdg = process.env['XDG_DATA_HOME'];
|
|
78
|
+
process.env['XDG_DATA_HOME'] = '/custom/data';
|
|
79
|
+
try {
|
|
80
|
+
const paths = resolveThreeTierPaths('/my/project');
|
|
81
|
+
expect(paths.global).toBe('/custom/data/cleo/cant');
|
|
82
|
+
} finally {
|
|
83
|
+
if (originalXdg) process.env['XDG_DATA_HOME'] = originalXdg;
|
|
84
|
+
else delete process.env['XDG_DATA_HOME'];
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('respects XDG_CONFIG_HOME for user tier', () => {
|
|
89
|
+
const originalXdg = process.env['XDG_CONFIG_HOME'];
|
|
90
|
+
process.env['XDG_CONFIG_HOME'] = '/custom/config';
|
|
91
|
+
try {
|
|
92
|
+
const paths = resolveThreeTierPaths('/my/project');
|
|
93
|
+
expect(paths.user).toBe('/custom/config/cleo/cant');
|
|
94
|
+
} finally {
|
|
95
|
+
if (originalXdg) process.env['XDG_CONFIG_HOME'] = originalXdg;
|
|
96
|
+
else delete process.env['XDG_CONFIG_HOME'];
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// discoverCantFilesMultiTier
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
describe('discoverCantFilesMultiTier', () => {
|
|
106
|
+
let origXdgData: string | undefined;
|
|
107
|
+
let origXdgConfig: string | undefined;
|
|
108
|
+
|
|
109
|
+
beforeEach(() => {
|
|
110
|
+
// Override XDG paths so global/user tiers point to empty temp subdirs
|
|
111
|
+
origXdgData = process.env['XDG_DATA_HOME'];
|
|
112
|
+
origXdgConfig = process.env['XDG_CONFIG_HOME'];
|
|
113
|
+
process.env['XDG_DATA_HOME'] = join(tempDir, 'xdg-data');
|
|
114
|
+
process.env['XDG_CONFIG_HOME'] = join(tempDir, 'xdg-config');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
afterEach(() => {
|
|
118
|
+
if (origXdgData) process.env['XDG_DATA_HOME'] = origXdgData;
|
|
119
|
+
else delete process.env['XDG_DATA_HOME'];
|
|
120
|
+
if (origXdgConfig) process.env['XDG_CONFIG_HOME'] = origXdgConfig;
|
|
121
|
+
else delete process.env['XDG_CONFIG_HOME'];
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('discovers files from project tier', () => {
|
|
125
|
+
const cantDir = join(tempDir, '.cleo', 'cant');
|
|
126
|
+
mkdirSync(cantDir, { recursive: true });
|
|
127
|
+
writeFileSync(join(cantDir, 'team.cant'), 'team: test');
|
|
128
|
+
|
|
129
|
+
const result = discoverCantFilesMultiTier(tempDir);
|
|
130
|
+
expect(result.files).toHaveLength(1);
|
|
131
|
+
expect(result.stats.project).toBe(1);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('returns empty when no tiers have .cant files', () => {
|
|
135
|
+
const result = discoverCantFilesMultiTier(tempDir);
|
|
136
|
+
expect(result.files).toHaveLength(0);
|
|
137
|
+
expect(result.stats.merged).toBe(0);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// readMemoryBridge
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
describe('readMemoryBridge', () => {
|
|
146
|
+
it('returns null when file does not exist', () => {
|
|
147
|
+
expect(readMemoryBridge(tempDir)).toBeNull();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('returns content when file exists', () => {
|
|
151
|
+
const cleoDir = join(tempDir, '.cleo');
|
|
152
|
+
mkdirSync(cleoDir, { recursive: true });
|
|
153
|
+
writeFileSync(join(cleoDir, 'memory-bridge.md'), '# Memory Bridge\nTest content');
|
|
154
|
+
|
|
155
|
+
const result = readMemoryBridge(tempDir);
|
|
156
|
+
expect(result).toContain('Memory Bridge');
|
|
157
|
+
expect(result).toContain('Test content');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('returns null for empty file', () => {
|
|
161
|
+
const cleoDir = join(tempDir, '.cleo');
|
|
162
|
+
mkdirSync(cleoDir, { recursive: true });
|
|
163
|
+
writeFileSync(join(cleoDir, 'memory-bridge.md'), '');
|
|
164
|
+
|
|
165
|
+
expect(readMemoryBridge(tempDir)).toBeNull();
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// buildMemoryBridgeBlock
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
describe('buildMemoryBridgeBlock', () => {
|
|
174
|
+
it('wraps content in labeled section markers', () => {
|
|
175
|
+
const result = buildMemoryBridgeBlock('Test content');
|
|
176
|
+
expect(result).toContain('===== CLEO MEMORY BRIDGE =====');
|
|
177
|
+
expect(result).toContain('Test content');
|
|
178
|
+
expect(result).toContain('===== END MEMORY BRIDGE =====');
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// buildMentalModelInjection
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
describe('buildMentalModelInjection', () => {
|
|
187
|
+
it('returns empty string for empty observations', () => {
|
|
188
|
+
expect(buildMentalModelInjection('test-agent', [])).toBe('');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('builds numbered list with preamble', () => {
|
|
192
|
+
const result = buildMentalModelInjection('code-worker', [
|
|
193
|
+
{ id: 'O-001', type: 'observation', title: 'Tests pass', date: '2026-04-14' },
|
|
194
|
+
{ id: 'O-002', type: 'pattern', title: 'Use vitest' },
|
|
195
|
+
]);
|
|
196
|
+
|
|
197
|
+
expect(result).toContain('MENTAL MODEL (validate-on-load)');
|
|
198
|
+
expect(result).toContain('Agent: code-worker');
|
|
199
|
+
expect(result).toContain('1. [O-001] (observation) [2026-04-14]: Tests pass');
|
|
200
|
+
expect(result).toContain('2. [O-002] (pattern): Use vitest');
|
|
201
|
+
expect(result).toContain('END MENTAL MODEL');
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// buildCantEnrichedPrompt
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
describe('buildCantEnrichedPrompt', () => {
|
|
210
|
+
it('returns basePrompt unchanged when no .cant files exist', async () => {
|
|
211
|
+
const result = await buildCantEnrichedPrompt({
|
|
212
|
+
projectDir: tempDir,
|
|
213
|
+
basePrompt: 'Execute the task.',
|
|
214
|
+
});
|
|
215
|
+
expect(result).toBe('Execute the task.');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('appends memory bridge when .cleo/memory-bridge.md exists', async () => {
|
|
219
|
+
const cleoDir = join(tempDir, '.cleo');
|
|
220
|
+
mkdirSync(cleoDir, { recursive: true });
|
|
221
|
+
writeFileSync(join(cleoDir, 'memory-bridge.md'), '# Bridge\nRecent decisions here');
|
|
222
|
+
|
|
223
|
+
const result = await buildCantEnrichedPrompt({
|
|
224
|
+
projectDir: tempDir,
|
|
225
|
+
basePrompt: 'Execute the task.',
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
expect(result).toContain('Execute the task.');
|
|
229
|
+
expect(result).toContain('CLEO MEMORY BRIDGE');
|
|
230
|
+
expect(result).toContain('Recent decisions here');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('includes both memory bridge and base prompt without duplication', async () => {
|
|
234
|
+
const cleoDir = join(tempDir, '.cleo');
|
|
235
|
+
mkdirSync(cleoDir, { recursive: true });
|
|
236
|
+
writeFileSync(join(cleoDir, 'memory-bridge.md'), 'Bridge content');
|
|
237
|
+
|
|
238
|
+
const result = await buildCantEnrichedPrompt({
|
|
239
|
+
projectDir: tempDir,
|
|
240
|
+
basePrompt: 'My prompt',
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// Base prompt should appear exactly once at the start
|
|
244
|
+
expect(result.startsWith('My prompt')).toBe(true);
|
|
245
|
+
// Should not duplicate the prompt
|
|
246
|
+
expect(result.indexOf('My prompt')).toBe(result.lastIndexOf('My prompt'));
|
|
247
|
+
});
|
|
248
|
+
});
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared CANT context builder for all spawn providers.
|
|
3
|
+
*
|
|
4
|
+
* Extracts the Pi bridge's CANT discovery/compile/inject logic into a reusable
|
|
5
|
+
* module that any spawn provider (Claude Code, OpenCode, Cursor, etc.) can call
|
|
6
|
+
* to enrich agent prompts with:
|
|
7
|
+
*
|
|
8
|
+
* 1. Compiled CANT bundle (team topology, agent personas, tool ACLs)
|
|
9
|
+
* 2. Memory bridge (recent decisions, handoff notes, key patterns)
|
|
10
|
+
* 3. Mental model injection (validate-on-load agent-specific observations)
|
|
11
|
+
*
|
|
12
|
+
* All operations are best-effort: if any step fails (missing packages, empty
|
|
13
|
+
* directories, compilation errors), the base prompt is returned unchanged.
|
|
14
|
+
* This guarantees agents always spawn — CANT context is an enrichment, not a gate.
|
|
15
|
+
*
|
|
16
|
+
* Reference implementation: packages/cleo-os/extensions/cleo-cant-bridge.ts
|
|
17
|
+
* (Pi-only; this module generalizes the same logic for all providers)
|
|
18
|
+
*
|
|
19
|
+
* @task T555
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
23
|
+
import { homedir } from 'node:os';
|
|
24
|
+
import { basename, join } from 'node:path';
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Types
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/** Per-tier file counts for diagnostic reporting. */
|
|
31
|
+
export interface TierDiscoveryStats {
|
|
32
|
+
global: number;
|
|
33
|
+
user: number;
|
|
34
|
+
project: number;
|
|
35
|
+
overrides: number;
|
|
36
|
+
merged: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Minimal observation shape returned by memoryFind / searchBrainCompact. */
|
|
40
|
+
export interface MentalModelObservation {
|
|
41
|
+
id: string;
|
|
42
|
+
type: string;
|
|
43
|
+
title: string;
|
|
44
|
+
date?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Options for the main enrichment function. */
|
|
48
|
+
export interface BuildCantEnrichedPromptOptions {
|
|
49
|
+
/** Project root directory for .cleo/cant/ discovery and brain.db access. */
|
|
50
|
+
projectDir: string;
|
|
51
|
+
/** The raw prompt to enrich. Returned unchanged if no CANT context is available. */
|
|
52
|
+
basePrompt: string;
|
|
53
|
+
/** Agent name for mental model injection. Omit to skip mental model fetch. */
|
|
54
|
+
agentName?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Constants
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Preamble text injected when an agent has mental model observations.
|
|
63
|
+
* The agent MUST re-evaluate each observation against current project state.
|
|
64
|
+
*/
|
|
65
|
+
const VALIDATE_ON_LOAD_PREAMBLE =
|
|
66
|
+
'===== MENTAL MODEL (validate-on-load) =====\n' +
|
|
67
|
+
'These are your prior observations, patterns, and learnings for this project.\n' +
|
|
68
|
+
'Before acting, you MUST re-evaluate each entry against current project state.\n' +
|
|
69
|
+
'If an entry is stale, note it and proceed with fresh understanding.';
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Discovery functions (ported from cleo-cant-bridge.ts lines 418-526)
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Recursively discover `.cant` files in a directory.
|
|
77
|
+
*
|
|
78
|
+
* @param dir - The directory to scan recursively.
|
|
79
|
+
* @returns An array of absolute paths to `.cant` files found.
|
|
80
|
+
*/
|
|
81
|
+
export function discoverCantFiles(dir: string): string[] {
|
|
82
|
+
try {
|
|
83
|
+
const entries = readdirSync(dir, { recursive: true, withFileTypes: true });
|
|
84
|
+
const files: string[] = [];
|
|
85
|
+
for (const entry of entries) {
|
|
86
|
+
if (entry.isFile() && entry.name.endsWith('.cant')) {
|
|
87
|
+
const parent = (entry as unknown as { parentPath?: string }).parentPath ?? dir;
|
|
88
|
+
files.push(join(parent, entry.name));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return files;
|
|
92
|
+
} catch {
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Resolve XDG-compliant paths for the 3-tier CANT hierarchy.
|
|
99
|
+
*
|
|
100
|
+
* Respects `XDG_DATA_HOME` and `XDG_CONFIG_HOME` environment variables.
|
|
101
|
+
* Falls back to XDG defaults (`~/.local/share/` and `~/.config/`).
|
|
102
|
+
*
|
|
103
|
+
* @param projectDir - The project root directory (for the project tier).
|
|
104
|
+
* @returns An object with `global`, `user`, and `project` CANT directory paths.
|
|
105
|
+
*/
|
|
106
|
+
export function resolveThreeTierPaths(projectDir: string): {
|
|
107
|
+
global: string;
|
|
108
|
+
user: string;
|
|
109
|
+
project: string;
|
|
110
|
+
} {
|
|
111
|
+
const home = homedir();
|
|
112
|
+
const xdgData = process.env['XDG_DATA_HOME'] ?? join(home, '.local', 'share');
|
|
113
|
+
const xdgConfig = process.env['XDG_CONFIG_HOME'] ?? join(home, '.config');
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
global: join(xdgData, 'cleo', 'cant'),
|
|
117
|
+
user: join(xdgConfig, 'cleo', 'cant'),
|
|
118
|
+
project: join(projectDir, '.cleo', 'cant'),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Discover `.cant` files across all three tiers with override semantics.
|
|
124
|
+
*
|
|
125
|
+
* Scans global, user, and project tiers. Files in higher-precedence tiers
|
|
126
|
+
* override files in lower-precedence tiers that share the same basename.
|
|
127
|
+
* The precedence order is: project > user > global.
|
|
128
|
+
*
|
|
129
|
+
* @param projectDir - The project root directory.
|
|
130
|
+
* @returns An object containing the merged file list and per-tier statistics.
|
|
131
|
+
*/
|
|
132
|
+
export function discoverCantFilesMultiTier(projectDir: string): {
|
|
133
|
+
files: string[];
|
|
134
|
+
stats: TierDiscoveryStats;
|
|
135
|
+
} {
|
|
136
|
+
const paths = resolveThreeTierPaths(projectDir);
|
|
137
|
+
|
|
138
|
+
const globalFiles = discoverCantFiles(paths.global);
|
|
139
|
+
const userFiles = discoverCantFiles(paths.user);
|
|
140
|
+
const projectFiles = discoverCantFiles(paths.project);
|
|
141
|
+
|
|
142
|
+
// Build basename-keyed map; lowest precedence first so higher tiers override
|
|
143
|
+
const fileMap = new Map<string, string>();
|
|
144
|
+
|
|
145
|
+
for (const file of globalFiles) {
|
|
146
|
+
fileMap.set(basename(file), file);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
for (const file of userFiles) {
|
|
150
|
+
fileMap.set(basename(file), file);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
for (const file of projectFiles) {
|
|
154
|
+
fileMap.set(basename(file), file);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const totalUniqueInputs = globalFiles.length + userFiles.length + projectFiles.length;
|
|
158
|
+
const overrides = totalUniqueInputs - fileMap.size;
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
files: Array.from(fileMap.values()),
|
|
162
|
+
stats: {
|
|
163
|
+
global: globalFiles.length,
|
|
164
|
+
user: userFiles.length,
|
|
165
|
+
project: projectFiles.length,
|
|
166
|
+
overrides,
|
|
167
|
+
merged: fileMap.size,
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// Memory bridge (ported from cleo-cant-bridge.ts lines 376-404)
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Read the memory bridge file from a project's .cleo/ directory.
|
|
178
|
+
*
|
|
179
|
+
* @param projectDir - The project root directory.
|
|
180
|
+
* @returns The memory bridge content, or null if not found or empty.
|
|
181
|
+
*/
|
|
182
|
+
export function readMemoryBridge(projectDir: string): string | null {
|
|
183
|
+
try {
|
|
184
|
+
const bridgePath = join(projectDir, '.cleo', 'memory-bridge.md');
|
|
185
|
+
if (!existsSync(bridgePath)) return null;
|
|
186
|
+
const content = readFileSync(bridgePath, 'utf-8');
|
|
187
|
+
return content.length > 0 ? content : null;
|
|
188
|
+
} catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Build the memory-bridge system-prompt block appended to every agent.
|
|
195
|
+
*
|
|
196
|
+
* Wraps the raw memory-bridge.md content in a clearly labeled section
|
|
197
|
+
* so the agent knows this is the CLEO project memory context.
|
|
198
|
+
*
|
|
199
|
+
* @param content - The raw memory-bridge.md content.
|
|
200
|
+
* @returns The formatted memory-bridge block for system prompt injection.
|
|
201
|
+
*/
|
|
202
|
+
export function buildMemoryBridgeBlock(content: string): string {
|
|
203
|
+
return (
|
|
204
|
+
'\n\n===== CLEO MEMORY BRIDGE =====\n' +
|
|
205
|
+
'This is your project memory context from .cleo/memory-bridge.md.\n' +
|
|
206
|
+
'Use it to understand recent decisions, handoff notes, and key patterns.\n\n' +
|
|
207
|
+
content.trim() +
|
|
208
|
+
'\n===== END MEMORY BRIDGE ====='
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// Mental model injection (ported from cleo-cant-bridge.ts lines 113-135, 543-589)
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Build the validate-on-load mental-model injection string.
|
|
218
|
+
*
|
|
219
|
+
* Pure function — no I/O, safe to call in tests without a real DB.
|
|
220
|
+
*
|
|
221
|
+
* @param agentName - Name of the spawned agent (used in the header line).
|
|
222
|
+
* @param observations - Prior mental-model observations to list.
|
|
223
|
+
* @returns System-prompt block with preamble and numbered observations,
|
|
224
|
+
* or empty string when `observations` is empty.
|
|
225
|
+
*/
|
|
226
|
+
export function buildMentalModelInjection(
|
|
227
|
+
agentName: string,
|
|
228
|
+
observations: MentalModelObservation[],
|
|
229
|
+
): string {
|
|
230
|
+
if (observations.length === 0) return '';
|
|
231
|
+
|
|
232
|
+
const lines: string[] = ['', `// Agent: ${agentName}`, VALIDATE_ON_LOAD_PREAMBLE, ''];
|
|
233
|
+
|
|
234
|
+
for (let i = 0; i < observations.length; i++) {
|
|
235
|
+
const obs = observations[i];
|
|
236
|
+
const datePart = obs.date ? ` [${obs.date}]` : '';
|
|
237
|
+
lines.push(`${i + 1}. [${obs.id}] (${obs.type})${datePart}: ${obs.title}`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
lines.push('===== END MENTAL MODEL =====');
|
|
241
|
+
return lines.join('\n');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Fetch mental model observations for an agent from brain.db.
|
|
246
|
+
*
|
|
247
|
+
* Uses dynamic import of `@cleocode/core` to avoid circular dependencies.
|
|
248
|
+
* Returns empty string on any failure (best-effort, never throws).
|
|
249
|
+
*
|
|
250
|
+
* @param agentName - The agent's name for scoped observation lookup.
|
|
251
|
+
* @param projectRoot - Project root directory for brain.db access.
|
|
252
|
+
* @returns The validate-on-load system-prompt block, or "" on failure/empty.
|
|
253
|
+
*/
|
|
254
|
+
async function fetchMentalModelInjection(agentName: string, projectRoot: string): Promise<string> {
|
|
255
|
+
try {
|
|
256
|
+
// Dynamic import — @cleocode/core is NOT a compile-time dependency of adapters.
|
|
257
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
258
|
+
const coreModule = (await import(/* webpackIgnore: true */ '@cleocode/core' as string)) as {
|
|
259
|
+
memoryFind?: (
|
|
260
|
+
params: {
|
|
261
|
+
query: string;
|
|
262
|
+
agent?: string;
|
|
263
|
+
limit?: number;
|
|
264
|
+
tables?: string[];
|
|
265
|
+
},
|
|
266
|
+
projectRoot?: string,
|
|
267
|
+
) => Promise<{
|
|
268
|
+
success: boolean;
|
|
269
|
+
data?: {
|
|
270
|
+
results?: MentalModelObservation[];
|
|
271
|
+
};
|
|
272
|
+
}>;
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
if (typeof coreModule.memoryFind !== 'function') return '';
|
|
276
|
+
|
|
277
|
+
const result = await coreModule.memoryFind(
|
|
278
|
+
{
|
|
279
|
+
query: agentName,
|
|
280
|
+
agent: agentName,
|
|
281
|
+
limit: 10,
|
|
282
|
+
tables: ['observations'],
|
|
283
|
+
},
|
|
284
|
+
projectRoot,
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
if (!result.success || !result.data?.results?.length) return '';
|
|
288
|
+
|
|
289
|
+
return buildMentalModelInjection(agentName, result.data.results);
|
|
290
|
+
} catch {
|
|
291
|
+
return '';
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
// Main entry point
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Build an enriched prompt with CANT context, memory bridge, and mental model.
|
|
301
|
+
*
|
|
302
|
+
* This is the universal entry point for all spawn providers. It performs the
|
|
303
|
+
* same operations as the Pi bridge (cleo-cant-bridge.ts) but returns a string
|
|
304
|
+
* rather than hooking into Pi events:
|
|
305
|
+
*
|
|
306
|
+
* 1. Discovers `.cant` files across 3 tiers (global → user → project)
|
|
307
|
+
* 2. Compiles the CANT bundle via `@cleocode/cant`'s `compileBundle()`
|
|
308
|
+
* 3. Renders the compiled system prompt
|
|
309
|
+
* 4. Reads the memory bridge from `.cleo/memory-bridge.md`
|
|
310
|
+
* 5. Fetches mental model observations for the named agent
|
|
311
|
+
* 6. Concatenates: basePrompt + CANT bundle + memory bridge + mental model
|
|
312
|
+
*
|
|
313
|
+
* All operations are best-effort. If any step fails, the base prompt is
|
|
314
|
+
* returned unchanged. CANT context is an enrichment, not a gate — agents
|
|
315
|
+
* always spawn regardless of CANT availability.
|
|
316
|
+
*
|
|
317
|
+
* @param options - Project dir, base prompt, and optional agent name.
|
|
318
|
+
* @returns The enriched prompt string, or basePrompt unchanged on failure.
|
|
319
|
+
*/
|
|
320
|
+
export async function buildCantEnrichedPrompt(
|
|
321
|
+
options: BuildCantEnrichedPromptOptions,
|
|
322
|
+
): Promise<string> {
|
|
323
|
+
const { projectDir, basePrompt, agentName } = options;
|
|
324
|
+
let appendix = '';
|
|
325
|
+
|
|
326
|
+
// Step 1-3: Discover and compile CANT bundle
|
|
327
|
+
try {
|
|
328
|
+
const { files } = discoverCantFilesMultiTier(projectDir);
|
|
329
|
+
|
|
330
|
+
if (files.length > 0) {
|
|
331
|
+
// Dynamic import — @cleocode/cant is NOT a compile-time dependency of adapters.
|
|
332
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
333
|
+
const cantModule = (await import(/* webpackIgnore: true */ '@cleocode/cant' as string)) as {
|
|
334
|
+
compileBundle?: (paths: string[]) => {
|
|
335
|
+
renderSystemPrompt: () => string;
|
|
336
|
+
valid: boolean;
|
|
337
|
+
diagnostics: unknown[];
|
|
338
|
+
};
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
if (typeof cantModule.compileBundle === 'function') {
|
|
342
|
+
const bundle = cantModule.compileBundle(files);
|
|
343
|
+
if (bundle.valid) {
|
|
344
|
+
const rendered = bundle.renderSystemPrompt();
|
|
345
|
+
if (rendered) {
|
|
346
|
+
appendix += `\n\n${rendered}`;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
} catch {
|
|
352
|
+
// CANT compilation failure — continue without bundle context
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Step 4: Append memory bridge
|
|
356
|
+
try {
|
|
357
|
+
const bridge = readMemoryBridge(projectDir);
|
|
358
|
+
if (bridge) {
|
|
359
|
+
appendix += buildMemoryBridgeBlock(bridge);
|
|
360
|
+
}
|
|
361
|
+
} catch {
|
|
362
|
+
// Memory bridge read failure — non-fatal
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Step 5: Append mental model for named agent
|
|
366
|
+
if (agentName) {
|
|
367
|
+
try {
|
|
368
|
+
const mentalModel = await fetchMentalModelInjection(agentName, projectDir);
|
|
369
|
+
if (mentalModel) {
|
|
370
|
+
appendix += `\n\n${mentalModel}`;
|
|
371
|
+
}
|
|
372
|
+
} catch {
|
|
373
|
+
// Mental model fetch failure — non-fatal
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Step 6: Return enriched prompt (or unchanged basePrompt if no context found)
|
|
378
|
+
return appendix ? basePrompt + appendix : basePrompt;
|
|
379
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
* registry enable dynamic adapter loading by AdapterManager.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
export type { BuildCantEnrichedPromptOptions, TierDiscoveryStats } from './cant-context.js';
|
|
17
|
+
// Shared CANT context builder — used by all spawn providers
|
|
18
|
+
export { buildCantEnrichedPrompt } from './cant-context.js';
|
|
16
19
|
// Re-export adapter classes for direct use
|
|
17
20
|
// Per-provider factory functions (renamed to avoid collisions)
|
|
18
21
|
export {
|