ei-tui 0.1.25 → 0.3.1

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 (88) hide show
  1. package/README.md +42 -0
  2. package/package.json +2 -1
  3. package/src/README.md +4 -11
  4. package/src/cli/README.md +87 -7
  5. package/src/cli/commands/facts.ts +2 -2
  6. package/src/cli/commands/people.ts +2 -2
  7. package/src/cli/commands/quotes.ts +2 -2
  8. package/src/cli/commands/topics.ts +2 -2
  9. package/src/cli/mcp.ts +94 -0
  10. package/src/cli/retrieval.ts +67 -31
  11. package/src/cli.ts +64 -23
  12. package/src/core/AGENTS.md +1 -1
  13. package/src/core/constants/built-in-facts.ts +49 -0
  14. package/src/core/constants/index.ts +1 -0
  15. package/src/core/context-utils.ts +0 -1
  16. package/src/core/embedding-service.ts +8 -0
  17. package/src/core/handlers/dedup.ts +11 -23
  18. package/src/core/handlers/heartbeat.ts +2 -3
  19. package/src/core/handlers/human-extraction.ts +96 -30
  20. package/src/core/handlers/human-matching.ts +328 -248
  21. package/src/core/handlers/index.ts +8 -6
  22. package/src/core/handlers/persona-generation.ts +8 -8
  23. package/src/core/handlers/rewrite.ts +4 -51
  24. package/src/core/handlers/utils.ts +23 -1
  25. package/src/core/heartbeat-manager.ts +2 -4
  26. package/src/core/human-data-manager.ts +38 -36
  27. package/src/core/message-manager.ts +10 -10
  28. package/src/core/orchestrators/ceremony.ts +49 -44
  29. package/src/core/orchestrators/dedup-phase.ts +2 -4
  30. package/src/core/orchestrators/human-extraction.ts +351 -207
  31. package/src/core/orchestrators/index.ts +6 -4
  32. package/src/core/orchestrators/persona-generation.ts +3 -3
  33. package/src/core/processor.ts +167 -20
  34. package/src/core/prompt-context-builder.ts +4 -6
  35. package/src/core/state/human.ts +1 -26
  36. package/src/core/state/personas.ts +2 -2
  37. package/src/core/state-manager.ts +107 -14
  38. package/src/core/tools/builtin/read-memory.ts +13 -18
  39. package/src/core/types/data-items.ts +3 -4
  40. package/src/core/types/entities.ts +7 -4
  41. package/src/core/types/enums.ts +6 -9
  42. package/src/core/types/llm.ts +2 -2
  43. package/src/core/utils/crossFind.ts +2 -5
  44. package/src/core/utils/event-windows.ts +31 -0
  45. package/src/integrations/claude-code/importer.ts +14 -5
  46. package/src/integrations/claude-code/types.ts +3 -0
  47. package/src/integrations/cursor/importer.ts +282 -0
  48. package/src/integrations/cursor/index.ts +10 -0
  49. package/src/integrations/cursor/reader.ts +209 -0
  50. package/src/integrations/cursor/types.ts +140 -0
  51. package/src/integrations/opencode/importer.ts +14 -4
  52. package/src/prompts/AGENTS.md +73 -1
  53. package/src/prompts/ceremony/dedup.ts +0 -33
  54. package/src/prompts/ceremony/rewrite.ts +6 -41
  55. package/src/prompts/ceremony/types.ts +4 -4
  56. package/src/prompts/generation/descriptions.ts +2 -2
  57. package/src/prompts/generation/types.ts +2 -2
  58. package/src/prompts/heartbeat/types.ts +2 -2
  59. package/src/prompts/human/event-scan.ts +122 -0
  60. package/src/prompts/human/fact-find.ts +106 -0
  61. package/src/prompts/human/fact-scan.ts +0 -2
  62. package/src/prompts/human/index.ts +17 -10
  63. package/src/prompts/human/person-match.ts +65 -0
  64. package/src/prompts/human/person-scan.ts +52 -59
  65. package/src/prompts/human/person-update.ts +241 -0
  66. package/src/prompts/human/topic-match.ts +65 -0
  67. package/src/prompts/human/topic-scan.ts +51 -71
  68. package/src/prompts/human/topic-update.ts +295 -0
  69. package/src/prompts/human/types.ts +63 -40
  70. package/src/prompts/index.ts +4 -8
  71. package/src/prompts/persona/topics-update.ts +2 -2
  72. package/src/prompts/persona/traits.ts +2 -2
  73. package/src/prompts/persona/types.ts +3 -3
  74. package/src/prompts/response/index.ts +1 -1
  75. package/src/prompts/response/sections.ts +9 -12
  76. package/src/prompts/response/types.ts +2 -3
  77. package/src/storage/embeddings.ts +1 -1
  78. package/src/storage/index.ts +1 -0
  79. package/src/storage/indexed.ts +174 -0
  80. package/src/storage/merge.ts +67 -2
  81. package/tui/src/commands/me.tsx +5 -14
  82. package/tui/src/commands/settings.tsx +15 -0
  83. package/tui/src/context/ei.tsx +5 -14
  84. package/tui/src/util/yaml-serializers.ts +76 -33
  85. package/src/cli/commands/traits.ts +0 -25
  86. package/src/prompts/human/item-match.ts +0 -74
  87. package/src/prompts/human/item-update.ts +0 -364
  88. package/src/prompts/human/trait-scan.ts +0 -115
@@ -0,0 +1,209 @@
1
+ import type {
2
+ ICursorReader,
3
+ CursorSession,
4
+ CursorMessage,
5
+ CursorComposerMeta,
6
+ CursorBubbleRaw,
7
+ CursorBubbleHeader,
8
+ } from "./types.js";
9
+
10
+ const isBrowser = typeof document !== "undefined";
11
+
12
+ let _join: typeof import("path").join;
13
+ let _readFile: typeof import("fs/promises").readFile;
14
+ let _readdir: typeof import("fs/promises").readdir;
15
+ let _nodeModulesLoaded = false;
16
+
17
+ async function ensureNodeModules(): Promise<boolean> {
18
+ if (isBrowser) return false;
19
+ if (_nodeModulesLoaded) return true;
20
+
21
+ const PATH_MODULE = "path";
22
+ const FS_MODULE = "fs/promises";
23
+
24
+ const pathMod = await import(/* @vite-ignore */ PATH_MODULE);
25
+ const fsMod = await import(/* @vite-ignore */ FS_MODULE);
26
+
27
+ _join = pathMod.join;
28
+ _readFile = fsMod.readFile;
29
+ _readdir = fsMod.readdir;
30
+ _nodeModulesLoaded = true;
31
+ return true;
32
+ }
33
+
34
+ function getPlatformBasePath(): string {
35
+ if (!_join) return "";
36
+ const home = process.env.HOME || process.env.USERPROFILE || "~";
37
+ const platform = process.platform;
38
+
39
+ if (platform === "darwin") {
40
+ return _join(home, "Library", "Application Support", "Cursor", "User");
41
+ }
42
+ if (platform === "win32") {
43
+ return _join(process.env.APPDATA || home, "Cursor", "User");
44
+ }
45
+ return _join(home, ".config", "Cursor", "User");
46
+ }
47
+
48
+ function titleFromPath(workspacePath: string): string {
49
+ if (!workspacePath) return "Unknown";
50
+ const parts = workspacePath.replace(/\\/g, "/").split("/").filter(Boolean);
51
+ return parts[parts.length - 1] ?? workspacePath;
52
+ }
53
+
54
+ export class CursorReader implements ICursorReader {
55
+ private readonly basePath?: string;
56
+
57
+ constructor(basePath?: string) {
58
+ this.basePath = basePath;
59
+ }
60
+
61
+ async isAvailable(): Promise<boolean> {
62
+ if (!(await ensureNodeModules())) return false;
63
+ const base = this.basePath ?? getPlatformBasePath();
64
+ const globalDb = _join(base, "globalStorage", "state.vscdb");
65
+ try {
66
+ await _readFile(globalDb);
67
+ return true;
68
+ } catch {
69
+ return false;
70
+ }
71
+ }
72
+
73
+ async getSessions(): Promise<CursorSession[]> {
74
+ if (!(await ensureNodeModules())) return [];
75
+
76
+ const base = this.basePath ?? getPlatformBasePath();
77
+ const globalDbPath = _join(base, "globalStorage", "state.vscdb");
78
+ const workspaceStoragePath = _join(base, "workspaceStorage");
79
+
80
+ let globalDb: import("bun:sqlite").Database;
81
+ try {
82
+ const { Database } = await import(/* @vite-ignore */ "bun:sqlite");
83
+ globalDb = new Database(globalDbPath, { readonly: true });
84
+ } catch (err) {
85
+ console.warn("[CursorReader] failed to open global DB:", err);
86
+ return [];
87
+ }
88
+
89
+ const hashToFolder = await this.buildHashToFolderMap(workspaceStoragePath);
90
+ const sessions: CursorSession[] = [];
91
+
92
+ for (const [hash, workspacePath] of hashToFolder.entries()) {
93
+ const workspaceDbPath = _join(workspaceStoragePath, hash, "state.vscdb");
94
+ let workspaceDb: import("bun:sqlite").Database | null = null;
95
+
96
+ try {
97
+ const { Database } = await import(/* @vite-ignore */ "bun:sqlite");
98
+ workspaceDb = new Database(workspaceDbPath, { readonly: true });
99
+
100
+ const row = workspaceDb
101
+ .query(`SELECT value FROM ItemTable WHERE key = 'composer.composerData'`)
102
+ .get() as { value: string } | null;
103
+
104
+ if (!row) continue;
105
+
106
+ const composerData = JSON.parse(row.value) as { allComposers?: CursorComposerMeta[] };
107
+ const allComposers: CursorComposerMeta[] = composerData.allComposers ?? [];
108
+
109
+ for (const meta of allComposers) {
110
+ if (meta.lastUpdatedAt == null) continue;
111
+ if (meta.isDraft === true) continue;
112
+
113
+ const composerId = meta.composerId;
114
+
115
+ const headerRow = globalDb
116
+ .query(`SELECT value FROM cursorDiskKV WHERE key = ?`)
117
+ .get(`composerData:${composerId}`) as { value: string } | null;
118
+
119
+ if (!headerRow) continue;
120
+
121
+ const composerGlobal = JSON.parse(headerRow.value) as {
122
+ fullConversationHeadersOnly?: CursorBubbleHeader[];
123
+ };
124
+ const headers: CursorBubbleHeader[] = composerGlobal.fullConversationHeadersOnly ?? [];
125
+ if (headers.length === 0) continue;
126
+
127
+ const messages: CursorMessage[] = [];
128
+
129
+ for (const header of headers) {
130
+ const bubbleKey = `bubbleId:${composerId}:${header.bubbleId}`;
131
+ const bubbleRow = globalDb
132
+ .query(`SELECT value FROM cursorDiskKV WHERE key = ?`)
133
+ .get(bubbleKey) as { value: string } | null;
134
+
135
+ if (!bubbleRow) continue;
136
+
137
+ const bubble = JSON.parse(bubbleRow.value) as CursorBubbleRaw;
138
+ if (!bubble.text || bubble.text.trim() === "") continue;
139
+
140
+ messages.push({
141
+ id: bubble.bubbleId,
142
+ type: bubble.type,
143
+ text: bubble.text,
144
+ timestamp: bubble.createdAt,
145
+ });
146
+ }
147
+
148
+ if (messages.length === 0) continue;
149
+
150
+ messages.sort(
151
+ (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
152
+ );
153
+
154
+ const lastMsg = messages[messages.length - 1];
155
+ const sessionName = meta.name?.trim()
156
+ ? meta.name.trim()
157
+ : `${meta.unifiedMode ?? "chat"} — ${titleFromPath(workspacePath)}`;
158
+
159
+ sessions.push({
160
+ id: composerId,
161
+ name: sessionName,
162
+ workspacePath,
163
+ unifiedMode: meta.unifiedMode ?? "chat",
164
+ createdAt: new Date(meta.createdAt).toISOString(),
165
+ lastMessageAt: lastMsg.timestamp,
166
+ messages,
167
+ });
168
+ }
169
+ } catch (err) {
170
+ console.warn(`[CursorReader] skipping workspace ${hash.slice(0, 8)}:`, err);
171
+ } finally {
172
+ workspaceDb?.close();
173
+ }
174
+ }
175
+
176
+ globalDb.close();
177
+
178
+ return sessions.sort(
179
+ (a, b) => new Date(a.lastMessageAt).getTime() - new Date(b.lastMessageAt).getTime()
180
+ );
181
+ }
182
+
183
+ private async buildHashToFolderMap(workspaceStoragePath: string): Promise<Map<string, string>> {
184
+ const map = new Map<string, string>();
185
+
186
+ let hashes: string[];
187
+ try {
188
+ hashes = await _readdir(workspaceStoragePath);
189
+ } catch {
190
+ return map;
191
+ }
192
+
193
+ for (const hash of hashes) {
194
+ if (hash.startsWith(".")) continue;
195
+ const wsJsonPath = _join(workspaceStoragePath, hash, "workspace.json");
196
+ try {
197
+ const text = await _readFile(wsJsonPath, "utf-8");
198
+ const wsData = JSON.parse(text) as { folder?: string };
199
+ if (wsData.folder) {
200
+ const folderPath = wsData.folder.replace(/^file:\/\//, "");
201
+ map.set(hash, folderPath);
202
+ }
203
+ } catch {
204
+ }
205
+ }
206
+
207
+ return map;
208
+ }
209
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Cursor IDE Integration Types
3
+ *
4
+ * Cursor stores sessions across two SQLite DBs:
5
+ *
6
+ * Workspace DBs:
7
+ * ~/Library/Application Support/Cursor/User/workspaceStorage/<hash>/state.vscdb
8
+ * ItemTable key "composer.composerData" → { allComposers: CursorComposerMeta[] }
9
+ *
10
+ * Global DB:
11
+ * ~/Library/Application Support/Cursor/User/globalStorage/state.vscdb
12
+ * cursorDiskKV key "composerData:<id>" → header list
13
+ * cursorDiskKV key "bubbleId:<id>:<bubbleId>" → bubble blob
14
+ */
15
+
16
+ // ============================================================================
17
+ // Reader Interface
18
+ // ============================================================================
19
+
20
+ export interface ICursorReader {
21
+ getSessions(): Promise<CursorSession[]>;
22
+ isAvailable(): Promise<boolean>;
23
+ }
24
+
25
+ // ============================================================================
26
+ // Raw DB Schema Types (internal to reader)
27
+ // ============================================================================
28
+
29
+ /**
30
+ * Raw entry from workspace DB's allComposers array.
31
+ */
32
+ export interface CursorComposerMeta {
33
+ composerId: string;
34
+ name?: string;
35
+ createdAt: number; // epoch ms
36
+ lastUpdatedAt?: number | null; // epoch ms, null for never-used sessions
37
+ unifiedMode?: string; // "agent" | "chat" | "plan" | "debug"
38
+ isArchived?: boolean;
39
+ isDraft?: boolean;
40
+ filesChangedCount?: number;
41
+ totalLinesAdded?: number;
42
+ totalLinesRemoved?: number;
43
+ }
44
+
45
+ /**
46
+ * Raw bubble from global DB bubbleId:<composerId>:<bubbleId>.
47
+ */
48
+ export interface CursorBubbleRaw {
49
+ _v?: number;
50
+ type: 1 | 2; // 1 = user, 2 = assistant
51
+ bubbleId: string;
52
+ text: string;
53
+ richText?: string; // Lexical JSON, same content as text — ignored
54
+ createdAt: string; // ISO timestamp
55
+ modelInfo?: { modelName: string };
56
+ requestId?: string;
57
+ }
58
+
59
+ /**
60
+ * Header entry from composerData:<id>.fullConversationHeadersOnly.
61
+ */
62
+ export interface CursorBubbleHeader {
63
+ bubbleId: string;
64
+ type: 1 | 2;
65
+ }
66
+
67
+ // ============================================================================
68
+ // Cleaned Session / Message Types (for Ei consumption)
69
+ // ============================================================================
70
+
71
+ /**
72
+ * A single message in a Cursor session, cleaned for Ei.
73
+ */
74
+ export interface CursorMessage {
75
+ /** bubbleId from the raw bubble */
76
+ id: string;
77
+ /** 1 = user, 2 = assistant */
78
+ type: 1 | 2;
79
+ /** Plain text content */
80
+ text: string;
81
+ /** ISO timestamp */
82
+ timestamp: string;
83
+ }
84
+
85
+ /**
86
+ * A Cursor composer session, cleaned for Ei.
87
+ */
88
+ export interface CursorSession {
89
+ /** composerId — the stable identifier */
90
+ id: string;
91
+ /** User-assigned name, or a generated fallback */
92
+ name: string;
93
+ /** Absolute path to the workspace folder, e.g. /Users/foo/Projects/myapp */
94
+ workspacePath: string;
95
+ /** "agent" | "chat" | "plan" | "debug" */
96
+ unifiedMode: string;
97
+ /** ISO timestamp of creation */
98
+ createdAt: string;
99
+ /** ISO timestamp of last message */
100
+ lastMessageAt: string;
101
+ /** All non-empty messages, sorted oldest-first */
102
+ messages: CursorMessage[];
103
+ }
104
+
105
+ // ============================================================================
106
+ // Constants
107
+ // ============================================================================
108
+
109
+ /** The single persona name for all Cursor sessions */
110
+ export const CURSOR_PERSONA_NAME = "Cursor";
111
+
112
+ /** Topic groups assigned to Cursor session topics */
113
+ export const CURSOR_TOPIC_GROUPS = ["General", "Coding", "Cursor"];
114
+
115
+ /**
116
+ * Minimum session age before we import it.
117
+ * Mirrors ClaudeCode's 20-minute rule — gives the session time to "settle."
118
+ */
119
+ export const MIN_SESSION_AGE_MS = 20 * 60 * 1000;
120
+
121
+ // ============================================================================
122
+ // Human Settings Shape
123
+ // ============================================================================
124
+
125
+ /**
126
+ * Stored under human.settings.cursor
127
+ *
128
+ * ⚠️ ADDING A NEW FIELD HERE?
129
+ * If it's runtime-managed (not user-editable), you MUST also add it to the
130
+ * cursor reconstruction block in settingsFromYAML() in:
131
+ * tui/src/util/yaml-serializers.ts
132
+ * Otherwise it will be silently wiped every time the user saves /settings.
133
+ */
134
+ export interface CursorSettings {
135
+ integration?: boolean;
136
+ polling_interval_ms?: number; // Default: 1800000 (30 min)
137
+ last_sync?: string; // ISO timestamp
138
+ extraction_point?: string; // ISO timestamp — floor for session filtering
139
+ processed_sessions?: Record<string, string>; // sessionId → ISO timestamp of last import
140
+ }
@@ -57,9 +57,9 @@ function convertToPreMarkedEiMessage(ocMsg: OpenCodeMessage): Message {
57
57
  return {
58
58
  ...convertToEiMessage(ocMsg),
59
59
  f: true,
60
- r: true,
60
+ t: true,
61
61
  p: true,
62
- o: true,
62
+ e: true,
63
63
  };
64
64
  }
65
65
 
@@ -79,12 +79,18 @@ function filterRelevantMessages(messages: OpenCodeMessage[]): OpenCodeMessage[]
79
79
  * Import one OpenCode session per call.
80
80
  *
81
81
  * Flow:
82
- * 1. Find the next unprocessed session after extraction_point.
82
+ * 1. Find the next unprocessed or updated session (oldest-first).
83
83
  * 2. Write all messages for that session to their persona(s) — archived,
84
84
  * messages cleared first. Messages before last_imported are pre-marked
85
85
  * [p,r,o,f]=true; newer messages are unmarked and queued for extraction.
86
86
  * 3. Advance extraction_point to session.time.updated.
87
87
  *
88
+ * NOTE: extraction_point is a progress indicator for user visibility only.
89
+ * It does NOT gate which sessions are imported. processed_sessions (per-session
90
+ * timestamps) is the sole source of truth for "have we seen this session and
91
+ * is it up to date." Sessions absent from processed_sessions are always
92
+ * candidates regardless of their timestamp vs extraction_point.
93
+ *
88
94
  * The processor gate (queue_length() === 0) ensures we never pile onto a
89
95
  * backed-up queue.
90
96
  *
@@ -229,7 +235,11 @@ export async function importOpenCodeSessions(
229
235
  };
230
236
 
231
237
  if (!signal?.aborted) {
232
- queueAllScans(context, stateManager);
238
+ const openCodeSettings = stateManager.getHuman().settings?.opencode;
239
+ queueAllScans(context, stateManager, {
240
+ extraction_model: openCodeSettings?.extraction_model,
241
+ extraction_token_limit: openCodeSettings?.extraction_token_limit,
242
+ });
233
243
  result.extractionScansQueued += 4;
234
244
  }
235
245
  }
@@ -29,7 +29,7 @@ interface PromptBuilder<T> {
29
29
 
30
30
  **Rules**:
31
31
  1. **Synchronous** - No async, no fetching
32
- 2. **Pure** - Same input → same output
32
+ 2. **Pure** - Same input → same output. No state reads, no side effects.
33
33
  3. **Pre-processed data** - Processor fetches/filters before calling
34
34
  4. **Minimal logic** - String interpolation, not computation
35
35
 
@@ -60,3 +60,75 @@ interface PromptBuilder<T> {
60
60
  **Prompt engineering lives here. Code logic lives in Processor.**
61
61
 
62
62
  When modifying persona behavior, check prompts first—the "personality" is in the English, not the TypeScript.
63
+
64
+ ---
65
+
66
+ ## VIOLATIONS
67
+
68
+ These are wrong. If you see them, fix them.
69
+
70
+ ### Prompt strings defined outside `src/prompts/`
71
+
72
+ **Violation:**
73
+ ```typescript
74
+ // ❌ In src/core/handlers/something.ts
75
+ const system = `You are an expert at JSON. Return only valid JSON with no commentary.`;
76
+ const user = `Fix this broken JSON: ${badJson}`;
77
+ const response = await llmClient.call({ system, user });
78
+ ```
79
+
80
+ **Correct:** Move the prompt to `src/prompts/[purpose]/index.ts`. The handler calls the builder with pre-fetched data; the builder returns `{ system, user }`.
81
+
82
+ ### Exception: JSON recovery prompt in `queue-processor.ts`
83
+
84
+ There is one deliberate exception to the "all prompts live in `src/prompts/`" rule: the JSON repair retry prompt in `queue-processor.ts`.
85
+
86
+ **Why it's an exception**: This is a *repair heuristic* — it fires after a JSON parse failure to ask the LLM to fix its own malformed output. It has no domain knowledge, no persona data, and no human data. It's infrastructure-level error recovery, not a domain prompt. Moving it to `src/prompts/` would create a degenerate prompt builder with a one-line body and no meaningful data contract.
87
+
88
+ **Criteria for a legitimate exception** (all must be true):
89
+ 1. The prompt contains zero domain knowledge (no persona names, no human data, no Ei concepts)
90
+ 2. It's error recovery or infrastructure glue, not business logic
91
+ 3. Moving it to `src/prompts/` would produce a builder with no real `types.ts` (no input data shape worth naming)
92
+
93
+ If your use case doesn't meet all three criteria, it belongs in `src/prompts/`.
94
+
95
+ ### Prompt builders that do computation
96
+
97
+ **Violation:**
98
+ ```typescript
99
+ // ❌ Prompt builder filtering its own data
100
+ function buildResponsePrompt(data: ResponsePromptData) {
101
+ const relevantFacts = data.facts.filter(f => f.sentiment > 0.5); // ← WRONG
102
+ const recentTopics = data.topics.slice(-5); // ← WRONG
103
+ return { system: `...${relevantFacts}...`, user: `...` };
104
+ }
105
+ ```
106
+
107
+ **Correct:** The Processor filters and slices before calling the builder. The builder receives already-filtered data and does string interpolation only.
108
+
109
+ > If you're writing a loop, a `.filter()`, or a `.map()` inside a prompt builder for anything other than formatting a string — stop. That logic belongs in the Processor or a pre-processing utility.
110
+
111
+ ### Async prompt builders
112
+
113
+ **Violation:**
114
+ ```typescript
115
+ // ❌ Async prompt builder
116
+ async function buildHeartbeatPrompt(personaId: string) {
117
+ const persona = await stateManager.getPersona(personaId); // ← WRONG
118
+ return { system: `...`, user: `...` };
119
+ }
120
+ ```
121
+
122
+ **Correct:** Processor calls `stateManager.getPersona()` first, then passes the result to the synchronous builder.
123
+
124
+ ---
125
+
126
+ ## Prompt Creep Warning
127
+
128
+ Prompt builders are the most-edited files in the codebase. Watch for these failure modes:
129
+
130
+ - **Logic creep**: A builder that started as pure string interpolation slowly accumulates conditionals, date math, or data filtering. Each addition seems small. After six changes it's a 200-line function that requires mocking to test. If you're adding branching logic to a prompt builder — reconsider. Move it to the Processor.
131
+
132
+ - **Data shape expansion**: A builder's input type grows to include things it doesn't actually use in the prompt string. This means the Processor is fetching data the prompt doesn't need. Audit `types.ts` when the data shape grows.
133
+
134
+ - **Responsibility leakage**: A prompt builder that calls another prompt builder, or calls a utility that reads from state, or has a side effect on logging that depends on runtime context. Builders must be standalone: same input, same output, every time, in any order.
@@ -121,8 +121,6 @@ function buildRecordFormatExamples(itemType: string): string {
121
121
  // When merging: HIGHER strength/confidence, AVERAGE sentiment, MAX exposure_desired.
122
122
 
123
123
  switch (itemType) {
124
- case "fact":
125
- return buildFactExamples();
126
124
  case "trait":
127
125
  return buildTraitExamples();
128
126
  case "topic":
@@ -134,37 +132,6 @@ function buildRecordFormatExamples(itemType: string): string {
134
132
  }
135
133
  }
136
134
 
137
- function buildFactExamples(): string {
138
- return `EXISTING FACT (being updated/merged):
139
- {
140
- "id": "uuid-of-canonical-record", // REQUIRED for updates
141
- "type": "fact", // REQUIRED
142
- "name": "Owns a 2019 Toyota Camry", // REQUIRED - descriptive, concise
143
- "description": "Silver sedan, purchased in March 2019. Primary commute vehicle. Has 45k miles as of Jan 2024.", // REQUIRED - ALL unique details from duplicates
144
- "sentiment": 0.2, // -1.0 to 1.0, emotional valence (average when merging)
145
- "validated": "by_human", // "unknown" | "by_ei" | "by_human" | "ai_generated" (keep highest trust level)
146
- "validated_date": "2024-01-15T10:30:00Z", // ISO timestamp (most recent)
147
- "last_updated": "2024-03-11T12:00:00Z", // ISO timestamp (set to now)
148
- "learned_by": "persona-uuid-123", // OPTIONAL - UUID of persona that learned this (preserve from source)
149
- "last_changed_by": "persona-uuid-456", // OPTIONAL - UUID of persona that last updated (your current context)
150
- "persona_groups": ["group1", "group2"] // OPTIONAL - visibility groups (union of all sources)
151
- }
152
-
153
- NEW FACT (creating missing concept):
154
- {
155
- "type": "fact", // REQUIRED (NO "id" field for new records)
156
- "name": "Lives in Seattle", // REQUIRED
157
- "description": "Resides in the Capitol Hill neighborhood. Has lived there since 2018.", // REQUIRED - concise (<300 chars ideal)
158
- "sentiment": 0.0, // -1.0 to 1.0 (neutral default for facts)
159
- "validated": "unknown", // Default for new records
160
- "validated_date": "" // Empty string for unvalidated
161
- }
162
-
163
- GOOD vs BAD descriptions:
164
- ✅ GOOD: "Works as a Senior Software Engineer at Microsoft. Started in 2020. Team focuses on Azure infrastructure."
165
- ❌ BAD: "The user has indicated through various conversations that they are employed..." (too verbose, meta-commentary)`;
166
- }
167
-
168
135
  function buildTraitExamples(): string {
169
136
  return `EXISTING TRAIT (being updated/merged):
170
137
  {
@@ -19,7 +19,7 @@ Rules:
19
19
 
20
20
  Return a raw JSON array of strings. No markdown fencing, no commentary, no explanation. Just the array.
21
21
 
22
- Example — a Fact named "Job" whose description also discusses vim keybindings, git conventions, and AI tooling:
22
+ Example — a Topic named "Software Engineering" whose description also discusses vim keybindings, git conventions, and AI tooling:
23
23
  ["vim keybindings and editor configuration", "git and GitHub workflow conventions", "AI coding assistant preferences"]`;
24
24
 
25
25
  const user = JSON.stringify(stripEmbedding(data.item), null, 2);
@@ -59,10 +59,9 @@ Rules:
59
59
  - The original record (id: "${data.item.id}") MUST appear in "existing", slimmed down.
60
60
  - Descriptions should be concise — ideally under 300 characters, never over 500.
61
61
  - Preserve sentiment, strength, confidence, and other numeric values from the source record where applicable.
62
- - "type" must be one of: "fact", "trait", "topic", "person".
63
- - Topics MUST include "category" — one of: Interest, Goal, Dream, Conflict, Concern, Fear, Hope, Plan, Project.
62
+ - "type" must be one of: "topic", "person".
63
+ - Topics MUST include "category" — one of: Interest, Goal, Dream, Conflict, Concern, Fear, Hope, Plan, Project, Event. For Event topics, the description should be a narrative account of a specific moment, not a general summary.
64
64
  - People MUST include "relationship" — a short label like "coworker", "friend", "mentor", etc.
65
- - Traits MUST include "strength" (0.0-1.0).
66
65
  - Do NOT invent information. Only redistribute what exists in the original record.`;
67
66
 
68
67
  const subjects = data.subjects.map(s => ({
@@ -92,24 +91,7 @@ function stripEmbedding<T extends { embedding?: unknown }>(item: T): Omit<T, "em
92
91
  }
93
92
 
94
93
  function buildExistingExamples(): string {
95
- return `Fact:
96
- {
97
- "id": "existing-uuid",
98
- "type": "fact",
99
- "name": "Record Name",
100
- "description": "Updated description with incorporated data"
101
- }
102
-
103
- Trait:
104
- {
105
- "id": "existing-uuid",
106
- "type": "trait",
107
- "name": "Trait Name",
108
- "description": "Updated trait description",
109
- "strength": 0.7
110
- }
111
-
112
- Topic:
94
+ return `Topic:
113
95
  {
114
96
  "id": "existing-uuid",
115
97
  "type": "topic",
@@ -129,30 +111,13 @@ Person:
129
111
  }
130
112
 
131
113
  function buildNewExamples(): string {
132
- return `Fact:
133
- {
134
- "type": "fact",
135
- "name": "New Subject Name",
136
- "description": "Concise description of this subject",
137
- "sentiment": 0.0
138
- }
139
-
140
- Trait:
141
- {
142
- "type": "trait",
143
- "name": "New Trait Name",
144
- "description": "Concise trait description",
145
- "sentiment": 0.0,
146
- "strength": 0.5
147
- }
148
-
149
- Topic:
114
+ return `Topic:
150
115
  {
151
116
  "type": "topic",
152
117
  "name": "New Topic Name",
153
118
  "description": "Concise topic description",
154
119
  "sentiment": 0.0,
155
- "category": "Interest"
120
+ "category": "Interest|Goal|Dream|Conflict|Concern|Fear|Hope|Plan|Project|Event"
156
121
  }
157
122
 
158
123
  Person:
@@ -1,4 +1,4 @@
1
- import type { Trait, PersonaTopic, DataItemBase } from "../../core/types.js";
1
+ import type { PersonaTrait, PersonaTopic, DataItemBase } from "../../core/types.js";
2
2
 
3
3
  export interface PersonaExpirePromptData {
4
4
  persona_name: string;
@@ -11,7 +11,7 @@ export interface PersonaExpireResult {
11
11
 
12
12
  export interface PersonaExplorePromptData {
13
13
  persona_name: string;
14
- traits: Trait[];
14
+ traits: PersonaTrait[];
15
15
  remaining_topics: PersonaTopic[];
16
16
  recent_conversation_themes: string[];
17
17
  }
@@ -32,7 +32,7 @@ export interface DescriptionCheckPromptData {
32
32
  persona_name: string;
33
33
  current_short_description?: string;
34
34
  current_long_description?: string;
35
- traits: Trait[];
35
+ traits: PersonaTrait[];
36
36
  topics: PersonaTopic[];
37
37
  }
38
38
 
@@ -45,7 +45,7 @@ export interface DescriptionCheckResult {
45
45
  // REWRITE (Item Reorganization)
46
46
  // =============================================================================
47
47
 
48
- export type RewriteItemType = "fact" | "trait" | "topic" | "person";
48
+ export type RewriteItemType = "trait" | "topic" | "person";
49
49
 
50
50
  /** Phase 1 input: the bloated item to scan for extra subjects. */
51
51
  export interface RewriteScanPromptData {
@@ -1,7 +1,7 @@
1
1
  import type { PersonaDescriptionsPromptData, PromptOutput } from "./types.js";
2
- import type { Trait, PersonaTopic } from "../../core/types.js";
2
+ import type { PersonaTrait, PersonaTopic } from "../../core/types.js";
3
3
 
4
- function formatTraitsForPrompt(traits: Trait[]): string {
4
+ function formatTraitsForPrompt(traits: PersonaTrait[]): string {
5
5
  if (traits.length === 0) return "(No traits defined)";
6
6
 
7
7
  return traits.map(t => {
@@ -1,4 +1,4 @@
1
- import type { Trait, PersonaTopic } from "../../core/types.js";
1
+ import type { PersonaTrait, PersonaTopic } from "../../core/types.js";
2
2
 
3
3
  export interface PromptOutput {
4
4
  system: string;
@@ -36,7 +36,7 @@ export interface PersonaGenerationResult {
36
36
  export interface PersonaDescriptionsPromptData {
37
37
  name: string;
38
38
  aliases: string[];
39
- traits: Trait[];
39
+ traits: PersonaTrait[];
40
40
  topics: PersonaTopic[];
41
41
  }
42
42
 
@@ -3,7 +3,7 @@
3
3
  * Based on CONTRACTS.md specifications
4
4
  */
5
5
 
6
- import type { Trait, Topic, Person, Message, PersonaTopic } from "../../core/types.js";
6
+ import type { PersonaTrait, Topic, Person, Message, PersonaTopic } from "../../core/types.js";
7
7
 
8
8
  /**
9
9
  * Common prompt output structure
@@ -19,7 +19,7 @@ export interface PromptOutput {
19
19
  export interface HeartbeatCheckPromptData {
20
20
  persona: {
21
21
  name: string;
22
- traits: Trait[];
22
+ traits: PersonaTrait[];
23
23
  topics: PersonaTopic[];
24
24
  };
25
25
  human: {