ei-tui 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +16 -0
  2. package/package.json +2 -23
  3. package/src/cli/README.md +12 -2
  4. package/src/cli/mcp.ts +12 -4
  5. package/src/cli/retrieval.ts +162 -0
  6. package/src/cli.ts +7 -1
  7. package/src/core/handlers/dedup.ts +4 -15
  8. package/src/core/handlers/document-segmentation.ts +5 -7
  9. package/src/core/handlers/heartbeat.ts +5 -10
  10. package/src/core/handlers/human-matching.ts +8 -0
  11. package/src/core/handlers/index.ts +2 -0
  12. package/src/core/handlers/knowledge-synthesis.ts +48 -0
  13. package/src/core/handlers/persona-generation.ts +4 -8
  14. package/src/core/handlers/persona-response.ts +3 -4
  15. package/src/core/handlers/persona-topics.ts +2 -4
  16. package/src/core/handlers/rewrite.ts +26 -9
  17. package/src/core/handlers/rooms.ts +6 -12
  18. package/src/core/heartbeat-manager.ts +10 -0
  19. package/src/core/llm-client.ts +13 -3
  20. package/src/core/message-manager.ts +2 -4
  21. package/src/core/orchestrators/ceremony.ts +45 -22
  22. package/src/core/orchestrators/human-extraction.ts +10 -1
  23. package/src/core/processor.ts +275 -7
  24. package/src/core/queue-manager.ts +10 -0
  25. package/src/core/state-manager.ts +35 -0
  26. package/src/core/tools/builtin/fetch-memory.ts +6 -6
  27. package/src/core/tools/builtin/fetch-message.ts +27 -1
  28. package/src/core/tools/builtin/find-memory.ts +11 -3
  29. package/src/core/tools/index.ts +3 -3
  30. package/src/core/tools/types.ts +1 -1
  31. package/src/core/types/data-items.ts +1 -1
  32. package/src/core/types/entities.ts +7 -1
  33. package/src/core/types/enums.ts +1 -0
  34. package/src/core/types/integrations.ts +3 -1
  35. package/src/core/types/llm.ts +0 -9
  36. package/src/core/utils/message-id.ts +114 -0
  37. package/src/integrations/claude-code/importer.ts +12 -5
  38. package/src/integrations/cursor/importer.ts +12 -5
  39. package/src/integrations/document/importer.ts +1 -1
  40. package/src/integrations/document/unsource.ts +11 -14
  41. package/src/integrations/opencode/importer.ts +19 -6
  42. package/src/integrations/opencode/json-reader.ts +65 -0
  43. package/src/integrations/opencode/sqlite-reader.ts +33 -0
  44. package/src/integrations/opencode/types.ts +8 -0
  45. package/src/integrations/persona-history/importer.ts +9 -0
  46. package/src/prompts/ceremony/people-rewrite.ts +2 -2
  47. package/src/prompts/ceremony/topic-rewrite.ts +2 -2
  48. package/src/prompts/heartbeat/check.ts +5 -2
  49. package/src/prompts/heartbeat/ei.ts +7 -0
  50. package/src/prompts/heartbeat/types.ts +5 -0
  51. package/src/prompts/index.ts +3 -0
  52. package/src/prompts/response/sections.ts +30 -16
  53. package/src/prompts/room/sections.ts +28 -6
  54. package/src/prompts/synthesis/index.ts +101 -0
  55. package/src/prompts/synthesis/types.ts +26 -0
  56. package/src/prompts/trait-utils.ts +33 -0
  57. package/tui/README.md +2 -0
  58. package/tui/src/commands/generate.tsx +98 -0
  59. package/tui/src/commands/unsource.tsx +17 -10
  60. package/tui/src/components/GeneratedDocsOverlay.tsx +136 -0
  61. package/tui/src/components/PromptInput.tsx +2 -0
  62. package/tui/src/context/ei.tsx +49 -2
  63. package/tui/src/util/help-content.ts +11 -0
  64. package/tui/src/util/logger.ts +22 -2
  65. package/tui/src/util/provider-detection.ts +5 -2
  66. package/tui/src/util/yaml-provider.ts +2 -8
@@ -6,6 +6,7 @@
6
6
  import type { PersonaTrait, Quote, PersonaTopic } from "../../core/types.js";
7
7
  import type { ResponsePromptData, TemporalAnchor } from "./types.js";
8
8
  import { formatTimestamp } from "../../core/format-utils.js";
9
+ import { partitionTraits, bucketTraits } from "../trait-utils.js";
9
10
 
10
11
  const DESCRIPTION_MAX_CHARS = 500;
11
12
 
@@ -69,25 +70,35 @@ export function buildGuidelinesSection(personaName: string): string {
69
70
  // TRAITS SECTION
70
71
  // =============================================================================
71
72
 
73
+ const TRAIT_BUCKETS = [
74
+ { min: 90, max: 100, header: "### Core Expression\nThese define you. They should be evident in every response." },
75
+ { min: 66, max: 89, header: "### Strong Tendencies\nFrequent and traceable, but not in every sentence." },
76
+ { min: 36, max: 65, header: "### Noticeable in Casual Messages\nPresent but not dominating — surfaces naturally, not constantly." },
77
+ { min: 1, max: 35, header: "### Subtle Undercurrents\nBackground texture only. Use sparingly or subtly." },
78
+ ] as const;
79
+
72
80
  export function buildTraitsSection(traits: PersonaTrait[], header: string): string {
73
81
  if (traits.length === 0) return "";
74
-
75
- const sorted = [...traits].sort((a, b) => (b.strength ?? 0.5) - (a.strength ?? 0.5)).slice(0, 15);
76
- const formatted = sorted.map(t => {
77
- const strength = t.strength !== undefined ? ` (${Math.round(t.strength * 100)}%)` : "";
78
- return `- **${t.name}**${strength}: ${truncateDescription(t.description)}`;
79
- }).join("\n");
80
-
81
- return `## ${header}
82
82
 
83
- > NOTE: Strength of a trait should guide you to your response style, meaning a Strength of:
84
- > - 0% should never be used - the user has asked you to stop
85
- > - 25% should be used sparingly or subtly
86
- > - 50% should be noticable in casual messages, but not dominating
87
- > - 75% should be frequently used, but not in every resposne or throughout the entire conversation
88
- > - 100% should be tracable throughout every response
83
+ const capped = [...traits].sort((a, b) => (b.strength ?? 0.5) - (a.strength ?? 0.5)).slice(0, 15);
84
+ const { guardrails, active } = partitionTraits(capped);
89
85
 
90
- ${formatted}`;
86
+ const sections: string[] = [];
87
+
88
+ if (guardrails.length > 0) {
89
+ const lines = guardrails.map(t => `**${t.name}**`).join("\n");
90
+ sections.push(`### Must NEVER Do — User Explicitly Asked You To Stop\n${lines}`);
91
+ }
92
+
93
+ for (const { bucket, traits: inBucket } of bucketTraits(active, TRAIT_BUCKETS)) {
94
+ if (inBucket.length === 0) continue;
95
+ const lines = inBucket.map(t => `**${t.name}**: ${truncateDescription(t.description)}`).join("\n");
96
+ sections.push(`${bucket.header}\n${lines}`);
97
+ }
98
+
99
+ if (sections.length === 0) return "";
100
+
101
+ return `## ${header}\n\n${sections.join("\n\n")}`;
91
102
  }
92
103
 
93
104
  // =============================================================================
@@ -310,6 +321,9 @@ export function buildQuotesSection(quotes: Quote[], human: ResponsePromptData["h
310
321
  .filter((name): name is string => name !== undefined);
311
322
 
312
323
  let line = `- "${q.text}" — ${speaker} (${date})`;
324
+ if (q.message_id) {
325
+ line += `\n → fetch_message("${q.message_id}") for surrounding context`;
326
+ }
313
327
  if (linkedNames.length > 0) {
314
328
  line += `\n Related to: ${linkedNames.join(", ")}`;
315
329
  }
@@ -318,7 +332,7 @@ export function buildQuotesSection(quotes: Quote[], human: ResponsePromptData["h
318
332
 
319
333
  return `## Memorable Moments
320
334
 
321
- These are quotes the human or the system found worth preserving:
335
+ These are quotes the human or the system found worth preserving. If one feels relevant, use fetch_message(message_id) to pull the surrounding conversation:
322
336
 
323
337
  ${formatted}`;
324
338
  }
@@ -5,6 +5,7 @@
5
5
  import type { RoomParticipantIdentity, RoomHistoryMessage, RoomJudgeCandidate } from "./types.js";
6
6
  import type { PersonaTrait, PersonaTopic } from "../../core/types.js";
7
7
  import { RoomMode } from "../../core/types.js";
8
+ import { partitionTraits, bucketTraits } from "../trait-utils.js";
8
9
 
9
10
  const DESCRIPTION_MAX_CHARS = 500;
10
11
 
@@ -70,14 +71,35 @@ export function buildRoomGuidelinesSection(personaName: string, mode?: RoomMode)
70
71
  return baseGuidelines;
71
72
  }
72
73
 
74
+ const ROOM_TRAIT_BUCKETS = [
75
+ { min: 90, max: 100, header: "### Core Expression\nEvident in every response." },
76
+ { min: 66, max: 89, header: "### Strong Tendencies\nFrequent, but not every sentence." },
77
+ { min: 36, max: 65, header: "### Noticeable in Casual Messages\nSurfaces naturally, not constantly." },
78
+ { min: 1, max: 35, header: "### Subtle Undercurrents\nBackground texture only." },
79
+ ] as const;
80
+
73
81
  export function buildRoomTraitsSection(traits: PersonaTrait[]): string {
74
82
  if (traits.length === 0) return "";
75
- const sorted = [...traits].sort((a, b) => (b.strength ?? 0.5) - (a.strength ?? 0.5)).slice(0, 12);
76
- const lines = sorted.map(t => {
77
- const pct = t.strength !== undefined ? ` (${Math.round(t.strength * 100)}%)` : "";
78
- return `- **${t.name}**${pct}: ${truncate(t.description)}`;
79
- });
80
- return `## Your Personality\n\n${lines.join("\n")}`;
83
+
84
+ const capped = [...traits].sort((a, b) => (b.strength ?? 0.5) - (a.strength ?? 0.5)).slice(0, 12);
85
+ const { guardrails, active } = partitionTraits(capped);
86
+
87
+ const sections: string[] = [];
88
+
89
+ if (guardrails.length > 0) {
90
+ const lines = guardrails.map(t => `**${t.name}**`).join("\n");
91
+ sections.push(`### Must NEVER Do — User Explicitly Asked You To Stop\n${lines}`);
92
+ }
93
+
94
+ for (const { bucket, traits: inBucket } of bucketTraits(active, ROOM_TRAIT_BUCKETS)) {
95
+ if (inBucket.length === 0) continue;
96
+ const lines = inBucket.map(t => `**${t.name}**: ${truncate(t.description)}`).join("\n");
97
+ sections.push(`${bucket.header}\n${lines}`);
98
+ }
99
+
100
+ if (sections.length === 0) return "";
101
+
102
+ return `## Your Personality\n\n${sections.join("\n\n")}`;
81
103
  }
82
104
 
83
105
  export function buildRoomTopicsSection(topics: PersonaTopic[]): string {
@@ -0,0 +1,101 @@
1
+ import type { SynthesisPromptData } from "./types.js";
2
+ import type { PromptOutput } from "../response/types.js";
3
+
4
+ export type { SynthesisPromptData, EnrichedTopic, EnrichedPerson } from "./types.js";
5
+
6
+ export function buildSynthesisPrompt(data: SynthesisPromptData): PromptOutput {
7
+ const hasEntityMap = data.loadedEntityNames !== undefined;
8
+
9
+ const system = `You are synthesizing a knowledge document from a personal knowledge base called Ei.
10
+
11
+ Your goal is to produce a well-structured markdown document that a human could share with a teammate, hand to their future self, or use as a reference. Write as if you are distilling what someone actually knows — not restating a list of facts, but synthesizing relationships, context, and meaning.
12
+
13
+ ## What you have been given
14
+
15
+ Everything below is complete as provided — do not use tools to re-fetch records already present here. Only use tools to fill genuine gaps not covered by the data below.
16
+
17
+ - **Facts**: Ground-truth statements.
18
+ - **Topics**: Areas of interest, work, or concern with descriptions.
19
+ - **People**: Individuals with relationship context.
20
+ - **Quotes**: Verbatim things said, with a \`message_id\`. Use \`fetch_message\` with the \`message_id\` if you want the surrounding conversation for additional context.${hasEntityMap ? `
21
+ - **Quote links**: Each quote lists the entities it was extracted from. Entities marked \`(not loaded)\` were referenced by that quote but are not present in this payload — use \`fetch_memory\` with the entity ID to retrieve them if the gap is relevant to your synthesis.` : ""}
22
+
23
+ ## Output
24
+
25
+ Write clean, structured markdown. Use headings. Synthesize — do not just restate the bullets. Where the data tells a story or shows a pattern, say so. Where something is uncertain or a work-in-progress, reflect that. Aim for the document a thoughtful person would write after reviewing all of this, not a formatted dump.`;
26
+
27
+ const lines: string[] = [`# ${data.subject}`, ""];
28
+
29
+ const formatQuoteLinks = (dataItemIds: string[]): string | null => {
30
+ if (!hasEntityMap || dataItemIds.length === 0) return null;
31
+ const labels = dataItemIds.map(id => {
32
+ const name = data.loadedEntityNames!.get(id);
33
+ return name ? `[id:${id}] ${name}` : `[id:${id}] (not loaded)`;
34
+ });
35
+ return ` _Links: ${labels.join(", ")}_`;
36
+ };
37
+
38
+ if (data.facts.length > 0) {
39
+ lines.push("## Facts");
40
+ for (const fact of data.facts) {
41
+ lines.push(`- [id:${fact.id}] **${fact.name}**: ${fact.description}`);
42
+ }
43
+ lines.push("");
44
+ }
45
+
46
+ if (data.topics.length > 0) {
47
+ lines.push("## Topics");
48
+ for (const { topic, quotes } of data.topics) {
49
+ const categoryTag = topic.category ? ` _(${topic.category})_` : "";
50
+ lines.push(`### [id:${topic.id}] ${topic.name}${categoryTag}`);
51
+ lines.push(topic.description);
52
+ if (quotes.length > 0) {
53
+ lines.push("");
54
+ lines.push("**Related quotes:**");
55
+ for (const q of quotes) {
56
+ const attribution = q.channel ? `${q.speaker} in ${q.channel}` : q.speaker;
57
+ lines.push(`- [message_id:${q.message_id ?? "none"}] "${q.text}" — ${attribution}`);
58
+ const linkLine = formatQuoteLinks(q.data_item_ids);
59
+ if (linkLine) lines.push(linkLine);
60
+ }
61
+ }
62
+ lines.push("");
63
+ }
64
+ }
65
+
66
+ if (data.people.length > 0) {
67
+ lines.push("## People");
68
+ for (const { person, quotes } of data.people) {
69
+ lines.push(`### [id:${person.id}] ${person.name}`);
70
+ lines.push(`_${person.relationship}_`);
71
+ lines.push("");
72
+ lines.push(person.description);
73
+ if (quotes.length > 0) {
74
+ lines.push("");
75
+ lines.push("**Related quotes:**");
76
+ for (const q of quotes) {
77
+ const attribution = q.channel ? `${q.speaker} in ${q.channel}` : q.speaker;
78
+ lines.push(`- [message_id:${q.message_id ?? "none"}] "${q.text}" — ${attribution}`);
79
+ const linkLine = formatQuoteLinks(q.data_item_ids);
80
+ if (linkLine) lines.push(linkLine);
81
+ }
82
+ }
83
+ lines.push("");
84
+ }
85
+ }
86
+
87
+ if (data.standaloneQuotes.length > 0) {
88
+ lines.push("## Additional Quotes");
89
+ for (const q of data.standaloneQuotes) {
90
+ const attribution = q.channel ? `${q.speaker} in ${q.channel}` : q.speaker;
91
+ lines.push(`- [message_id:${q.message_id ?? "none"}] "${q.text}" — ${attribution}`);
92
+ const linkLine = formatQuoteLinks(q.data_item_ids);
93
+ if (linkLine) lines.push(linkLine);
94
+ }
95
+ lines.push("");
96
+ }
97
+
98
+ const user = lines.join("\n");
99
+
100
+ return { system, user };
101
+ }
@@ -0,0 +1,26 @@
1
+ import type { Fact, Topic, Person, Quote } from "../../core/types.js";
2
+
3
+ export interface EnrichedTopic {
4
+ topic: Topic;
5
+ quotes: Quote[];
6
+ }
7
+
8
+ export interface EnrichedPerson {
9
+ person: Person;
10
+ quotes: Quote[];
11
+ }
12
+
13
+ export interface SynthesisPromptData {
14
+ subject: string;
15
+ facts: Fact[];
16
+ topics: EnrichedTopic[];
17
+ people: EnrichedPerson[];
18
+ standaloneQuotes: Quote[];
19
+ /**
20
+ * Map of entity ID → display name for all entities included in this payload.
21
+ * Used to annotate quote links: IDs present in a quote's data_item_ids but
22
+ * absent from this map are rendered as "(not loaded)" — a signal to the LLM
23
+ * that a related record exists and can be fetched via fetch_memory.
24
+ */
25
+ loadedEntityNames?: Map<string, string>;
26
+ }
@@ -0,0 +1,33 @@
1
+ import type { PersonaTrait } from "../core/types.js";
2
+
3
+ export interface TraitBucket {
4
+ min: number;
5
+ max: number;
6
+ header: string;
7
+ }
8
+
9
+ export interface PartitionedTraits {
10
+ guardrails: PersonaTrait[];
11
+ active: PersonaTrait[];
12
+ }
13
+
14
+ // Special-case: .filter() is intentional here. This file is the designated home
15
+ // for trait partitioning logic that prompt builders need for rendering. Keeping it
16
+ // here (rather than inline in each builder) is what lets the structural check
17
+ // enforce "no .filter() in prompt builders" elsewhere.
18
+ export function partitionTraits(traits: PersonaTrait[]): PartitionedTraits {
19
+ return {
20
+ guardrails: traits.filter(t => (t.strength ?? 0.5) === 0),
21
+ active: traits.filter(t => (t.strength ?? 0.5) > 0),
22
+ };
23
+ }
24
+
25
+ export function bucketTraits(active: PersonaTrait[], buckets: readonly TraitBucket[]): Array<{ bucket: TraitBucket; traits: PersonaTrait[] }> {
26
+ return buckets.map(bucket => ({
27
+ bucket,
28
+ traits: active.filter(t => {
29
+ const pct = Math.round((t.strength ?? 0.5) * 100);
30
+ return pct >= bucket.min && pct <= bucket.max;
31
+ }),
32
+ }));
33
+ }
package/tui/README.md CHANGED
@@ -132,6 +132,8 @@ Rooms have three modes, set at creation time:
132
132
  | `/me <type>` | | Edit one type: `facts`, `topics`, or `people` |
133
133
  | `/import <path>` | | Import a document (txt, md, pdf, etc.) into Ei — extracted knowledge is attributed to the "Emmett" persona |
134
134
  | `/unsource <source_tag>` | | Remove all knowledge extracted from a previously imported document |
135
+ | `/generate <subject>` | | Synthesize everything Ei knows about a subject into a markdown document — lands in `$EI_DATA_PATH/docs/` automatically |
136
+ | `/generate` | | (no args) Manage your generated documents — re-run, re-export, or delete |
135
137
  | `/dedupe <person\|topic> <term> [term2 ...]` | | Fuzzy-search and merge duplicate people or topics in `$EDITOR`. Unquoted words are individual OR terms; quoted strings match as exact phrases: `/dedupe person Flare "Jeremy Scherer"` finds records matching `Flare` OR `Jeremy Scherer` |
136
138
  | `/settings` | `/set` | Edit your global settings in `$EDITOR` |
137
139
  | `/setsync <user> <pass>` | `/ss` | Set sync credentials (triggers restart) |
@@ -0,0 +1,98 @@
1
+ import type { Command } from "./registry";
2
+ import { ConfirmOverlay } from "../components/ConfirmOverlay";
3
+ import { GeneratedDocsOverlay } from "../components/GeneratedDocsOverlay";
4
+
5
+ async function doGenerate(subject: string, ctx: Parameters<Command["execute"]>[1]): Promise<void> {
6
+ const { model, isRewriteModel } = ctx.ei.checkGenerationModel();
7
+
8
+ if (!isRewriteModel) {
9
+ const confirmed = await new Promise<boolean>((resolve) => {
10
+ const msg = [
11
+ `Generating with your default model (${model}).`,
12
+ "A high-capability model (Opus-class) is recommended.",
13
+ "Set one via /settings → rewrite_model, or continue anyway?",
14
+ ].join("\n");
15
+
16
+ ctx.showOverlay((hideOverlay) => (
17
+ <ConfirmOverlay
18
+ message={msg}
19
+ onConfirm={() => { hideOverlay(); resolve(true); }}
20
+ onCancel={() => { hideOverlay(); resolve(false); }}
21
+ />
22
+ ), ctx.renderer);
23
+ });
24
+
25
+ if (!confirmed) {
26
+ ctx.showNotification("Cancelled", "info");
27
+ return;
28
+ }
29
+ }
30
+
31
+ ctx.showNotification(`Generating knowledge document about: ${subject.slice(0, 60)}`, "info");
32
+
33
+ try {
34
+ await ctx.ei.generateDocument(subject);
35
+ } catch (err) {
36
+ const message = err instanceof Error ? err.message : String(err);
37
+ ctx.showNotification(`Generation failed: ${message}`, "error");
38
+ }
39
+ }
40
+
41
+ async function writeDoc(slug: string, ctx: Parameters<Command["execute"]>[1]): Promise<void> {
42
+ const outPath = await ctx.ei.writeGeneratedDocument(slug);
43
+ if (!outPath) {
44
+ ctx.showNotification(`No content found for document "${slug}"`, "error");
45
+ return;
46
+ }
47
+ ctx.showNotification(`Written to ${outPath}`, "info");
48
+ }
49
+
50
+ export const generateCommand: Command = {
51
+ name: "generate",
52
+ aliases: [],
53
+ description: "Generate a knowledge document | /generate <subject> to create, /generate to manage",
54
+ usage: "/generate [subject description]",
55
+
56
+ async execute(args, ctx) {
57
+ if (args.length === 0) {
58
+ const human = await ctx.ei.getHuman();
59
+ const docs = human.settings?.document?.processed_documents ?? {};
60
+ const generated = Object.entries(docs)
61
+ .filter(([, r]) => r.type === "generated" && r.subject)
62
+ .sort(([, a], [, b]) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
63
+ .map(([slug, r]) => ({ slug, subject: r.subject!, created_at: r.created_at }));
64
+
65
+ if (generated.length === 0) {
66
+ ctx.showNotification(
67
+ "No generated documents yet. Use /generate <subject description> to create one.",
68
+ "info"
69
+ );
70
+ return;
71
+ }
72
+
73
+ ctx.showOverlay((hideOverlay) => (
74
+ <GeneratedDocsOverlay
75
+ docs={generated}
76
+ onWrite={async (doc) => {
77
+ hideOverlay();
78
+ await writeDoc(doc.slug, ctx);
79
+ }}
80
+ onReRun={async (doc) => {
81
+ hideOverlay();
82
+ ctx.showNotification(`Re-running generation for: ${doc.subject.slice(0, 60)}`, "info");
83
+ try {
84
+ await ctx.ei.reRunDocument(doc.slug);
85
+ } catch (err) {
86
+ const message = err instanceof Error ? err.message : String(err);
87
+ ctx.showNotification(`Re-run failed: ${message}`, "error");
88
+ }
89
+ }}
90
+ onDismiss={hideOverlay}
91
+ />
92
+ ), ctx.renderer);
93
+ return;
94
+ }
95
+
96
+ await doGenerate(args.join(" "), ctx);
97
+ },
98
+ };
@@ -19,15 +19,19 @@ export const unsourceCommand: Command = {
19
19
  return;
20
20
  }
21
21
 
22
- const items = sources.map(f => ({
23
- id: `import:document:${f}`,
24
- display_name: `import:document:${f}`,
25
- aliases: [] as string[],
26
- is_paused: false,
27
- is_archived: false,
28
- unread_count: 0,
29
- has_pending_update: false,
30
- }));
22
+ const items = sources.map(f => {
23
+ const prefix = docs[f]?.type === "generated" ? "generate:document:" : "import:document:";
24
+ const tag = `${prefix}${f}`;
25
+ return {
26
+ id: tag,
27
+ display_name: tag,
28
+ aliases: [] as string[],
29
+ is_paused: false,
30
+ is_archived: false,
31
+ unread_count: 0,
32
+ has_pending_update: false,
33
+ };
34
+ });
31
35
 
32
36
  ctx.showOverlay((hideOverlay) => (
33
37
  <PersonaListOverlay
@@ -50,7 +54,10 @@ export const unsourceCommand: Command = {
50
54
  if (!rawArg.includes(":")) {
51
55
  const human = await ctx.ei.getHuman();
52
56
  const docs = human.settings?.document?.processed_documents ?? {};
53
- const allSources = Object.keys(docs).map(f => `import:document:${f}`);
57
+ const allSources = Object.keys(docs).map(f => {
58
+ const prefix = docs[f]?.type === "generated" ? "generate:document:" : "import:document:";
59
+ return `${prefix}${f}`;
60
+ });
54
61
  const matches = allSources.filter(s => s.endsWith(rawArg) || s.includes(rawArg));
55
62
  if (matches.length === 1) {
56
63
  sourceTag = matches[0];
@@ -0,0 +1,136 @@
1
+ import { useKeyboard } from "@opentui/solid";
2
+ import { For, createSignal, createMemo, onMount, onCleanup } from "solid-js";
3
+ import type { KeyEvent } from "@opentui/core";
4
+ import { useKeyboardNav } from "../context/keyboard.js";
5
+
6
+ export interface GeneratedDocItem {
7
+ slug: string;
8
+ subject: string;
9
+ created_at: string;
10
+ }
11
+
12
+ interface GeneratedDocsOverlayProps {
13
+ docs: GeneratedDocItem[];
14
+ onWrite: (doc: GeneratedDocItem) => void;
15
+ onReRun: (doc: GeneratedDocItem) => void;
16
+ onDismiss: () => void;
17
+ }
18
+
19
+ export function GeneratedDocsOverlay(props: GeneratedDocsOverlayProps) {
20
+ const { setOverlayActive } = useKeyboardNav();
21
+ onMount(() => setOverlayActive(true));
22
+ onCleanup(() => setOverlayActive(false));
23
+
24
+ const [selectedIndex, setSelectedIndex] = createSignal(0);
25
+
26
+ const clampedIndex = createMemo(() =>
27
+ Math.min(selectedIndex(), Math.max(0, props.docs.length - 1))
28
+ );
29
+
30
+ useKeyboard((event: KeyEvent) => {
31
+ const key = event.name;
32
+ const listLength = props.docs.length;
33
+
34
+ if (key === "j" || key === "down") {
35
+ event.preventDefault();
36
+ setSelectedIndex((prev) => Math.min(prev + 1, listLength - 1));
37
+ return;
38
+ }
39
+
40
+ if (key === "k" || key === "up") {
41
+ event.preventDefault();
42
+ setSelectedIndex((prev) => Math.max(prev - 1, 0));
43
+ return;
44
+ }
45
+
46
+ if (key === "return" || key === "w") {
47
+ event.preventDefault();
48
+ if (listLength > 0) props.onWrite(props.docs[clampedIndex()]);
49
+ return;
50
+ }
51
+
52
+ if (key === "r") {
53
+ event.preventDefault();
54
+ if (listLength > 0) props.onReRun(props.docs[clampedIndex()]);
55
+ return;
56
+ }
57
+
58
+ if (key === "escape") {
59
+ event.preventDefault();
60
+ props.onDismiss();
61
+ return;
62
+ }
63
+ });
64
+
65
+ const formatDate = (iso: string) => {
66
+ try {
67
+ return new Date(iso).toLocaleDateString(undefined, {
68
+ year: "numeric",
69
+ month: "short",
70
+ day: "numeric",
71
+ });
72
+ } catch {
73
+ return iso.slice(0, 10);
74
+ }
75
+ };
76
+
77
+ const truncate = (s: string, max: number) =>
78
+ s.length > max ? s.slice(0, max - 3) + "..." : s;
79
+
80
+ return (
81
+ <box
82
+ position="absolute"
83
+ width="100%"
84
+ height="100%"
85
+ left={0}
86
+ top={0}
87
+ backgroundColor="#000000"
88
+ alignItems="center"
89
+ justifyContent="center"
90
+ >
91
+ <box
92
+ width={72}
93
+ height="80%"
94
+ backgroundColor="#1a1a2e"
95
+ borderStyle="single"
96
+ borderColor="#586e75"
97
+ padding={2}
98
+ flexDirection="column"
99
+ >
100
+ <text fg="#eee8d5" marginBottom={1}>
101
+ Generated Documents
102
+ </text>
103
+
104
+ <scrollbox height="100%">
105
+ <For each={props.docs}>
106
+ {(doc, index) => {
107
+ const isSelected = () => clampedIndex() === index();
108
+ const label = () =>
109
+ ` ${truncate(doc.subject, 48)} ${formatDate(doc.created_at)}`;
110
+
111
+ return (
112
+ <box
113
+ backgroundColor={isSelected() ? "#2d3748" : "transparent"}
114
+ paddingLeft={1}
115
+ paddingRight={1}
116
+ >
117
+ <text fg={isSelected() ? "#eee8d5" : "#839496"}>
118
+ {label()}
119
+ </text>
120
+ </box>
121
+ );
122
+ }}
123
+ </For>
124
+ </scrollbox>
125
+
126
+ <text> </text>
127
+ <text fg="#586e75">
128
+ j/k: navigate | Enter/w: write file | r: re-run | Esc: cancel
129
+ </text>
130
+ <text fg="#dc322f">
131
+ ⚠ Re-running replaces the existing file — write first to keep it
132
+ </text>
133
+ </box>
134
+ </box>
135
+ );
136
+ }
@@ -32,6 +32,7 @@ import { silenceCommand } from "../commands/silence.js";
32
32
  import { captureCommand } from "../commands/capture.js";
33
33
  import { importCommand } from "../commands/import.js";
34
34
  import { unsourceCommand } from "../commands/unsource.js";
35
+ import { generateCommand } from "../commands/generate.js";
35
36
  import { openCYPEditor } from "../util/cyp-editor.js";
36
37
  import { useOverlay } from "../context/overlay";
37
38
  import { CommandSuggest } from "./CommandSuggest";
@@ -90,6 +91,7 @@ export function PromptInput() {
90
91
  registerCommand(captureCommand);
91
92
  registerCommand(importCommand);
92
93
  registerCommand(unsourceCommand);
94
+ registerCommand(generateCommand);
93
95
  registerCommand(authCommand);
94
96
  registerCommand(pauseCommand);
95
97
  registerCommand(resumeCommand);