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 +1 -1
- package/src/core/handlers/dedup.ts +4 -1
- package/src/core/handlers/human-matching.ts +14 -7
- package/src/core/handlers/index.ts +1 -0
- package/src/core/orchestrators/human-extraction.ts +70 -11
- package/src/core/orchestrators/index.ts +2 -0
- package/src/core/types/enums.ts +1 -0
- package/src/prompts/ceremony/dedup.ts +89 -1
- package/src/prompts/ceremony/index.ts +2 -1
- package/src/prompts/ceremony/types.ts +8 -0
- package/tui/src/commands/editor.tsx +3 -0
- package/tui/src/commands/help.tsx +1 -1
- package/tui/src/commands/me.tsx +52 -16
- package/tui/src/components/HelpOverlay.tsx +63 -33
- package/tui/src/context/overlay.tsx +2 -0
- package/tui/src/index.tsx +7 -0
- package/tui/src/util/editor.ts +36 -3
- package/tui/src/util/help-content.ts +136 -0
- package/tui/src/util/yaml-human.ts +129 -34
- package/tui/src/util/yaml-persona.ts +11 -10
- package/tui/src/util/yaml-settings.ts +21 -15
package/package.json
CHANGED
|
@@ -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
|
-
|
|
36
|
-
if (!
|
|
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
|
-
|
|
82
|
-
if (!
|
|
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
|
|
|
@@ -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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
package/src/core/types/enums.ts
CHANGED
|
@@ -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
|
};
|
package/tui/src/commands/me.tsx
CHANGED
|
@@ -6,37 +6,73 @@ import { ConfirmOverlay } from "../components/ConfirmOverlay.js";
|
|
|
6
6
|
|
|
7
7
|
type DataType = "facts" | "topics" | "people";
|
|
8
8
|
|
|
9
|
-
const
|
|
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 [
|
|
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
|
|
21
|
-
const filterType: DataType | null =
|
|
22
|
-
|
|
23
|
-
|
|
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:
|
|
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
|
-
|
|
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 {
|
|
3
|
-
import {
|
|
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
|
-
|
|
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={
|
|
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
|
-
<
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
/
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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 <name> 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
|
-
<
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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());
|
package/tui/src/util/editor.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
.
|
|
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 |
|
|
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
|
|
179
|
+
heartbeat_delay_ms: data.heartbeat_delay_ms == null
|
|
179
180
|
? undefined
|
|
180
|
-
:
|
|
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
|
|
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
|
|
328
|
+
heartbeat_delay_ms: data.heartbeat_delay_ms == null
|
|
328
329
|
? undefined
|
|
329
|
-
:
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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),
|