ei-tui 0.6.3 → 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.
@@ -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,