ei-tui 0.9.4 → 1.0.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 +22 -3
- package/package.json +5 -1
- package/src/README.md +9 -25
- package/src/core/handlers/document-segmentation.ts +113 -0
- package/src/core/handlers/index.ts +2 -0
- package/src/core/handlers/rewrite.ts +13 -9
- package/src/core/heartbeat-manager.ts +2 -2
- package/src/core/llm-client.ts +11 -1
- package/src/core/message-manager.ts +20 -18
- package/src/core/orchestrators/ceremony.ts +83 -40
- package/src/core/orchestrators/human-extraction.ts +5 -1
- package/src/core/persona-manager.ts +4 -0
- package/src/core/processor.ts +90 -1
- package/src/core/queue-manager.ts +35 -0
- package/src/core/state/queue.ts +9 -1
- package/src/core/state-manager.ts +4 -0
- package/src/core/types/entities.ts +15 -0
- package/src/core/types/enums.ts +1 -0
- package/src/core/types/integrations.ts +2 -0
- package/src/core/types/llm.ts +9 -0
- package/src/integrations/document/chunker.ts +88 -0
- package/src/integrations/document/importer.ts +82 -0
- package/src/integrations/document/index.ts +2 -0
- package/src/integrations/document/invoice.ts +63 -0
- package/src/integrations/document/types.ts +16 -0
- package/src/integrations/document/unsource.ts +164 -0
- package/src/integrations/persona-history/importer.ts +197 -0
- package/src/integrations/persona-history/index.ts +3 -0
- package/src/integrations/persona-history/types.ts +7 -0
- package/src/prompts/ceremony/dedup.ts +7 -3
- package/src/prompts/ceremony/index.ts +2 -1
- package/src/prompts/ceremony/people-rewrite.ts +190 -0
- package/src/prompts/ceremony/{rewrite.ts → topic-rewrite.ts} +103 -78
- package/src/prompts/human/person-scan.ts +13 -4
- package/src/prompts/human/topic-scan.ts +16 -2
- package/src/prompts/human/topic-update.ts +36 -4
- package/src/prompts/human/types.ts +1 -0
- package/src/storage/indexed.ts +4 -0
- package/src/storage/interface.ts +1 -0
- package/src/storage/local.ts +4 -0
- package/src/templates/emmett.ts +49 -0
- package/tui/README.md +22 -0
- package/tui/src/app.tsx +9 -6
- package/tui/src/commands/delete.tsx +7 -1
- package/tui/src/commands/import.tsx +30 -0
- package/tui/src/commands/unsource.tsx +115 -0
- package/tui/src/components/PromptInput.tsx +4 -0
- package/tui/src/components/WelcomeOverlay.tsx +58 -32
- package/tui/src/context/ei.tsx +80 -60
- package/tui/src/index.tsx +14 -0
- package/tui/src/storage/file.ts +11 -5
- package/tui/src/util/e2e-flags.ts +4 -3
- package/tui/src/util/help-content.ts +20 -0
- package/tui/src/util/provider-detection.ts +251 -0
- package/tui/src/util/yaml-human.ts +7 -1
package/README.md
CHANGED
|
@@ -83,7 +83,7 @@ Ei can operate with three types of input, and three types of output.
|
|
|
83
83
|
^
|
|
84
84
|
Sessions
|
|
85
85
|
|
|
|
86
|
-
|
|
86
|
+
[OpenCode / Claude Code / Cursor]
|
|
87
87
|
```
|
|
88
88
|
|
|
89
89
|
```
|
|
@@ -169,6 +169,22 @@ All sessions map to a single "Cursor" persona.
|
|
|
169
169
|
|
|
170
170
|
Sessions are processed oldest-first, one per queue cycle, so Ei won't overwhelm your LLM provider on first run. See [TUI Readme](tui/README.md)
|
|
171
171
|
|
|
172
|
+
## Document Import
|
|
173
|
+
|
|
174
|
+
Got notes, journals, markdown files? You can feed them directly to Ei.
|
|
175
|
+
|
|
176
|
+
**Web**: Open **☰ menu** → **My Data** → **Documents** tab. Drop a `.txt`, `.md`, or `.markdown` file and Ei gets to work.
|
|
177
|
+
|
|
178
|
+
**TUI**:
|
|
179
|
+
```bash
|
|
180
|
+
/import ~/notes/my-journal.md
|
|
181
|
+
/import /path/to/report.pdf
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Ei splits the document into segments, runs them through the extraction pipeline, and pulls out facts, topics, people, and quotes — exactly like it does with your conversations. The extracted knowledge is attributed to a reserved persona called **Emmett** so it doesn't pollute your chat history.
|
|
185
|
+
|
|
186
|
+
Both surfaces show you which documents have been imported and let you remove their extracted knowledge (web: Delete button in the Documents tab; TUI: `/unsource <source_tag>`).
|
|
187
|
+
|
|
172
188
|
## Built-in Tool Integrations
|
|
173
189
|
|
|
174
190
|
Personas can use tools. Not just read-from-memory tools — *actual* tools. Web search. Your music. Your filesystem. Here's what ships with Ei out of the box:
|
|
@@ -184,6 +200,7 @@ Personas can use tools. Not just read-from-memory tools — *actual* tools. Web
|
|
|
184
200
|
| `search_files` | Find files by name pattern *(TUI only)* |
|
|
185
201
|
| `grep` | Search file contents by regex *(TUI only)* |
|
|
186
202
|
| `get_file_info` | File/directory metadata *(TUI only)* |
|
|
203
|
+
| `web_fetch` | Fetch a URL and return its text content *(TUI only — blocked by CORS in browsers)* |
|
|
187
204
|
|
|
188
205
|
The filesystem tools make Ei a legitimate coding assistant in the TUI. Ask a persona to review a file, understand a project structure, or track down where something is defined — it can actually look.
|
|
189
206
|
|
|
@@ -264,13 +281,15 @@ Tag a version to publish automatically:
|
|
|
264
281
|
|
|
265
282
|
```bash
|
|
266
283
|
# bump version in package.json
|
|
267
|
-
git commit -am "chore: bump to
|
|
268
|
-
git tag
|
|
284
|
+
git commit -am "chore: bump to v1.0.0"
|
|
285
|
+
git tag v1.0.0
|
|
269
286
|
git push && git push --tags
|
|
270
287
|
```
|
|
271
288
|
|
|
272
289
|
GitHub Actions picks up the tag and publishes to npm with provenance via OIDC. No stored secrets.
|
|
273
290
|
|
|
291
|
+
> **Note**: Run the pre-flight checklist in `AGENTS.md` (or use the `release` skill in OpenCode) before tagging. The v0.1.9 incident is a cautionary tale.
|
|
292
|
+
|
|
274
293
|
## Project Structure
|
|
275
294
|
|
|
276
295
|
See `AGENTS.md` for detailed architecture and contribution guidelines.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ei-tui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"author": "Flare576",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -57,6 +57,10 @@
|
|
|
57
57
|
"test:evals:topic-scan": "vite-node tests/evals/topic-scan.eval.ts",
|
|
58
58
|
"test:evals:topic-match": "vite-node tests/evals/topic-match.eval.ts",
|
|
59
59
|
"test:evals:topic-update": "vite-node tests/evals/topic-update.eval.ts",
|
|
60
|
+
"test:evals:topic-technical": "vite-node tests/evals/topic-technical.eval.ts",
|
|
61
|
+
"test:evals:rewrite-scan": "vite-node tests/evals/rewrite-scan.eval.ts",
|
|
62
|
+
"test:evals:rewrite-rewrite": "vite-node tests/evals/rewrite-rewrite.eval.ts",
|
|
63
|
+
"test:evals:rewrite-real-data": "vite-node tests/evals/rewrite-real-data.eval.ts",
|
|
60
64
|
"test:evals:topic-validate": "vite-node tests/evals/topic-validate.eval.ts",
|
|
61
65
|
"test:evals:person-scan": "vite-node tests/evals/person-scan.eval.ts",
|
|
62
66
|
"test:evals:person-update": "vite-node tests/evals/person-update.eval.ts",
|
package/src/README.md
CHANGED
|
@@ -57,39 +57,23 @@ Each Topic will have an "exposure" rating similar to those on Human Data points.
|
|
|
57
57
|
|
|
58
58
|
# Ceremony Intent
|
|
59
59
|
|
|
60
|
-
Every 24 hours,
|
|
60
|
+
Every 24 hours, the system runs a ceremony to keep knowledge fresh and healthy. Phases run sequentially via `ceremony_progress`:
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
**Phase 1 → Dedup**: User-triggered only (not automated). Merges confirmed duplicate records.
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
**Phase 2 → Expose**: Human extraction catch-up (facts, topics, people) + persona topic rating for any messages that didn't hit the per-send threshold during the day.
|
|
65
65
|
|
|
66
|
-
|
|
66
|
+
**Phase 3 → EventSummary**: Summarizes significant events from recent conversations.
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
**Decay** (synchronous after Phase 3): Applies exposure decay to persona topics + prunes old messages. Human ceremony (decay for human topics/people) runs here too.
|
|
69
69
|
|
|
70
|
-
|
|
70
|
+
**Phase 4 → Person Rewrite**: Scans bloated Person records (>750 chars) and extracts non-relationship content into Topics. Gated so Topic Rewrite can snapshot the updated Topic list afterward.
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
**Topic Rewrite** (fire-and-forget after Phase 4): Scans bloated Topic records (>750 chars) and splits them into focused sub-topics. Topics created by Person Rewrite are included.
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
**Reflection** (fire-and-forget alongside Phase 4): Persona-side critic pass on person records.
|
|
75
75
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
## Decay
|
|
79
|
-
|
|
80
|
-
After we determine if topics were discussed (increasing exposure), we adjust exposure the _other_ way. Based on some heuristics (like current level, desired level, and time-since-discussion), we decrease the current exposure levels down.
|
|
81
|
-
|
|
82
|
-
## Expire
|
|
83
|
-
|
|
84
|
-
This and the following step (Explore) are exclusive to Persona Topics right now. In Expire, we analyze the Person Topics to determine if any of them have
|
|
85
|
-
- Lost their meaning to the Persona
|
|
86
|
-
- Been ignored or dismissed by the user
|
|
87
|
-
|
|
88
|
-
This is largely tracked by exposure, but expiration is dictated by an Agent.
|
|
89
|
-
|
|
90
|
-
## Explore
|
|
91
|
-
|
|
92
|
-
After we've removed irrelevant topics, this is the Agent's opportunity to add NEW topics that might be of interest to the Persona (and the user). Again, it's a prompt to an agent if the Persona doesn't have its full capacity of Topics.
|
|
76
|
+
> **Note**: Expire and Explore phases were removed in the Persona Ceremony Simplification (2026-04-05). Persona topics now only update `exposure_current` during ceremony. See CONTRACTS.md changelog for details.
|
|
93
77
|
|
|
94
78
|
# Opencode Importer
|
|
95
79
|
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { ContextStatus } from "../types.js";
|
|
2
|
+
import type { LLMResponse, Message } from "../types.js";
|
|
3
|
+
import type { StateManager } from "../state-manager.js";
|
|
4
|
+
import {
|
|
5
|
+
queueAllScans,
|
|
6
|
+
type ExtractionContext,
|
|
7
|
+
} from "../orchestrators/human-extraction.js";
|
|
8
|
+
|
|
9
|
+
function parseSegmentArray(content: string): string[] | null {
|
|
10
|
+
const jsonMatch = content.match(/```json\s*([\s\S]*?)```/) ?? content.match(/```\s*([\s\S]*?)```/);
|
|
11
|
+
const jsonText = jsonMatch ? jsonMatch[1].trim() : content.trim();
|
|
12
|
+
|
|
13
|
+
const arrayMatch = jsonText.match(/\[[\s\S]*\]/);
|
|
14
|
+
if (!arrayMatch) return null;
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const parsed = JSON.parse(arrayMatch[0]);
|
|
18
|
+
if (!Array.isArray(parsed)) return null;
|
|
19
|
+
return parsed.filter((item): item is string => typeof item === "string" && item.trim().length > 0);
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function handleDocumentSegmentation(response: LLMResponse, state: StateManager): void {
|
|
26
|
+
const { batchId, filename, originalContent } = response.request.data as {
|
|
27
|
+
batchId: string;
|
|
28
|
+
filename: string;
|
|
29
|
+
originalContent: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
if (!batchId || !filename) {
|
|
33
|
+
console.error("[handleDocumentSegmentation] Missing batchId or filename in request data");
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let segments: string[];
|
|
38
|
+
if (response.content) {
|
|
39
|
+
const parsed = parseSegmentArray(response.content);
|
|
40
|
+
segments = (parsed && parsed.length > 0) ? parsed : [originalContent];
|
|
41
|
+
} else {
|
|
42
|
+
segments = [originalContent];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const emmett = state.persona_getById("emmet");
|
|
46
|
+
if (!emmett) {
|
|
47
|
+
console.warn("[handleDocumentSegmentation] Emmett persona not found — skipping segment write");
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const now = new Date().toISOString();
|
|
52
|
+
const sourceTag = `import:document:${filename}`;
|
|
53
|
+
|
|
54
|
+
for (const segment of segments) {
|
|
55
|
+
const message: Message = {
|
|
56
|
+
id: crypto.randomUUID(),
|
|
57
|
+
role: "system",
|
|
58
|
+
content: segment,
|
|
59
|
+
timestamp: now,
|
|
60
|
+
read: true,
|
|
61
|
+
context_status: ContextStatus.Always,
|
|
62
|
+
external: true,
|
|
63
|
+
source_tag: sourceTag,
|
|
64
|
+
};
|
|
65
|
+
state.messages_append("emmet", message);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
console.log(`[handleDocumentSegmentation] Wrote ${segments.length} segment(s) for batch ${batchId} (${filename})`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function finishDocumentBatch(batchId: string, filename: string, state: StateManager): void {
|
|
72
|
+
const sourceTag = `import:document:${filename}`;
|
|
73
|
+
|
|
74
|
+
const emmettMessages = state.messages_get("emmet");
|
|
75
|
+
const docMessages = emmettMessages.filter(m => m.external === true && m.source_tag === sourceTag);
|
|
76
|
+
|
|
77
|
+
if (docMessages.length === 0) {
|
|
78
|
+
console.warn(`[finishDocumentBatch] No messages found for ${sourceTag} — skipping extraction`);
|
|
79
|
+
} else {
|
|
80
|
+
const extractionContext: ExtractionContext = {
|
|
81
|
+
personaId: "emmet",
|
|
82
|
+
channelDisplayName: "Document",
|
|
83
|
+
messages_context: [],
|
|
84
|
+
messages_analyze: docMessages,
|
|
85
|
+
sources: [sourceTag],
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const docSettings = state.getHuman().settings?.document;
|
|
89
|
+
queueAllScans(extractionContext, state, {
|
|
90
|
+
extraction_model: docSettings?.extraction_model,
|
|
91
|
+
external_filter: "only",
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
console.log(`[finishDocumentBatch] Queued extraction for ${docMessages.length} message(s) from ${filename}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const updatedHuman = state.getHuman();
|
|
98
|
+
state.setHuman({
|
|
99
|
+
...updatedHuman,
|
|
100
|
+
settings: {
|
|
101
|
+
...updatedHuman.settings,
|
|
102
|
+
document: {
|
|
103
|
+
...updatedHuman.settings?.document,
|
|
104
|
+
processed_documents: {
|
|
105
|
+
...(updatedHuman.settings?.document?.processed_documents ?? {}),
|
|
106
|
+
[filename]: new Date().toISOString(),
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
console.log(`[finishDocumentBatch] Batch ${batchId} complete, ${filename} marked processed`);
|
|
113
|
+
}
|
|
@@ -15,6 +15,7 @@ import { handleRewriteScan, handleRewriteRewrite } from "./rewrite.js";
|
|
|
15
15
|
import { handleDedupCurate } from "./dedup.js";
|
|
16
16
|
import { handleRoomResponse, handleRoomJudge } from "./rooms.js";
|
|
17
17
|
import { handlePersonaPreview } from "./persona-preview.js";
|
|
18
|
+
import { handleDocumentSegmentation } from "./document-segmentation.js";
|
|
18
19
|
|
|
19
20
|
export const handlers: Record<LLMNextStep, ResponseHandler> = {
|
|
20
21
|
handlePersonaResponse,
|
|
@@ -41,4 +42,5 @@ export const handlers: Record<LLMNextStep, ResponseHandler> = {
|
|
|
41
42
|
handlePersonaPreview,
|
|
42
43
|
[LLMNextStep.HandleTopicValidate]: handleDedupCurate,
|
|
43
44
|
[LLMNextStep.HandleReflectionCritic]: handleReflectionCritic,
|
|
45
|
+
[LLMNextStep.HandleDocumentSegmentation]: handleDocumentSegmentation,
|
|
44
46
|
};
|
|
@@ -14,7 +14,8 @@ import type {
|
|
|
14
14
|
RewriteResult,
|
|
15
15
|
RewriteSubjectMatch,
|
|
16
16
|
} from "../../prompts/ceremony/types.js";
|
|
17
|
-
import {
|
|
17
|
+
import { buildPersonRewriteSplitPrompt } from "../../prompts/ceremony/people-rewrite.js";
|
|
18
|
+
import { buildTopicRewriteSplitPrompt } from "../../prompts/ceremony/topic-rewrite.js";
|
|
18
19
|
import { getEmbeddingService, getItemEmbeddingText } from "../embedding-service.js";
|
|
19
20
|
|
|
20
21
|
import { searchHumanData } from "../human-data-manager.js";
|
|
@@ -79,12 +80,10 @@ export async function handleRewriteScan(response: LLMResponse, state: StateManag
|
|
|
79
80
|
}
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
|
|
83
|
-
const prompt =
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
subjects: subjectMatches,
|
|
87
|
-
});
|
|
83
|
+
const splitData = { item: currentItem, itemType, subjects: subjectMatches };
|
|
84
|
+
const prompt = itemType === "person"
|
|
85
|
+
? buildPersonRewriteSplitPrompt(splitData)
|
|
86
|
+
: buildTopicRewriteSplitPrompt(splitData);
|
|
88
87
|
|
|
89
88
|
state.queue_enqueue({
|
|
90
89
|
type: LLMRequestType.JSON,
|
|
@@ -125,6 +124,11 @@ export async function handleRewriteRewrite(response: LLMResponse, state: StateMa
|
|
|
125
124
|
const human = state.getHuman();
|
|
126
125
|
const now = new Date().toISOString();
|
|
127
126
|
|
|
127
|
+
const originalItem = itemType === "topic"
|
|
128
|
+
? human.topics.find(t => t.id === itemId)
|
|
129
|
+
: human.people.find(p => p.id === itemId);
|
|
130
|
+
const originalCategory = itemType === "topic" ? (originalItem as Topic | undefined)?.category : undefined;
|
|
131
|
+
|
|
128
132
|
const allItems: DataItemBase[] = [
|
|
129
133
|
...human.topics, ...human.people,
|
|
130
134
|
];
|
|
@@ -228,11 +232,11 @@ export async function handleRewriteRewrite(response: LLMResponse, state: StateMa
|
|
|
228
232
|
switch (item.type) {
|
|
229
233
|
case "topic": {
|
|
230
234
|
if (!item.category) {
|
|
231
|
-
console.warn(`[handleRewriteRewrite] New topic "${item.name}" missing category —
|
|
235
|
+
console.warn(`[handleRewriteRewrite] New topic "${item.name}" missing category — inheriting from original (${originalCategory ?? "Interest"})`);
|
|
232
236
|
}
|
|
233
237
|
const topic: Topic = {
|
|
234
238
|
...baseFields,
|
|
235
|
-
category: item.category ?? "Interest",
|
|
239
|
+
category: item.category ?? originalCategory ?? "Interest",
|
|
236
240
|
exposure_current: 0.5,
|
|
237
241
|
exposure_desired: 0.5,
|
|
238
242
|
};
|
|
@@ -143,7 +143,7 @@ export async function queueEiHeartbeat(
|
|
|
143
143
|
}
|
|
144
144
|
|
|
145
145
|
const activePersonas = personas
|
|
146
|
-
.filter((p) => !p.is_archived && !p.is_paused && p.id !== "ei")
|
|
146
|
+
.filter((p) => !p.is_archived && !p.is_paused && !p.is_static && p.id !== "ei")
|
|
147
147
|
.map((p) => {
|
|
148
148
|
const msgs = sm.messages_get(p.id);
|
|
149
149
|
const lastHuman = [...msgs].reverse().find((m) => m.role === "human");
|
|
@@ -169,7 +169,7 @@ export async function queueEiHeartbeat(
|
|
|
169
169
|
}
|
|
170
170
|
|
|
171
171
|
const personasWithPendingUpdate = personas.filter(
|
|
172
|
-
(p) => !p.is_archived && !p.is_paused && p.id !== "ei" && p.pending_update?.critique
|
|
172
|
+
(p) => !p.is_archived && !p.is_paused && !p.is_static && p.id !== "ei" && p.pending_update?.critique
|
|
173
173
|
);
|
|
174
174
|
for (const p of personasWithPendingUpdate) {
|
|
175
175
|
items.push({
|
package/src/core/llm-client.ts
CHANGED
|
@@ -274,7 +274,17 @@ export async function callLLMRaw(
|
|
|
274
274
|
};
|
|
275
275
|
|
|
276
276
|
if (modelConfig?.thinking_budget !== undefined) {
|
|
277
|
-
|
|
277
|
+
if (modelConfig.thinking_budget === 0) {
|
|
278
|
+
// Universal kill switch — works on Ollama, LM Studio, and all OpenAI-compat providers.
|
|
279
|
+
requestBody.reasoning_effort = "none";
|
|
280
|
+
} else {
|
|
281
|
+
// Pass both signals: providers that honor the token budget get it (Qwen3 via Ollama,
|
|
282
|
+
// Anthropic), providers that reduce thinking to on/off use reasoning_effort as the
|
|
283
|
+
// on-signal (Gemma4 via Ollama/LM Studio). Non-conflicting — each provider reads
|
|
284
|
+
// whichever field it understands.
|
|
285
|
+
requestBody.reasoning_effort = "high";
|
|
286
|
+
requestBody.think = { budget_tokens: modelConfig.thinking_budget };
|
|
287
|
+
}
|
|
278
288
|
}
|
|
279
289
|
|
|
280
290
|
if (options.tools && options.tools.length > 0) {
|
|
@@ -177,25 +177,27 @@ export async function sendMessage(
|
|
|
177
177
|
|
|
178
178
|
const history = sm.messages_get(persona.id);
|
|
179
179
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
180
|
+
if (!persona.is_static) {
|
|
181
|
+
const traitExtractionData: PersonaTraitExtractionPromptData = {
|
|
182
|
+
persona_name: persona.display_name,
|
|
183
|
+
current_traits: persona.traits,
|
|
184
|
+
messages_context: history.slice(-11, -1),
|
|
185
|
+
messages_analyze: [message],
|
|
186
|
+
};
|
|
187
|
+
const traitPrompt = buildPersonaTraitExtractionPrompt(traitExtractionData);
|
|
188
|
+
|
|
189
|
+
sm.queue_enqueue({
|
|
190
|
+
type: LLMRequestType.JSON,
|
|
191
|
+
priority: LLMPriority.Low,
|
|
192
|
+
system: traitPrompt.system,
|
|
193
|
+
user: traitPrompt.user,
|
|
194
|
+
next_step: LLMNextStep.HandlePersonaTraitExtraction,
|
|
195
|
+
model: getModelForPersona(persona.id),
|
|
196
|
+
data: { personaId: persona.id, personaDisplayName: persona.display_name },
|
|
197
|
+
});
|
|
197
198
|
|
|
198
|
-
|
|
199
|
+
checkAndQueueHumanExtraction(sm, persona.id, persona.display_name, history);
|
|
200
|
+
}
|
|
199
201
|
}
|
|
200
202
|
|
|
201
203
|
// =============================================================================
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { LLMRequestType, LLMPriority, LLMNextStep, RoomMode, ContextStatus, type CeremonyConfig, type PersonaTopic, type Topic
|
|
1
|
+
import { LLMRequestType, LLMPriority, LLMNextStep, RoomMode, ContextStatus, type CeremonyConfig, type PersonaTopic, type Topic } from "../types.js";
|
|
2
2
|
import type { StateManager } from "../state-manager.js";
|
|
3
3
|
import { normalizeRoomMessages } from "../handlers/utils.js";
|
|
4
4
|
import { applyDecayToValue } from "../utils/index.js";
|
|
@@ -12,7 +12,9 @@ import {
|
|
|
12
12
|
} from "./human-extraction.js";
|
|
13
13
|
import { queuePersonaTopicRating, type PersonaTopicContext, type PersonaTopicOptions } from "./persona-topics.js";
|
|
14
14
|
import { getRoomVisibleMessages, queueRoomHumanExtraction } from "./room-extraction.js";
|
|
15
|
-
import {
|
|
15
|
+
import { type RewriteItemType } from "../../prompts/ceremony/index.js";
|
|
16
|
+
import { buildPersonRewriteScanPrompt } from "../../prompts/ceremony/people-rewrite.js";
|
|
17
|
+
import { buildTopicRewriteScanPrompt } from "../../prompts/ceremony/topic-rewrite.js";
|
|
16
18
|
import { buildReflectionCriticPrompt } from "../../prompts/reflection/index.js";
|
|
17
19
|
import { getModelForPersona } from "../heartbeat-manager.js";
|
|
18
20
|
|
|
@@ -51,7 +53,7 @@ export function shouldStartCeremony(config: CeremonyConfig, state: StateManager,
|
|
|
51
53
|
* Start the ceremony by queuing Exposure scans for all active personas with recent activity.
|
|
52
54
|
*
|
|
53
55
|
* IMPORTANT: Sets last_ceremony FIRST to prevent re-triggering from the processor loop.
|
|
54
|
-
* The actual Decay →
|
|
56
|
+
* The actual Decay → Person Rewrite → Topic Rewrite phases happen later via handleCeremonyProgress
|
|
55
57
|
* once all exposure scans have completed.
|
|
56
58
|
*/
|
|
57
59
|
export function startCeremony(state: StateManager): void {
|
|
@@ -167,7 +169,7 @@ function queueExposurePhase(personaId: string, state: StateManager, options?: Ex
|
|
|
167
169
|
* AND at the end of startCeremony (for the zero-messages edge case).
|
|
168
170
|
*
|
|
169
171
|
* If any ceremony_progress items remain in the queue, does nothing — more work pending.
|
|
170
|
-
* Phase 1: Dedup → Phase 2: Expose → Phase 3: EventSummary → Decay →
|
|
172
|
+
* Phase 1: Dedup → Phase 2: Expose → Phase 3: EventSummary → Decay → Phase 4: Person Rewrite → Topic Rewrite (fire-and-forget)
|
|
171
173
|
*/
|
|
172
174
|
export function handleCeremonyProgress(state: StateManager, lastPhase: number): void {
|
|
173
175
|
if (state.queue_hasPendingCeremonies()) {
|
|
@@ -236,6 +238,12 @@ export function handleCeremonyProgress(state: StateManager, lastPhase: number):
|
|
|
236
238
|
return;
|
|
237
239
|
}
|
|
238
240
|
|
|
241
|
+
if (lastPhase === 4) {
|
|
242
|
+
console.log("[ceremony:progress] Person Rewrite complete, starting Topic Rewrite");
|
|
243
|
+
queueTopicRewritePhase(state);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
239
247
|
if (lastPhase === 2) {
|
|
240
248
|
console.log("[ceremony:progress] Expose complete, starting EventSummary phase");
|
|
241
249
|
const options: ExtractionOptions = { ceremony_progress: 3 };
|
|
@@ -249,7 +257,7 @@ export function handleCeremonyProgress(state: StateManager, lastPhase: number):
|
|
|
249
257
|
return;
|
|
250
258
|
}
|
|
251
259
|
|
|
252
|
-
// Phase 3 (EventSummary) complete → advance to Decay/Prune
|
|
260
|
+
// Phase 3 (EventSummary) complete → advance to Decay/Prune then Person Rewrite (phase 4)
|
|
253
261
|
console.log("[ceremony:progress] EventSummary complete, advancing to Decay");
|
|
254
262
|
|
|
255
263
|
const personas = state.persona_getAll();
|
|
@@ -276,8 +284,16 @@ export function handleCeremonyProgress(state: StateManager, lastPhase: number):
|
|
|
276
284
|
// Human ceremony: decay topics + people
|
|
277
285
|
runHumanCeremony(state);
|
|
278
286
|
|
|
279
|
-
// Rewrite phase
|
|
280
|
-
|
|
287
|
+
// Person Rewrite phase (phase 4): scan bloated Person records, extract Topics from them.
|
|
288
|
+
// Gated via ceremony_progress so Topic Rewrite can run after — Topics created here
|
|
289
|
+
// need to be visible before Topic Rewrite snapshots the threshold.
|
|
290
|
+
queuePersonRewritePhase(state);
|
|
291
|
+
|
|
292
|
+
// Zero-work guard: if no person rewrites queued, advance to topic rewrite immediately
|
|
293
|
+
if (!state.queue_hasPendingCeremonies()) {
|
|
294
|
+
console.log("[ceremony:progress] No person rewrite work, advancing to Topic Rewrite");
|
|
295
|
+
handleCeremonyProgress(state, 4);
|
|
296
|
+
}
|
|
281
297
|
|
|
282
298
|
// Reflection phase: fire-and-forget critic calls for persona person records above threshold
|
|
283
299
|
queueReflectionPhase(state);
|
|
@@ -441,15 +457,6 @@ export function runHumanCeremony(state: StateManager): void {
|
|
|
441
457
|
|
|
442
458
|
const REWRITE_DESCRIPTION_THRESHOLD = 750;
|
|
443
459
|
|
|
444
|
-
/**
|
|
445
|
-
* Queue Phase 1 "scan" for every human data item whose description exceeds the
|
|
446
|
-
* threshold. Gated on rewrite_model being set in HumanSettings.
|
|
447
|
-
*
|
|
448
|
-
* Fire-and-forget: no ceremony_progress, no blocking. Expire/Explore proceed
|
|
449
|
-
* immediately since they only touch persona topics (zero overlap with human data).
|
|
450
|
-
* Phase 2 items enqueue at Normal priority, naturally processing before more
|
|
451
|
-
* Low-priority Phase 1 scans.
|
|
452
|
-
*/
|
|
453
460
|
/**
|
|
454
461
|
* Forces an unconditional, threshold-bypassing Person scan on Apply/Dismiss.
|
|
455
462
|
* Cannot be replaced by checkAndQueueHumanExtraction — that function gates on
|
|
@@ -479,41 +486,77 @@ export function queueReflectionDrain(personaId: string, state: StateManager): vo
|
|
|
479
486
|
console.log(`[reflection:drain] Queued Person scan for ${persona.display_name} (${unextractedPeople.length} messages) — clears on completion`);
|
|
480
487
|
}
|
|
481
488
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
489
|
+
function getRewriteModel(state: StateManager): string | undefined {
|
|
490
|
+
return state.getHuman().settings?.rewrite_model;
|
|
491
|
+
}
|
|
485
492
|
|
|
493
|
+
export function queuePersonRewritePhase(state: StateManager): void {
|
|
494
|
+
const rewriteModel = getRewriteModel(state);
|
|
486
495
|
if (!rewriteModel) {
|
|
487
|
-
console.log("[ceremony:rewrite] rewrite_model not set — skipping rewrite phase");
|
|
496
|
+
console.log("[ceremony:rewrite] rewrite_model not set — skipping person rewrite phase");
|
|
488
497
|
return;
|
|
489
498
|
}
|
|
490
499
|
|
|
491
|
-
const
|
|
492
|
-
|
|
493
|
-
for (const topic of human.topics) {
|
|
494
|
-
if ((topic.description?.length ?? 0) > REWRITE_DESCRIPTION_THRESHOLD && !topic.rewrite_checked) {
|
|
495
|
-
itemsToScan.push({ item: topic, type: "topic" });
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
for (const person of human.people) {
|
|
500
|
+
const human = state.getHuman();
|
|
501
|
+
const personsToScan = human.people.filter(person => {
|
|
499
502
|
const isPersonaLinked = (person.identifiers ?? []).some(
|
|
500
503
|
i => i.type.toLowerCase() === 'ei persona'
|
|
501
504
|
);
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
+
return !isPersonaLinked
|
|
506
|
+
&& (person.description?.length ?? 0) > REWRITE_DESCRIPTION_THRESHOLD
|
|
507
|
+
&& !person.rewrite_checked;
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
if (personsToScan.length === 0) {
|
|
511
|
+
console.log("[ceremony:rewrite] No persons above threshold — skipping person rewrite phase");
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
console.log(`[ceremony:rewrite] Found ${personsToScan.length} person(s) above ${REWRITE_DESCRIPTION_THRESHOLD} chars — queueing person rewrite scans`);
|
|
516
|
+
|
|
517
|
+
for (const person of personsToScan) {
|
|
518
|
+
const prompt = buildPersonRewriteScanPrompt({ item: person, itemType: "person" });
|
|
519
|
+
state.queue_enqueue({
|
|
520
|
+
type: LLMRequestType.JSON,
|
|
521
|
+
priority: LLMPriority.Low,
|
|
522
|
+
system: prompt.system,
|
|
523
|
+
user: prompt.user,
|
|
524
|
+
next_step: LLMNextStep.HandleRewriteScan,
|
|
525
|
+
model: rewriteModel,
|
|
526
|
+
data: {
|
|
527
|
+
itemId: person.id,
|
|
528
|
+
itemType: "person" as RewriteItemType,
|
|
529
|
+
rewriteModel,
|
|
530
|
+
ceremony_progress: 4,
|
|
531
|
+
},
|
|
532
|
+
});
|
|
505
533
|
}
|
|
506
534
|
|
|
507
|
-
|
|
508
|
-
|
|
535
|
+
console.log(`[ceremony:rewrite] Queued ${personsToScan.length} person rewrite scan(s)`);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export function queueTopicRewritePhase(state: StateManager): void {
|
|
539
|
+
const rewriteModel = getRewriteModel(state);
|
|
540
|
+
if (!rewriteModel) {
|
|
541
|
+
console.log("[ceremony:rewrite] rewrite_model not set — skipping topic rewrite phase");
|
|
509
542
|
return;
|
|
510
543
|
}
|
|
511
544
|
|
|
512
|
-
|
|
545
|
+
const human = state.getHuman();
|
|
546
|
+
const topicsToScan = human.topics.filter(topic =>
|
|
547
|
+
(topic.description?.length ?? 0) > REWRITE_DESCRIPTION_THRESHOLD
|
|
548
|
+
&& !topic.rewrite_checked
|
|
549
|
+
);
|
|
550
|
+
|
|
551
|
+
if (topicsToScan.length === 0) {
|
|
552
|
+
console.log("[ceremony:rewrite] No topics above threshold — skipping topic rewrite phase");
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
513
555
|
|
|
514
|
-
|
|
515
|
-
const prompt = buildRewriteScanPrompt({ item, itemType: type });
|
|
556
|
+
console.log(`[ceremony:rewrite] Found ${topicsToScan.length} topic(s) above ${REWRITE_DESCRIPTION_THRESHOLD} chars — queueing topic rewrite scans`);
|
|
516
557
|
|
|
558
|
+
for (const topic of topicsToScan) {
|
|
559
|
+
const prompt = buildTopicRewriteScanPrompt({ item: topic, itemType: "topic" });
|
|
517
560
|
state.queue_enqueue({
|
|
518
561
|
type: LLMRequestType.JSON,
|
|
519
562
|
priority: LLMPriority.Low,
|
|
@@ -522,14 +565,14 @@ export function queueRewritePhase(state: StateManager): void {
|
|
|
522
565
|
next_step: LLMNextStep.HandleRewriteScan,
|
|
523
566
|
model: rewriteModel,
|
|
524
567
|
data: {
|
|
525
|
-
itemId:
|
|
526
|
-
itemType:
|
|
527
|
-
rewriteModel,
|
|
568
|
+
itemId: topic.id,
|
|
569
|
+
itemType: "topic" as RewriteItemType,
|
|
570
|
+
rewriteModel,
|
|
528
571
|
},
|
|
529
572
|
});
|
|
530
573
|
}
|
|
531
574
|
|
|
532
|
-
console.log(`[ceremony:rewrite] Queued ${
|
|
575
|
+
console.log(`[ceremony:rewrite] Queued ${topicsToScan.length} topic rewrite scan(s)`);
|
|
533
576
|
}
|
|
534
577
|
|
|
535
578
|
function queueEventSummaryForAll(state: StateManager, options?: ExtractionOptions): void {
|
|
@@ -164,6 +164,7 @@ export function queueTopicScan(context: ExtractionContext, state: StateManager,
|
|
|
164
164
|
messages_context: chunk.messages_context,
|
|
165
165
|
messages_analyze: chunk.messages_analyze,
|
|
166
166
|
participant_context: buildParticipantContext(context.personaId, state),
|
|
167
|
+
technical_context: (context.sources?.length ?? 0) > 0,
|
|
167
168
|
});
|
|
168
169
|
|
|
169
170
|
state.queue_enqueue({
|
|
@@ -275,6 +276,7 @@ export function queueDirectTopicUpdate(
|
|
|
275
276
|
messages_analyze: chunk.messages_analyze,
|
|
276
277
|
persona_name: chunk.channelDisplayName,
|
|
277
278
|
participant_context: buildParticipantContext(context.personaId, state),
|
|
279
|
+
technical_context: (context.sources?.length ?? 0) > 0,
|
|
278
280
|
});
|
|
279
281
|
|
|
280
282
|
state.queue_enqueue({
|
|
@@ -291,6 +293,7 @@ export function queueDirectTopicUpdate(
|
|
|
291
293
|
existingItemId: topic.id,
|
|
292
294
|
analyze_from_timestamp: getAnalyzeFromTimestamp(chunk),
|
|
293
295
|
extraction_model: extractionModel,
|
|
296
|
+
sources: context.sources,
|
|
294
297
|
},
|
|
295
298
|
});
|
|
296
299
|
}
|
|
@@ -306,7 +309,7 @@ const EMBEDDING_MIN_SIMILARITY = 0.3;
|
|
|
306
309
|
* Higher than EMBEDDING_MIN_SIMILARITY (0.3) because we need near-duplicates,
|
|
307
310
|
* not just vague thematic overlap.
|
|
308
311
|
*/
|
|
309
|
-
export const VALIDATE_MIN_SIMILARITY = 0.
|
|
312
|
+
export const VALIDATE_MIN_SIMILARITY = 0.92;
|
|
310
313
|
|
|
311
314
|
/**
|
|
312
315
|
* Queue a topic match request using embedding-based similarity (topics only).
|
|
@@ -425,6 +428,7 @@ export function queueTopicUpdate(
|
|
|
425
428
|
messages_analyze: chunk.messages_analyze,
|
|
426
429
|
persona_name: chunk.channelDisplayName,
|
|
427
430
|
participant_context: buildParticipantContext(primaryPersonaId, state),
|
|
431
|
+
technical_context: (context.sources?.length ?? 0) > 0,
|
|
428
432
|
});
|
|
429
433
|
|
|
430
434
|
state.queue_enqueue({
|