ei-tui 1.2.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.
- package/README.md +16 -0
- package/package.json +1 -1
- package/src/cli/README.md +12 -2
- package/src/cli/mcp.ts +12 -4
- package/src/cli/retrieval.ts +162 -0
- package/src/cli.ts +7 -1
- package/src/core/handlers/document-segmentation.ts +3 -4
- package/src/core/handlers/knowledge-synthesis.ts +1 -3
- package/src/core/heartbeat-manager.ts +10 -0
- package/src/core/orchestrators/ceremony.ts +1 -9
- package/src/core/processor.ts +122 -9
- package/src/core/tools/builtin/fetch-message.ts +27 -1
- package/src/core/tools/builtin/find-memory.ts +11 -3
- package/src/core/tools/index.ts +2 -2
- package/src/core/types/llm.ts +0 -9
- package/src/core/utils/message-id.ts +114 -0
- package/src/integrations/claude-code/importer.ts +6 -5
- package/src/integrations/cursor/importer.ts +6 -5
- package/src/integrations/document/importer.ts +1 -1
- package/src/integrations/document/unsource.ts +6 -11
- package/src/integrations/opencode/importer.ts +6 -5
- package/src/integrations/opencode/json-reader.ts +65 -0
- package/src/integrations/opencode/sqlite-reader.ts +33 -0
- package/src/integrations/opencode/types.ts +8 -0
- package/src/prompts/heartbeat/check.ts +5 -2
- package/src/prompts/heartbeat/ei.ts +7 -0
- package/src/prompts/heartbeat/types.ts +5 -0
- package/src/prompts/response/sections.ts +30 -16
- package/src/prompts/room/sections.ts +28 -6
- package/src/prompts/trait-utils.ts +33 -0
- package/tui/README.md +2 -0
- package/tui/src/util/help-content.ts +11 -0
|
@@ -15,6 +15,14 @@ type GetPersonaList = () => Promise<PersonaSummary[]>;
|
|
|
15
15
|
|
|
16
16
|
type GetHuman = () => HumanEntity;
|
|
17
17
|
|
|
18
|
+
function formatSentiment(s: number): string {
|
|
19
|
+
const pct = Math.round(Math.abs(s) * 100);
|
|
20
|
+
const direction = s > 0.2 ? "positive" : s < -0.2 ? "negative" : "neutral";
|
|
21
|
+
if (direction === "neutral") return "neutral";
|
|
22
|
+
const intensity = pct >= 80 ? "strongly " : pct >= 50 ? "" : "slightly ";
|
|
23
|
+
return `${pct}% ${intensity}${direction}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
18
26
|
export function createFindMemoryExecutor(searchHumanData: SearchHumanData, getPersonaList?: GetPersonaList, getHuman?: GetHuman): ToolExecutor {
|
|
19
27
|
return {
|
|
20
28
|
name: "find_memory",
|
|
@@ -68,8 +76,8 @@ export function createFindMemoryExecutor(searchHumanData: SearchHumanData, getPe
|
|
|
68
76
|
|
|
69
77
|
const output: Record<string, unknown[]> = {};
|
|
70
78
|
if (results.facts.length > 0) output.facts = results.facts.map(f => ({ id: f.id, name: f.name, description: f.description }));
|
|
71
|
-
if (results.topics.length > 0) output.topics = results.topics.map(t => ({ id: t.id, name: t.name, description: t.description }));
|
|
72
|
-
if (results.people.length > 0) output.people = results.people.map(p => ({ id: p.id, name: p.name, relationship: p.relationship, description: p.description, identifiers: p.identifiers ?? [] }));
|
|
79
|
+
if (results.topics.length > 0) output.topics = results.topics.map(t => ({ id: t.id, name: t.name, description: t.description, sentiment: formatSentiment(t.sentiment) }));
|
|
80
|
+
if (results.people.length > 0) output.people = results.people.map(p => ({ id: p.id, name: p.name, relationship: p.relationship, description: p.description, identifiers: p.identifiers ?? [], sentiment: formatSentiment(p.sentiment) }));
|
|
73
81
|
|
|
74
82
|
if (results.quotes.length > 0) {
|
|
75
83
|
const human = getHuman ? getHuman() : null;
|
|
@@ -85,7 +93,7 @@ export function createFindMemoryExecutor(searchHumanData: SearchHumanData, getPe
|
|
|
85
93
|
if (person) { linked_items.push({ id: person.id, name: person.name, type: "person" }); }
|
|
86
94
|
}
|
|
87
95
|
}
|
|
88
|
-
return { id: q.id, text: q.text, speaker: q.speaker, linked_items };
|
|
96
|
+
return { id: q.id, text: q.text, speaker: q.speaker, message_id: q.message_id, linked_items };
|
|
89
97
|
});
|
|
90
98
|
}
|
|
91
99
|
|
package/src/core/tools/index.ts
CHANGED
|
@@ -29,7 +29,7 @@ export const SYSTEM_TOOLS: ToolDefinition[] = [
|
|
|
29
29
|
provider_id: "ei",
|
|
30
30
|
name: "find_memory",
|
|
31
31
|
display_name: "Find Memory",
|
|
32
|
-
description: "Semantic search of your personal memory — facts, topics, people, and quotes learned across ALL conversations over time, not just this one. Use when the human references something from the past, mentions a person, or asks about a topic you might have learned about. Supports optional filters: types (array of 'facts', 'topics', 'people', 'quotes'), limit (1-20, default 10), recent (true = sort by recency), persona (filter to what a specific persona has learned — use display name).",
|
|
32
|
+
description: "Semantic search of your personal memory — facts, topics, people, and quotes learned across ALL conversations over time, not just this one. Use when the human references something from the past, mentions a person, or asks about a topic you might have learned about. People and topic results include a sentiment field (e.g. '72% positive', 'neutral', '45% slightly negative') indicating how the human generally feels about that person or subject. Supports optional filters: types (array of 'facts', 'topics', 'people', 'quotes'), limit (1-20, default 10), recent (true = sort by recency), persona (filter to what a specific persona has learned — use display name).",
|
|
33
33
|
input_schema: {
|
|
34
34
|
type: "object",
|
|
35
35
|
properties: {
|
|
@@ -52,7 +52,7 @@ export const SYSTEM_TOOLS: ToolDefinition[] = [
|
|
|
52
52
|
provider_id: "ei",
|
|
53
53
|
name: "fetch_memory",
|
|
54
54
|
display_name: "Fetch Memory",
|
|
55
|
-
description: "Retrieve the full record for a specific memory by its ID.
|
|
55
|
+
description: "Retrieve the full record for a specific memory by its ID. For most conversational use, find_memory results are sufficient. Use fetch_memory when you need provenance details (which sessions or documents the memory came from) or the raw sentiment score. Returns the complete Fact, Topic, Person, or Quote record including all fields.",
|
|
56
56
|
input_schema: {
|
|
57
57
|
type: "object",
|
|
58
58
|
properties: {
|
package/src/core/types/llm.ts
CHANGED
|
@@ -27,15 +27,6 @@ export interface Message {
|
|
|
27
27
|
|
|
28
28
|
external?: boolean; // Set by integration importers (OpenCode, Cursor, Claude Code); invisible to LLM context
|
|
29
29
|
|
|
30
|
-
/**
|
|
31
|
-
* Integration source tag. Set ONLY on external: true messages by importers (document, Slack, etc.)
|
|
32
|
-
* to identify which external source this synthetic message came from.
|
|
33
|
-
* Format: "import:document:filename" | "slack:channelId" | etc.
|
|
34
|
-
* Enables quote provenance tracing: quote.message_id → message.source_tag → original source.
|
|
35
|
-
* Never set on conversational messages.
|
|
36
|
-
*/
|
|
37
|
-
source_tag?: string;
|
|
38
|
-
|
|
39
30
|
}
|
|
40
31
|
|
|
41
32
|
export interface ChatMessage {
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fully-qualified message ID format:
|
|
3
|
+
* ei:${uuid}
|
|
4
|
+
* opencode:${machine}:${session}:${nativeId}
|
|
5
|
+
* claudecode:${machine}:${session}:${nativeId}
|
|
6
|
+
* cursor:${machine}:${session}:${nativeId}
|
|
7
|
+
* import:document:${slug}:${uuid}
|
|
8
|
+
* slack:${workspace}:${channel}:${ts}
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type MessageIdIntegration =
|
|
12
|
+
| "ei"
|
|
13
|
+
| "opencode"
|
|
14
|
+
| "claudecode"
|
|
15
|
+
| "cursor"
|
|
16
|
+
| "import"
|
|
17
|
+
| "slack"
|
|
18
|
+
| "unknown"
|
|
19
|
+
|
|
20
|
+
export interface ParsedMessageId {
|
|
21
|
+
integration: MessageIdIntegration
|
|
22
|
+
machine?: string
|
|
23
|
+
session?: string
|
|
24
|
+
nativeId: string
|
|
25
|
+
raw: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function parseMessageId(id: string): ParsedMessageId {
|
|
29
|
+
if (!id || typeof id !== "string") {
|
|
30
|
+
const raw = String(id ?? "")
|
|
31
|
+
return { integration: "unknown", nativeId: raw, raw }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const parts = id.split(":")
|
|
35
|
+
|
|
36
|
+
if (parts[0] === "ei" && parts.length === 2) {
|
|
37
|
+
return { integration: "ei", nativeId: parts[1], raw: id }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (parts[0] === "opencode" && parts.length >= 4) {
|
|
41
|
+
return {
|
|
42
|
+
integration: "opencode",
|
|
43
|
+
machine: parts[1],
|
|
44
|
+
session: parts[2],
|
|
45
|
+
nativeId: parts.slice(3).join(":"),
|
|
46
|
+
raw: id,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (parts[0] === "claudecode" && parts.length >= 4) {
|
|
51
|
+
return {
|
|
52
|
+
integration: "claudecode",
|
|
53
|
+
machine: parts[1],
|
|
54
|
+
session: parts[2],
|
|
55
|
+
nativeId: parts.slice(3).join(":"),
|
|
56
|
+
raw: id,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (parts[0] === "cursor" && parts.length >= 4) {
|
|
61
|
+
return {
|
|
62
|
+
integration: "cursor",
|
|
63
|
+
machine: parts[1],
|
|
64
|
+
session: parts[2],
|
|
65
|
+
nativeId: parts.slice(3).join(":"),
|
|
66
|
+
raw: id,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (parts[0] === "import" && parts[1] === "document" && parts.length >= 4) {
|
|
71
|
+
return {
|
|
72
|
+
integration: "import",
|
|
73
|
+
session: parts[2],
|
|
74
|
+
nativeId: parts.slice(3).join(":"),
|
|
75
|
+
raw: id,
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (parts[0] === "slack" && parts.length >= 4) {
|
|
80
|
+
return {
|
|
81
|
+
integration: "slack",
|
|
82
|
+
machine: parts[1],
|
|
83
|
+
session: parts[2],
|
|
84
|
+
nativeId: parts.slice(3).join(":"),
|
|
85
|
+
raw: id,
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { integration: "unknown", nativeId: id, raw: id }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function isQualifiedMessageId(id: string): boolean {
|
|
93
|
+
return id.includes(":")
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function qualifyEiMessage(uuid: string): string {
|
|
97
|
+
return `ei:${uuid}`
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function qualifyOpenCodeMessage(machine: string, sessionId: string, nativeId: string): string {
|
|
101
|
+
return `opencode:${machine}:${sessionId}:${nativeId}`
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function qualifyClaudeCodeMessage(machine: string, sessionId: string, nativeId: string): string {
|
|
105
|
+
return `claudecode:${machine}:${sessionId}:${nativeId}`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function qualifyCursorMessage(machine: string, sessionId: string, nativeId: string): string {
|
|
109
|
+
return `cursor:${machine}:${sessionId}:${nativeId}`
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function qualifyDocumentMessage(slug: string, uuid: string): string {
|
|
113
|
+
return `import:document:${slug}:${uuid}`
|
|
114
|
+
}
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from "../../core/orchestrators/ceremony.js";
|
|
18
18
|
import { isProcessRunning } from "../process-check.js";
|
|
19
19
|
import { getMachineId } from "../machine-id.js";
|
|
20
|
+
import { qualifyClaudeCodeMessage } from "../../core/utils/message-id.js";
|
|
20
21
|
|
|
21
22
|
// =============================================================================
|
|
22
23
|
// Export Types
|
|
@@ -43,9 +44,9 @@ export interface ClaudeCodeImporterOptions {
|
|
|
43
44
|
const TWELVE_HOURS_MS = 43_200_000;
|
|
44
45
|
const CLAUDE_CODE_GROUP = "Claude Code";
|
|
45
46
|
|
|
46
|
-
function convertToEiMessage(msg: ClaudeCodeMessage): Message {
|
|
47
|
+
function convertToEiMessage(msg: ClaudeCodeMessage, sessionId: string): Message {
|
|
47
48
|
return {
|
|
48
|
-
id: msg.id,
|
|
49
|
+
id: qualifyClaudeCodeMessage(getMachineId(), sessionId, msg.id),
|
|
49
50
|
role: msg.role === "user" ? "human" : "system",
|
|
50
51
|
content: msg.content,
|
|
51
52
|
timestamp: msg.timestamp,
|
|
@@ -55,9 +56,9 @@ function convertToEiMessage(msg: ClaudeCodeMessage): Message {
|
|
|
55
56
|
};
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
function convertToPreMarkedEiMessage(msg: ClaudeCodeMessage): Message {
|
|
59
|
+
function convertToPreMarkedEiMessage(msg: ClaudeCodeMessage, sessionId: string): Message {
|
|
59
60
|
return {
|
|
60
|
-
...convertToEiMessage(msg),
|
|
61
|
+
...convertToEiMessage(msg, sessionId),
|
|
61
62
|
f: true,
|
|
62
63
|
t: true,
|
|
63
64
|
p: true,
|
|
@@ -248,7 +249,7 @@ export async function importClaudeCodeSessions(
|
|
|
248
249
|
for (const msg of messages) {
|
|
249
250
|
const msgMs = new Date(msg.timestamp).getTime();
|
|
250
251
|
const isOld = cutoffMs !== null && msgMs < cutoffMs;
|
|
251
|
-
const eiMsg = isOld ? convertToPreMarkedEiMessage(msg) : convertToEiMessage(msg);
|
|
252
|
+
const eiMsg = isOld ? convertToPreMarkedEiMessage(msg, targetSession.id) : convertToEiMessage(msg, targetSession.id);
|
|
252
253
|
stateManager.messages_append(persona.id, eiMsg);
|
|
253
254
|
result.messagesImported++;
|
|
254
255
|
if (!isOld) toAnalyze.push(eiMsg);
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
import { CursorReader } from "./reader.js";
|
|
10
10
|
import { isProcessRunning } from "../process-check.js";
|
|
11
11
|
import { getMachineId } from "../machine-id.js";
|
|
12
|
+
import { qualifyCursorMessage } from "../../core/utils/message-id.js";
|
|
12
13
|
import {
|
|
13
14
|
queueAllScans,
|
|
14
15
|
type ExtractionContext,
|
|
@@ -35,9 +36,9 @@ export interface CursorImporterOptions {
|
|
|
35
36
|
const TWELVE_HOURS_MS = 43_200_000;
|
|
36
37
|
const CURSOR_GROUP = "Cursor";
|
|
37
38
|
|
|
38
|
-
function convertToEiMessage(msg: CursorMessage): Message {
|
|
39
|
+
function convertToEiMessage(msg: CursorMessage, sessionId: string): Message {
|
|
39
40
|
return {
|
|
40
|
-
id: msg.id,
|
|
41
|
+
id: qualifyCursorMessage(getMachineId(), sessionId, msg.id),
|
|
41
42
|
role: msg.type === 1 ? "human" : "system",
|
|
42
43
|
content: msg.text,
|
|
43
44
|
timestamp: msg.timestamp,
|
|
@@ -47,9 +48,9 @@ function convertToEiMessage(msg: CursorMessage): Message {
|
|
|
47
48
|
};
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
function convertToPreMarkedEiMessage(msg: CursorMessage): Message {
|
|
51
|
+
function convertToPreMarkedEiMessage(msg: CursorMessage, sessionId: string): Message {
|
|
51
52
|
return {
|
|
52
|
-
...convertToEiMessage(msg),
|
|
53
|
+
...convertToEiMessage(msg, sessionId),
|
|
53
54
|
f: true,
|
|
54
55
|
t: true,
|
|
55
56
|
p: true,
|
|
@@ -208,7 +209,7 @@ export async function importCursorSessions(
|
|
|
208
209
|
for (const msg of messages) {
|
|
209
210
|
const msgMs = new Date(msg.timestamp).getTime();
|
|
210
211
|
const isOld = cutoffMs !== null && msgMs < cutoffMs;
|
|
211
|
-
const eiMsg = isOld ? convertToPreMarkedEiMessage(msg) : convertToEiMessage(msg);
|
|
212
|
+
const eiMsg = isOld ? convertToPreMarkedEiMessage(msg, targetSession.id) : convertToEiMessage(msg, targetSession.id);
|
|
212
213
|
stateManager.messages_append(persona.id, eiMsg);
|
|
213
214
|
result.messagesImported++;
|
|
214
215
|
if (!isOld) toAnalyze.push(eiMsg);
|
|
@@ -42,7 +42,7 @@ export async function importDocument(options: DocumentImportOptions): Promise<Do
|
|
|
42
42
|
const sourceTag = `import:document:${filename}`;
|
|
43
43
|
const existingMsgs = stateManager.messages_get("emmet");
|
|
44
44
|
const staleIds = existingMsgs
|
|
45
|
-
.filter(m => m.external === true && m.
|
|
45
|
+
.filter(m => m.external === true && m.id.startsWith(`${sourceTag}:`))
|
|
46
46
|
.map(m => m.id);
|
|
47
47
|
if (staleIds.length > 0) {
|
|
48
48
|
stateManager.messages_remove("emmet", staleIds);
|
|
@@ -63,15 +63,10 @@ export function previewUnsource(sourceTag: string, stateManager: StateManager):
|
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
const
|
|
67
|
-
const sourceMessageIds = new Set(
|
|
68
|
-
emmettMessages
|
|
69
|
-
.filter(m => m.source_tag === sourceTag)
|
|
70
|
-
.map(m => m.id)
|
|
71
|
-
);
|
|
66
|
+
const msgIdPrefix = `${sourceTag}:`;
|
|
72
67
|
|
|
73
68
|
for (const quote of human.quotes) {
|
|
74
|
-
if (quote.message_id
|
|
69
|
+
if (quote.message_id?.startsWith(msgIdPrefix)) {
|
|
75
70
|
preview.toDelete.quotes.push({ id: quote.id, text: quote.text });
|
|
76
71
|
}
|
|
77
72
|
}
|
|
@@ -143,11 +138,11 @@ export async function executeUnsource(
|
|
|
143
138
|
stateManager.setHuman(human);
|
|
144
139
|
}
|
|
145
140
|
|
|
146
|
-
const
|
|
147
|
-
.filter(m => m.
|
|
141
|
+
const idsToRemove = stateManager.messages_get("emmet")
|
|
142
|
+
.filter(m => m.id.startsWith(`${preview.sourceTag}:`))
|
|
148
143
|
.map(m => m.id);
|
|
149
|
-
if (
|
|
150
|
-
stateManager.messages_remove("emmet",
|
|
144
|
+
if (idsToRemove.length > 0) {
|
|
145
|
+
stateManager.messages_remove("emmet", idsToRemove);
|
|
151
146
|
}
|
|
152
147
|
|
|
153
148
|
const key = preview.sourceTag.startsWith("import:document:")
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from "../../core/orchestrators/ceremony.js";
|
|
15
15
|
import { isProcessRunning } from "../process-check.js";
|
|
16
16
|
import { getMachineId } from "../machine-id.js";
|
|
17
|
+
import { qualifyOpenCodeMessage } from "../../core/utils/message-id.js";
|
|
17
18
|
|
|
18
19
|
// =============================================================================
|
|
19
20
|
// Constants
|
|
@@ -47,9 +48,9 @@ function isAgentToAgentMessage(content: string): boolean {
|
|
|
47
48
|
return AGENT_TO_AGENT_PREFIXES.some(prefix => trimmed.startsWith(prefix));
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
function convertToEiMessage(ocMsg: OpenCodeMessage): Message {
|
|
51
|
+
function convertToEiMessage(ocMsg: OpenCodeMessage, sessionId: string): Message {
|
|
51
52
|
return {
|
|
52
|
-
id: ocMsg.id,
|
|
53
|
+
id: qualifyOpenCodeMessage(getMachineId(), sessionId, ocMsg.id),
|
|
53
54
|
role: ocMsg.role === "user" ? "human" : "system",
|
|
54
55
|
content: ocMsg.content,
|
|
55
56
|
timestamp: ocMsg.timestamp,
|
|
@@ -59,9 +60,9 @@ function convertToEiMessage(ocMsg: OpenCodeMessage): Message {
|
|
|
59
60
|
};
|
|
60
61
|
}
|
|
61
62
|
|
|
62
|
-
function convertToPreMarkedEiMessage(ocMsg: OpenCodeMessage): Message {
|
|
63
|
+
function convertToPreMarkedEiMessage(ocMsg: OpenCodeMessage, sessionId: string): Message {
|
|
63
64
|
return {
|
|
64
|
-
...convertToEiMessage(ocMsg),
|
|
65
|
+
...convertToEiMessage(ocMsg, sessionId),
|
|
65
66
|
f: true,
|
|
66
67
|
t: true,
|
|
67
68
|
p: true,
|
|
@@ -232,7 +233,7 @@ export async function importOpenCodeSessions(
|
|
|
232
233
|
for (const ocMsg of agentMsgs) {
|
|
233
234
|
const msgMs = new Date(ocMsg.timestamp).getTime();
|
|
234
235
|
const isOld = cutoffMs !== null && msgMs < cutoffMs;
|
|
235
|
-
const eiMsg = isOld ? convertToPreMarkedEiMessage(ocMsg) : convertToEiMessage(ocMsg);
|
|
236
|
+
const eiMsg = isOld ? convertToPreMarkedEiMessage(ocMsg, targetSession.id) : convertToEiMessage(ocMsg, targetSession.id);
|
|
236
237
|
stateManager.messages_append(persona.id, eiMsg);
|
|
237
238
|
result.messagesImported++;
|
|
238
239
|
if (!isOld) toAnalyze.push(eiMsg);
|
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
OpenCodeSessionRaw,
|
|
5
5
|
OpenCodeMessage,
|
|
6
6
|
OpenCodeMessageRaw,
|
|
7
|
+
OpenCodeMessageWindow,
|
|
7
8
|
OpenCodePartRaw,
|
|
8
9
|
OpenCodeAgent,
|
|
9
10
|
} from "./types.js";
|
|
@@ -208,6 +209,70 @@ export class JsonReader implements IOpenCodeReader {
|
|
|
208
209
|
);
|
|
209
210
|
}
|
|
210
211
|
|
|
212
|
+
async getMessageById(messageId: string, before = 0, after = 0): Promise<OpenCodeMessageWindow | null> {
|
|
213
|
+
if (!(await this.init())) return null;
|
|
214
|
+
|
|
215
|
+
const messageBaseDir = _join(this.storagePath!, "message");
|
|
216
|
+
let sessionDirs: string[];
|
|
217
|
+
try {
|
|
218
|
+
sessionDirs = await _readdir(messageBaseDir);
|
|
219
|
+
} catch {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
for (const sessionId of sessionDirs) {
|
|
224
|
+
if (sessionId.startsWith(".")) continue;
|
|
225
|
+
const sessionMsgDir = _join(messageBaseDir, sessionId);
|
|
226
|
+
let files: string[];
|
|
227
|
+
try {
|
|
228
|
+
files = await _readdir(sessionMsgDir);
|
|
229
|
+
} catch {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (!files.includes(`${messageId}.json`)) continue;
|
|
233
|
+
|
|
234
|
+
const allMessages = await this.getMessagesForSession(sessionId);
|
|
235
|
+
const idx = allMessages.findIndex(m => m.id === messageId);
|
|
236
|
+
if (idx === -1) return null;
|
|
237
|
+
|
|
238
|
+
const sessionFilePath = await this.findSessionFile(sessionId);
|
|
239
|
+
const sessionRaw = sessionFilePath ? await this.readJsonFile<OpenCodeSessionRaw>(sessionFilePath) : null;
|
|
240
|
+
const session: OpenCodeSession = sessionRaw
|
|
241
|
+
? { id: sessionRaw.id, title: sessionRaw.title, directory: sessionRaw.directory, projectId: sessionRaw.projectID, parentId: sessionRaw.parentID, time: { created: sessionRaw.time.created, updated: sessionRaw.time.updated } }
|
|
242
|
+
: { id: sessionId, title: "", directory: "", projectId: "", time: { created: 0, updated: 0 } };
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
message: allMessages[idx],
|
|
246
|
+
before: allMessages.slice(Math.max(0, idx - before), idx),
|
|
247
|
+
after: allMessages.slice(idx + 1, idx + 1 + after),
|
|
248
|
+
session,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private async findSessionFile(sessionId: string): Promise<string | null> {
|
|
256
|
+
const sessionDir = _join(this.storagePath!, "session");
|
|
257
|
+
let projectDirs: string[];
|
|
258
|
+
try {
|
|
259
|
+
projectDirs = await _readdir(sessionDir);
|
|
260
|
+
} catch {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
for (const projectHash of projectDirs) {
|
|
264
|
+
if (projectHash.startsWith(".")) continue;
|
|
265
|
+
const candidate = _join(sessionDir, projectHash, `${sessionId}.json`);
|
|
266
|
+
try {
|
|
267
|
+
await _readFile(candidate, "utf-8");
|
|
268
|
+
return candidate;
|
|
269
|
+
} catch {
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
|
|
211
276
|
async getAgentInfo(agentName: string): Promise<OpenCodeAgent | null> {
|
|
212
277
|
const normalized = agentName.toLowerCase();
|
|
213
278
|
if (BUILTIN_AGENTS[normalized]) {
|
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
IOpenCodeReader,
|
|
4
4
|
OpenCodeSession,
|
|
5
5
|
OpenCodeMessage,
|
|
6
|
+
OpenCodeMessageWindow,
|
|
6
7
|
OpenCodeAgent,
|
|
7
8
|
} from "./types.js";
|
|
8
9
|
import { BUILTIN_AGENTS } from "./types.js";
|
|
@@ -153,6 +154,38 @@ export class SqliteReader implements IOpenCodeReader {
|
|
|
153
154
|
return textParts.length > 0 ? textParts.join("\n\n") : null;
|
|
154
155
|
}
|
|
155
156
|
|
|
157
|
+
async getMessageById(messageId: string, before = 0, after = 0): Promise<OpenCodeMessageWindow | null> {
|
|
158
|
+
const row = this.db
|
|
159
|
+
.query(`SELECT session_id FROM message WHERE id = ?1 LIMIT 1`)
|
|
160
|
+
.get(messageId) as { session_id: string } | null;
|
|
161
|
+
if (!row) return null;
|
|
162
|
+
|
|
163
|
+
const sessionRow = this.db
|
|
164
|
+
.query(`SELECT id, title, directory, project_id, parent_id, time_created, time_updated FROM session WHERE id = ?1 LIMIT 1`)
|
|
165
|
+
.get(row.session_id) as { id: string; title: string; directory: string; project_id: string; parent_id: string | null; time_created: number; time_updated: number } | null;
|
|
166
|
+
if (!sessionRow) return null;
|
|
167
|
+
|
|
168
|
+
const session: OpenCodeSession = {
|
|
169
|
+
id: sessionRow.id,
|
|
170
|
+
title: sessionRow.title,
|
|
171
|
+
directory: sessionRow.directory,
|
|
172
|
+
projectId: sessionRow.project_id,
|
|
173
|
+
parentId: sessionRow.parent_id ?? undefined,
|
|
174
|
+
time: { created: sessionRow.time_created, updated: sessionRow.time_updated },
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const allMessages = await this.getMessagesForSession(row.session_id);
|
|
178
|
+
const idx = allMessages.findIndex(m => m.id === messageId);
|
|
179
|
+
if (idx === -1) return null;
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
message: allMessages[idx],
|
|
183
|
+
before: allMessages.slice(Math.max(0, idx - before), idx),
|
|
184
|
+
after: allMessages.slice(idx + 1, idx + 1 + after),
|
|
185
|
+
session,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
156
189
|
async getAgentInfo(agentName: string): Promise<OpenCodeAgent | null> {
|
|
157
190
|
const normalized = agentName.toLowerCase();
|
|
158
191
|
if (BUILTIN_AGENTS[normalized]) {
|
|
@@ -14,10 +14,18 @@
|
|
|
14
14
|
* Common interface for reading OpenCode data.
|
|
15
15
|
* Implemented by both JsonReader (legacy) and SqliteReader (1.2+).
|
|
16
16
|
*/
|
|
17
|
+
export interface OpenCodeMessageWindow {
|
|
18
|
+
message: OpenCodeMessage;
|
|
19
|
+
before: OpenCodeMessage[];
|
|
20
|
+
after: OpenCodeMessage[];
|
|
21
|
+
session: OpenCodeSession;
|
|
22
|
+
}
|
|
23
|
+
|
|
17
24
|
export interface IOpenCodeReader {
|
|
18
25
|
getSessionsUpdatedSince(since: Date): Promise<OpenCodeSession[]>;
|
|
19
26
|
getSessionsInRange(from: Date, to: Date): Promise<OpenCodeSession[]>;
|
|
20
27
|
getMessagesForSession(sessionId: string, since?: Date): Promise<OpenCodeMessage[]>;
|
|
28
|
+
getMessageById(messageId: string, before?: number, after?: number): Promise<OpenCodeMessageWindow | null>;
|
|
21
29
|
getAgentInfo(agentName: string): Promise<OpenCodeAgent | null>;
|
|
22
30
|
getAllUniqueAgents(sessionId: string): Promise<string[]>;
|
|
23
31
|
getFirstAgent(sessionId: string): Promise<string | null>;
|
|
@@ -9,6 +9,7 @@ import type { HeartbeatCheckPromptData, PromptOutput } from "./types.js";
|
|
|
9
9
|
import { type Message, type Topic, type Person } from "../../core/types.js";
|
|
10
10
|
import { formatMessagesAsPlaceholders, getMessageDisplayText } from "../message-utils.js";
|
|
11
11
|
import { getMessageContent } from "../../core/handlers/utils.js";
|
|
12
|
+
import { partitionTraits } from "../trait-utils.js";
|
|
12
13
|
function formatTopicsWithGaps(topics: Topic[]): string {
|
|
13
14
|
if (topics.length === 0) return "(No topics with engagement gaps)";
|
|
14
15
|
|
|
@@ -85,13 +86,15 @@ export function buildHeartbeatCheckPrompt(data: HeartbeatCheckPromptData): Promp
|
|
|
85
86
|
|
|
86
87
|
You are NOT having a conversation right now - you are deciding IF you should start one.`;
|
|
87
88
|
|
|
89
|
+
const { active: activeTraits } = partitionTraits(data.persona.traits);
|
|
90
|
+
|
|
88
91
|
const contextFragment = `## Context
|
|
89
92
|
|
|
90
93
|
It has been ${data.inactive_days} day${data.inactive_days !== 1 ? 's' : ''} since your last interaction.
|
|
91
94
|
|
|
92
95
|
### Your Personality
|
|
93
|
-
${
|
|
94
|
-
?
|
|
96
|
+
${activeTraits.length > 0
|
|
97
|
+
? activeTraits.map(t => `- **${t.name}**: ${t.description}`).join('\n')
|
|
95
98
|
: "(No specific traits defined)"}
|
|
96
99
|
|
|
97
100
|
### Topics You Care About
|
|
@@ -41,6 +41,12 @@ function formatItem(item: EiHeartbeatItem): string {
|
|
|
41
41
|
` ${item.critique}`,
|
|
42
42
|
].join("\n");
|
|
43
43
|
|
|
44
|
+
case "Self Reflection Alert":
|
|
45
|
+
return [
|
|
46
|
+
`- **${item.id}** Self Reflection Alert (your own identity)`,
|
|
47
|
+
` ${item.critique}`,
|
|
48
|
+
].join("\n");
|
|
49
|
+
|
|
44
50
|
default:
|
|
45
51
|
return '';
|
|
46
52
|
}
|
|
@@ -86,6 +92,7 @@ ${itemsSection}
|
|
|
86
92
|
- **Low-Engagement Person / Topic**: Write a natural, warm message that naturally brings up this person or topic. Set the id and my_response.
|
|
87
93
|
- **Inactive Persona**: Write a message that gently mentions the persona might be worth checking in with. Set the id and my_response.
|
|
88
94
|
- **Persona Reflection Alert**: The nightly review proposed identity changes for this persona. Mention it naturally — the user can talk to the persona and then use the command shown in the status bar to review the changes. Set the id and my_response.
|
|
95
|
+
- **Self Reflection Alert**: The nightly review proposed changes to *your own* identity. Mention it naturally — you've grown and the system noticed. The user can review your proposed changes using the command shown in the status bar. Set the id and my_response.
|
|
89
96
|
|
|
90
97
|
## When NOT to Reach Out
|
|
91
98
|
|
|
@@ -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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
}
|