ei-tui 0.1.3

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 (133) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +170 -0
  3. package/package.json +63 -0
  4. package/src/README.md +96 -0
  5. package/src/cli/README.md +47 -0
  6. package/src/cli/commands/facts.ts +25 -0
  7. package/src/cli/commands/people.ts +25 -0
  8. package/src/cli/commands/quotes.ts +19 -0
  9. package/src/cli/commands/topics.ts +25 -0
  10. package/src/cli/commands/traits.ts +25 -0
  11. package/src/cli/retrieval.ts +269 -0
  12. package/src/cli.ts +176 -0
  13. package/src/core/AGENTS.md +104 -0
  14. package/src/core/embedding-service.ts +241 -0
  15. package/src/core/handlers/index.ts +1057 -0
  16. package/src/core/index.ts +4 -0
  17. package/src/core/llm-client.ts +265 -0
  18. package/src/core/model-context-windows.ts +49 -0
  19. package/src/core/orchestrators/ceremony.ts +500 -0
  20. package/src/core/orchestrators/extraction-chunker.ts +138 -0
  21. package/src/core/orchestrators/human-extraction.ts +457 -0
  22. package/src/core/orchestrators/index.ts +28 -0
  23. package/src/core/orchestrators/persona-generation.ts +76 -0
  24. package/src/core/orchestrators/persona-topics.ts +117 -0
  25. package/src/core/personas/index.ts +5 -0
  26. package/src/core/personas/opencode-agent.ts +81 -0
  27. package/src/core/processor.ts +1413 -0
  28. package/src/core/queue-processor.ts +197 -0
  29. package/src/core/state/checkpoints.ts +68 -0
  30. package/src/core/state/human.ts +176 -0
  31. package/src/core/state/index.ts +5 -0
  32. package/src/core/state/personas.ts +217 -0
  33. package/src/core/state/queue.ts +144 -0
  34. package/src/core/state-manager.ts +347 -0
  35. package/src/core/types.ts +421 -0
  36. package/src/core/utils/decay.ts +33 -0
  37. package/src/index.ts +1 -0
  38. package/src/integrations/opencode/importer.ts +896 -0
  39. package/src/integrations/opencode/index.ts +16 -0
  40. package/src/integrations/opencode/json-reader.ts +304 -0
  41. package/src/integrations/opencode/reader-factory.ts +35 -0
  42. package/src/integrations/opencode/sqlite-reader.ts +189 -0
  43. package/src/integrations/opencode/types.ts +244 -0
  44. package/src/prompts/AGENTS.md +62 -0
  45. package/src/prompts/ceremony/description-check.ts +47 -0
  46. package/src/prompts/ceremony/expire.ts +30 -0
  47. package/src/prompts/ceremony/explore.ts +60 -0
  48. package/src/prompts/ceremony/index.ts +11 -0
  49. package/src/prompts/ceremony/types.ts +42 -0
  50. package/src/prompts/generation/descriptions.ts +91 -0
  51. package/src/prompts/generation/index.ts +15 -0
  52. package/src/prompts/generation/persona.ts +155 -0
  53. package/src/prompts/generation/seeds.ts +31 -0
  54. package/src/prompts/generation/types.ts +47 -0
  55. package/src/prompts/heartbeat/check.ts +179 -0
  56. package/src/prompts/heartbeat/ei.ts +208 -0
  57. package/src/prompts/heartbeat/index.ts +15 -0
  58. package/src/prompts/heartbeat/types.ts +70 -0
  59. package/src/prompts/human/fact-scan.ts +152 -0
  60. package/src/prompts/human/index.ts +32 -0
  61. package/src/prompts/human/item-match.ts +74 -0
  62. package/src/prompts/human/item-update.ts +322 -0
  63. package/src/prompts/human/person-scan.ts +115 -0
  64. package/src/prompts/human/topic-scan.ts +135 -0
  65. package/src/prompts/human/trait-scan.ts +115 -0
  66. package/src/prompts/human/types.ts +127 -0
  67. package/src/prompts/index.ts +90 -0
  68. package/src/prompts/message-utils.ts +39 -0
  69. package/src/prompts/persona/index.ts +16 -0
  70. package/src/prompts/persona/topics-match.ts +69 -0
  71. package/src/prompts/persona/topics-scan.ts +98 -0
  72. package/src/prompts/persona/topics-update.ts +157 -0
  73. package/src/prompts/persona/traits.ts +117 -0
  74. package/src/prompts/persona/types.ts +74 -0
  75. package/src/prompts/response/index.ts +147 -0
  76. package/src/prompts/response/sections.ts +355 -0
  77. package/src/prompts/response/types.ts +38 -0
  78. package/src/prompts/validation/ei.ts +93 -0
  79. package/src/prompts/validation/index.ts +6 -0
  80. package/src/prompts/validation/types.ts +22 -0
  81. package/src/storage/crypto.ts +96 -0
  82. package/src/storage/index.ts +5 -0
  83. package/src/storage/interface.ts +9 -0
  84. package/src/storage/local.ts +79 -0
  85. package/src/storage/merge.ts +69 -0
  86. package/src/storage/remote.ts +145 -0
  87. package/src/templates/welcome.ts +91 -0
  88. package/tui/README.md +62 -0
  89. package/tui/bunfig.toml +4 -0
  90. package/tui/src/app.tsx +55 -0
  91. package/tui/src/commands/archive.tsx +93 -0
  92. package/tui/src/commands/context.tsx +124 -0
  93. package/tui/src/commands/delete.tsx +71 -0
  94. package/tui/src/commands/details.tsx +41 -0
  95. package/tui/src/commands/editor.tsx +46 -0
  96. package/tui/src/commands/help.tsx +12 -0
  97. package/tui/src/commands/me.tsx +145 -0
  98. package/tui/src/commands/model.ts +47 -0
  99. package/tui/src/commands/new.ts +31 -0
  100. package/tui/src/commands/pause.ts +46 -0
  101. package/tui/src/commands/persona.tsx +58 -0
  102. package/tui/src/commands/provider.tsx +124 -0
  103. package/tui/src/commands/quit.ts +22 -0
  104. package/tui/src/commands/quotes.tsx +172 -0
  105. package/tui/src/commands/registry.test.ts +137 -0
  106. package/tui/src/commands/registry.ts +130 -0
  107. package/tui/src/commands/resume.ts +39 -0
  108. package/tui/src/commands/setsync.tsx +43 -0
  109. package/tui/src/commands/settings.tsx +83 -0
  110. package/tui/src/components/ConfirmOverlay.tsx +51 -0
  111. package/tui/src/components/ConflictOverlay.tsx +78 -0
  112. package/tui/src/components/HelpOverlay.tsx +69 -0
  113. package/tui/src/components/Layout.tsx +24 -0
  114. package/tui/src/components/MessageList.tsx +174 -0
  115. package/tui/src/components/PersonaListOverlay.tsx +186 -0
  116. package/tui/src/components/PromptInput.tsx +145 -0
  117. package/tui/src/components/ProviderListOverlay.tsx +208 -0
  118. package/tui/src/components/QuotesOverlay.tsx +157 -0
  119. package/tui/src/components/Sidebar.tsx +95 -0
  120. package/tui/src/components/StatusBar.tsx +77 -0
  121. package/tui/src/components/WelcomeOverlay.tsx +73 -0
  122. package/tui/src/context/ei.tsx +623 -0
  123. package/tui/src/context/keyboard.tsx +164 -0
  124. package/tui/src/context/overlay.tsx +53 -0
  125. package/tui/src/index.tsx +8 -0
  126. package/tui/src/storage/file.ts +185 -0
  127. package/tui/src/util/duration.ts +32 -0
  128. package/tui/src/util/editor.ts +188 -0
  129. package/tui/src/util/logger.ts +109 -0
  130. package/tui/src/util/persona-editor.tsx +181 -0
  131. package/tui/src/util/provider-editor.tsx +168 -0
  132. package/tui/src/util/syntax.ts +35 -0
  133. package/tui/src/util/yaml-serializers.ts +755 -0
@@ -0,0 +1,269 @@
1
+ import type { StorageState, Quote, Fact, Trait, Person, Topic } from "../core/types";
2
+ import { join } from "path";
3
+ import { readFile } from "fs/promises";
4
+ import { getEmbeddingService, findTopK } from "../core/embedding-service";
5
+
6
+ const STATE_FILE = "state.json";
7
+ const BACKUP_FILE = "state.backup.json";
8
+ const EMBEDDING_MIN_SIMILARITY = 0.3;
9
+
10
+ export function getDataPath(): string {
11
+ if (process.env.EI_DATA_PATH) {
12
+ return process.env.EI_DATA_PATH;
13
+ }
14
+ const xdgData = process.env.XDG_DATA_HOME || join(process.env.HOME || "~", ".local", "share");
15
+ return join(xdgData, "ei");
16
+ }
17
+
18
+ export async function loadLatestState(): Promise<StorageState | null> {
19
+ const dataPath = getDataPath();
20
+ for (const file of [STATE_FILE, BACKUP_FILE]) {
21
+ try {
22
+ const text = await readFile(join(dataPath, file), "utf-8");
23
+ if (text) return JSON.parse(text) as StorageState;
24
+ } catch {
25
+ continue;
26
+ }
27
+ }
28
+ return null;
29
+ }
30
+
31
+ export async function retrieve<T extends { id: string; embedding?: number[] }>(
32
+ items: T[],
33
+ query: string,
34
+ limit: number = 10
35
+ ): Promise<T[]> {
36
+ if (items.length === 0 || !query) {
37
+ return [];
38
+ }
39
+
40
+ const embeddingService = getEmbeddingService();
41
+ const queryVector = await embeddingService.embed(query);
42
+
43
+ const results = findTopK(queryVector, items, limit);
44
+
45
+ return results
46
+ .filter(({ similarity }) => similarity >= EMBEDDING_MIN_SIMILARITY)
47
+ .map(({ item }) => item);
48
+ }
49
+
50
+ export interface LinkedItem {
51
+ id: string;
52
+ name: string;
53
+ type: string;
54
+ }
55
+ export interface QuoteResult {
56
+ id: string;
57
+ text: string;
58
+ speaker: string;
59
+ timestamp: string;
60
+ linked_items: LinkedItem[];
61
+ }
62
+
63
+ export interface FactResult {
64
+ id: string;
65
+ name: string;
66
+ description: string;
67
+ sentiment: number;
68
+ validated: string;
69
+ }
70
+
71
+ export interface TraitResult {
72
+ id: string;
73
+ name: string;
74
+ description: string;
75
+ strength: number;
76
+ sentiment: number;
77
+ }
78
+
79
+ export interface PersonResult {
80
+ id: string;
81
+ name: string;
82
+ description: string;
83
+ relationship: string;
84
+ sentiment: number;
85
+ }
86
+
87
+ export interface TopicResult {
88
+ id: string;
89
+ name: string;
90
+ description: string;
91
+ category?: string;
92
+ sentiment: number;
93
+ }
94
+
95
+ export type BalancedResult =
96
+ | ({ type: "quote" } & QuoteResult)
97
+ | ({ type: "fact" } & FactResult)
98
+ | ({ type: "trait" } & TraitResult)
99
+ | ({ type: "person" } & PersonResult)
100
+ | ({ type: "topic" } & TopicResult);
101
+
102
+ const DATA_TYPES = ["quote", "fact", "trait", "person", "topic"] as const;
103
+ type DataType = typeof DATA_TYPES[number];
104
+
105
+ interface ScoredEntry {
106
+ type: DataType;
107
+ similarity: number;
108
+ mapped: QuoteResult | FactResult | TraitResult | PersonResult | TopicResult;
109
+ itemId: string;
110
+ }
111
+
112
+ export function resolveLinkedItems(dataItemIds: string[], state: StorageState): LinkedItem[] {
113
+ const items: LinkedItem[] = [];
114
+ const collections: Array<{ type: string; source: Array<{ id: string; name: string }> }> = [
115
+ { type: "topic", source: state.human.topics },
116
+ { type: "person", source: state.human.people },
117
+ { type: "fact", source: state.human.facts },
118
+ { type: "trait", source: state.human.traits },
119
+ ];
120
+ for (const { type, source } of collections) {
121
+ for (const entity of source) {
122
+ if (dataItemIds.includes(entity.id)) {
123
+ items.push({ id: entity.id, name: entity.name, type });
124
+ }
125
+ }
126
+ }
127
+ return items;
128
+ }
129
+ export function mapQuote(quote: Quote, state: StorageState): QuoteResult {
130
+ return {
131
+ id: quote.id,
132
+ text: quote.text,
133
+ speaker: quote.speaker,
134
+ timestamp: quote.timestamp,
135
+ linked_items: resolveLinkedItems(quote.data_item_ids, state),
136
+ };
137
+ }
138
+
139
+ function mapFact(fact: Fact): FactResult {
140
+ return {
141
+ id: fact.id,
142
+ name: fact.name,
143
+ description: fact.description,
144
+ sentiment: fact.sentiment,
145
+ validated: fact.validated,
146
+ };
147
+ }
148
+
149
+ function mapTrait(trait: Trait): TraitResult {
150
+ return {
151
+ id: trait.id,
152
+ name: trait.name,
153
+ description: trait.description,
154
+ strength: trait.strength ?? 0.5,
155
+ sentiment: trait.sentiment,
156
+ };
157
+ }
158
+
159
+ function mapPerson(person: Person): PersonResult {
160
+ return {
161
+ id: person.id,
162
+ name: person.name,
163
+ description: person.description,
164
+ relationship: person.relationship,
165
+ sentiment: person.sentiment,
166
+ };
167
+ }
168
+
169
+ function mapTopic(topic: Topic): TopicResult {
170
+ return {
171
+ id: topic.id,
172
+ name: topic.name,
173
+ description: topic.description,
174
+ category: topic.category,
175
+ sentiment: topic.sentiment,
176
+ };
177
+ }
178
+
179
+ export async function retrieveBalanced(
180
+ query: string,
181
+ limit: number = 10
182
+ ): Promise<BalancedResult[]> {
183
+ const state = await loadLatestState();
184
+ if (!state) {
185
+ console.error("No saved state found. Is EI_DATA_PATH set correctly?");
186
+ return [];
187
+ }
188
+
189
+ const embeddingService = getEmbeddingService();
190
+ const queryVector = await embeddingService.embed(query);
191
+
192
+ const allScored: ScoredEntry[] = [];
193
+
194
+ const typeConfigs: Array<{
195
+ type: DataType;
196
+ items: Array<{ id: string; embedding?: number[] }>;
197
+ mapper: (item: any) => any;
198
+ }> = [
199
+ { type: "quote", items: state.human.quotes, mapper: (q: Quote) => mapQuote(q, state) },
200
+ { type: "fact", items: state.human.facts, mapper: mapFact },
201
+ { type: "trait", items: state.human.traits, mapper: mapTrait },
202
+ { type: "person", items: state.human.people, mapper: mapPerson },
203
+ { type: "topic", items: state.human.topics, mapper: mapTopic },
204
+ ];
205
+
206
+ for (const { type, items, mapper } of typeConfigs) {
207
+ const scored = findTopK(queryVector, items, items.length);
208
+ for (const { item, similarity } of scored) {
209
+ if (similarity >= EMBEDDING_MIN_SIMILARITY) {
210
+ allScored.push({ type, similarity, mapped: mapper(item), itemId: item.id });
211
+ }
212
+ }
213
+ }
214
+
215
+ const result: ScoredEntry[] = [];
216
+ const used = new Set<string>();
217
+
218
+ // Floor: at least 1 result per type (if available and meets threshold)
219
+ for (const type of DATA_TYPES) {
220
+ if (result.length >= limit) break;
221
+ const best = allScored
222
+ .filter(r => r.type === type && !used.has(r.itemId))
223
+ .sort((a, b) => b.similarity - a.similarity)[0];
224
+ if (best) {
225
+ result.push(best);
226
+ used.add(best.itemId);
227
+ }
228
+ }
229
+
230
+ // Fill remaining slots with highest-similarity results across all types
231
+ const remaining = allScored
232
+ .filter(r => !used.has(r.itemId))
233
+ .sort((a, b) => b.similarity - a.similarity);
234
+
235
+ for (const entry of remaining) {
236
+ if (result.length >= limit) break;
237
+ result.push(entry);
238
+ used.add(entry.itemId);
239
+ }
240
+
241
+ result.sort((a, b) => b.similarity - a.similarity);
242
+
243
+ return result.map(({ type, mapped }) => ({ type, ...mapped }) as BalancedResult);
244
+ }
245
+
246
+ export async function lookupById(id: string): Promise<({ type: string } & Record<string, unknown>) | null> {
247
+ const state = await loadLatestState();
248
+ if (!state) {
249
+ return null;
250
+ }
251
+
252
+ const collections: Array<{ type: string; source: Array<{ id: string; [k: string]: unknown }> }> = [
253
+ { type: "fact", source: state.human.facts },
254
+ { type: "trait", source: state.human.traits },
255
+ { type: "person", source: state.human.people },
256
+ { type: "topic", source: state.human.topics },
257
+ { type: "quote", source: state.human.quotes },
258
+ ];
259
+
260
+ for (const { type, source } of collections) {
261
+ const entity = source.find(item => item.id === id);
262
+ if (entity) {
263
+ const { embedding, ...rest } = entity;
264
+ return { type, ...rest };
265
+ }
266
+ }
267
+
268
+ return null;
269
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,176 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * EI CLI - Memory retrieval interface for OpenCode integration
4
+ *
5
+ * Usage:
6
+ * ei "search text" Search all data types
7
+ * ei -n 5 "search text" Limit results
8
+ * ei quote "search text" Search specific type
9
+ * ei quote -n 5 "search text" Type-specific with limit
10
+ * ei --id <id> Look up entity by ID
11
+ * echo <id> | ei --id Look up entity by ID from stdin
12
+ */
13
+
14
+ import { parseArgs } from "util";
15
+ import { retrieveBalanced, lookupById } from "./cli/retrieval";
16
+
17
+ const TYPE_ALIASES: Record<string, string> = {
18
+ quote: "quotes",
19
+ quotes: "quotes",
20
+ fact: "facts",
21
+ facts: "facts",
22
+ trait: "traits",
23
+ traits: "traits",
24
+ person: "people",
25
+ people: "people",
26
+ topic: "topics",
27
+ topics: "topics",
28
+ };
29
+
30
+ function printHelp(): void {
31
+ console.log(`
32
+ Ei
33
+
34
+ Usage:
35
+ ei Launch the TUI chat interface
36
+ ei "search text" Search all data types (top 10)
37
+ ei -n 5 "search text" Limit results
38
+ ei <type> "search text" Search a specific data type
39
+ ei <type> -n 5 "search text" Type-specific with limit
40
+ ei --id <id> Look up a specific entity by ID
41
+ echo <id> | ei --id Look up entity by ID from stdin
42
+
43
+ Types:
44
+ quote / quotes Quotes from conversation history
45
+ fact / facts Facts about the user
46
+ trait / traits Personality traits
47
+ person / people People from the user's life
48
+ topic / topics Topics of interest
49
+
50
+ Options:
51
+ --number, -n Maximum number of results (default: 10)
52
+ --id Look up entity by ID (accepts value or stdin)
53
+ --help, -h Show this help message
54
+
55
+ Examples:
56
+ ei "debugging" # Search everything
57
+ ei -n 5 "API design" # Top 5 across all types
58
+ ei quote "you guessed it" # Search quotes only
59
+ ei trait -n 3 "problem solving" # Top 3 matching traits
60
+ ei --id abc-123 # Look up entity by ID
61
+ ei "memory leak" | jq .[0].id | ei --id # Pipe ID from search
62
+ `);
63
+ }
64
+
65
+ async function main(): Promise<void> {
66
+ const args = process.argv.slice(2);
67
+
68
+ if (args.length === 0) {
69
+ const tuiDir = new URL("../tui", import.meta.url).pathname;
70
+ const tuiEntry = new URL("../tui/src/index.tsx", import.meta.url).pathname;
71
+ const proc = Bun.spawn(["bun", "--conditions=browser", "run", tuiEntry], {
72
+ stdio: ["inherit", "inherit", "inherit"],
73
+ env: { ...process.env },
74
+ cwd: tuiDir,
75
+ });
76
+ await proc.exited;
77
+ process.exit(proc.exitCode ?? 0);
78
+ }
79
+
80
+ if (args[0] === "--help" || args[0] === "-h") {
81
+ printHelp();
82
+ process.exit(0);
83
+ }
84
+
85
+
86
+ // Handle --id flag: look up entity by ID
87
+ const idFlagIndex = args.indexOf("--id");
88
+ if (idFlagIndex !== -1) {
89
+ let id = args[idFlagIndex + 1]?.trim();
90
+
91
+ // If no value after --id, try reading from stdin
92
+ if (!id && !process.stdin.isTTY) {
93
+ const chunks: Buffer[] = [];
94
+ for await (const chunk of process.stdin) {
95
+ chunks.push(chunk as Buffer);
96
+ }
97
+ id = Buffer.concat(chunks).toString("utf-8").trim();
98
+ }
99
+
100
+ if (!id) {
101
+ console.error("--id requires a value. Usage: ei --id <id> or echo <id> | ei --id");
102
+ process.exit(1);
103
+ }
104
+
105
+ // Strip surrounding quotes (from jq output or shell quoting)
106
+ id = id.replace(/^["']|["']$/g, "");
107
+
108
+ const entity = await lookupById(id);
109
+ if (!entity) {
110
+ console.error(`No entity found with ID: ${id}`);
111
+ process.exit(1);
112
+ }
113
+ console.log(JSON.stringify(entity, null, 2));
114
+ process.exit(0);
115
+ }
116
+ let targetType: string | null = null;
117
+ let parseableArgs = args;
118
+
119
+ if (TYPE_ALIASES[args[0]]) {
120
+ targetType = TYPE_ALIASES[args[0]];
121
+ parseableArgs = args.slice(1);
122
+ }
123
+
124
+ let parsed;
125
+ try {
126
+ parsed = parseArgs({
127
+ args: parseableArgs,
128
+ options: {
129
+ number: { type: "string", short: "n" },
130
+ help: { type: "boolean", short: "h" },
131
+ },
132
+ allowPositionals: true,
133
+ strict: true,
134
+ });
135
+ } catch (e) {
136
+ console.error(`Error parsing arguments: ${(e as Error).message}`);
137
+ process.exit(1);
138
+ }
139
+
140
+ if (parsed.values.help) {
141
+ printHelp();
142
+ process.exit(0);
143
+ }
144
+
145
+ const query = parsed.positionals.join(" ").trim();
146
+ const limit = parsed.values.number ? parseInt(parsed.values.number, 10) : 10;
147
+
148
+ if (!query) {
149
+ if (targetType) {
150
+ console.error(`Search text required. Usage: ei ${targetType} "search text"`);
151
+ } else {
152
+ console.error(`Search text required. Usage: ei "search text"`);
153
+ }
154
+ process.exit(1);
155
+ }
156
+
157
+ if (isNaN(limit) || limit < 1) {
158
+ console.error("--number must be a positive integer");
159
+ process.exit(1);
160
+ }
161
+
162
+ let result;
163
+ if (targetType) {
164
+ const module = await import(`./cli/commands/${targetType}.js`);
165
+ result = await module.execute(query, limit);
166
+ } else {
167
+ result = await retrieveBalanced(query, limit);
168
+ }
169
+
170
+ console.log(JSON.stringify(result, null, 2));
171
+ }
172
+
173
+ main().catch((e) => {
174
+ console.error(`Fatal error: ${e.message}`);
175
+ process.exit(1);
176
+ });
@@ -0,0 +1,104 @@
1
+ # Core Module
2
+
3
+ The brain of Ei. Handles state, queue, LLM communication, and orchestration.
4
+
5
+ ## Architecture
6
+
7
+ ```
8
+ core/
9
+ ├── processor.ts # Main orchestrator (1100+ lines)
10
+ ├── state-manager.ts # In-memory state + persistence
11
+ ├── queue-processor.ts # LLM request queue with priorities
12
+ ├── llm-client.ts # Multi-provider LLM abstraction
13
+ ├── types.ts # All core types (source: CONTRACTS.md)
14
+ ├── handlers/ # LLM response handlers
15
+ ├── orchestrators/ # Multi-step workflows
16
+ ├── personas/ # Persona loading logic
17
+ └── state/ # State slices (human, persona, messages)
18
+ ```
19
+
20
+ ## Key Files
21
+
22
+ ### processor.ts (The Hub)
23
+
24
+ Everything flows through Processor:
25
+ - **Main loop**: 100ms tick checking queue, auto-save, heartbeat
26
+ - **Message flow**: User input → queue response request → handle result → update state
27
+ - **Background work**: Extraction, ceremony, heartbeat (all async, queued)
28
+
29
+ ```typescript
30
+ // Entry points
31
+ processor.start() // Begin main loop
32
+ processor.sendMessage(persona, text) // User sends message
33
+ processor.stop() // Graceful shutdown
34
+ ```
35
+
36
+ ### state-manager.ts
37
+
38
+ In-memory state with dirty tracking:
39
+ - `loadState()` / `saveState()` for persistence
40
+ - Slices: human, personas, messages, config
41
+ - Auto-save every 60s when dirty
42
+
43
+ ### queue-processor.ts
44
+
45
+ Priority queue for LLM requests:
46
+ - High: User-facing responses
47
+ - Normal: Extraction, analysis
48
+ - Low: Background maintenance
49
+
50
+ **Async model**: Handlers queue work, don't await results inline.
51
+
52
+ ### handlers/index.ts (1000+ lines)
53
+
54
+ All `LLMNextStep` handlers in one file. Each handler:
55
+ 1. Parses LLM response (JSON or text)
56
+ 2. Updates state via StateManager
57
+ 3. May queue follow-up requests
58
+
59
+ ### orchestrators/
60
+
61
+ Multi-step workflows:
62
+ - `persona-generation.ts`: Create new persona (multi-LLM-call process)
63
+ - `extraction.ts`: Scan messages for facts/topics/people
64
+ - `ceremony.ts`: Periodic exposure decay + persona enrichment
65
+
66
+ ## Patterns
67
+
68
+ ### Time-Based Triggers
69
+
70
+ ```typescript
71
+ // ✅ CORRECT: Update timestamp BEFORE async work
72
+ if (timeSinceLastX >= delay) {
73
+ lastX = Date.now(); // Prevent duplicate queueing
74
+ await doAsyncWork();
75
+ }
76
+
77
+ // ❌ WRONG: Other loop iterations queue duplicates
78
+ if (timeSinceLastX >= delay) {
79
+ await doAsyncWork();
80
+ lastX = Date.now();
81
+ }
82
+ ```
83
+
84
+ ### Adding New Handlers
85
+
86
+ 1. Add enum to `LLMNextStep` in types.ts
87
+ 2. Add handler function in handlers/index.ts
88
+ 3. Register in `handlers` map at bottom of file
89
+ 4. Queue from Processor or orchestrator
90
+
91
+ ### State Updates
92
+
93
+ Always use StateManager methods, never mutate directly:
94
+ ```typescript
95
+ // ✅ Correct
96
+ stateManager.updateHuman(h => ({ ...h, last_interaction: now }))
97
+
98
+ // ❌ Wrong - bypasses dirty tracking
99
+ state.human.last_interaction = now
100
+ ```
101
+
102
+ ## Testing
103
+
104
+ Unit tests in `tests/unit/core/`. Mock LLM responses for deterministic tests.