ei-tui 0.6.4 → 0.6.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ei-tui",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
4
4
  "author": "Flare576",
5
5
  "repository": {
6
6
  "type": "git",
@@ -114,7 +114,9 @@ export async function handleDedupCurate(
114
114
  // =========================================================================
115
115
  // PHASE 1: Update Quote foreign keys FIRST (before deletions)
116
116
  // =========================================================================
117
-
117
+
118
+ const liveEntityIds = new Set(entityList.map((e: Fact | Topic | Person) => e.id));
119
+
118
120
  for (const removal of decisions.remove) {
119
121
  const quotes = state.quotes.filter((q: Quote) =>
120
122
  q.data_item_ids.includes(removal.to_be_removed)
@@ -123,6 +125,7 @@ export async function handleDedupCurate(
123
125
  for (const quote of quotes) {
124
126
  const updatedIds = quote.data_item_ids
125
127
  .map((id: string) => id === removal.to_be_removed ? removal.replaced_by : id)
128
+ .filter((id: string) => liveEntityIds.has(id)) // Drop links to already-merged entities
126
129
  .filter((id: string, idx: number, arr: string[]) => arr.indexOf(id) === idx); // Dedupe
127
130
 
128
131
  stateManager.human_quote_update(quote.id, {
@@ -8,7 +8,7 @@ import {
8
8
  import type { PersonIdentifier } from "../types/data-items.js";
9
9
  import type { StateManager } from "../state-manager.js";
10
10
  import type { ItemMatchResult, ExposureImpact, TopicUpdateResult, PersonUpdateResult } from "../../prompts/human/types.js";
11
- import { queueTopicUpdate, queuePersonUpdate, type ExtractionContext } from "../orchestrators/index.js";
11
+ import { queueTopicUpdate, queuePersonUpdate, queueTopicValidate, type ExtractionContext } from "../orchestrators/index.js";
12
12
  import { getEmbeddingService, getTopicEmbeddingText, getPersonEmbeddingText } from "../embedding-service.js";
13
13
  import { calculateExposureCurrent } from "../utils/exposure.js";
14
14
 
@@ -28,12 +28,13 @@ export function handleTopicMatch(response: LLMResponse, state: StateManager): vo
28
28
  const { messages_context, messages_analyze } = resolveMessageWindow(response, state);
29
29
 
30
30
  let matched_guid = result.matched_guid;
31
+ let resolvedTopic: import('../types/data-items.js').Topic | null = null;
31
32
  if (matched_guid === "new") {
32
33
  matched_guid = null;
33
34
  } else if (matched_guid) {
34
35
  const human = state.getHuman();
35
- const found = human.topics.find(t => t.id === matched_guid);
36
- if (!found) {
36
+ resolvedTopic = human.topics.find(t => t.id === matched_guid) ?? null;
37
+ if (!resolvedTopic) {
37
38
  console.warn(`[handleTopicMatch] matched_guid "${matched_guid}" not found in topics — treating as new`);
38
39
  matched_guid = null;
39
40
  }
@@ -57,7 +58,7 @@ export function handleTopicMatch(response: LLMResponse, state: StateManager): vo
57
58
  extraction_model: response.request.data.extraction_model as string | undefined,
58
59
  };
59
60
 
60
- queueTopicUpdate(result, context, state);
61
+ queueTopicUpdate(result, context, state, resolvedTopic);
61
62
  const matched = matched_guid ? `matched GUID "${matched_guid}"` : "no match (new topic)";
62
63
  console.log(`[handleTopicMatch] topic "${context.candidateName}": ${matched}`);
63
64
  }
@@ -74,12 +75,13 @@ export function handlePersonMatch(response: LLMResponse, state: StateManager): v
74
75
  const { messages_context, messages_analyze } = resolveMessageWindow(response, state);
75
76
 
76
77
  let matched_guid = result.matched_guid;
78
+ let resolvedPerson: import('../types/data-items.js').Person | null = null;
77
79
  if (matched_guid === "new") {
78
80
  matched_guid = null;
79
81
  } else if (matched_guid) {
80
82
  const human = state.getHuman();
81
- const found = human.people.find(p => p.id === matched_guid);
82
- if (!found) {
83
+ resolvedPerson = human.people.find(p => p.id === matched_guid) ?? null;
84
+ if (!resolvedPerson) {
83
85
  console.warn(`[handlePersonMatch] matched_guid "${matched_guid}" not found in people — treating as new`);
84
86
  matched_guid = null;
85
87
  }
@@ -103,7 +105,7 @@ export function handlePersonMatch(response: LLMResponse, state: StateManager): v
103
105
  extraction_model: response.request.data.extraction_model as string | undefined,
104
106
  };
105
107
 
106
- queuePersonUpdate(result, context, state);
108
+ queuePersonUpdate(result, context, state, resolvedPerson);
107
109
  const matched = matched_guid ? `matched GUID "${matched_guid}"` : "no match (new person)";
108
110
  console.log(`[handlePersonMatch] person "${context.candidateName}": ${matched}`);
109
111
  }
@@ -199,6 +201,11 @@ export async function handleTopicUpdate(response: LLMResponse, state: StateManag
199
201
  : state.messages_get(personaId);
200
202
  await validateAndStoreQuotes(result.quotes, allMessages, itemId, personaDisplayName, personaGroup, state);
201
203
 
204
+ if (isNewItem && embedding) {
205
+ const extractionModel = (response.request.data as Record<string, unknown>).extraction_model as string | undefined;
206
+ await queueTopicValidate(topic, state, extractionModel);
207
+ }
208
+
202
209
  console.log(`[handleTopicUpdate] ${isNewItem ? "Created" : "Updated"} topic "${resolvedName}"`);
203
210
  }
204
211
 
@@ -88,4 +88,5 @@ export const handlers: Record<LLMNextStep, ResponseHandler> = {
88
88
  handleRoomJudge,
89
89
  handlePersonaPreview,
90
90
  [LLMNextStep.HandlePersonIdentifierMigration]: handlePersonIdentifierMigration,
91
+ [LLMNextStep.HandleTopicValidate]: handleDedupCurate,
91
92
  };
@@ -13,6 +13,7 @@ import {
13
13
  type ItemMatchResult,
14
14
  type ParticipantContext,
15
15
  } from "../../prompts/human/index.js";
16
+ import { buildValidatePrompt } from "../../prompts/ceremony/dedup.js";
16
17
  import { chunkExtractionContext } from "./extraction-chunker.js";
17
18
  import { getEmbeddingService, findTopK, getTopicEmbeddingText } from "../embedding-service.js";
18
19
  import { resolveTokenLimit } from "../llm-client.js";
@@ -290,6 +291,13 @@ export function queueDirectTopicUpdate(
290
291
  const EMBEDDING_TOP_K = 20;
291
292
  const EMBEDDING_MIN_SIMILARITY = 0.3;
292
293
 
294
+ /**
295
+ * Minimum cosine similarity to trigger the post-create validate step.
296
+ * Higher than EMBEDDING_MIN_SIMILARITY (0.3) because we need near-duplicates,
297
+ * not just vague thematic overlap.
298
+ */
299
+ export const VALIDATE_MIN_SIMILARITY = 0.85;
300
+
293
301
  /**
294
302
  * Queue a topic match request using embedding-based similarity (topics only).
295
303
  */
@@ -380,15 +388,15 @@ export function queueTopicUpdate(
380
388
  candidateCategory: string;
381
389
  extraction_model?: string;
382
390
  },
383
- state: StateManager
391
+ state: StateManager,
392
+ resolvedItem?: Topic | null
384
393
  ): number {
385
- const human = state.getHuman();
386
394
  const matchedGuid = matchResult.matched_guid;
387
395
  const isNewItem = matchedGuid === null;
388
396
 
389
- let existingItem: Topic | null = null;
390
- if (!isNewItem && matchedGuid) {
391
- existingItem = human.topics.find(t => t.id === matchedGuid) ?? null;
397
+ let existingItem: Topic | null = resolvedItem ?? null;
398
+ if (!isNewItem && matchedGuid && existingItem === null) {
399
+ existingItem = state.getHuman().topics.find(t => t.id === matchedGuid) ?? null;
392
400
  }
393
401
 
394
402
  const extractionOptions: ExtractionOptions = { extraction_model: context.extraction_model };
@@ -523,15 +531,15 @@ export function queuePersonUpdate(
523
531
  candidateIdentifiers?: PersonIdentifier[];
524
532
  extraction_model?: string;
525
533
  },
526
- state: StateManager
534
+ state: StateManager,
535
+ resolvedItem?: Person | null
527
536
  ): number {
528
- const human = state.getHuman();
529
537
  const matchedGuid = matchResult.matched_guid;
530
538
  const isNewItem = matchedGuid === null;
531
539
 
532
- let existingItem: Person | null = null;
533
- if (!isNewItem && matchedGuid) {
534
- existingItem = human.people.find(p => p.id === matchedGuid) ?? null;
540
+ let existingItem: Person | null = resolvedItem ?? null;
541
+ if (!isNewItem && matchedGuid && existingItem === null) {
542
+ existingItem = state.getHuman().people.find(p => p.id === matchedGuid) ?? null;
535
543
  }
536
544
 
537
545
  const candidateIdentifiers = context.candidateIdentifiers ?? [];
@@ -548,7 +556,7 @@ export function queuePersonUpdate(
548
556
  }
549
557
 
550
558
  const userIdentifierTypes = [...new Set(
551
- human.people
559
+ state.getHuman().people
552
560
  .flatMap(p => (p.identifiers ?? []).map(i => i.type))
553
561
  .filter(Boolean)
554
562
  )];
@@ -598,4 +606,55 @@ export function queuePersonUpdate(
598
606
  return chunks.length;
599
607
  }
600
608
 
609
+ export async function queueTopicValidate(
610
+ newTopic: Topic,
611
+ state: StateManager,
612
+ extractionModel?: string
613
+ ): Promise<void> {
614
+ if (!newTopic.embedding || newTopic.embedding.length === 0) {
615
+ console.log(`[queueTopicValidate] Skipping "${newTopic.name}" — no embedding available`);
616
+ return;
617
+ }
618
+
619
+ const human = state.getHuman();
620
+ const candidates = human.topics.filter(t => t.id !== newTopic.id && t.embedding && t.embedding.length > 0);
621
+
622
+ if (candidates.length === 0) {
623
+ console.log(`[queueTopicValidate] No existing topics with embeddings to compare against`);
624
+ return;
625
+ }
626
+
627
+ const topResult = findTopK(newTopic.embedding, candidates, 1);
628
+ if (topResult.length === 0 || topResult[0].similarity < VALIDATE_MIN_SIMILARITY) {
629
+ const best = topResult[0];
630
+ console.log(`[queueTopicValidate] "${newTopic.name}" is genuinely new (best match: ${best ? `"${best.item.name}" @ ${best.similarity.toFixed(3)}` : "none"})`);
631
+ return;
632
+ }
633
+
634
+ const existingTopic = topResult[0].item;
635
+ const similarity = topResult[0].similarity;
636
+
637
+ console.log(`[queueTopicValidate] Near-duplicate candidate: "${newTopic.name}" ↔ "${existingTopic.name}" (${similarity.toFixed(3)}) — queuing validate`);
638
+
639
+ const prompt = buildValidatePrompt({
640
+ established: existingTopic,
641
+ newcomer: newTopic,
642
+ itemType: "topic",
643
+ similarity,
644
+ });
645
+
646
+ state.queue_enqueue({
647
+ type: LLMRequestType.JSON,
648
+ priority: LLMPriority.Normal,
649
+ model: extractionModel,
650
+ system: prompt.system,
651
+ user: prompt.user,
652
+ next_step: LLMNextStep.HandleTopicValidate,
653
+ data: {
654
+ entity_type: "topic",
655
+ entity_ids: [existingTopic.id, newTopic.id],
656
+ },
657
+ });
658
+ }
659
+
601
660
 
@@ -8,6 +8,8 @@ export {
8
8
  queueTopicUpdate,
9
9
  queuePersonUpdate,
10
10
  queueEventSummary,
11
+ queueTopicValidate,
12
+ VALIDATE_MIN_SIMILARITY,
11
13
  type ExtractionContext,
12
14
  type ExtractionOptions,
13
15
  } from "./human-extraction.js";
@@ -51,6 +51,7 @@ export enum LLMNextStep {
51
51
  HandleRoomJudge = "handleRoomJudge",
52
52
  HandlePersonaPreview = "handlePersonaPreview",
53
53
  HandlePersonIdentifierMigration = "handlePersonIdentifierMigration",
54
+ HandleTopicValidate = "handleTopicValidate",
54
55
  }
55
56
 
56
57
  export enum ProviderType {
@@ -1,4 +1,4 @@
1
- import type { DedupPromptData } from "./types.js";
1
+ import type { DedupPromptData, ValidatePromptData } from "./types.js";
2
2
 
3
3
  // =============================================================================
4
4
  // DEDUP CURATOR — Merge duplicate entities with data preservation
@@ -140,6 +140,94 @@ ${schemaReminder}`;
140
140
  return { system, user };
141
141
  }
142
142
 
143
+ // =============================================================================
144
+ // VALIDATE — Binary merge decision for a newly created record
145
+ // =============================================================================
146
+
147
+ export function buildValidatePrompt(data: ValidatePromptData): { system: string; user: string } {
148
+ const typeLabel = data.itemType.charAt(0).toUpperCase() + data.itemType.slice(1);
149
+ const pct = Math.round(data.similarity * 100);
150
+
151
+ const system = `# Your Task
152
+
153
+ A new ${typeLabel} record was just created from a real conversation. The moment it landed in the system, we checked it against everything already stored and found one record with a similarity score of ${pct}% — high enough that they might be the same thing under different words.
154
+
155
+ You are the last gate before a duplicate takes root.
156
+
157
+ **Established record**: Has been in the system. Learned from prior conversations.
158
+ **Newcomer**: Just synthesized from the most recent conversation. Description is current-state, not a log.
159
+
160
+ ## What You're Deciding
161
+
162
+ Are these the same thing — the same interest, concern, goal, or moment — described twice? Or are they genuinely distinct, and both deserve to exist?
163
+
164
+ Similarity of meaning is not the same as identity. "Concern about job security" and "Fear of career stagnation" share semantic space. They are not the same record.
165
+
166
+ Ask yourself: *If a persona referenced the established record in conversation, would the newcomer feel like a repeat? Or would it feel like something different being said?*
167
+
168
+ If they are the same thing: **merge**. Preserve every unique detail from both. The newcomer's description is synthesized and current — weight it, but don't discard what the established record learned first.
169
+
170
+ If they are distinct: **keep both**. Return them both in \`update\` unchanged. Leave \`remove\` and \`add\` empty.
171
+
172
+ ## Output Format
173
+
174
+ \`\`\`json
175
+ {
176
+ "update": [ /* one or both records — include ALL fields from whichever survive */ ],
177
+ "remove": [ /* { "to_be_removed": "uuid", "replaced_by": "uuid" } — only if merging */ ],
178
+ "add": []
179
+ }
180
+ \`\`\`
181
+
182
+ Rules:
183
+ - \`add\` is always empty here. We are not creating new records from this decision.
184
+ - If merging: the merged record goes in \`update\`, the absorbed record goes in \`remove\`.
185
+ - If keeping both: return both in \`update\` exactly as received. Do not modify either.
186
+ - Descriptions must stay concise — under 300 characters, never over 500. Synthesize; don't concatenate.
187
+ - When merging numeric fields: take the HIGHER value for \`exposure_current\`, \`exposure_desired\`, \`strength\`, \`confidence\`. Average \`sentiment\`.
188
+ - Do NOT invent information. Only what exists in these two records.
189
+
190
+ Return raw JSON only. No markdown fencing, no commentary.`;
191
+
192
+ const payload = JSON.stringify({
193
+ established: stripEmbedding(data.established),
194
+ newcomer: stripEmbedding(data.newcomer),
195
+ item_type: data.itemType,
196
+ similarity_score: data.similarity,
197
+ }, null, 2);
198
+
199
+ const schemaReminder = `**Return JSON:**
200
+ \n\`\`\`json
201
+ {
202
+ "update": [
203
+ {
204
+ "id": "uuid-of-surviving-record",
205
+ "type": "${data.itemType}",
206
+ "name": "canonical name",
207
+ "description": "merged or unchanged description"
208
+ }
209
+ ],
210
+ "remove": [
211
+ {
212
+ "to_be_removed": "uuid-of-absorbed-record",
213
+ "replaced_by": "uuid-of-surviving-record"
214
+ }
215
+ ],
216
+ "add": []
217
+ }
218
+ \`\`\`
219
+
220
+ If keeping both, return both in \`update\` unchanged with empty \`remove\` and \`add\`.`;
221
+
222
+ const user = `${payload}
223
+
224
+ ---
225
+
226
+ ${schemaReminder}`;
227
+
228
+ return { system, user };
229
+ }
230
+
143
231
  // =============================================================================
144
232
  // Helpers
145
233
  // =============================================================================
@@ -2,7 +2,7 @@ export { buildPersonaExpirePrompt } from "./expire.js";
2
2
  export { buildPersonaExplorePrompt } from "./explore.js";
3
3
  export { buildDescriptionCheckPrompt } from "./description-check.js";
4
4
  export { buildRewriteScanPrompt, buildRewritePrompt } from "./rewrite.js";
5
- export { buildDedupPrompt } from "./dedup.js";
5
+ export { buildDedupPrompt, buildValidatePrompt } from "./dedup.js";
6
6
  export { buildUserDedupPrompt } from "./user-dedup.js";
7
7
  export { buildPersonMigrationPrompt, type PersonMigrationPromptData } from "./person-migration.js";
8
8
  export type {
@@ -20,4 +20,5 @@ export type {
20
20
  RewriteResult,
21
21
  DedupPromptData,
22
22
  DedupResult,
23
+ ValidatePromptData,
23
24
  } from "./types.js";
@@ -107,6 +107,14 @@ export interface DedupPromptData {
107
107
  similarityRange: { min: number; max: number }; // e.g., { min: 0.90, max: 0.98 }
108
108
  }
109
109
 
110
+ /** Input: exactly 2 records — one established, one just created — for binary merge decision. */
111
+ export interface ValidatePromptData {
112
+ established: DataItemBase;
113
+ newcomer: DataItemBase;
114
+ itemType: RewriteItemType;
115
+ similarity: number;
116
+ }
117
+
110
118
  /** Output: merge decisions (update/remove/add). */
111
119
  export interface DedupResult {
112
120
  update: Array<{
@@ -26,16 +26,19 @@ export const editorCommand: Command = {
26
26
  });
27
27
 
28
28
  if (result.aborted) {
29
+ ctx.setInputText("");
29
30
  ctx.showNotification("Editor cancelled", "info");
30
31
  return;
31
32
  }
32
33
 
33
34
  if (!result.success) {
35
+ ctx.setInputText("");
34
36
  ctx.showNotification("Editor failed to open", "error");
35
37
  return;
36
38
  }
37
39
 
38
40
  if (result.content === null) {
41
+ ctx.setInputText("");
39
42
  ctx.showNotification("No changes made", "info");
40
43
  return;
41
44
  }
@@ -7,6 +7,6 @@ export const helpCommand: Command = {
7
7
  description: "Show help screen",
8
8
  usage: "/help or /h",
9
9
  execute: async (_args, ctx) => {
10
- ctx.showOverlay((_hideOverlay, _hideForEditor) => <HelpOverlay onDismiss={_hideOverlay} />, ctx.renderer);
10
+ ctx.showOverlay((_hideOverlay, _hideForEditor) => <HelpOverlay onDismiss={_hideOverlay} renderer={ctx.renderer} />, ctx.renderer);
11
11
  },
12
12
  };
@@ -6,37 +6,73 @@ import { ConfirmOverlay } from "../components/ConfirmOverlay.js";
6
6
 
7
7
  type DataType = "facts" | "topics" | "people";
8
8
 
9
- const VALID_TYPES: DataType[] = ["facts", "topics", "people"];
9
+ const TYPE_ALIASES: Record<string, DataType> = {
10
+ facts: "facts", fact: "facts",
11
+ topics: "topics", topic: "topics",
12
+ people: "people", person: "people", persons: "people",
13
+ };
10
14
 
11
15
  export const meCommand: Command = {
12
16
  name: "me",
13
17
  aliases: [],
14
18
  description: "Edit your data in $EDITOR",
15
- usage: "/me [facts|topics|people]",
16
-
19
+ usage: "/me [fact|topic|person] [new | <search>]",
20
+
17
21
  async execute(args, ctx) {
18
22
  const human = await ctx.ei.getHuman();
19
-
20
- const filterArg = args[0]?.toLowerCase();
21
- const filterType: DataType | null = filterArg && VALID_TYPES.includes(filterArg as DataType)
22
- ? filterArg as DataType
23
- : null;
24
-
25
- if (filterArg && !filterType) {
26
- ctx.showNotification(`Invalid type: ${filterArg}. Use: facts, topics, people`, "error");
23
+
24
+ const typeArg = args[0]?.toLowerCase();
25
+ const filterType: DataType | null = typeArg ? (TYPE_ALIASES[typeArg] ?? null) : null;
26
+
27
+ if (typeArg && !filterType) {
28
+ ctx.showNotification(`Unknown type: ${typeArg}. Use: fact, topic, person`, "error");
27
29
  return;
28
30
  }
29
-
31
+
32
+ const secondArg = args[1]?.toLowerCase();
33
+ const isNew = secondArg === "new";
34
+ const searchTerm = !isNew && secondArg ? args.slice(1).join(" ") : null;
35
+
36
+ if (isNew && args.length > 2) {
37
+ ctx.showNotification(
38
+ `Use /me ${typeArg} new to create, or /me ${typeArg} ${args.slice(2).join(" ")} to search`,
39
+ "error"
40
+ );
41
+ return;
42
+ }
43
+
44
+ if ((isNew || searchTerm) && !filterType) {
45
+ ctx.showNotification(`Specify a type first: /me fact|topic|person [new | <search>]`, "error");
46
+ return;
47
+ }
48
+
49
+ const filterItems = <T extends { name: string }>(items: T[]): T[] => {
50
+ if (isNew) return [];
51
+ if (searchTerm) return items.filter(i => i.name.toLowerCase().includes(searchTerm.toLowerCase()));
52
+ return items;
53
+ };
54
+
30
55
  const filteredHuman = filterType ? {
31
56
  ...human,
32
- facts: filterType === "facts" ? human.facts : [],
33
- topics: filterType === "topics" ? human.topics : [],
34
- people: filterType === "people" ? human.people : [],
57
+ facts: filterType === "facts" ? filterItems(human.facts) : [],
58
+ topics: filterType === "topics" ? filterItems(human.topics) : [],
59
+ people: filterType === "people" ? filterItems(human.people) : [],
35
60
  } : human;
61
+
62
+ const isEmpty = filteredHuman.facts.length === 0
63
+ && filteredHuman.topics.length === 0
64
+ && filteredHuman.people.length === 0;
65
+
66
+ if (searchTerm && isEmpty) {
67
+ ctx.showNotification(`No ${filterType} matching "${searchTerm}" — open editor to create one`, "info");
68
+ }
36
69
 
37
70
  const personaLookup = new Map(ctx.ei.personas().map(p => [p.id, p.display_name]));
38
71
  const allGroups = await ctx.ei.getGroupList();
39
- let yamlContent = humanToYAML(filteredHuman, personaLookup, allGroups);
72
+ const sections = filterType
73
+ ? new Set<"facts" | "topics" | "people">([filterType])
74
+ : new Set<"facts" | "topics" | "people">(["facts", "topics", "people"]);
75
+ let yamlContent = humanToYAML(filteredHuman, personaLookup, allGroups, sections);
40
76
  let editorIteration = 0;
41
77
 
42
78
  while (true) {
@@ -1,10 +1,13 @@
1
1
  import { useKeyboard } from "@opentui/solid";
2
- import { For, onMount, onCleanup } from "solid-js";
3
- import { getAllCommands } from "../commands/registry";
2
+ import { onMount, onCleanup } from "solid-js";
3
+ import type { CliRenderer } from "@opentui/core";
4
4
  import { useKeyboardNav } from "../context/keyboard.js";
5
+ import { spawnPager } from "../util/editor.js";
6
+ import { buildManPage } from "../util/help-content.js";
5
7
 
6
8
  interface HelpOverlayProps {
7
9
  onDismiss: () => void;
10
+ renderer: CliRenderer;
8
11
  }
9
12
 
10
13
  export function HelpOverlay(props: HelpOverlayProps) {
@@ -14,11 +17,14 @@ export function HelpOverlay(props: HelpOverlayProps) {
14
17
 
15
18
  useKeyboard((event) => {
16
19
  event.preventDefault();
17
- props.onDismiss();
20
+ if (event.name === "m") {
21
+ props.onDismiss();
22
+ void spawnPager(buildManPage(), props.renderer);
23
+ } else {
24
+ props.onDismiss();
25
+ }
18
26
  });
19
27
 
20
- const commands = getAllCommands();
21
-
22
28
  return (
23
29
  <box
24
30
  position="absolute"
@@ -31,43 +37,67 @@ export function HelpOverlay(props: HelpOverlayProps) {
31
37
  justifyContent="center"
32
38
  >
33
39
  <box
34
- width={60}
40
+ width={82}
35
41
  backgroundColor="#1a1a2e"
36
42
  borderStyle="single"
37
43
  borderColor="#586e75"
38
44
  padding={2}
39
45
  flexDirection="column"
46
+ gap={1}
40
47
  >
41
48
 
42
- <text fg="#eee8d5">
43
- Commands:
44
- </text>
45
- <For each={commands.sort()}>
46
- {(cmd) => (
47
- <text fg="#93a1a1">
48
- /{cmd.name} - {cmd.description}
49
- </text>
50
- )}
51
- </For>
52
- <text> </text>
49
+ <box flexDirection="row" gap={2}>
50
+
51
+ <box flexDirection="column" gap={1} width={38}>
52
+ <box flexDirection="column">
53
+ <text fg="#eee8d5">Keybindings</text>
54
+ <text fg="#93a1a1"> Ctrl+E Open $EDITOR (preserves input)</text>
55
+ <text fg="#93a1a1"> Ctrl+C Clear input / exit</text>
56
+ <text fg="#93a1a1"> Ctrl+B Toggle sidebar</text>
57
+ <text fg="#93a1a1"> Escape Abort / resume queue</text>
58
+ <text fg="#93a1a1"> PgUp/Dn Scroll messages</text>
59
+ </box>
60
+
61
+ <box flexDirection="column">
62
+ <text fg="#eee8d5">Core</text>
63
+ <text fg="#93a1a1"> /set Edit global settings</text>
64
+ <text fg="#93a1a1"> /q /q! Quit (! = skip sync)</text>
65
+ <text fg="#93a1a1"> /provider Manage LLM providers</text>
66
+ <text fg="#93a1a1"> /me Edit your data</text>
67
+ <text fg="#93a1a1"> /d /d &lt;name&gt; Edit persona details</text>
68
+ </box>
69
+ </box>
70
+
71
+ <box flexDirection="column" gap={1} width={38}>
72
+ <box flexDirection="column">
73
+ <text fg="#eee8d5">Persona</text>
74
+ <text fg="#93a1a1"> /p /p new /p update</text>
75
+ <text fg="#93a1a1"> /context Message context</text>
76
+ <text fg="#93a1a1"> /pause /resume</text>
77
+ </box>
78
+
79
+ <box flexDirection="column">
80
+ <text fg="#eee8d5">Rooms</text>
81
+ <text fg="#93a1a1"> /r /r new Room picker / create</text>
82
+ <text fg="#93a1a1"> /activate Advance active node</text>
83
+ <text fg="#93a1a1"> /silence Pass your turn</text>
84
+ </box>
85
+
86
+ <box flexDirection="column">
87
+ <text fg="#eee8d5">Extended</text>
88
+ <text fg="#93a1a1"> /tools Tool providers</text>
89
+ <text fg="#93a1a1"> /auth spotify Spotify OAuth</text>
90
+ <text fg="#93a1a1"> /queue /dlq Inspect queues</text>
91
+ </box>
92
+ </box>
93
+
94
+ </box>
53
95
 
54
- <text fg="#eee8d5">
55
- Keybindings:
56
- </text>
57
- <text fg="#93a1a1">Escape - Abort operation / Resume queue</text>
58
- <text fg="#93a1a1">Ctrl+C - Clear input / Exit</text>
59
- <text fg="#93a1a1">Ctrl+B - Toggle sidebar</text>
60
- <text fg="#93a1a1">Ctrl+E - Edit in $EDITOR</text>
61
- <text fg="#93a1a1">PageUp/Down - Scroll messages</text>
62
- <text> </text>
96
+ <box flexDirection="column">
97
+ <text fg="#586e75"> m - full manual | any key - dismiss</text>
98
+ <text fg="#2a2a3e"> Ei - 永 (ei) - eternal</text>
99
+ </box>
63
100
 
64
- <text fg="#586e75">
65
- Press any key to dismiss
66
- </text>
67
- <text> </text>
68
- <text fg="#2a2a3e">
69
- Ei - 永 (ei) - eternal
70
- </text>
71
101
  </box>
72
102
  </box>
73
103
  );
@@ -33,8 +33,10 @@ export const OverlayProvider: ParentComponent = (props) => {
33
33
  const hideOverlay = () => {
34
34
  logger.debug("[overlay] hideOverlay called");
35
35
  setOverlayRenderer(null);
36
+ cliRenderer?.requestRender();
36
37
  };
37
38
  setOverlayRenderer(() => () => renderer(hideOverlay, hideForEditor));
39
+ queueMicrotask(() => cliRenderer?.requestRender());
38
40
  };
39
41
 
40
42
  const hideOverlay = () => {
package/tui/src/index.tsx CHANGED
@@ -4,6 +4,13 @@ import { App } from "./app";
4
4
 
5
5
  import { InstanceLock } from "./util/instance-lock";
6
6
  import { FileStorage } from "./storage/file";
7
+ import pkg from "../../package.json";
8
+
9
+ const args = process.argv.slice(2);
10
+ if (args.includes("--version") || args.includes("version") || args.includes("-v")) {
11
+ process.stdout.write(`${pkg.version}\n`);
12
+ process.exit(0);
13
+ }
7
14
 
8
15
  const storage = new FileStorage(Bun.env.EI_DATA_PATH);
9
16
  const lock = new InstanceLock(storage.getDataPath());
@@ -23,6 +23,41 @@ export interface EditorResult {
23
23
  aborted: boolean;
24
24
  }
25
25
 
26
+ export async function spawnPager(content: string, renderer: CliRenderer): Promise<void> {
27
+ const pager = process.env.PAGER || "less";
28
+ const tmpDir = os.tmpdir();
29
+ const tmpFile = path.join(tmpDir, `ei-help-${Date.now()}.txt`);
30
+
31
+ fs.writeFileSync(tmpFile, content, "utf-8");
32
+
33
+ await new Promise(resolve => setTimeout(resolve, 50));
34
+
35
+ return new Promise((resolve) => {
36
+ renderer.suspend();
37
+ renderer.currentRenderBuffer.clear();
38
+
39
+ const child = spawn(pager, [tmpFile], {
40
+ stdio: "inherit",
41
+ shell: true,
42
+ });
43
+
44
+ child.on("error", () => {
45
+ try { fs.unlinkSync(tmpFile); } catch {}
46
+ renderer.resume();
47
+ renderer.requestRender();
48
+ resolve();
49
+ });
50
+
51
+ child.on("exit", () => {
52
+ try { fs.unlinkSync(tmpFile); } catch {}
53
+ renderer.currentRenderBuffer.clear();
54
+ renderer.resume();
55
+ renderer.requestRender();
56
+ resolve();
57
+ });
58
+ });
59
+ }
60
+
26
61
  export async function spawnEditorRaw(options: EditorRawOptions): Promise<EditorResult> {
27
62
  const { initialContent, filename } = options;
28
63
  const editor = process.env.EDITOR || process.env.VISUAL || "vi";
@@ -152,9 +187,7 @@ export async function spawnEditor(options: EditorOptions): Promise<EditorResult>
152
187
  logger.debug("[editor] already suspended before spawn, skipping resume");
153
188
  }
154
189
 
155
- queueMicrotask(() => {
156
- renderer.requestRender();
157
- });
190
+ renderer.requestRender();
158
191
 
159
192
  if (code !== 0) {
160
193
  try { fs.unlinkSync(tmpFile); } catch {}
@@ -0,0 +1,136 @@
1
+ export function buildManPage(): string {
2
+ return `EI(1) Ei Terminal UI EI(1)
3
+
4
+ NAME
5
+ ei - local-first AI companion with persistent personas
6
+
7
+ KEYBINDINGS
8
+ Ctrl+E Open current input in $EDITOR (preserves text)
9
+ Ctrl+C Clear input field (second press exits)
10
+ Ctrl+B Toggle sidebar
11
+ Escape Abort operation / resume queue
12
+ PageUp/Down Scroll message history
13
+
14
+ CORE COMMANDS
15
+ /settings, /set
16
+ Edit global settings in $EDITOR. Configure default model,
17
+ heartbeat interval, context window, integrations, and more.
18
+
19
+ /quit, /q
20
+ Save, sync, and exit. Add ! to force quit without syncing: /q!
21
+
22
+ /provider
23
+ Open provider picker to select, edit, or create LLM providers.
24
+ /provider <Name> Set provider on active persona
25
+ /provider <Name>:<Model> Set provider and model explicitly
26
+ /provider new Create a new provider
27
+
28
+ /me
29
+ Edit your personal data (facts, topics, people) in $EDITOR.
30
+ Each section includes a commented stub — uncomment and fill it in
31
+ to create a new entry. No UUID required; one is generated for you.
32
+
33
+ /me fact Edit only facts (stub included)
34
+ /me topic Edit only topics (stub included)
35
+ /me person Edit only people (stub included)
36
+ /me fact new Open with just the new-fact stub
37
+ /me fact coffee Filter facts whose name contains "coffee"
38
+ /me person "New York" Quoted search for multi-word names
39
+
40
+ /details, /d
41
+ Edit the current persona's details in $EDITOR.
42
+ /d <name> Edit a specific persona by name
43
+
44
+ PERSONA COMMANDS
45
+ /persona, /p
46
+ Open persona picker. Switch, list, or create personas.
47
+ /p new <name> Create a new persona
48
+ /p update <name> [person] Regenerate persona from a person record
49
+
50
+ /context, /messages
51
+ Edit which messages are included in LLM context.
52
+
53
+ /pause
54
+ Pause the current persona indefinitely.
55
+ /pause <duration> Pause for a duration: 2h, 1d, 1w
56
+
57
+ /resume, /unpause
58
+ Resume the current paused persona.
59
+ /resume <name> Resume a specific persona
60
+
61
+ /new
62
+ Toggle a context boundary — starts a fresh conversation thread
63
+ without deleting history.
64
+
65
+ /quotes, /quote
66
+ Manage quotes attached to messages.
67
+ /quotes <N> View quotes from message N
68
+ /quotes me View your own quotes
69
+ /quotes search "term" Search quotes by keyword
70
+ /quotes <persona> View a persona's quotes
71
+
72
+ ROOM COMMANDS
73
+ /room, /r
74
+ Open room picker. Switch or create rooms.
75
+ /room new Create a new room (FFA, CYP, or MAP mode)
76
+ /room new <name> Create with a pre-filled name
77
+
78
+ /activate, /a
79
+ Advance the active node in a CYP or MAP room.
80
+ /activate <num> Activate a specific response by number
81
+
82
+ /silence
83
+ Pass your turn in a room with an optional reason.
84
+ /silence [reason]
85
+
86
+ /capture
87
+ Force-extract quotes, topics, and people from the current chat now.
88
+
89
+ EXTENDED COMMANDS
90
+ /tools
91
+ Manage tool providers — enable or disable tools per persona.
92
+
93
+ /auth
94
+ Authenticate with an external service.
95
+ /auth spotify Connect your Spotify account
96
+
97
+ /queue
98
+ Pause the queue and inspect or edit active items in $EDITOR.
99
+
100
+ /dlq
101
+ Inspect and recover failed (dead-letter) queue items in $EDITOR.
102
+
103
+ /dedupe
104
+ Find and merge duplicate people or topics.
105
+ /dedupe person Flare "Jeremy Scherer"
106
+ /dedupe topic AI "artificial intelligence"
107
+
108
+ /archive
109
+ Archive a persona or room. Lists archived items if no name given.
110
+ /archive <name> Archive by name
111
+
112
+ /unarchive
113
+ Restore an archived persona or room and switch to it.
114
+ /unarchive <name>
115
+
116
+ /delete, /del
117
+ Permanently delete a persona. Cannot be undone.
118
+ /delete <name>
119
+
120
+ /setsync, /ss
121
+ Set sync credentials (triggers restart).
122
+ /setsync <username> <passphrase>
123
+
124
+ /editor, /e, /edit
125
+ Open $EDITOR with the current input field contents.
126
+ Note: Ctrl+E does the same thing without clearing the input first.
127
+
128
+ TIPS
129
+ - Append ! to any command as shorthand for --force: /quit!
130
+ - Duration strings: 30m, 2h, 1d, 1w (used by /pause, /settings)
131
+ - All editor fields that say "null" inherit from your global settings
132
+ - $EDITOR and $PAGER are respected throughout
133
+
134
+ Ei - 永 (ei) - eternal
135
+ `;
136
+ }
@@ -51,6 +51,19 @@ function readOnlyToEnd<T extends WithReadOnlyFields>(item: T): T {
51
51
  return { ...rest, learned_on, learned_by, validated_date, last_mentioned, last_updated, last_changed_by } as T;
52
52
  }
53
53
 
54
+ const FIELD_ORDER = ['id', 'name', 'description', 'sentiment', 'relationship', 'category', 'exposure_current', 'exposure_desired'];
55
+
56
+ function canonicalFieldOrder<T extends object>(item: T): T {
57
+ const ordered: Record<string, unknown> = {};
58
+ for (const key of FIELD_ORDER) {
59
+ if (key in item) ordered[key] = (item as Record<string, unknown>)[key];
60
+ }
61
+ for (const [key, val] of Object.entries(item)) {
62
+ if (!(key in ordered)) ordered[key] = val;
63
+ }
64
+ return ordered as T;
65
+ }
66
+
54
67
  function buildGroupCheckboxMap(itemGroups: string[], allGroups: string[]): Record<string, boolean>[] {
55
68
  const activeSet = new Set(itemGroups);
56
69
  return [...new Set([...allGroups, ...itemGroups])].map(g => ({ [g]: activeSet.has(g) }));
@@ -65,8 +78,10 @@ function toYAMLIdentifiers(identifiers: PersonIdentifier[], personaLookup?: Map<
65
78
  });
66
79
  }
67
80
 
68
- function knownTypesComment(personaLookup?: Map<string, string>): string {
69
- const lines = [`# Valid types: ${BUILT_IN_IDENTIFIER_TYPES.join(', ')}`];
81
+ function knownTypesComment(people: Person[], personaLookup?: Map<string, string>): string {
82
+ const userTypes = people.flatMap(p => (p.identifiers ?? []).map(i => i.type));
83
+ const allTypes = [...new Set([...BUILT_IN_IDENTIFIER_TYPES, ...userTypes])];
84
+ const lines = [`# Valid types: ${allTypes.join(', ')}`];
70
85
  if (personaLookup && personaLookup.size > 0) {
71
86
  lines.push(`# Personas: ${Array.from(personaLookup.values()).join(', ')}`);
72
87
  }
@@ -84,11 +99,68 @@ function parseGroupCheckboxMap(groups: Record<string, boolean>[] | undefined): s
84
99
  return result;
85
100
  }
86
101
 
87
- export function humanToYAML(human: HumanEntity, personaLookup?: Map<string, string>, allGroups: string[] = []): string {
88
- const data: EditableHumanData = {
89
- facts: human.facts.map(f => { const { interested_personas: _ip, persona_groups, ...rest } = readOnlyToEnd(f); return { ...rest, persona_groups: buildGroupCheckboxMap(persona_groups ?? [], allGroups), _delete: false }; }),
90
- topics: human.topics.map(t => { const { interested_personas: _ip, persona_groups, ...rest } = readOnlyToEnd(t); return { ...rest, persona_groups: buildGroupCheckboxMap(persona_groups ?? [], allGroups), _delete: false }; }),
91
- people: human.people.map(p => {
102
+ function sectionStub(type: "facts" | "topics" | "people", people: Person[], personaLookup?: Map<string, string>): string {
103
+ if (type === "facts") {
104
+ return [
105
+ ` # --- New Fact (uncomment to create) ---`,
106
+ ` # - name: ''`,
107
+ ` # description: ''`,
108
+ ` # sentiment: 0`,
109
+ ].join('\n');
110
+ }
111
+
112
+ if (type === "topics") {
113
+ return [
114
+ ` # --- New Topic (uncomment to create) ---`,
115
+ ` # - name: ''`,
116
+ ` # description: ''`,
117
+ ` # category: '' # Interest, Goal, Dream, Conflict, Concern, Fear, Hope, Plan, Project`,
118
+ ` # exposure_desired: 0.5`,
119
+ ` # sentiment: 0`,
120
+ ].join('\n');
121
+ }
122
+
123
+ const userTypes = people.flatMap(p => (p.identifiers ?? []).map(i => i.type));
124
+ const allTypes = [...new Set([...BUILT_IN_IDENTIFIER_TYPES, ...userTypes])];
125
+ const identifierTypeHint = allTypes.join(', ');
126
+ const personaNames = personaLookup && personaLookup.size > 0
127
+ ? Array.from(personaLookup.values()).join(', ')
128
+ : null;
129
+
130
+ return [
131
+ ` # --- New Person (uncomment to create) ---`,
132
+ ` # - name: ''`,
133
+ ` # description: ''`,
134
+ ` # relationship: ''`,
135
+ ` # exposure_desired: 0.5`,
136
+ ` # sentiment: 0`,
137
+ ` # identifiers:`,
138
+ ` # # Valid types: ${identifierTypeHint}`,
139
+ ...(personaNames ? [` # # Personas: ${personaNames}`] : []),
140
+ ` # - type: ''`,
141
+ ` # value: ''`,
142
+ ` # primary: true`,
143
+ ].join('\n');
144
+ }
145
+
146
+ export function humanToYAML(
147
+ human: HumanEntity,
148
+ personaLookup?: Map<string, string>,
149
+ allGroups: string[] = [],
150
+ sections?: Set<"facts" | "topics" | "people">,
151
+ ): string {
152
+ const activeSections = sections ?? new Set<"facts" | "topics" | "people">(["facts", "topics", "people"]);
153
+
154
+ const data: Partial<EditableHumanData> = {};
155
+
156
+ if (activeSections.has("facts") && human.facts.length > 0) {
157
+ data.facts = human.facts.map(f => { const { interested_personas: _ip, persona_groups, ...rest } = readOnlyToEnd(f); return { ...rest, persona_groups: buildGroupCheckboxMap(persona_groups ?? [], allGroups), _delete: false }; });
158
+ }
159
+ if (activeSections.has("topics") && human.topics.length > 0) {
160
+ data.topics = human.topics.map(t => { const { interested_personas: _ip, persona_groups, ...rest } = readOnlyToEnd(t); return { ...rest, persona_groups: buildGroupCheckboxMap(persona_groups ?? [], allGroups), _delete: false }; });
161
+ }
162
+ if (activeSections.has("people") && human.people.length > 0) {
163
+ data.people = human.people.map(p => {
92
164
  const { identifiers, interested_personas: _ip, persona_groups, ...rest } = readOnlyToEnd(p);
93
165
  return {
94
166
  ...rest,
@@ -96,31 +168,51 @@ export function humanToYAML(human: HumanEntity, personaLookup?: Map<string, stri
96
168
  identifiers: toYAMLIdentifiers(identifiers ?? [], personaLookup),
97
169
  _delete: false as const,
98
170
  };
99
- }),
171
+ });
172
+ }
173
+
174
+ const personComment = knownTypesComment(human.people, personaLookup);
175
+
176
+ const applyReadOnlyMarkers = (yaml: string): string =>
177
+ yaml
178
+ .replace(/^(\s+)(learned_on: .+)$/mg, '$1# [read-only] $2')
179
+ .replace(/^(\s+)(learned_by: )(.+)$/mg, (_, indent, key, val) => {
180
+ const trimmed = val.trim();
181
+ const displayName = personaLookup?.get(trimmed) ?? trimmed;
182
+ return `${indent}# [read-only] ${key}${displayName}`;
183
+ })
184
+ .replace(/^(\s+)(validated_date: .+)$/mg, '$1# [read-only] $2')
185
+ .replace(/^(\s+)(last_mentioned: .+)$/mg, '$1# [read-only] $2')
186
+ .replace(/^(\s+)(last_updated: .+)$/mg, '$1# [read-only] $2')
187
+ .replace(/^(\s+)(last_changed_by: )(.+)$/mg, (_, indent, key, val) => {
188
+ const trimmed = val.trim();
189
+ const displayName = personaLookup?.get(trimmed) ?? trimmed;
190
+ return `${indent}# [read-only] ${key}${displayName}`;
191
+ })
192
+ .replace(/^(\s+)(identifiers:)/mg, (_, indent, _key) => {
193
+ return `${indent}${personComment}\n${indent}identifiers:`;
194
+ });
195
+
196
+ const serializeSection = (key: "facts" | "topics" | "people", items: unknown[] | undefined): string => {
197
+ const stub = sectionStub(key, human.people, personaLookup);
198
+ if (!items || items.length === 0) {
199
+ return `${key}:\n${stub}`;
200
+ }
201
+ const ordered = (items as object[]).map(canonicalFieldOrder);
202
+ const itemsYaml = YAML.stringify(ordered, { lineWidth: 0 })
203
+ .split('\n')
204
+ .map(line => ` ${line}`)
205
+ .join('\n')
206
+ .trimEnd();
207
+ return `${key}:\n${applyReadOnlyMarkers(itemsYaml)}\n${stub}`;
100
208
  };
101
209
 
102
- const personComment = knownTypesComment(personaLookup);
103
-
104
- return YAML.stringify(data, {
105
- lineWidth: 0,
106
- })
107
- .replace(/^(\s+)(learned_on: .+)$/mg, '$1# [read-only] $2')
108
- .replace(/^(\s+)(learned_by: )(.+)$/mg, (_, indent, key, val) => {
109
- const trimmed = val.trim();
110
- const displayName = personaLookup?.get(trimmed) ?? trimmed;
111
- return `${indent}# [read-only] ${key}${displayName}`;
112
- })
113
- .replace(/^(\s+)(validated_date: .+)$/mg, '$1# [read-only] $2')
114
- .replace(/^(\s+)(last_mentioned: .+)$/mg, '$1# [read-only] $2')
115
- .replace(/^(\s+)(last_updated: .+)$/mg, '$1# [read-only] $2')
116
- .replace(/^(\s+)(last_changed_by: )(.+)$/mg, (_, indent, key, val) => {
117
- const trimmed = val.trim();
118
- const displayName = personaLookup?.get(trimmed) ?? trimmed;
119
- return `${indent}# [read-only] ${key}${displayName}`;
120
- })
121
- .replace(/^(\s+)(identifiers:)/mg, (_, indent, _key) => {
122
- return `${indent}${personComment}\n${indent}identifiers:`;
123
- });
210
+ const parts: string[] = [];
211
+ if (activeSections.has("facts")) parts.push(serializeSection("facts", data.facts));
212
+ if (activeSections.has("topics")) parts.push(serializeSection("topics", data.topics));
213
+ if (activeSections.has("people")) parts.push(serializeSection("people", data.people));
214
+
215
+ return parts.join('\n') + '\n';
124
216
  }
125
217
 
126
218
  export interface HumanYAMLResult {
@@ -192,7 +284,7 @@ export function humanFromYAML(yamlContent: string, original?: HumanEntity): Huma
192
284
  .split('\n')
193
285
  .filter(line => !/^\s*#\s*\[read-only\]/.test(line))
194
286
  .join('\n');
195
- const data = YAML.parse(stripped) as EditableHumanData;
287
+ const data = (YAML.parse(stripped) ?? {}) as EditableHumanData;
196
288
 
197
289
  const deletedFactIds: string[] = [];
198
290
  const deletedTopicIds: string[] = [];
@@ -207,10 +299,11 @@ export function humanFromYAML(yamlContent: string, original?: HumanEntity): Huma
207
299
  deletedFactIds.push(f.id);
208
300
  } else {
209
301
  const { _delete, persona_groups: groupMap, ...parsed } = f;
302
+ if (!parsed.id) parsed.id = crypto.randomUUID();
210
303
  const originalFact = original?.facts.find(of => of.id === parsed.id);
211
304
  const fact: Fact = originalFact
212
305
  ? { ...originalFact, ...parsed, persona_groups: parseGroupCheckboxMap(groupMap) }
213
- : { ...parsed, persona_groups: parseGroupCheckboxMap(groupMap) };
306
+ : { ...parsed, last_updated: new Date().toISOString(), persona_groups: parseGroupCheckboxMap(groupMap) };
214
307
  facts.push(fact);
215
308
  if (!originalFact || factChanged(fact, originalFact)) {
216
309
  if (fact.description && !originalFact?.validated_date) {
@@ -227,10 +320,11 @@ export function humanFromYAML(yamlContent: string, original?: HumanEntity): Huma
227
320
  deletedTopicIds.push(t.id);
228
321
  } else {
229
322
  const { _delete, persona_groups: groupMap, ...parsed } = t;
323
+ if (!parsed.id) parsed.id = crypto.randomUUID();
230
324
  const originalTopic = original?.topics.find(ot => ot.id === parsed.id);
231
325
  const topic: Topic = originalTopic
232
326
  ? { ...originalTopic, ...parsed, persona_groups: parseGroupCheckboxMap(groupMap) }
233
- : { ...parsed, persona_groups: parseGroupCheckboxMap(groupMap) };
327
+ : { ...parsed, last_updated: new Date().toISOString(), persona_groups: parseGroupCheckboxMap(groupMap) };
234
328
  topics.push(topic);
235
329
  if (!originalTopic || topicChanged(topic, originalTopic)) {
236
330
  changedTopicIds.add(topic.id);
@@ -244,6 +338,7 @@ export function humanFromYAML(yamlContent: string, original?: HumanEntity): Huma
244
338
  deletedPersonIds.push(p.id);
245
339
  } else {
246
340
  const { _delete, identifiers: yamlIdentifiers, persona_groups: groupMap, ...parsed } = p;
341
+ if (!parsed.id) parsed.id = crypto.randomUUID();
247
342
  const identifiers: PersonIdentifier[] = (yamlIdentifiers ?? []).map(({ type, value, primary }) => ({
248
343
  type,
249
344
  value,
@@ -252,7 +347,7 @@ export function humanFromYAML(yamlContent: string, original?: HumanEntity): Huma
252
347
  const originalPerson = original?.people.find(op => op.id === parsed.id);
253
348
  const person: Person = originalPerson
254
349
  ? { ...originalPerson, ...parsed, identifiers, persona_groups: parseGroupCheckboxMap(groupMap) }
255
- : { ...parsed, identifiers, persona_groups: parseGroupCheckboxMap(groupMap) };
350
+ : { ...parsed, last_updated: new Date().toISOString(), identifiers, persona_groups: parseGroupCheckboxMap(groupMap) };
256
351
  people.push(person);
257
352
  if (!originalPerson || personChanged(person, originalPerson)) {
258
353
  changedPersonIds.add(person.id);
@@ -7,6 +7,7 @@ import type {
7
7
  ProviderAccount,
8
8
  } from "../../../src/core/types.js";
9
9
  import { modelGuidToDisplay, displayToModelGuid } from "./yaml-shared.js";
10
+ import { parseDuration, formatDuration } from "./duration.js";
10
11
 
11
12
  const PLACEHOLDER_LONG_DESC = "Detailed description of this persona's personality, background, and role";
12
13
 
@@ -36,8 +37,8 @@ interface EditablePersonaData {
36
37
  groups_visible?: Record<string, boolean>[];
37
38
  traits: YAMLTrait[];
38
39
  topics: YAMLPersonaTopic[];
39
- heartbeat_delay_ms?: string | number;
40
- context_window_hours?: number;
40
+ heartbeat_delay_ms?: string | null;
41
+ context_window_hours?: number | null;
41
42
  is_paused?: boolean;
42
43
  pause_until?: string;
43
44
  is_static?: boolean;
@@ -175,10 +176,10 @@ export function newPersonaFromYAML(yamlContent: string, allTools?: ToolDefinitio
175
176
  groups_visible: groupsVisible.length > 0 ? groupsVisible : ["General"],
176
177
  traits,
177
178
  topics,
178
- heartbeat_delay_ms: data.heartbeat_delay_ms === 'default' || data.heartbeat_delay_ms === undefined
179
+ heartbeat_delay_ms: data.heartbeat_delay_ms == null
179
180
  ? undefined
180
- : Number(data.heartbeat_delay_ms) || undefined,
181
- context_window_hours: data.context_window_hours,
181
+ : parseDuration(data.heartbeat_delay_ms) ?? undefined,
182
+ context_window_hours: data.context_window_hours ?? undefined,
182
183
  tools: resolvePersonaToolsFromMap(data.tools, allTools ?? [], allProviders ?? []),
183
184
  };
184
185
  }
@@ -216,8 +217,8 @@ export function personaToYAML(persona: PersonaEntity, allGroups?: string[], allT
216
217
  : persona.topics.map(({ name, perspective, approach, personal_stake, exposure_current, exposure_desired }) => ({
217
218
  name, perspective, approach, personal_stake, exposure_current, exposure_desired
218
219
  })),
219
- heartbeat_delay_ms: persona.heartbeat_delay_ms || 'default',
220
- context_window_hours: persona.context_window_hours,
220
+ heartbeat_delay_ms: persona.heartbeat_delay_ms ? formatDuration(persona.heartbeat_delay_ms) : null,
221
+ context_window_hours: persona.context_window_hours ?? null,
221
222
  is_paused: persona.is_paused || undefined,
222
223
  pause_until: persona.pause_until,
223
224
  is_static: persona.is_static || undefined,
@@ -324,10 +325,10 @@ export function personaFromYAML(yamlContent: string, original: PersonaEntity, al
324
325
  groups_visible: groupsVisible,
325
326
  traits,
326
327
  topics,
327
- heartbeat_delay_ms: data.heartbeat_delay_ms === 'default' || data.heartbeat_delay_ms === undefined
328
+ heartbeat_delay_ms: data.heartbeat_delay_ms == null
328
329
  ? undefined
329
- : Number(data.heartbeat_delay_ms) || undefined,
330
- context_window_hours: data.context_window_hours,
330
+ : parseDuration(data.heartbeat_delay_ms) ?? undefined,
331
+ context_window_hours: data.context_window_hours ?? undefined,
331
332
  is_paused: data.is_paused ?? false,
332
333
  pause_until: data.pause_until,
333
334
  is_static: data.is_static ?? false,
@@ -8,6 +8,7 @@ import type {
8
8
  import type { ClaudeCodeSettings } from "../../../src/integrations/claude-code/types.js";
9
9
  import type { CursorSettings } from "../../../src/integrations/cursor/types.js";
10
10
  import { modelGuidToDisplay, displayToModelGuid } from "./yaml-shared.js";
11
+ import { parseDuration, formatDuration } from "./duration.js";
11
12
 
12
13
  interface EditableSettingsData {
13
14
  default_model?: string | null;
@@ -15,7 +16,7 @@ interface EditableSettingsData {
15
16
  rewrite_model?: string | null;
16
17
  time_mode?: "24h" | "12h" | "local" | "utc" | null;
17
18
  name_display?: string | null;
18
- default_heartbeat_ms?: number | null;
19
+ default_heartbeat_ms?: string | null;
19
20
  default_context_window_hours?: number | null;
20
21
  message_min_count?: number | null;
21
22
  message_max_age_days?: number | null;
@@ -28,28 +29,28 @@ interface EditableSettingsData {
28
29
  };
29
30
  opencode?: {
30
31
  integration?: boolean | null;
31
- polling_interval_ms?: number | null;
32
+ polling_interval_ms?: string | null;
32
33
  last_sync?: string | null;
33
34
  extraction_point?: string | null;
34
35
  extraction_model?: string | null;
35
36
  };
36
37
  claudeCode?: {
37
38
  integration?: boolean | null;
38
- polling_interval_ms?: number | null;
39
+ polling_interval_ms?: string | null;
39
40
  last_sync?: string | null;
40
41
  extraction_point?: string | null;
41
42
  extraction_model?: string | null;
42
43
  };
43
44
  cursor?: {
44
45
  integration?: boolean | null;
45
- polling_interval_ms?: number | null;
46
+ polling_interval_ms?: string | null;
46
47
  last_sync?: string | null;
47
48
  extraction_point?: string | null;
48
49
  };
49
50
  backup?: {
50
51
  enabled?: boolean | null;
51
52
  max_backups?: number | null;
52
- interval_ms?: number | null;
53
+ interval_ms?: string | null;
53
54
  };
54
55
  }
55
56
 
@@ -65,7 +66,7 @@ export function settingsToYAML(settings: HumanSettings | undefined, accounts: Pr
65
66
  rewrite_model: guidToDisplay(settings?.rewrite_model),
66
67
  time_mode: settings?.time_mode ?? null,
67
68
  name_display: settings?.name_display ?? null,
68
- default_heartbeat_ms: settings?.default_heartbeat_ms ?? 1800000,
69
+ default_heartbeat_ms: formatDuration(settings?.default_heartbeat_ms ?? 1800000),
69
70
  default_context_window_hours: settings?.default_context_window_hours ?? 8,
70
71
  message_min_count: settings?.message_min_count ?? 200,
71
72
  message_max_age_days: settings?.message_max_age_days ?? 14,
@@ -78,28 +79,28 @@ export function settingsToYAML(settings: HumanSettings | undefined, accounts: Pr
78
79
  },
79
80
  opencode: {
80
81
  integration: settings?.opencode?.integration ?? false,
81
- polling_interval_ms: settings?.opencode?.polling_interval_ms ?? 60000,
82
+ polling_interval_ms: formatDuration(settings?.opencode?.polling_interval_ms ?? 60000),
82
83
  extraction_model: guidToDisplay(settings?.opencode?.extraction_model) ?? 'default',
83
84
  last_sync: settings?.opencode?.last_sync ?? null,
84
85
  extraction_point: settings?.opencode?.extraction_point ?? null,
85
86
  },
86
87
  claudeCode: {
87
88
  integration: settings?.claudeCode?.integration ?? false,
88
- polling_interval_ms: settings?.claudeCode?.polling_interval_ms ?? 60000,
89
+ polling_interval_ms: formatDuration(settings?.claudeCode?.polling_interval_ms ?? 60000),
89
90
  extraction_model: guidToDisplay(settings?.claudeCode?.extraction_model) ?? 'default',
90
91
  last_sync: settings?.claudeCode?.last_sync ?? null,
91
92
  extraction_point: settings?.claudeCode?.extraction_point ?? null,
92
93
  },
93
94
  cursor: {
94
95
  integration: settings?.cursor?.integration ?? false,
95
- polling_interval_ms: settings?.cursor?.polling_interval_ms ?? 60000,
96
+ polling_interval_ms: formatDuration(settings?.cursor?.polling_interval_ms ?? 60000),
96
97
  last_sync: settings?.cursor?.last_sync ?? null,
97
98
  extraction_point: settings?.cursor?.extraction_point ?? null,
98
99
  },
99
100
  backup: {
100
101
  enabled: settings?.backup?.enabled ?? false,
101
102
  max_backups: settings?.backup?.max_backups ?? 24,
102
- interval_ms: settings?.backup?.interval_ms ?? 3600000,
103
+ interval_ms: formatDuration(settings?.backup?.interval_ms ?? 3600000),
103
104
  },
104
105
  };
105
106
 
@@ -117,6 +118,11 @@ export function settingsFromYAML(yamlContent: string, original: HumanSettings |
117
118
  const nullToUndefined = <T>(value: T | null | undefined): T | undefined =>
118
119
  value === null ? undefined : value;
119
120
 
121
+ const parseMsDuration = (value: string | null | undefined, fallback: number): number | undefined => {
122
+ if (value == null) return undefined;
123
+ return parseDuration(value) ?? fallback;
124
+ };
125
+
120
126
  const displayToGuid = (display: string | null | undefined): string | undefined => {
121
127
  if (!display || display === 'default') return undefined;
122
128
  return displayToModelGuid(display, accounts) ?? display;
@@ -138,7 +144,7 @@ export function settingsFromYAML(yamlContent: string, original: HumanSettings |
138
144
  if (data.opencode) {
139
145
  opencode = {
140
146
  integration: nullToUndefined(data.opencode.integration),
141
- polling_interval_ms: nullToUndefined(data.opencode.polling_interval_ms),
147
+ polling_interval_ms: parseMsDuration(data.opencode.polling_interval_ms, 60000),
142
148
  last_sync: original?.opencode?.last_sync,
143
149
  extraction_point: original?.opencode?.extraction_point,
144
150
  processed_sessions: original?.opencode?.processed_sessions,
@@ -150,7 +156,7 @@ export function settingsFromYAML(yamlContent: string, original: HumanSettings |
150
156
  if (data.claudeCode) {
151
157
  claudeCode = {
152
158
  integration: nullToUndefined(data.claudeCode.integration),
153
- polling_interval_ms: nullToUndefined(data.claudeCode.polling_interval_ms),
159
+ polling_interval_ms: parseMsDuration(data.claudeCode.polling_interval_ms, 60000),
154
160
  last_sync: original?.claudeCode?.last_sync,
155
161
  extraction_point: original?.claudeCode?.extraction_point,
156
162
  processed_sessions: original?.claudeCode?.processed_sessions,
@@ -162,7 +168,7 @@ export function settingsFromYAML(yamlContent: string, original: HumanSettings |
162
168
  if (data.cursor) {
163
169
  cursor = {
164
170
  integration: nullToUndefined(data.cursor.integration),
165
- polling_interval_ms: nullToUndefined(data.cursor.polling_interval_ms),
171
+ polling_interval_ms: parseMsDuration(data.cursor.polling_interval_ms, 60000),
166
172
  last_sync: original?.cursor?.last_sync,
167
173
  extraction_point: original?.cursor?.extraction_point,
168
174
  processed_sessions: original?.cursor?.processed_sessions,
@@ -174,7 +180,7 @@ export function settingsFromYAML(yamlContent: string, original: HumanSettings |
174
180
  backup = {
175
181
  enabled: nullToUndefined(data.backup.enabled),
176
182
  max_backups: nullToUndefined(data.backup.max_backups),
177
- interval_ms: nullToUndefined(data.backup.interval_ms),
183
+ interval_ms: parseMsDuration(data.backup.interval_ms, 3600000),
178
184
  last_backup: original?.backup?.last_backup,
179
185
  };
180
186
  }
@@ -186,7 +192,7 @@ export function settingsFromYAML(yamlContent: string, original: HumanSettings |
186
192
  rewrite_model: displayToGuid(data.rewrite_model),
187
193
  time_mode: nullToUndefined(data.time_mode),
188
194
  name_display: nullToUndefined(data.name_display),
189
- default_heartbeat_ms: nullToUndefined(data.default_heartbeat_ms),
195
+ default_heartbeat_ms: parseMsDuration(data.default_heartbeat_ms, 1800000),
190
196
  default_context_window_hours: nullToUndefined(data.default_context_window_hours),
191
197
  message_min_count: nullToUndefined(data.message_min_count),
192
198
  message_max_age_days: nullToUndefined(data.message_max_age_days),