ei-tui 0.1.18 → 0.1.19

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.1.18",
3
+ "version": "0.1.19",
4
4
  "author": "Flare576",
5
5
  "repository": {
6
6
  "type": "git",
@@ -29,8 +29,13 @@ import type {
29
29
  PersonaExpireResult,
30
30
  PersonaExploreResult,
31
31
  DescriptionCheckResult,
32
+ RewriteItemType,
33
+ RewriteScanResult,
34
+ RewriteResult,
35
+ RewriteSubjectMatch,
32
36
  } from "../../prompts/ceremony/types.js";
33
- import {
37
+ import { buildRewritePrompt } from "../../prompts/ceremony/rewrite.js";
38
+ import {
34
39
  orchestratePersonaGeneration,
35
40
  queueItemMatch,
36
41
  queueItemUpdate,
@@ -59,6 +64,30 @@ import { crossFind } from "../utils/index.js";
59
64
 
60
65
  export type ResponseHandler = (response: LLMResponse, state: StateManager) => void | Promise<void>;
61
66
 
67
+ // =============================================================================
68
+ // REWRITE HANDLER INJECTION
69
+ // searchHumanData lives on Processor — inject it at startup to avoid circular deps.
70
+ // Same pattern as registerReadMemoryExecutor in tools/index.ts.
71
+ // =============================================================================
72
+
73
+ type SearchHumanDataFn = (
74
+ query: string,
75
+ options?: { types?: Array<'fact' | 'trait' | 'topic' | 'person' | 'quote'>; limit?: number }
76
+ ) => Promise<{
77
+ facts: Fact[];
78
+ traits: Trait[];
79
+ topics: Topic[];
80
+ people: Person[];
81
+ quotes: Quote[];
82
+ }>;
83
+
84
+ let _searchHumanData: SearchHumanDataFn | null = null;
85
+
86
+ /** Called by Processor at startup to inject searchHumanData for rewrite handlers. */
87
+ export function registerSearchHumanData(fn: SearchHumanDataFn): void {
88
+ _searchHumanData = fn;
89
+ }
90
+
62
91
  function splitMessagesByTimestamp(
63
92
  messages: Message[],
64
93
  analyzeFromTimestamp: string | null
@@ -1117,6 +1146,287 @@ function handlePersonaTopicUpdate(response: LLMResponse, state: StateManager): v
1117
1146
 
1118
1147
 
1119
1148
 
1149
+ // =============================================================================
1150
+ // REWRITE HANDLERS (Ceremony — fire-and-forget)
1151
+ // =============================================================================
1152
+
1153
+ /**
1154
+ * handleRewriteScan — Phase 1 of Rewrite.
1155
+ * LLM returns an array of subject strings found in the bloated item.
1156
+ * For each subject we search the knowledge base, then queue Phase 2.
1157
+ */
1158
+ async function handleRewriteScan(response: LLMResponse, state: StateManager): Promise<void> {
1159
+ const itemId = response.request.data.itemId as string;
1160
+ const itemType = response.request.data.itemType as RewriteItemType;
1161
+ const rewriteModel = response.request.data.rewriteModel as string;
1162
+
1163
+ if (!itemId || !itemType) {
1164
+ console.error("[handleRewriteScan] Missing itemId or itemType in request data");
1165
+ return;
1166
+ }
1167
+
1168
+ const subjects = response.parsed as RewriteScanResult | undefined;
1169
+ if (!subjects || !Array.isArray(subjects) || subjects.length === 0) {
1170
+ console.log(`[handleRewriteScan] No extra subjects found for ${itemType} "${itemId}" — item is cohesive`);
1171
+ return;
1172
+ }
1173
+
1174
+ if (!_searchHumanData) {
1175
+ console.error("[handleRewriteScan] searchHumanData not injected — cannot search for matches");
1176
+ return;
1177
+ }
1178
+
1179
+ // Re-read the item from current state (it may have changed since scan was queued)
1180
+ const human = state.getHuman();
1181
+ const allItems: DataItemBase[] = [
1182
+ ...human.facts, ...human.traits, ...human.topics, ...human.people,
1183
+ ];
1184
+ const currentItem = allItems.find(i => i.id === itemId);
1185
+ if (!currentItem) {
1186
+ console.warn(`[handleRewriteScan] Item ${itemId} no longer exists — skipping rewrite`);
1187
+ return;
1188
+ }
1189
+
1190
+ // Search for matches per subject, excluding the original item
1191
+ const subjectMatches: RewriteSubjectMatch[] = [];
1192
+ for (const searchTerm of subjects) {
1193
+ try {
1194
+ const results = await _searchHumanData(searchTerm, {
1195
+ types: ["fact", "trait", "topic", "person"],
1196
+ limit: 4, // fetch 4 so we can exclude original and still have 3
1197
+ });
1198
+ const allMatches: DataItemBase[] = [
1199
+ ...results.facts, ...results.traits, ...results.topics, ...results.people,
1200
+ ].filter(m => m.id !== itemId); // exclude original
1201
+ subjectMatches.push({ searchTerm, matches: allMatches.slice(0, 3) });
1202
+ } catch (err) {
1203
+ console.warn(`[handleRewriteScan] Search failed for "${searchTerm}":`, err);
1204
+ subjectMatches.push({ searchTerm, matches: [] });
1205
+ }
1206
+ }
1207
+
1208
+ // Build Phase 2 prompt and queue it
1209
+ const prompt = buildRewritePrompt({
1210
+ item: currentItem,
1211
+ itemType,
1212
+ subjects: subjectMatches,
1213
+ });
1214
+
1215
+ state.queue_enqueue({
1216
+ type: LLMRequestType.JSON,
1217
+ priority: LLMPriority.Normal,
1218
+ system: prompt.system,
1219
+ user: prompt.user,
1220
+ next_step: LLMNextStep.HandleRewriteRewrite,
1221
+ model: rewriteModel,
1222
+ data: {
1223
+ itemId,
1224
+ itemType,
1225
+ },
1226
+ });
1227
+
1228
+ console.log(`[handleRewriteScan] Queued Phase 2 for ${itemType} "${currentItem.name}" with ${subjectMatches.length} subject(s)`);
1229
+ }
1230
+
1231
+ /**
1232
+ * handleRewriteRewrite — Phase 2 of Rewrite.
1233
+ * LLM returns { existing: [...], new: [...] }.
1234
+ * Upsert existing items by id, create new items with defensive defaults + embeddings.
1235
+ */
1236
+ async function handleRewriteRewrite(response: LLMResponse, state: StateManager): Promise<void> {
1237
+ const itemId = response.request.data.itemId as string;
1238
+ const itemType = response.request.data.itemType as RewriteItemType;
1239
+
1240
+ if (!itemId || !itemType) {
1241
+ console.error("[handleRewriteRewrite] Missing itemId or itemType in request data");
1242
+ return;
1243
+ }
1244
+
1245
+ const result = response.parsed as RewriteResult | undefined;
1246
+ if (!result || (!result.existing?.length && !result.new?.length)) {
1247
+ console.log(`[handleRewriteRewrite] No changes returned for ${itemType} "${itemId}"`);
1248
+ return;
1249
+ }
1250
+
1251
+ const human = state.getHuman();
1252
+ const now = new Date().toISOString();
1253
+
1254
+ // Look up the original item to inherit persona_groups
1255
+ const allItems: DataItemBase[] = [
1256
+ ...human.facts, ...human.traits, ...human.topics, ...human.people,
1257
+ ];
1258
+ const originalItem = allItems.find(i => i.id === itemId);
1259
+ const inheritedGroups = originalItem?.persona_groups;
1260
+
1261
+ // Helper: resolve actual type from existing records (don't trust LLM's type field)
1262
+ const resolveExistingType = (id: string): RewriteItemType | null => {
1263
+ if (human.facts.find(f => f.id === id)) return "fact";
1264
+ if (human.traits.find(t => t.id === id)) return "trait";
1265
+ if (human.topics.find(t => t.id === id)) return "topic";
1266
+ if (human.people.find(p => p.id === id)) return "person";
1267
+ return null;
1268
+ };
1269
+
1270
+ let existingCount = 0;
1271
+ let newCount = 0;
1272
+
1273
+ // --- Process existing items ---
1274
+ for (const item of result.existing ?? []) {
1275
+ if (!item.id || !item.name || !item.description) {
1276
+ console.warn(`[handleRewriteRewrite] Skipping existing item with missing fields: ${JSON.stringify(item).slice(0, 100)}`);
1277
+ continue;
1278
+ }
1279
+
1280
+ // Resolve type from actual records, not from LLM response
1281
+ const resolvedType = resolveExistingType(item.id);
1282
+ if (!resolvedType) {
1283
+ console.warn(`[handleRewriteRewrite] Existing item id "${item.id}" not found in human data — skipping`);
1284
+ continue;
1285
+ }
1286
+
1287
+ let embedding: number[] | undefined;
1288
+ try {
1289
+ const embeddingService = getEmbeddingService();
1290
+ const text = getItemEmbeddingText({ name: item.name, description: item.description });
1291
+ embedding = await embeddingService.embed(text);
1292
+ } catch (err) {
1293
+ console.warn(`[handleRewriteRewrite] Failed to compute embedding for existing ${resolvedType} "${item.name}":`, err);
1294
+ }
1295
+
1296
+ switch (resolvedType) {
1297
+ case "fact": {
1298
+ const existing = human.facts.find(f => f.id === item.id)!;
1299
+ state.human_fact_upsert({
1300
+ ...existing,
1301
+ name: item.name,
1302
+ description: item.description,
1303
+ sentiment: item.sentiment ?? existing.sentiment,
1304
+ last_updated: now,
1305
+ embedding,
1306
+ });
1307
+ break;
1308
+ }
1309
+ case "trait": {
1310
+ const existing = human.traits.find(t => t.id === item.id)!;
1311
+ state.human_trait_upsert({
1312
+ ...existing,
1313
+ name: item.name,
1314
+ description: item.description,
1315
+ sentiment: item.sentiment ?? existing.sentiment,
1316
+ strength: item.strength ?? existing.strength,
1317
+ last_updated: now,
1318
+ embedding,
1319
+ });
1320
+ break;
1321
+ }
1322
+ case "topic": {
1323
+ const existing = human.topics.find(t => t.id === item.id)!;
1324
+ state.human_topic_upsert({
1325
+ ...existing,
1326
+ name: item.name,
1327
+ description: item.description,
1328
+ sentiment: item.sentiment ?? existing.sentiment,
1329
+ last_updated: now,
1330
+ embedding,
1331
+ });
1332
+ break;
1333
+ }
1334
+ case "person": {
1335
+ const existing = human.people.find(p => p.id === item.id)!;
1336
+ state.human_person_upsert({
1337
+ ...existing,
1338
+ name: item.name,
1339
+ description: item.description,
1340
+ sentiment: item.sentiment ?? existing.sentiment,
1341
+ last_updated: now,
1342
+ embedding,
1343
+ });
1344
+ break;
1345
+ }
1346
+ }
1347
+ existingCount++;
1348
+ }
1349
+
1350
+ // --- Process new items ---
1351
+ for (const item of result.new ?? []) {
1352
+ if (!item.type || !item.name || !item.description) {
1353
+ console.warn(`[handleRewriteRewrite] Skipping new item with missing fields: ${JSON.stringify(item).slice(0, 100)}`);
1354
+ continue;
1355
+ }
1356
+
1357
+ let embedding: number[] | undefined;
1358
+ try {
1359
+ const embeddingService = getEmbeddingService();
1360
+ const text = getItemEmbeddingText({ name: item.name, description: item.description });
1361
+ embedding = await embeddingService.embed(text);
1362
+ } catch (err) {
1363
+ console.warn(`[handleRewriteRewrite] Failed to compute embedding for new ${item.type} "${item.name}":`, err);
1364
+ }
1365
+
1366
+ const baseFields = {
1367
+ id: crypto.randomUUID(),
1368
+ name: item.name,
1369
+ description: item.description,
1370
+ sentiment: item.sentiment ?? 0,
1371
+ last_updated: now,
1372
+ learned_by: "ei",
1373
+ persona_groups: inheritedGroups,
1374
+ embedding,
1375
+ };
1376
+
1377
+ switch (item.type) {
1378
+ case "fact": {
1379
+ const fact: Fact = {
1380
+ ...baseFields,
1381
+ validated: ValidationLevel.None,
1382
+ validated_date: now,
1383
+ };
1384
+ state.human_fact_upsert(fact);
1385
+ break;
1386
+ }
1387
+ case "trait": {
1388
+ const trait: Trait = {
1389
+ ...baseFields,
1390
+ strength: item.strength ?? 0.5,
1391
+ };
1392
+ state.human_trait_upsert(trait);
1393
+ break;
1394
+ }
1395
+ case "topic": {
1396
+ if (!item.category) {
1397
+ console.warn(`[handleRewriteRewrite] New topic "${item.name}" missing category — defaulting to "Interest"`);
1398
+ }
1399
+ const topic: Topic = {
1400
+ ...baseFields,
1401
+ category: item.category ?? "Interest",
1402
+ exposure_current: 0.5,
1403
+ exposure_desired: 0.5,
1404
+ };
1405
+ state.human_topic_upsert(topic);
1406
+ break;
1407
+ }
1408
+ case "person": {
1409
+ if (!item.relationship) {
1410
+ console.warn(`[handleRewriteRewrite] New person "${item.name}" missing relationship — defaulting to "Unknown"`);
1411
+ }
1412
+ const person: Person = {
1413
+ ...baseFields,
1414
+ relationship: item.relationship ?? "Unknown",
1415
+ exposure_current: 0.5,
1416
+ exposure_desired: 0.5,
1417
+ };
1418
+ state.human_person_upsert(person);
1419
+ break;
1420
+ }
1421
+ default:
1422
+ console.warn(`[handleRewriteRewrite] Unknown type "${item.type}" for new item "${item.name}" — skipping`);
1423
+ }
1424
+ newCount++;
1425
+ }
1426
+
1427
+ console.log(`[handleRewriteRewrite] Complete for ${itemType} "${itemId}": ${existingCount} existing updated, ${newCount} new created`);
1428
+ }
1429
+
1120
1430
  /**
1121
1431
  * handleToolSynthesis — second LLM call in the tool flow.
1122
1432
  * The QueueProcessor already injected tool history into messages and got the
@@ -1149,4 +1459,6 @@ export const handlers: Record<LLMNextStep, ResponseHandler> = {
1149
1459
  handlePersonaExplore,
1150
1460
  handleDescriptionCheck,
1151
1461
  handleToolSynthesis,
1462
+ handleRewriteScan,
1463
+ handleRewriteRewrite,
1152
1464
  };
@@ -10,7 +10,7 @@ import {
10
10
  type ExtractionOptions,
11
11
  } from "./human-extraction.js";
12
12
  import { queuePersonaTopicScan, type PersonaTopicContext } from "./persona-topics.js";
13
- import { buildPersonaExpirePrompt, buildPersonaExplorePrompt, buildDescriptionCheckPrompt } from "../../prompts/ceremony/index.js";
13
+ import { buildPersonaExpirePrompt, buildPersonaExplorePrompt, buildDescriptionCheckPrompt, buildRewriteScanPrompt, type RewriteItemType } from "../../prompts/ceremony/index.js";
14
14
 
15
15
  export function isNewDay(lastCeremony: string | undefined, now: Date): boolean {
16
16
  if (!lastCeremony) return true;
@@ -227,6 +227,10 @@ export function handleCeremonyProgress(state: StateManager): void {
227
227
 
228
228
  // Dedup phase: log near-duplicate human entities (dry-run only, no mutations)
229
229
  runDedupPhase(state);
230
+
231
+ // Rewrite phase: fire-and-forget scans for bloated human data items
232
+ // No ceremony_progress gating — Expire/Explore only touch persona topics, zero overlap
233
+ queueRewritePhase(state);
230
234
 
231
235
  // Expire phase: queue LLM calls for each active persona
232
236
  // handlePersonaExpire already chains to Explore → DescriptionCheck
@@ -574,3 +578,78 @@ export function runDedupPhase(state: StateManager): void {
574
578
 
575
579
  console.log(`[ceremony:dedup] Done. ${totalCandidates} total candidate pair(s) found.`);
576
580
  }
581
+
582
+ // =============================================================================
583
+ // REWRITE PHASE (fire-and-forget — queues Low-priority Phase 1 scans)
584
+ // =============================================================================
585
+
586
+ const REWRITE_DESCRIPTION_THRESHOLD = 750;
587
+
588
+ /**
589
+ * Queue Phase 1 "scan" for every human data item whose description exceeds the
590
+ * threshold. Gated on rewrite_model being set in HumanSettings.
591
+ *
592
+ * Fire-and-forget: no ceremony_progress, no blocking. Expire/Explore proceed
593
+ * immediately since they only touch persona topics (zero overlap with human data).
594
+ * Phase 2 items enqueue at Normal priority, naturally processing before more
595
+ * Low-priority Phase 1 scans.
596
+ */
597
+ export function queueRewritePhase(state: StateManager): void {
598
+ const human = state.getHuman();
599
+ const rewriteModel = human.settings?.rewrite_model;
600
+
601
+ if (!rewriteModel) {
602
+ console.log("[ceremony:rewrite] rewrite_model not set — skipping rewrite phase");
603
+ return;
604
+ }
605
+
606
+ const itemsToScan: Array<{ item: DataItemBase; type: RewriteItemType }> = [];
607
+
608
+ for (const fact of human.facts) {
609
+ if ((fact.description?.length ?? 0) > REWRITE_DESCRIPTION_THRESHOLD) {
610
+ itemsToScan.push({ item: fact, type: "fact" });
611
+ }
612
+ }
613
+ for (const trait of human.traits) {
614
+ if ((trait.description?.length ?? 0) > REWRITE_DESCRIPTION_THRESHOLD) {
615
+ itemsToScan.push({ item: trait, type: "trait" });
616
+ }
617
+ }
618
+ for (const topic of human.topics) {
619
+ if ((topic.description?.length ?? 0) > REWRITE_DESCRIPTION_THRESHOLD) {
620
+ itemsToScan.push({ item: topic, type: "topic" });
621
+ }
622
+ }
623
+ for (const person of human.people) {
624
+ if ((person.description?.length ?? 0) > REWRITE_DESCRIPTION_THRESHOLD) {
625
+ itemsToScan.push({ item: person, type: "person" });
626
+ }
627
+ }
628
+
629
+ if (itemsToScan.length === 0) {
630
+ console.log("[ceremony:rewrite] No items above threshold — nothing to rewrite");
631
+ return;
632
+ }
633
+
634
+ console.log(`[ceremony:rewrite] Found ${itemsToScan.length} item(s) above ${REWRITE_DESCRIPTION_THRESHOLD} chars — queueing Phase 1 scans`);
635
+
636
+ for (const { item, type } of itemsToScan) {
637
+ const prompt = buildRewriteScanPrompt({ item, itemType: type });
638
+
639
+ state.queue_enqueue({
640
+ type: LLMRequestType.JSON,
641
+ priority: LLMPriority.Low,
642
+ system: prompt.system,
643
+ user: prompt.user,
644
+ next_step: LLMNextStep.HandleRewriteScan,
645
+ model: rewriteModel,
646
+ data: {
647
+ itemId: item.id,
648
+ itemType: type,
649
+ rewriteModel, // pass through so Phase 1 handler can queue Phase 2 with the same model
650
+ },
651
+ });
652
+ }
653
+
654
+ console.log(`[ceremony:rewrite] Queued ${itemsToScan.length} Phase 1 scan(s) at Low priority`);
655
+ }
@@ -19,6 +19,7 @@ export {
19
19
  queueExplorePhase,
20
20
  queueDescriptionCheck,
21
21
  runHumanCeremony,
22
+ queueRewritePhase,
22
23
  } from "./ceremony.js";
23
24
  export {
24
25
  queuePersonaTopicScan,
@@ -33,7 +33,7 @@ import { remoteSync } from "../storage/remote.js";
33
33
  import { yoloMerge } from "../storage/merge.js";
34
34
  import { StateManager } from "./state-manager.js";
35
35
  import { QueueProcessor } from "./queue-processor.js";
36
- import { handlers } from "./handlers/index.js";
36
+ import { handlers, registerSearchHumanData } from "./handlers/index.js";
37
37
  import {
38
38
  buildResponsePrompt,
39
39
  buildPersonaTraitExtractionPrompt,
@@ -216,6 +216,8 @@ export class Processor {
216
216
  this.bootstrapTools();
217
217
  // Register read_memory executor (injected to avoid circular deps)
218
218
  registerReadMemoryExecutor(createReadMemoryExecutor(this.searchHumanData.bind(this)));
219
+ // Inject searchHumanData for rewrite handlers (same pattern as read_memory)
220
+ registerSearchHumanData(this.searchHumanData.bind(this));
219
221
  // file_read is Node-only — dynamic import to keep node:fs/promises out of the web bundle
220
222
  if (this.isTUI) {
221
223
  registerFileReadExecutor();
@@ -1085,6 +1087,10 @@ export class Processor {
1085
1087
  this.interface.onQuoteAdded?.();
1086
1088
  }
1087
1089
 
1090
+ if (response.request.next_step === LLMNextStep.HandleRewriteRewrite) {
1091
+ this.interface.onHumanUpdated?.();
1092
+ }
1093
+
1088
1094
  if (response.request.data.ceremony_progress) {
1089
1095
  handleCeremonyProgress(this.stateManager);
1090
1096
  }
package/src/core/types.ts CHANGED
@@ -54,6 +54,8 @@ export enum LLMNextStep {
54
54
  // data.toolHistory: serialized LLMHistoryMessage[] (assistant + tool result messages)
55
55
  // data.originalNextStep: the next_step value from the originating request
56
56
  HandleToolSynthesis = "handleToolSynthesis",
57
+ HandleRewriteScan = "handleRewriteScan",
58
+ HandleRewriteRewrite = "handleRewriteRewrite",
57
59
  }
58
60
  // =============================================================================
59
61
  // DATA ITEMS
@@ -244,6 +246,7 @@ export interface BackupConfig {
244
246
  export interface HumanSettings {
245
247
  default_model?: string;
246
248
  oneshot_model?: string; // Model for AI-assist (wand) requests; falls back to default_model
249
+ rewrite_model?: string; // Model for rewrite ceremony step; must be capable (Sonnet/Opus class). Unset = rewrite disabled.
247
250
  queue_paused?: boolean;
248
251
  skip_quote_delete_confirm?: boolean;
249
252
  name_display?: string;
@@ -1,6 +1,7 @@
1
1
  export { buildPersonaExpirePrompt } from "./expire.js";
2
2
  export { buildPersonaExplorePrompt } from "./explore.js";
3
3
  export { buildDescriptionCheckPrompt } from "./description-check.js";
4
+ export { buildRewriteScanPrompt, buildRewritePrompt } from "./rewrite.js";
4
5
  export type {
5
6
  PersonaExpirePromptData,
6
7
  PersonaExpireResult,
@@ -8,4 +9,10 @@ export type {
8
9
  PersonaExploreResult,
9
10
  DescriptionCheckPromptData,
10
11
  DescriptionCheckResult,
12
+ RewriteItemType,
13
+ RewriteScanPromptData,
14
+ RewriteScanResult,
15
+ RewriteSubjectMatch,
16
+ RewritePromptData,
17
+ RewriteResult,
11
18
  } from "./types.js";
@@ -0,0 +1,166 @@
1
+ import type { RewriteScanPromptData, RewritePromptData } from "./types.js";
2
+
3
+ // =============================================================================
4
+ // PHASE 1: SCAN — Identify distinct subjects in a bloated item
5
+ // =============================================================================
6
+
7
+ export function buildRewriteScanPrompt(data: RewriteScanPromptData): { system: string; user: string } {
8
+ const typeLabel = data.itemType.charAt(0).toUpperCase() + data.itemType.slice(1);
9
+
10
+ const system = `You are auditing a personal knowledge base. A single ${typeLabel} record has grown too large because unrelated information was repeatedly appended to it over time. The record's Name suggests its intended subject, but its Description now covers many additional, unrelated subjects.
11
+
12
+ Your job: identify the **additional** subjects buried in this record that do NOT belong under the record's Name.
13
+
14
+ Rules:
15
+ - Do NOT include the record's primary subject (what its Name describes) — only the extra, unrelated subjects.
16
+ - Each subject should be a succinct phrase (2-8 words) that could serve as a search query.
17
+ - Be specific. "Technical preferences" is too vague. "TypeScript coding conventions" is better.
18
+ - If the record is actually cohesive and on-topic despite its length, return an empty array.
19
+
20
+ Return a raw JSON array of strings. No markdown fencing, no commentary, no explanation. Just the array.
21
+
22
+ Example — a Fact named "Job" whose description also discusses vim keybindings, git conventions, and AI tooling:
23
+ ["vim keybindings and editor configuration", "git and GitHub workflow conventions", "AI coding assistant preferences"]`;
24
+
25
+ const user = JSON.stringify(stripEmbedding(data.item), null, 2);
26
+
27
+ return { system, user };
28
+ }
29
+
30
+ // =============================================================================
31
+ // PHASE 2: REWRITE — Reorganize data across existing and new items
32
+ // =============================================================================
33
+
34
+ export function buildRewritePrompt(data: RewritePromptData): { system: string; user: string } {
35
+ const typeLabel = data.itemType.charAt(0).toUpperCase() + data.itemType.slice(1);
36
+
37
+ const system = `You are reorganizing a personal knowledge base. A ${typeLabel} record has become a catch-all for several unrelated subjects. An earlier analysis identified the extra subjects, and we searched our knowledge base for potentially matching existing records.
38
+
39
+ The search results under each subject are our **best guesses** — they may not be accurate matches. Only merge data into an existing record if the subject matter genuinely overlaps. Similar names with different meanings should produce a NEW record instead.
40
+
41
+ Your job:
42
+ 1. **Update existing records**: For subjects that match an existing record, incorporate the relevant data from the original entry into that record's description. Preserve the existing record's "id", "name", and "type".
43
+ 2. **Create new records**: For subjects with no appropriate match among the search results, create a new record.
44
+ 3. **Slim the original**: Remove all data from the original record that now lives elsewhere. The original should contain ONLY information directly relevant to its Name.
45
+
46
+ Return raw JSON with exactly two keys. No markdown fencing, no commentary. Just the JSON object:
47
+ {
48
+ "existing": [ /* updated records, including the slimmed-down original */ ],
49
+ "new": [ /* brand-new records for subjects with no match */ ]
50
+ }
51
+
52
+ Record format for "existing" entries (MUST include "id" and "type"):
53
+ ${buildExistingExamples()}
54
+
55
+ Record format for "new" entries (NO "id" field — the system assigns one):
56
+ ${buildNewExamples()}
57
+
58
+ Rules:
59
+ - The original record (id: "${data.item.id}") MUST appear in "existing", slimmed down.
60
+ - Descriptions should be concise — ideally under 300 characters, never over 500.
61
+ - Preserve sentiment, strength, confidence, and other numeric values from the source record where applicable.
62
+ - "type" must be one of: "fact", "trait", "topic", "person".
63
+ - Topics MUST include "category" — one of: Interest, Goal, Dream, Conflict, Concern, Fear, Hope, Plan, Project.
64
+ - People MUST include "relationship" — a short label like "coworker", "friend", "mentor", etc.
65
+ - Traits MUST include "strength" (0.0-1.0).
66
+ - Do NOT invent information. Only redistribute what exists in the original record.`;
67
+
68
+ const subjects = data.subjects.map(s => ({
69
+ search_term: s.searchTerm,
70
+ matches: s.matches.map(m => stripEmbedding(m)),
71
+ }));
72
+
73
+ const userPayload = {
74
+ original: stripEmbedding(data.item),
75
+ original_type: data.itemType,
76
+ subjects,
77
+ };
78
+
79
+ const user = JSON.stringify(userPayload, null, 2);
80
+
81
+ return { system, user };
82
+ }
83
+
84
+ // =============================================================================
85
+ // Helpers
86
+ // =============================================================================
87
+
88
+ /** Strip embedding arrays from items before putting them in prompts — they're huge and useless to the LLM. */
89
+ function stripEmbedding<T extends { embedding?: unknown }>(item: T): Omit<T, "embedding"> {
90
+ const { embedding: _, ...rest } = item;
91
+ return rest as Omit<T, "embedding">;
92
+ }
93
+
94
+ function buildExistingExamples(): string {
95
+ return `Fact:
96
+ {
97
+ "id": "existing-uuid",
98
+ "type": "fact",
99
+ "name": "Record Name",
100
+ "description": "Updated description with incorporated data"
101
+ }
102
+
103
+ Trait:
104
+ {
105
+ "id": "existing-uuid",
106
+ "type": "trait",
107
+ "name": "Trait Name",
108
+ "description": "Updated trait description",
109
+ "strength": 0.7
110
+ }
111
+
112
+ Topic:
113
+ {
114
+ "id": "existing-uuid",
115
+ "type": "topic",
116
+ "name": "Topic Name",
117
+ "description": "Updated topic description",
118
+ "category": "Interest"
119
+ }
120
+
121
+ Person:
122
+ {
123
+ "id": "existing-uuid",
124
+ "type": "person",
125
+ "name": "Person Name",
126
+ "description": "Updated person description",
127
+ "relationship": "coworker"
128
+ }`;
129
+ }
130
+
131
+ function buildNewExamples(): string {
132
+ return `Fact:
133
+ {
134
+ "type": "fact",
135
+ "name": "New Subject Name",
136
+ "description": "Concise description of this subject",
137
+ "sentiment": 0.0
138
+ }
139
+
140
+ Trait:
141
+ {
142
+ "type": "trait",
143
+ "name": "New Trait Name",
144
+ "description": "Concise trait description",
145
+ "sentiment": 0.0,
146
+ "strength": 0.5
147
+ }
148
+
149
+ Topic:
150
+ {
151
+ "type": "topic",
152
+ "name": "New Topic Name",
153
+ "description": "Concise topic description",
154
+ "sentiment": 0.0,
155
+ "category": "Interest"
156
+ }
157
+
158
+ Person:
159
+ {
160
+ "type": "person",
161
+ "name": "New Person Name",
162
+ "description": "Concise person description",
163
+ "sentiment": 0.0,
164
+ "relationship": "friend"
165
+ }`;
166
+ }
@@ -1,4 +1,4 @@
1
- import type { Trait, PersonaTopic } from "../../core/types.js";
1
+ import type { Trait, PersonaTopic, DataItemBase } from "../../core/types.js";
2
2
 
3
3
  export interface PersonaExpirePromptData {
4
4
  persona_name: string;
@@ -40,3 +40,58 @@ export interface DescriptionCheckResult {
40
40
  should_update: boolean;
41
41
  reason?: string;
42
42
  }
43
+
44
+ // =============================================================================
45
+ // REWRITE (Item Reorganization)
46
+ // =============================================================================
47
+
48
+ export type RewriteItemType = "fact" | "trait" | "topic" | "person";
49
+
50
+ /** Phase 1 input: the bloated item to scan for extra subjects. */
51
+ export interface RewriteScanPromptData {
52
+ item: DataItemBase;
53
+ itemType: RewriteItemType;
54
+ }
55
+
56
+ /** Phase 1 output: array of subject strings (parsed from LLM JSON response). */
57
+ export type RewriteScanResult = string[];
58
+
59
+ /** A single subject and the read_memory matches found for it. */
60
+ export interface RewriteSubjectMatch {
61
+ searchTerm: string;
62
+ matches: DataItemBase[]; // Top 3 from searchHumanData, may be empty
63
+ }
64
+
65
+ /** Phase 2 input: the bloated item + all subject matches. */
66
+ export interface RewritePromptData {
67
+ item: DataItemBase;
68
+ itemType: RewriteItemType;
69
+ subjects: RewriteSubjectMatch[];
70
+ }
71
+
72
+ /** Phase 2 output: existing items to upsert + new items to create. */
73
+ export interface RewriteResult {
74
+ existing: Array<{
75
+ id: string;
76
+ type: RewriteItemType;
77
+ name: string;
78
+ description: string;
79
+ sentiment?: number;
80
+ strength?: number; // traits
81
+ exposure_current?: number; // topics, people
82
+ exposure_desired?: number; // topics, people
83
+ relationship?: string; // people
84
+ category?: string; // topics
85
+ }>;
86
+ new: Array<{
87
+ type: RewriteItemType;
88
+ name: string;
89
+ description: string;
90
+ sentiment?: number;
91
+ strength?: number;
92
+ exposure_current?: number;
93
+ exposure_desired?: number;
94
+ relationship?: string;
95
+ category?: string;
96
+ }>;
97
+ }
@@ -501,6 +501,8 @@ export function humanFromYAML(yamlContent: string): HumanYAMLResult {
501
501
 
502
502
  interface EditableSettingsData {
503
503
  default_model?: string | null;
504
+ oneshot_model?: string | null;
505
+ rewrite_model?: string | null;
504
506
  time_mode?: "24h" | "12h" | "local" | "utc" | null;
505
507
  name_display?: string | null;
506
508
  ceremony?: {
@@ -531,6 +533,8 @@ export function settingsToYAML(settings: HumanSettings | undefined): string {
531
533
  // Always show all editable fields, using null for unset values so YAML displays them
532
534
  const data: EditableSettingsData = {
533
535
  default_model: settings?.default_model ?? null,
536
+ oneshot_model: settings?.oneshot_model ?? null,
537
+ rewrite_model: settings?.rewrite_model ?? null,
534
538
  time_mode: settings?.time_mode ?? null,
535
539
  name_display: settings?.name_display ?? null,
536
540
  ceremony: {
@@ -614,6 +618,8 @@ export function settingsFromYAML(yamlContent: string, original: HumanSettings |
614
618
  return {
615
619
  ...original,
616
620
  default_model: nullToUndefined(data.default_model),
621
+ oneshot_model: nullToUndefined(data.oneshot_model),
622
+ rewrite_model: nullToUndefined(data.rewrite_model),
617
623
  time_mode: nullToUndefined(data.time_mode),
618
624
  name_display: nullToUndefined(data.name_display),
619
625
  ceremony,