ei-tui 1.0.1 → 1.2.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.
- package/README.md +3 -1
- package/package.json +2 -21
- package/src/cli/README.md +42 -14
- package/src/cli/mcp.ts +237 -0
- package/src/cli.ts +17 -51
- package/src/core/handlers/dedup.ts +4 -15
- package/src/core/handlers/document-segmentation.ts +2 -3
- package/src/core/handlers/heartbeat.ts +5 -10
- package/src/core/handlers/human-extraction.ts +6 -0
- package/src/core/handlers/human-matching.ts +53 -10
- package/src/core/handlers/index.ts +2 -0
- package/src/core/handlers/knowledge-synthesis.ts +50 -0
- package/src/core/handlers/persona-generation.ts +4 -8
- package/src/core/handlers/persona-response.ts +3 -4
- package/src/core/handlers/persona-topics.ts +2 -4
- package/src/core/handlers/rewrite.ts +26 -9
- package/src/core/handlers/rooms.ts +6 -12
- package/src/core/llm-client.ts +53 -7
- package/src/core/message-manager.ts +2 -4
- package/src/core/orchestrators/ceremony.ts +44 -13
- package/src/core/orchestrators/human-extraction.ts +38 -1
- package/src/core/orchestrators/index.ts +1 -0
- package/src/core/processor.ts +192 -41
- package/src/core/prompt-context-builder.ts +1 -0
- package/src/core/queue-manager.ts +10 -0
- package/src/core/queue-processor.ts +13 -4
- package/src/core/state-manager.ts +35 -0
- package/src/core/tools/builtin/fetch-memory.ts +92 -0
- package/src/core/tools/builtin/fetch-message.ts +123 -0
- package/src/core/tools/builtin/find-memory.ts +99 -0
- package/src/core/tools/index.ts +88 -5
- package/src/core/tools/types.ts +1 -1
- package/src/core/types/data-items.ts +1 -1
- package/src/core/types/entities.ts +7 -1
- package/src/core/types/enums.ts +1 -0
- package/src/core/types/integrations.ts +3 -1
- package/src/integrations/claude-code/importer.ts +6 -0
- package/src/integrations/cursor/importer.ts +6 -0
- package/src/integrations/document/unsource.ts +5 -3
- package/src/integrations/opencode/importer.ts +13 -1
- package/src/integrations/persona-history/importer.ts +12 -1
- package/src/prompts/ceremony/dedup.ts +3 -3
- package/src/prompts/ceremony/people-rewrite.ts +2 -2
- package/src/prompts/ceremony/topic-rewrite.ts +2 -2
- package/src/prompts/ceremony/types.ts +1 -1
- package/src/prompts/human/person-scan.ts +17 -0
- package/src/prompts/human/types.ts +4 -0
- package/src/prompts/index.ts +3 -0
- package/src/prompts/response/sections.ts +14 -7
- package/src/prompts/response/types.ts +1 -0
- package/src/prompts/synthesis/index.ts +101 -0
- package/src/prompts/synthesis/types.ts +26 -0
- package/tui/src/commands/generate.tsx +98 -0
- package/tui/src/commands/unsource.tsx +17 -10
- package/tui/src/components/GeneratedDocsOverlay.tsx +136 -0
- package/tui/src/components/PromptInput.tsx +2 -0
- package/tui/src/context/ei.tsx +49 -2
- package/tui/src/util/logger.ts +22 -2
- package/tui/src/util/provider-detection.ts +5 -2
- package/tui/src/util/yaml-provider.ts +2 -8
- package/src/core/tools/builtin/read-memory.ts +0 -70
|
@@ -20,7 +20,7 @@ export function buildDedupPrompt(data: DedupPromptData): { system: string; user:
|
|
|
20
20
|
You are working with Opus 4.6 constraints. These rules prevent overthinking and ensure decisive action:
|
|
21
21
|
|
|
22
22
|
### 1. TOOL BUDGET
|
|
23
|
-
- You have **6 \`
|
|
23
|
+
- You have **6 \`find_memory\` calls** for this cluster
|
|
24
24
|
- Prioritize: verify ambiguous relationships > check parent concepts > validate new entities
|
|
25
25
|
- After 6 calls, make decisions with available information
|
|
26
26
|
- Do NOT waste calls re-checking pairs you already examined
|
|
@@ -55,7 +55,7 @@ You are acting as the curator for a user's internal database. You have been give
|
|
|
55
55
|
|
|
56
56
|
Your secondary directive is to ORGANIZE IT into small, non-repetitive components. The user NEEDS the data, but the data is used by AI agents, so duplication limits usefulness—agents waste tokens re-reading the same information under different names.
|
|
57
57
|
|
|
58
|
-
You have access to a tool called \`
|
|
58
|
+
You have access to a tool called \`find_memory\` (6 calls max — see HARD RULES above). Use it strategically to verify relationships, check for related records, or gather context before making merge decisions.
|
|
59
59
|
|
|
60
60
|
### Decision Process:
|
|
61
61
|
1. **Identify true duplicates**: Examine each record. Are these genuinely the same thing with different wording (85%+ core meaning overlap), or are they distinct but related concepts?
|
|
@@ -94,7 +94,7 @@ ${buildRecordFormatExamples(data.itemType)}
|
|
|
94
94
|
- Every removed record MUST have "replaced_by" pointing to the canonical record that absorbed its data.
|
|
95
95
|
- The "update" array should contain AT LEAST ONE record (the canonical/merged one), even if all others are removed.
|
|
96
96
|
- If records are NOT duplicates (just similar), return them ALL in "update" unchanged, with empty "remove" and "add" arrays.
|
|
97
|
-
- Use \`
|
|
97
|
+
- Use \`find_memory\` strategically (6 calls max) to check for related records or gather context before making irreversible merge decisions.`;
|
|
98
98
|
|
|
99
99
|
const payload = JSON.stringify({
|
|
100
100
|
cluster: data.cluster.map(stripEmbedding),
|
|
@@ -45,7 +45,7 @@ Rules:
|
|
|
45
45
|
- Be specific: "React performance patterns" beats "technical stuff"
|
|
46
46
|
- If the record is clean — everything in it passes the test — return an empty array
|
|
47
47
|
|
|
48
|
-
Return a raw JSON array of strings. No markdown fencing, no commentary.
|
|
48
|
+
Return a raw JSON array of strings. No markdown fencing, no commentary. Thinking text WILL break the parser.
|
|
49
49
|
|
|
50
50
|
Example — a Person named "Nicholas" whose description includes sprint ticket numbers:
|
|
51
51
|
["CMIDP sprint ticket assignments", "ASU Data Lake access provisioning details"]`;
|
|
@@ -60,7 +60,7 @@ Example — a Person named "Nicholas" whose description includes sprint ticket n
|
|
|
60
60
|
|
|
61
61
|
---
|
|
62
62
|
|
|
63
|
-
Return a raw JSON array of subject phrases found in this Person record that don't belong there. Return [] if the record is clean.`;
|
|
63
|
+
Return a raw JSON array of subject phrases found in this Person record that don't belong there. Return [] if the record is clean. Thinking text WILL break the parser.`;
|
|
64
64
|
|
|
65
65
|
return { system, user };
|
|
66
66
|
}
|
|
@@ -35,7 +35,7 @@ Rules:
|
|
|
35
35
|
- Be specific: "TypeScript coding conventions" beats "technical preferences"
|
|
36
36
|
- If the record is cohesive and on-topic despite its length, return an empty array
|
|
37
37
|
${technicalGuidance}
|
|
38
|
-
Return a raw JSON array of strings. No markdown fencing, no commentary.
|
|
38
|
+
Return a raw JSON array of strings. No markdown fencing, no commentary. Thinking text WILL break the parser.
|
|
39
39
|
|
|
40
40
|
Example — a Topic named "Software Engineering" whose description also discusses vim keybindings, git conventions, and AI tooling:
|
|
41
41
|
["vim keybindings and editor configuration", "git and GitHub workflow conventions", "AI coding assistant preferences"]`;
|
|
@@ -51,7 +51,7 @@ Example — a Topic named "Software Engineering" whose description also discusse
|
|
|
51
51
|
]
|
|
52
52
|
\`\`\`
|
|
53
53
|
|
|
54
|
-
Respond with raw JSON array only.`;
|
|
54
|
+
Respond with raw JSON array only. Thinking text WILL break the parser.`;
|
|
55
55
|
|
|
56
56
|
const user = `${payload}
|
|
57
57
|
|
|
@@ -15,7 +15,7 @@ export interface RewriteScanPromptData {
|
|
|
15
15
|
/** Phase 1 output: array of subject strings (parsed from LLM JSON response). */
|
|
16
16
|
export type RewriteScanResult = string[];
|
|
17
17
|
|
|
18
|
-
/** A single subject and the
|
|
18
|
+
/** A single subject and the find_memory matches found for it. */
|
|
19
19
|
export interface RewriteSubjectMatch {
|
|
20
20
|
searchTerm: string;
|
|
21
21
|
matches: DataItemBase[]; // Top 3 from searchHumanData, may be empty
|
|
@@ -93,6 +93,19 @@ If you are unsure of the type, use \`Nickname\` as a fallback. Do NOT invent typ
|
|
|
93
93
|
|
|
94
94
|
Only include \`identifiers\` when explicitly mentioned in the conversation — omit it entirely if nothing qualifies.
|
|
95
95
|
|
|
96
|
+
## Confidence & Relationship Type
|
|
97
|
+
|
|
98
|
+
For each person, rate how important they are to the human user's life:
|
|
99
|
+
|
|
100
|
+
- \`confidence\`: integer 1–5
|
|
101
|
+
- 1–2 = mentioned in passing, single event, no ongoing relevance
|
|
102
|
+
- 3 = unclear significance — may matter, may not
|
|
103
|
+
- 4–5 = clearly important, recurring presence, meaningful relationship
|
|
104
|
+
- \`relationship_type\`: one of \`"family"\` | \`"friend"\` | \`"colleague"\` | \`"acquaintance"\` | \`"transactional"\` | \`"unknown"\`
|
|
105
|
+
- Use \`"transactional"\` when the person appeared only in the context of a single transaction (purchase, sale, support ticket, delivery)
|
|
106
|
+
|
|
107
|
+
Use the full range. Most extractions should score 1–3. A confidence of 4–5 means this person genuinely matters to the user's life.
|
|
108
|
+
|
|
96
109
|
## Output Format
|
|
97
110
|
|
|
98
111
|
\`\`\`json
|
|
@@ -105,6 +118,8 @@ Only include \`identifiers\` when explicitly mentioned in the conversation — o
|
|
|
105
118
|
],
|
|
106
119
|
"description": "1-2 sentences: who this person is and their role in the user's life",
|
|
107
120
|
"relationship": "Father|Mother|Brother|Son|Friend|Coworker|Self|etc.",
|
|
121
|
+
"relationship_type": "family|friend|colleague|acquaintance|transactional|unknown",
|
|
122
|
+
"confidence": 4,
|
|
108
123
|
"reason": "Evidence from the conversation that justified flagging this person"
|
|
109
124
|
}
|
|
110
125
|
]
|
|
@@ -143,6 +158,8 @@ Scan the "Most Recent Messages" for PEOPLE in the human user's life.
|
|
|
143
158
|
"identifiers": [{ "type": "GitHub", "value": "handle" }],
|
|
144
159
|
"description": "1-2 sentences: who this person is and their role in the user's life",
|
|
145
160
|
"relationship": "Father|Mother|Brother|Son|Friend|Coworker|Self|etc.",
|
|
161
|
+
"relationship_type": "family|friend|colleague|acquaintance|transactional|unknown",
|
|
162
|
+
"confidence": 4,
|
|
146
163
|
"reason": "Evidence from the conversation that justified flagging this person"
|
|
147
164
|
}
|
|
148
165
|
]
|
|
@@ -55,11 +55,15 @@ export interface TopicScanCandidate {
|
|
|
55
55
|
reason: string;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
export type PersonRelationshipType = "family" | "friend" | "colleague" | "acquaintance" | "transactional" | "unknown";
|
|
59
|
+
|
|
58
60
|
export interface PersonScanCandidate {
|
|
59
61
|
name: string;
|
|
60
62
|
identifiers?: Array<{ type: string; value: string; is_primary?: boolean }>;
|
|
61
63
|
description: string;
|
|
62
64
|
relationship: string;
|
|
65
|
+
relationship_type?: PersonRelationshipType;
|
|
66
|
+
confidence?: number;
|
|
63
67
|
reason: string;
|
|
64
68
|
}
|
|
65
69
|
|
package/src/prompts/index.ts
CHANGED
|
@@ -464,21 +464,28 @@ export function buildTemporalAnchorsSection(anchors: TemporalAnchor[], humanName
|
|
|
464
464
|
|
|
465
465
|
const formatted = anchors.map(a => {
|
|
466
466
|
const speaker = a.role === "human" ? humanName : "You";
|
|
467
|
-
let
|
|
468
|
-
if (a._synthesis
|
|
469
|
-
|
|
467
|
+
let preview: string;
|
|
468
|
+
if (a._synthesis) {
|
|
469
|
+
const raw = a.content ?? "";
|
|
470
|
+
const firstSentenceEnd = raw.search(/\.\s/);
|
|
471
|
+
const snippet = firstSentenceEnd > 0 && firstSentenceEnd <= 120
|
|
472
|
+
? raw.slice(0, firstSentenceEnd + 1)
|
|
473
|
+
: raw.slice(0, 100);
|
|
474
|
+
preview = `[${humanName} generated an image: "${snippet}…"]`;
|
|
470
475
|
} else if (a.silence_reason) {
|
|
471
476
|
const silentParty = a.role === "human" ? humanName : "You";
|
|
472
|
-
|
|
477
|
+
const truncated = a.silence_reason.length > 80 ? `${a.silence_reason.slice(0, 80)}…` : a.silence_reason;
|
|
478
|
+
preview = `${silentParty} chose not to respond: "${truncated}"`;
|
|
473
479
|
} else {
|
|
474
|
-
|
|
480
|
+
const raw = a.content ?? "";
|
|
481
|
+
preview = raw.length > 80 ? `${raw.slice(0, 80)}…` : raw;
|
|
475
482
|
}
|
|
476
|
-
return `[${formatTimestamp(a.timestamp)}] ${speaker}: ${
|
|
483
|
+
return `[${formatTimestamp(a.timestamp)}] ${speaker}: ${preview}\n → fetch_message("${a.id}") for full content`;
|
|
477
484
|
}).join("\n\n");
|
|
478
485
|
|
|
479
486
|
return `## Temporal Anchors
|
|
480
487
|
|
|
481
|
-
|
|
488
|
+
Pinned moments from your shared history. These are snapshots — use fetch_message(id) if one feels relevant to pull the full memory:
|
|
482
489
|
|
|
483
490
|
${formatted}`;
|
|
484
491
|
}
|
|
@@ -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,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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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 =>
|
|
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);
|