@auxiora/personality 1.0.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 (149) hide show
  1. package/LICENSE +191 -0
  2. package/dist/__tests__/builder.test.d.ts +2 -0
  3. package/dist/__tests__/builder.test.d.ts.map +1 -0
  4. package/dist/__tests__/builder.test.js +67 -0
  5. package/dist/__tests__/builder.test.js.map +1 -0
  6. package/dist/__tests__/conversation-builder.test.d.ts +2 -0
  7. package/dist/__tests__/conversation-builder.test.d.ts.map +1 -0
  8. package/dist/__tests__/conversation-builder.test.js +324 -0
  9. package/dist/__tests__/conversation-builder.test.js.map +1 -0
  10. package/dist/__tests__/escalation.test.d.ts +2 -0
  11. package/dist/__tests__/escalation.test.d.ts.map +1 -0
  12. package/dist/__tests__/escalation.test.js +143 -0
  13. package/dist/__tests__/escalation.test.js.map +1 -0
  14. package/dist/__tests__/manager.test.d.ts +2 -0
  15. package/dist/__tests__/manager.test.d.ts.map +1 -0
  16. package/dist/__tests__/manager.test.js +96 -0
  17. package/dist/__tests__/manager.test.js.map +1 -0
  18. package/dist/__tests__/parser.test.d.ts +2 -0
  19. package/dist/__tests__/parser.test.d.ts.map +1 -0
  20. package/dist/__tests__/parser.test.js +89 -0
  21. package/dist/__tests__/parser.test.js.map +1 -0
  22. package/dist/__tests__/security-floor.test.d.ts +2 -0
  23. package/dist/__tests__/security-floor.test.d.ts.map +1 -0
  24. package/dist/__tests__/security-floor.test.js +183 -0
  25. package/dist/__tests__/security-floor.test.js.map +1 -0
  26. package/dist/builder.d.ts +6 -0
  27. package/dist/builder.d.ts.map +1 -0
  28. package/dist/builder.js +65 -0
  29. package/dist/builder.js.map +1 -0
  30. package/dist/conversation-builder.d.ts +30 -0
  31. package/dist/conversation-builder.d.ts.map +1 -0
  32. package/dist/conversation-builder.js +232 -0
  33. package/dist/conversation-builder.js.map +1 -0
  34. package/dist/escalation.d.ts +35 -0
  35. package/dist/escalation.d.ts.map +1 -0
  36. package/dist/escalation.js +134 -0
  37. package/dist/escalation.js.map +1 -0
  38. package/dist/index.d.ts +21 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/index.js +20 -0
  41. package/dist/index.js.map +1 -0
  42. package/dist/manager.d.ts +28 -0
  43. package/dist/manager.d.ts.map +1 -0
  44. package/dist/manager.js +114 -0
  45. package/dist/manager.js.map +1 -0
  46. package/dist/marketplace/__tests__/scanner.test.d.ts +2 -0
  47. package/dist/marketplace/__tests__/scanner.test.d.ts.map +1 -0
  48. package/dist/marketplace/__tests__/scanner.test.js +134 -0
  49. package/dist/marketplace/__tests__/scanner.test.js.map +1 -0
  50. package/dist/marketplace/__tests__/schema.test.d.ts +2 -0
  51. package/dist/marketplace/__tests__/schema.test.d.ts.map +1 -0
  52. package/dist/marketplace/__tests__/schema.test.js +243 -0
  53. package/dist/marketplace/__tests__/schema.test.js.map +1 -0
  54. package/dist/marketplace/scanner.d.ts +19 -0
  55. package/dist/marketplace/scanner.d.ts.map +1 -0
  56. package/dist/marketplace/scanner.js +62 -0
  57. package/dist/marketplace/scanner.js.map +1 -0
  58. package/dist/marketplace/schema.d.ts +150 -0
  59. package/dist/marketplace/schema.d.ts.map +1 -0
  60. package/dist/marketplace/schema.js +122 -0
  61. package/dist/marketplace/schema.js.map +1 -0
  62. package/dist/modes/__tests__/mode-detector.test.d.ts +2 -0
  63. package/dist/modes/__tests__/mode-detector.test.d.ts.map +1 -0
  64. package/dist/modes/__tests__/mode-detector.test.js +130 -0
  65. package/dist/modes/__tests__/mode-detector.test.js.map +1 -0
  66. package/dist/modes/__tests__/mode-loader.test.d.ts +2 -0
  67. package/dist/modes/__tests__/mode-loader.test.d.ts.map +1 -0
  68. package/dist/modes/__tests__/mode-loader.test.js +91 -0
  69. package/dist/modes/__tests__/mode-loader.test.js.map +1 -0
  70. package/dist/modes/__tests__/prompt-assembler.test.d.ts +2 -0
  71. package/dist/modes/__tests__/prompt-assembler.test.d.ts.map +1 -0
  72. package/dist/modes/__tests__/prompt-assembler.test.js +241 -0
  73. package/dist/modes/__tests__/prompt-assembler.test.js.map +1 -0
  74. package/dist/modes/mode-detector.d.ts +10 -0
  75. package/dist/modes/mode-detector.d.ts.map +1 -0
  76. package/dist/modes/mode-detector.js +70 -0
  77. package/dist/modes/mode-detector.js.map +1 -0
  78. package/dist/modes/mode-loader.d.ts +14 -0
  79. package/dist/modes/mode-loader.d.ts.map +1 -0
  80. package/dist/modes/mode-loader.js +94 -0
  81. package/dist/modes/mode-loader.js.map +1 -0
  82. package/dist/modes/prompt-assembler.d.ts +27 -0
  83. package/dist/modes/prompt-assembler.d.ts.map +1 -0
  84. package/dist/modes/prompt-assembler.js +224 -0
  85. package/dist/modes/prompt-assembler.js.map +1 -0
  86. package/dist/modes/types.d.ts +42 -0
  87. package/dist/modes/types.d.ts.map +1 -0
  88. package/dist/modes/types.js +24 -0
  89. package/dist/modes/types.js.map +1 -0
  90. package/dist/parser.d.ts +6 -0
  91. package/dist/parser.d.ts.map +1 -0
  92. package/dist/parser.js +122 -0
  93. package/dist/parser.js.map +1 -0
  94. package/dist/security-floor.d.ts +31 -0
  95. package/dist/security-floor.d.ts.map +1 -0
  96. package/dist/security-floor.js +113 -0
  97. package/dist/security-floor.js.map +1 -0
  98. package/dist/types.d.ts +26 -0
  99. package/dist/types.d.ts.map +1 -0
  100. package/dist/types.js +2 -0
  101. package/dist/types.js.map +1 -0
  102. package/dist/voice-profiles.d.ts +23 -0
  103. package/dist/voice-profiles.d.ts.map +1 -0
  104. package/dist/voice-profiles.js +72 -0
  105. package/dist/voice-profiles.js.map +1 -0
  106. package/modes/advisor.md +24 -0
  107. package/modes/analyst.md +25 -0
  108. package/modes/companion.md +24 -0
  109. package/modes/legal.md +1188 -0
  110. package/modes/operator.md +24 -0
  111. package/modes/roast.md +24 -0
  112. package/modes/socratic.md +24 -0
  113. package/modes/writer.md +23 -0
  114. package/package.json +27 -0
  115. package/src/__tests__/builder.test.ts +78 -0
  116. package/src/__tests__/conversation-builder.test.ts +386 -0
  117. package/src/__tests__/escalation.test.ts +172 -0
  118. package/src/__tests__/manager.test.ts +141 -0
  119. package/src/__tests__/parser.test.ts +101 -0
  120. package/src/__tests__/security-floor.test.ts +212 -0
  121. package/src/builder.ts +75 -0
  122. package/src/conversation-builder.ts +279 -0
  123. package/src/escalation.ts +162 -0
  124. package/src/index.ts +55 -0
  125. package/src/manager.ts +119 -0
  126. package/src/marketplace/__tests__/scanner.test.ts +159 -0
  127. package/src/marketplace/__tests__/schema.test.ts +269 -0
  128. package/src/marketplace/scanner.ts +85 -0
  129. package/src/marketplace/schema.ts +141 -0
  130. package/src/modes/__tests__/mode-detector.test.ts +149 -0
  131. package/src/modes/__tests__/mode-loader.test.ts +143 -0
  132. package/src/modes/__tests__/prompt-assembler.test.ts +291 -0
  133. package/src/modes/mode-detector.ts +84 -0
  134. package/src/modes/mode-loader.ts +105 -0
  135. package/src/modes/prompt-assembler.ts +278 -0
  136. package/src/modes/types.ts +67 -0
  137. package/src/parser.ts +132 -0
  138. package/src/security-floor.ts +147 -0
  139. package/src/types.ts +27 -0
  140. package/src/voice-profiles.ts +88 -0
  141. package/templates/chill.md +30 -0
  142. package/templates/creative.md +29 -0
  143. package/templates/friendly.md +28 -0
  144. package/templates/mentor.md +31 -0
  145. package/templates/minimal.md +24 -0
  146. package/templates/professional.md +28 -0
  147. package/templates/technical.md +30 -0
  148. package/tsconfig.json +12 -0
  149. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,149 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { ModeDetector } from '../mode-detector.js';
3
+ import type { ModeId, ModeTemplate, SessionModeState } from '../types.js';
4
+
5
+ function makeMode(id: ModeId, signals: Array<{ phrase: string; weight: number }>): ModeTemplate {
6
+ return { id, name: id, description: `${id} mode`, promptContent: `# ${id}`, signals };
7
+ }
8
+
9
+ function buildModes(): Map<ModeId, ModeTemplate> {
10
+ const modes = new Map<ModeId, ModeTemplate>();
11
+ modes.set('operator', makeMode('operator', [
12
+ { phrase: 'run', weight: 0.8 }, { phrase: 'execute', weight: 0.8 },
13
+ { phrase: 'deploy', weight: 0.8 }, { phrase: 'status', weight: 0.6 },
14
+ ]));
15
+ modes.set('analyst', makeMode('analyst', [
16
+ { phrase: 'analyze', weight: 0.8 }, { phrase: 'investigate', weight: 0.8 },
17
+ { phrase: "what's the risk", weight: 0.9 }, { phrase: 'compare', weight: 0.7 },
18
+ ]));
19
+ modes.set('advisor', makeMode('advisor', [
20
+ { phrase: 'should i', weight: 0.8 }, { phrase: 'help me decide', weight: 0.9 },
21
+ { phrase: 'trade-offs', weight: 0.8 }, { phrase: 'options', weight: 0.6 },
22
+ ]));
23
+ modes.set('writer', makeMode('writer', [
24
+ { phrase: 'write', weight: 0.7 }, { phrase: 'draft', weight: 0.8 },
25
+ { phrase: 'blog post', weight: 0.9 }, { phrase: 'email', weight: 0.7 },
26
+ ]));
27
+ modes.set('socratic', makeMode('socratic', [
28
+ { phrase: 'challenge me', weight: 0.9 }, { phrase: 'red team', weight: 0.9 },
29
+ { phrase: 'what am i missing', weight: 0.8 }, { phrase: 'poke holes', weight: 0.8 },
30
+ ]));
31
+ modes.set('legal', makeMode('legal', [
32
+ { phrase: 'compliance', weight: 0.9 }, { phrase: 'regulation', weight: 0.8 },
33
+ { phrase: 'contract', weight: 0.8 }, { phrase: 'legal', weight: 0.8 },
34
+ ]));
35
+ modes.set('roast', makeMode('roast', [
36
+ { phrase: 'roast', weight: 0.9 }, { phrase: "don't sugarcoat", weight: 0.8 },
37
+ { phrase: 'give it to me straight', weight: 0.8 },
38
+ ]));
39
+ modes.set('companion', makeMode('companion', [
40
+ { phrase: 'how are you', weight: 0.4 }, { phrase: 'just chatting', weight: 0.6 },
41
+ { phrase: 'feeling', weight: 0.5 },
42
+ ]));
43
+ return modes;
44
+ }
45
+
46
+ describe('ModeDetector', () => {
47
+ const modes = buildModes();
48
+ const detector = new ModeDetector(modes);
49
+
50
+ it('should detect operator mode from "run" signals', () => {
51
+ const result = detector.detect('run the test suite and deploy');
52
+ expect(result).not.toBeNull();
53
+ expect(result!.mode).toBe('operator');
54
+ });
55
+
56
+ it('should detect analyst mode from analysis signals', () => {
57
+ const result = detector.detect("analyze this data and what's the risk here");
58
+ expect(result).not.toBeNull();
59
+ expect(result!.mode).toBe('analyst');
60
+ });
61
+
62
+ it('should detect advisor mode from decision signals', () => {
63
+ const result = detector.detect('should i go with option A? help me decide');
64
+ expect(result).not.toBeNull();
65
+ expect(result!.mode).toBe('advisor');
66
+ });
67
+
68
+ it('should detect writer mode from writing signals', () => {
69
+ const result = detector.detect('draft a blog post about TypeScript');
70
+ expect(result).not.toBeNull();
71
+ expect(result!.mode).toBe('writer');
72
+ });
73
+
74
+ it('should detect socratic mode from challenge signals', () => {
75
+ const result = detector.detect('challenge me on this. red team my plan');
76
+ expect(result).not.toBeNull();
77
+ expect(result!.mode).toBe('socratic');
78
+ });
79
+
80
+ it('should detect legal mode from compliance signals', () => {
81
+ const result = detector.detect('check this contract for compliance issues');
82
+ expect(result).not.toBeNull();
83
+ expect(result!.mode).toBe('legal');
84
+ });
85
+
86
+ it('should detect roast mode from roast signals', () => {
87
+ const result = detector.detect("roast my code, don't sugarcoat it");
88
+ expect(result).not.toBeNull();
89
+ expect(result!.mode).toBe('roast');
90
+ });
91
+
92
+ it('should detect companion mode from casual signals', () => {
93
+ const result = detector.detect("how are you feeling today? just chatting");
94
+ expect(result).not.toBeNull();
95
+ expect(result!.mode).toBe('companion');
96
+ });
97
+
98
+ it('should return null for empty messages', () => {
99
+ expect(detector.detect('')).toBeNull();
100
+ });
101
+
102
+ it('should return null for very short messages', () => {
103
+ expect(detector.detect('hi')).toBeNull();
104
+ });
105
+
106
+ it('should return null when no signals match', () => {
107
+ expect(detector.detect('tell me a random fact about penguins')).toBeNull();
108
+ });
109
+
110
+ it('should score higher with multiple matching keywords', () => {
111
+ const single = detector.detect('run this');
112
+ const multiple = detector.detect('run and execute, then deploy');
113
+ expect(single).not.toBeNull();
114
+ expect(multiple).not.toBeNull();
115
+ // Multiple matches should still resolve to operator
116
+ expect(multiple!.mode).toBe('operator');
117
+ });
118
+
119
+ it('should apply hysteresis bias to current mode', () => {
120
+ // When current mode is "operator" and message has weak operator signal
121
+ const state: SessionModeState = { activeMode: 'operator', autoDetected: true };
122
+ const result = detector.detect('check the status', { currentState: state });
123
+ expect(result).not.toBeNull();
124
+ expect(result!.mode).toBe('operator');
125
+ });
126
+
127
+ it('should apply task type boosting for code messages', () => {
128
+ // "execute" is a weak operator signal; "code" task type should boost it
129
+ const withBoost = detector.detect('run and execute this', { taskType: 'code' });
130
+ expect(withBoost).not.toBeNull();
131
+ expect(withBoost!.mode).toBe('operator');
132
+ });
133
+
134
+ it('should return candidates sorted by score', () => {
135
+ const result = detector.detect('analyze the status and run diagnostics');
136
+ expect(result).not.toBeNull();
137
+ expect(result!.candidates.length).toBeGreaterThan(0);
138
+ // Candidates should be sorted descending by score
139
+ for (let i = 1; i < result!.candidates.length; i++) {
140
+ expect(result!.candidates[i - 1].score).toBeGreaterThanOrEqual(result!.candidates[i].score);
141
+ }
142
+ });
143
+
144
+ it('should handle case-insensitive matching', () => {
145
+ const result = detector.detect('ANALYZE this data');
146
+ expect(result).not.toBeNull();
147
+ expect(result!.mode).toBe('analyst');
148
+ });
149
+ });
@@ -0,0 +1,143 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import * as fs from 'node:fs/promises';
3
+ import * as path from 'node:path';
4
+ import * as os from 'node:os';
5
+ import { ModeLoader } from '../mode-loader.js';
6
+
7
+ describe('ModeLoader', () => {
8
+ let tmpDir: string;
9
+ let builtInDir: string;
10
+ let userDir: string;
11
+
12
+ beforeEach(async () => {
13
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mode-loader-test-'));
14
+ builtInDir = path.join(tmpDir, 'built-in');
15
+ userDir = path.join(tmpDir, 'user');
16
+ await fs.mkdir(builtInDir, { recursive: true });
17
+ await fs.mkdir(userDir, { recursive: true });
18
+ });
19
+
20
+ afterEach(async () => {
21
+ await fs.rm(tmpDir, { recursive: true, force: true });
22
+ });
23
+
24
+ it('should load built-in mode files', async () => {
25
+ await fs.writeFile(
26
+ path.join(builtInDir, 'operator.md'),
27
+ `<!-- mode: operator\n name: Operator\n description: Fast execution\n signals: run:0.8, execute:0.7 -->\n# Operator mode content`,
28
+ );
29
+ await fs.writeFile(
30
+ path.join(builtInDir, 'analyst.md'),
31
+ `<!-- mode: analyst\n name: Analyst\n description: Deep analysis\n signals: analyze:0.8 -->\n# Analyst mode content`,
32
+ );
33
+
34
+ const loader = new ModeLoader(builtInDir, userDir);
35
+ const modes = await loader.loadAll();
36
+
37
+ expect(modes.size).toBe(2);
38
+ expect(modes.get('operator')).toBeDefined();
39
+ expect(modes.get('analyst')).toBeDefined();
40
+ });
41
+
42
+ it('should parse metadata correctly', async () => {
43
+ await fs.writeFile(
44
+ path.join(builtInDir, 'writer.md'),
45
+ `<!-- mode: writer\n name: Writer\n description: Creative writing\n signals: write:0.7, draft:0.8, blog post:0.9 -->\n# Writer mode\nBe creative.`,
46
+ );
47
+
48
+ const loader = new ModeLoader(builtInDir, userDir);
49
+ await loader.loadAll();
50
+ const mode = loader.get('writer');
51
+
52
+ expect(mode).toBeDefined();
53
+ expect(mode!.name).toBe('Writer');
54
+ expect(mode!.description).toBe('Creative writing');
55
+ expect(mode!.signals).toHaveLength(3);
56
+ expect(mode!.signals[0]).toEqual({ phrase: 'write', weight: 0.7 });
57
+ expect(mode!.signals[1]).toEqual({ phrase: 'draft', weight: 0.8 });
58
+ expect(mode!.signals[2]).toEqual({ phrase: 'blog post', weight: 0.9 });
59
+ expect(mode!.promptContent).toContain('# Writer mode');
60
+ expect(mode!.promptContent).toContain('Be creative.');
61
+ });
62
+
63
+ it('should return empty map when directory does not exist', async () => {
64
+ const loader = new ModeLoader('/nonexistent/path', '/also/nonexistent');
65
+ const modes = await loader.loadAll();
66
+ expect(modes.size).toBe(0);
67
+ });
68
+
69
+ it('should skip files without valid metadata', async () => {
70
+ // File with no HTML comment header
71
+ await fs.writeFile(
72
+ path.join(builtInDir, 'operator.md'),
73
+ `# Just some markdown\nNo metadata here.`,
74
+ );
75
+
76
+ const loader = new ModeLoader(builtInDir, userDir);
77
+ const modes = await loader.loadAll();
78
+ expect(modes.size).toBe(0);
79
+ });
80
+
81
+ it('should skip non-.md files', async () => {
82
+ await fs.writeFile(path.join(builtInDir, 'readme.txt'), 'not a mode');
83
+ await fs.writeFile(
84
+ path.join(builtInDir, 'operator.md'),
85
+ `<!-- mode: operator\n name: Operator\n description: test\n signals: run:0.8 -->\ncontent`,
86
+ );
87
+
88
+ const loader = new ModeLoader(builtInDir, userDir);
89
+ const modes = await loader.loadAll();
90
+ expect(modes.size).toBe(1);
91
+ });
92
+
93
+ it('should skip files with unknown mode IDs', async () => {
94
+ await fs.writeFile(
95
+ path.join(builtInDir, 'custom-mode.md'),
96
+ `<!-- mode: custom-mode\n name: Custom\n description: test\n signals: foo:0.8 -->\ncontent`,
97
+ );
98
+
99
+ const loader = new ModeLoader(builtInDir, userDir);
100
+ const modes = await loader.loadAll();
101
+ expect(modes.size).toBe(0);
102
+ });
103
+
104
+ it('should allow user modes to override built-in modes', async () => {
105
+ await fs.writeFile(
106
+ path.join(builtInDir, 'operator.md'),
107
+ `<!-- mode: operator\n name: Operator\n description: Built-in operator\n signals: run:0.8 -->\nBuilt-in content`,
108
+ );
109
+ await fs.writeFile(
110
+ path.join(userDir, 'operator.md'),
111
+ `<!-- mode: operator\n name: My Operator\n description: Custom operator\n signals: run:0.9, go:0.7 -->\nCustom content`,
112
+ );
113
+
114
+ const loader = new ModeLoader(builtInDir, userDir);
115
+ await loader.loadAll();
116
+ const mode = loader.get('operator');
117
+
118
+ expect(mode).toBeDefined();
119
+ expect(mode!.name).toBe('My Operator');
120
+ expect(mode!.description).toBe('Custom operator');
121
+ expect(mode!.promptContent).toBe('Custom content');
122
+ });
123
+
124
+ it('should reload modes', async () => {
125
+ await fs.writeFile(
126
+ path.join(builtInDir, 'operator.md'),
127
+ `<!-- mode: operator\n name: Operator\n description: v1\n signals: run:0.8 -->\nv1`,
128
+ );
129
+
130
+ const loader = new ModeLoader(builtInDir, userDir);
131
+ await loader.loadAll();
132
+ expect(loader.get('operator')!.description).toBe('v1');
133
+
134
+ // Modify file
135
+ await fs.writeFile(
136
+ path.join(builtInDir, 'operator.md'),
137
+ `<!-- mode: operator\n name: Operator\n description: v2\n signals: run:0.8 -->\nv2`,
138
+ );
139
+
140
+ await loader.reload();
141
+ expect(loader.get('operator')!.description).toBe('v2');
142
+ });
143
+ });
@@ -0,0 +1,291 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import * as fs from 'node:fs/promises';
3
+ import * as path from 'node:path';
4
+ import * as os from 'node:os';
5
+ import { PromptAssembler } from '../prompt-assembler.js';
6
+ import { ModeLoader } from '../mode-loader.js';
7
+ import type { AgentIdentity } from '@auxiora/config';
8
+ import type { SessionModeState, UserPreferences } from '../types.js';
9
+ import { DEFAULT_PREFERENCES } from '../types.js';
10
+ import { SecurityFloor } from '../../security-floor.js';
11
+ import type { EscalationState } from '../../escalation.js';
12
+
13
+ // Mock @auxiora/core path getters to use temp dirs
14
+ let workspaceDir: string;
15
+ vi.mock('@auxiora/core', () => ({
16
+ getSoulPath: () => path.join(workspaceDir, 'SOUL.md'),
17
+ getAgentsPath: () => path.join(workspaceDir, 'AGENTS.md'),
18
+ getIdentityPath: () => path.join(workspaceDir, 'IDENTITY.md'),
19
+ getUserPath: () => path.join(workspaceDir, 'USER.md'),
20
+ }));
21
+
22
+ const defaultAgent: AgentIdentity = {
23
+ name: 'TestBot',
24
+ pronouns: 'they/them',
25
+ personality: 'professional',
26
+ tone: { warmth: 0.6, directness: 0.5, humor: 0.3, formality: 0.5 },
27
+ expertise: [],
28
+ errorStyle: 'professional',
29
+ catchphrases: {},
30
+ boundaries: { neverJokeAbout: [], neverAdviseOn: [] },
31
+ };
32
+
33
+ describe('PromptAssembler', () => {
34
+ let tmpDir: string;
35
+ let modesDir: string;
36
+
37
+ beforeEach(async () => {
38
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'prompt-assembler-test-'));
39
+ workspaceDir = path.join(tmpDir, 'workspace');
40
+ modesDir = path.join(tmpDir, 'modes');
41
+ await fs.mkdir(workspaceDir, { recursive: true });
42
+ await fs.mkdir(modesDir, { recursive: true });
43
+ });
44
+
45
+ afterEach(async () => {
46
+ await fs.rm(tmpDir, { recursive: true, force: true });
47
+ });
48
+
49
+ async function createAssembler(modeFiles?: Record<string, string>) {
50
+ if (modeFiles) {
51
+ for (const [name, content] of Object.entries(modeFiles)) {
52
+ await fs.writeFile(path.join(modesDir, name), content);
53
+ }
54
+ }
55
+ const loader = new ModeLoader(modesDir, path.join(tmpDir, 'user-modes'));
56
+ await loader.loadAll();
57
+ return new PromptAssembler(defaultAgent, loader);
58
+ }
59
+
60
+ it('should build base prompt with identity preamble when SOUL.md exists', async () => {
61
+ await fs.writeFile(path.join(workspaceDir, 'SOUL.md'), '# Soul');
62
+ const assembler = await createAssembler();
63
+ const base = await assembler.buildBase();
64
+ expect(base).toContain('# Agent Identity');
65
+ expect(base).toContain('TestBot');
66
+ expect(base).toContain('they/them');
67
+ });
68
+
69
+ it('should include SOUL.md when present', async () => {
70
+ await fs.writeFile(path.join(workspaceDir, 'SOUL.md'), '# My Soul\nI am helpful.');
71
+ const assembler = await createAssembler();
72
+ const base = await assembler.buildBase();
73
+ expect(base).toContain('# My Soul');
74
+ expect(base).toContain('I am helpful.');
75
+ });
76
+
77
+ it('should handle missing optional files gracefully', async () => {
78
+ // No SOUL.md, AGENTS.md, IDENTITY.md, or USER.md
79
+ const assembler = await createAssembler();
80
+ const base = await assembler.buildBase();
81
+ // Should fall through to default prompt
82
+ expect(base).toContain('TestBot');
83
+ expect(base).toContain('helpful AI assistant');
84
+ });
85
+
86
+ it('should include all context files when present', async () => {
87
+ await fs.writeFile(path.join(workspaceDir, 'SOUL.md'), '# Soul content');
88
+ await fs.writeFile(path.join(workspaceDir, 'AGENTS.md'), '# Agent definitions');
89
+ await fs.writeFile(path.join(workspaceDir, 'IDENTITY.md'), '# Custom identity');
90
+ await fs.writeFile(path.join(workspaceDir, 'USER.md'), 'User profile info');
91
+
92
+ const assembler = await createAssembler();
93
+ const base = await assembler.buildBase();
94
+
95
+ expect(base).toContain('# Soul content');
96
+ expect(base).toContain('# Agent definitions');
97
+ expect(base).toContain('# Custom identity');
98
+ expect(base).toContain('About the User');
99
+ expect(base).toContain('User profile info');
100
+ });
101
+
102
+ it('should inject mode instructions when mode is active', async () => {
103
+ const assembler = await createAssembler({
104
+ 'operator.md': `<!-- mode: operator\n name: Operator\n description: Fast exec\n signals: run:0.8 -->\nBe fast and direct.`,
105
+ });
106
+ await assembler.buildBase();
107
+
108
+ const state: SessionModeState = { activeMode: 'operator', autoDetected: false };
109
+ const enriched = assembler.enrichForMessage(state, null);
110
+
111
+ expect(enriched).toContain('Active Mode: Operator');
112
+ expect(enriched).toContain('Be fast and direct.');
113
+ });
114
+
115
+ it('should not inject mode when mode is "auto"', async () => {
116
+ const assembler = await createAssembler({
117
+ 'operator.md': `<!-- mode: operator\n name: Operator\n description: test\n signals: run:0.8 -->\nOperator content`,
118
+ });
119
+ await assembler.buildBase();
120
+
121
+ const state: SessionModeState = { activeMode: 'auto', autoDetected: false };
122
+ const enriched = assembler.enrichForMessage(state, null);
123
+
124
+ expect(enriched).not.toContain('Active Mode');
125
+ });
126
+
127
+ it('should not inject mode when mode is "off"', async () => {
128
+ const assembler = await createAssembler({
129
+ 'operator.md': `<!-- mode: operator\n name: Operator\n description: test\n signals: run:0.8 -->\nOperator content`,
130
+ });
131
+ await assembler.buildBase();
132
+
133
+ const state: SessionModeState = { activeMode: 'off', autoDetected: false };
134
+ const enriched = assembler.enrichForMessage(state, null);
135
+
136
+ expect(enriched).not.toContain('Active Mode');
137
+ });
138
+
139
+ it('should append memory section to enriched prompt', async () => {
140
+ const assembler = await createAssembler();
141
+ await assembler.buildBase();
142
+
143
+ const state: SessionModeState = { activeMode: 'auto', autoDetected: false };
144
+ const enriched = assembler.enrichForMessage(state, '\n\n## Memories\nUser likes TypeScript.');
145
+
146
+ expect(enriched).toContain('## Memories');
147
+ expect(enriched).toContain('User likes TypeScript.');
148
+ });
149
+
150
+ it('should render extreme preference values', async () => {
151
+ const assembler = await createAssembler();
152
+ const extreme: UserPreferences = {
153
+ verbosity: 0.0,
154
+ formality: 1.0,
155
+ proactiveness: 0.0,
156
+ riskTolerance: 1.0,
157
+ humor: 0.0,
158
+ feedbackStyle: 'sandwich',
159
+ expertiseAssumption: 'expert',
160
+ };
161
+
162
+ const rendered = assembler.renderPreferences(extreme);
163
+ expect(rendered).toContain('concise');
164
+ expect(rendered).toContain('formal');
165
+ expect(rendered).toContain('Only answer what is directly asked');
166
+ expect(rendered).toContain('bold');
167
+ expect(rendered).toContain('serious');
168
+ expect(rendered).toContain('sandwich');
169
+ expect(rendered).toContain('deep technical knowledge');
170
+ });
171
+
172
+ it('should not render preferences at default values', async () => {
173
+ const assembler = await createAssembler();
174
+ const rendered = assembler.renderPreferences(DEFAULT_PREFERENCES);
175
+ // At 0.5 values, no extreme language should appear
176
+ expect(rendered).not.toContain('concise');
177
+ expect(rendered).not.toContain('thorough');
178
+ expect(rendered).not.toContain('casual');
179
+ expect(rendered).not.toContain('formal');
180
+ });
181
+
182
+ it('should produce backward-compatible prompt when no modes active', async () => {
183
+ await fs.writeFile(path.join(workspaceDir, 'SOUL.md'), '# Soul');
184
+ const assembler = await createAssembler();
185
+ const base = await assembler.buildBase();
186
+
187
+ // With no mode state, enrichment should just be base + memory
188
+ const enriched = assembler.enrichForMessage(undefined, '\n\nmemory');
189
+ expect(enriched).toBe(base + '\n\nmemory');
190
+ });
191
+
192
+ // --- Security context and escalation tests ---
193
+
194
+ it('should produce security floor section without mode instructions', async () => {
195
+ const assembler = await createAssembler({
196
+ 'operator.md': `<!-- mode: operator\n name: Operator\n description: test\n signals: run:0.8 -->\nOperator content`,
197
+ });
198
+ await assembler.buildBase();
199
+
200
+ const sf = new SecurityFloor();
201
+ const ctx = sf.detectSecurityContext({ userMessage: 'rotate my API key' });
202
+ const enriched = assembler.enrichForSecurityContext(ctx, sf, null);
203
+
204
+ expect(enriched).toContain('Security Floor Active');
205
+ expect(enriched).not.toContain('Active Mode');
206
+ expect(enriched).not.toContain('User Preferences');
207
+ });
208
+
209
+ it('should include memories in security context prompt', async () => {
210
+ const assembler = await createAssembler();
211
+ await assembler.buildBase();
212
+
213
+ const sf = new SecurityFloor();
214
+ const ctx = sf.detectSecurityContext({ userMessage: 'revoke access' });
215
+ const enriched = assembler.enrichForSecurityContext(ctx, sf, '\n\n## Memories\nUser prefers verbose.');
216
+
217
+ expect(enriched).toContain('Security Floor Active');
218
+ expect(enriched).toContain('## Memories');
219
+ expect(enriched).toContain('User prefers verbose.');
220
+ });
221
+
222
+ it('should dampen tone for caution escalation', async () => {
223
+ await fs.writeFile(path.join(workspaceDir, 'SOUL.md'), '# Soul');
224
+ const assembler = await createAssembler();
225
+ await assembler.buildBase();
226
+
227
+ const escalation: EscalationState = { level: 'caution', lastEvent: 'uncertainty' };
228
+ const enriched = assembler.enrichForMessage(undefined, null, undefined, escalation);
229
+
230
+ // Humor should be halved: 0.3 * 0.5 = 0.15
231
+ expect(enriched).toContain('Humor: 0.15/1.0');
232
+ });
233
+
234
+ it('should zero humor for serious escalation', async () => {
235
+ await fs.writeFile(path.join(workspaceDir, 'SOUL.md'), '# Soul');
236
+ const assembler = await createAssembler();
237
+ await assembler.buildBase();
238
+
239
+ const escalation: EscalationState = { level: 'serious', lastEvent: 'access_failure' };
240
+ const enriched = assembler.enrichForMessage(undefined, null, undefined, escalation);
241
+
242
+ expect(enriched).toContain('Humor: 0/1.0');
243
+ expect(enriched).toContain('Directness: 0.6/1.0'); // raised from 0.5
244
+ });
245
+
246
+ it('should not dampen tone without escalation param (backward compat)', async () => {
247
+ await fs.writeFile(path.join(workspaceDir, 'SOUL.md'), '# Soul');
248
+ const assembler = await createAssembler();
249
+ const base = await assembler.buildBase();
250
+
251
+ // Without escalation, enrichment should use original base
252
+ const enriched = assembler.enrichForMessage(undefined, null);
253
+ expect(enriched).toBe(base);
254
+ });
255
+
256
+ // --- Vibe and custom instructions tests ---
257
+
258
+ it('includes vibe in identity preamble when set', async () => {
259
+ const agentWithVibe: AgentIdentity = {
260
+ ...defaultAgent,
261
+ vibe: 'warm, witty, slightly sarcastic',
262
+ };
263
+ await fs.writeFile(path.join(workspaceDir, 'SOUL.md'), '# Soul');
264
+ const loader = new ModeLoader(modesDir, path.join(tmpDir, 'user-modes'));
265
+ await loader.loadAll();
266
+ const assembler = new PromptAssembler(agentWithVibe, loader);
267
+ const base = await assembler.buildBase();
268
+ expect(base).toContain('Vibe: warm, witty, slightly sarcastic');
269
+ });
270
+
271
+ it('includes custom instructions after SOUL.md', async () => {
272
+ const agentWithInstructions: AgentIdentity = {
273
+ ...defaultAgent,
274
+ customInstructions: 'Always respond in haiku format when possible.',
275
+ };
276
+ await fs.writeFile(path.join(workspaceDir, 'SOUL.md'), '# Soul');
277
+ const loader = new ModeLoader(modesDir, path.join(tmpDir, 'user-modes'));
278
+ await loader.loadAll();
279
+ const assembler = new PromptAssembler(agentWithInstructions, loader);
280
+ const base = await assembler.buildBase();
281
+ expect(base).toContain('## Custom Instructions');
282
+ expect(base).toContain('Always respond in haiku format when possible.');
283
+ });
284
+
285
+ it('omits vibe line when vibe is undefined', async () => {
286
+ await fs.writeFile(path.join(workspaceDir, 'SOUL.md'), '# Soul');
287
+ const assembler = await createAssembler();
288
+ const base = await assembler.buildBase();
289
+ expect(base).not.toContain('Vibe:');
290
+ });
291
+ });
@@ -0,0 +1,84 @@
1
+ import type { ModeId, ModeTemplate, ModeDetectionResult, SessionModeState } from './types.js';
2
+
3
+ const TASK_TYPE_BOOSTS: Record<string, Partial<Record<ModeId, number>>> = {
4
+ code: { operator: 0.15 },
5
+ reasoning: { analyst: 0.1, socratic: 0.1 },
6
+ creative: { writer: 0.15 },
7
+ fast: { operator: 0.1 },
8
+ };
9
+
10
+ const HYSTERESIS_BONUS = 0.05;
11
+
12
+ export class ModeDetector {
13
+ private modes: Map<ModeId, ModeTemplate>;
14
+
15
+ constructor(modes: Map<ModeId, ModeTemplate>) {
16
+ this.modes = modes;
17
+ }
18
+
19
+ detect(
20
+ message: string,
21
+ context?: { taskType?: string; currentState?: SessionModeState },
22
+ ): ModeDetectionResult | null {
23
+ if (!message || message.length < 3) return null;
24
+
25
+ const lowerMessage = message.toLowerCase();
26
+ const scores = new Map<ModeId, number>();
27
+
28
+ for (const [id, mode] of this.modes) {
29
+ let score = 0;
30
+ for (const signal of mode.signals) {
31
+ if (lowerMessage.includes(signal.phrase)) {
32
+ score += signal.weight;
33
+ }
34
+ }
35
+ if (score > 0) {
36
+ scores.set(id, score);
37
+ }
38
+ }
39
+
40
+ if (scores.size === 0) return null;
41
+
42
+ // Apply task-type boosts
43
+ if (context?.taskType) {
44
+ const boosts = TASK_TYPE_BOOSTS[context.taskType];
45
+ if (boosts) {
46
+ for (const [modeId, boost] of Object.entries(boosts)) {
47
+ const current = scores.get(modeId as ModeId) ?? 0;
48
+ if (current > 0) {
49
+ scores.set(modeId as ModeId, current + boost);
50
+ }
51
+ }
52
+ }
53
+ }
54
+
55
+ // Apply hysteresis for current mode
56
+ if (context?.currentState?.activeMode && context.currentState.activeMode !== 'auto' && context.currentState.activeMode !== 'off') {
57
+ const currentMode = context.currentState.activeMode as ModeId;
58
+ const current = scores.get(currentMode);
59
+ if (current !== undefined) {
60
+ scores.set(currentMode, current + HYSTERESIS_BONUS);
61
+ }
62
+ }
63
+
64
+ // Normalize scores
65
+ const maxScore = Math.max(...scores.values());
66
+ const candidates: Array<{ mode: ModeId; score: number }> = [];
67
+ for (const [mode, score] of scores) {
68
+ candidates.push({ mode, score: score / maxScore });
69
+ }
70
+ candidates.sort((a, b) => b.score - a.score);
71
+
72
+ const best = candidates[0];
73
+ // Confidence is based on the raw normalized score
74
+ const confidence = best.score;
75
+
76
+ if (confidence < 0.4) return null;
77
+
78
+ return {
79
+ mode: best.mode,
80
+ confidence,
81
+ candidates,
82
+ };
83
+ }
84
+ }