ei-tui 1.6.2 → 1.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli/README.md +2 -0
- package/src/cli/retrieval.ts +22 -0
- package/src/cli.ts +142 -3
- package/src/core/processor.ts +69 -0
- package/src/core/types/entities.ts +1 -0
- package/src/core/utils/message-id.ts +15 -0
- package/src/integrations/claude-code/importer.ts +9 -30
- package/src/integrations/claude-code/types.ts +1 -1
- package/src/integrations/codex/importer.ts +6 -27
- package/src/integrations/codex/types.ts +1 -1
- package/src/integrations/constants.ts +3 -0
- package/src/integrations/cursor/importer.ts +9 -26
- package/src/integrations/cursor/types.ts +1 -1
- package/src/integrations/pi/importer.ts +235 -0
- package/src/integrations/pi/index.ts +3 -0
- package/src/integrations/pi/reader.ts +247 -0
- package/src/integrations/pi/types.ts +151 -0
- package/src/integrations/shared/message-converter.ts +41 -0
- package/tui/README.md +1 -0
- package/tui/src/util/yaml-settings.ts +28 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import type { StateManager } from "../../core/state-manager.js";
|
|
2
|
+
import type { Ei_Interface, Message, PersonaEntity, PersonaTrait } from "../../core/types.js";
|
|
3
|
+
import { DEFAULT_SEED_TRAITS } from "../../core/constants/seed-traits.js";
|
|
4
|
+
import {
|
|
5
|
+
queueAllScans,
|
|
6
|
+
type ExtractionContext,
|
|
7
|
+
} from "../../core/orchestrators/human-extraction.js";
|
|
8
|
+
import {
|
|
9
|
+
queuePersonRewritePhase,
|
|
10
|
+
queueTopicRewritePhase,
|
|
11
|
+
} from "../../core/orchestrators/ceremony.js";
|
|
12
|
+
import { qualifyPiMessage } from "../../core/utils/message-id.js";
|
|
13
|
+
import { convertToEiMessage, convertToPreMarkedEiMessage } from "../shared/message-converter.js";
|
|
14
|
+
import { getMachineId } from "../machine-id.js";
|
|
15
|
+
import { isProcessRunning } from "../process-check.js";
|
|
16
|
+
import { PiReader } from "./reader.js";
|
|
17
|
+
import {
|
|
18
|
+
PI_PERSONA_NAME,
|
|
19
|
+
type PiSession,
|
|
20
|
+
type IPiReader,
|
|
21
|
+
} from "./types.js";
|
|
22
|
+
import { MIN_SESSION_AGE_MS, TWELVE_HOURS_MS } from "../constants.js";
|
|
23
|
+
|
|
24
|
+
export interface PiImportResult {
|
|
25
|
+
sessionsProcessed: number;
|
|
26
|
+
messagesImported: number;
|
|
27
|
+
personaCreated: boolean;
|
|
28
|
+
extractionScansQueued: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface PiImporterOptions {
|
|
32
|
+
stateManager: StateManager;
|
|
33
|
+
interface?: Ei_Interface;
|
|
34
|
+
reader?: IPiReader;
|
|
35
|
+
signal?: AbortSignal;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const PI_GROUP = "Pi";
|
|
39
|
+
|
|
40
|
+
const qualify = qualifyPiMessage;
|
|
41
|
+
|
|
42
|
+
function ensurePiPersona(
|
|
43
|
+
stateManager: StateManager,
|
|
44
|
+
eiInterface?: Ei_Interface
|
|
45
|
+
): PersonaEntity {
|
|
46
|
+
const existing = stateManager.persona_getByName(PI_PERSONA_NAME);
|
|
47
|
+
if (existing) return existing;
|
|
48
|
+
|
|
49
|
+
const now = new Date().toISOString();
|
|
50
|
+
const seedTraits: PersonaTrait[] = DEFAULT_SEED_TRAITS.map((t) => ({
|
|
51
|
+
id: crypto.randomUUID(),
|
|
52
|
+
name: t.name,
|
|
53
|
+
description: t.description,
|
|
54
|
+
sentiment: t.sentiment,
|
|
55
|
+
strength: t.strength,
|
|
56
|
+
last_updated: now,
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
const persona: PersonaEntity = {
|
|
60
|
+
id: crypto.randomUUID(),
|
|
61
|
+
display_name: PI_PERSONA_NAME,
|
|
62
|
+
entity: "system",
|
|
63
|
+
aliases: ["pi", "pi coding agent", "omp", "oh-my-pi"],
|
|
64
|
+
short_description: "Pi - minimal terminal coding harness",
|
|
65
|
+
long_description:
|
|
66
|
+
"Pi is a minimal terminal coding harness. Covers both vanilla Pi (earendil-works/pi) and the oh-my-pi fork (omp), which share the same JSONL session format.",
|
|
67
|
+
group_primary: PI_GROUP,
|
|
68
|
+
groups_visible: [PI_GROUP],
|
|
69
|
+
traits: seedTraits,
|
|
70
|
+
topics: [],
|
|
71
|
+
is_paused: false,
|
|
72
|
+
is_archived: false,
|
|
73
|
+
is_static: false,
|
|
74
|
+
heartbeat_delay_ms: TWELVE_HOURS_MS,
|
|
75
|
+
last_heartbeat: now,
|
|
76
|
+
last_updated: now,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
stateManager.persona_add(persona);
|
|
80
|
+
eiInterface?.onPersonaAdded?.();
|
|
81
|
+
return persona;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function updateProcessedState(stateManager: StateManager, session: PiSession): void {
|
|
85
|
+
const human = stateManager.getHuman();
|
|
86
|
+
const lastMessageMs = new Date(session.lastMessageAt).getTime();
|
|
87
|
+
const extractionPoint = human.settings?.pi?.extraction_point;
|
|
88
|
+
const currentPointMs = extractionPoint ? new Date(extractionPoint).getTime() : 0;
|
|
89
|
+
const newPointMs = Math.max(currentPointMs, lastMessageMs);
|
|
90
|
+
|
|
91
|
+
const processedSessions = {
|
|
92
|
+
...(human.settings?.pi?.processed_sessions ?? {}),
|
|
93
|
+
[session.id]: new Date().toISOString(),
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
stateManager.setHuman({
|
|
97
|
+
...human,
|
|
98
|
+
settings: {
|
|
99
|
+
...human.settings,
|
|
100
|
+
pi: {
|
|
101
|
+
...human.settings?.pi,
|
|
102
|
+
extraction_point: new Date(newPointMs).toISOString(),
|
|
103
|
+
processed_sessions: processedSessions,
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function isPiRunning(): Promise<boolean> {
|
|
110
|
+
return (
|
|
111
|
+
(await isProcessRunning("pi")) ||
|
|
112
|
+
(await isProcessRunning("omp"))
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function importPiSessions(options: PiImporterOptions): Promise<PiImportResult> {
|
|
117
|
+
const { stateManager, interface: eiInterface, signal } = options;
|
|
118
|
+
const reader = options.reader ?? new PiReader();
|
|
119
|
+
|
|
120
|
+
const result: PiImportResult = {
|
|
121
|
+
sessionsProcessed: 0,
|
|
122
|
+
messagesImported: 0,
|
|
123
|
+
personaCreated: false,
|
|
124
|
+
extractionScansQueued: 0,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const allSessions = await reader.getSessions();
|
|
128
|
+
if (signal?.aborted) return result;
|
|
129
|
+
|
|
130
|
+
const human = stateManager.getHuman();
|
|
131
|
+
const processedSessions = human.settings?.pi?.processed_sessions ?? {};
|
|
132
|
+
const now = Date.now();
|
|
133
|
+
const toolRunning = await isPiRunning();
|
|
134
|
+
|
|
135
|
+
let targetSession: PiSession | null = null;
|
|
136
|
+
|
|
137
|
+
for (const session of allSessions) {
|
|
138
|
+
const sessionLastMs = new Date(session.lastMessageAt).getTime();
|
|
139
|
+
const ageMs = now - sessionLastMs;
|
|
140
|
+
|
|
141
|
+
if (ageMs < MIN_SESSION_AGE_MS && toolRunning) continue;
|
|
142
|
+
|
|
143
|
+
const lastImported = processedSessions[session.id];
|
|
144
|
+
if (lastImported && sessionLastMs <= new Date(lastImported).getTime()) continue;
|
|
145
|
+
|
|
146
|
+
targetSession = session;
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (!targetSession) {
|
|
151
|
+
console.log("[Pi] All sessions processed, nothing new to import");
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (signal?.aborted) return result;
|
|
156
|
+
|
|
157
|
+
console.log(
|
|
158
|
+
`[Pi] Processing session: "${targetSession.title}" ` +
|
|
159
|
+
`(last message: ${targetSession.lastMessageAt})`
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
const messages = targetSession.messages;
|
|
163
|
+
if (messages.length === 0) {
|
|
164
|
+
updateProcessedState(stateManager, targetSession);
|
|
165
|
+
return result;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (signal?.aborted) return result;
|
|
169
|
+
|
|
170
|
+
const personaExistedBefore = stateManager.persona_getByName(PI_PERSONA_NAME) !== null;
|
|
171
|
+
const persona = ensurePiPersona(stateManager, eiInterface);
|
|
172
|
+
result.personaCreated = !personaExistedBefore;
|
|
173
|
+
|
|
174
|
+
if (!personaExistedBefore) {
|
|
175
|
+
stateManager.persona_archive(persona.id);
|
|
176
|
+
} else {
|
|
177
|
+
const existingMsgs = stateManager.messages_get(persona.id);
|
|
178
|
+
const externalIds = existingMsgs.filter((m) => m.external === true).map((m) => m.id);
|
|
179
|
+
if (externalIds.length > 0) {
|
|
180
|
+
stateManager.messages_remove(persona.id, externalIds);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const cutoffIso = processedSessions[targetSession.id] ?? null;
|
|
185
|
+
const cutoffMs = cutoffIso ? new Date(cutoffIso).getTime() : null;
|
|
186
|
+
const toAnalyze: Message[] = [];
|
|
187
|
+
|
|
188
|
+
for (const msg of messages) {
|
|
189
|
+
const msgMs = new Date(msg.timestamp).getTime();
|
|
190
|
+
const isOld = cutoffMs !== null && msgMs < cutoffMs;
|
|
191
|
+
const eiMsg = isOld
|
|
192
|
+
? convertToPreMarkedEiMessage(msg, targetSession.id, qualify)
|
|
193
|
+
: convertToEiMessage(msg, targetSession.id, qualify);
|
|
194
|
+
|
|
195
|
+
stateManager.messages_append(persona.id, eiMsg);
|
|
196
|
+
result.messagesImported++;
|
|
197
|
+
if (!isOld) toAnalyze.push(eiMsg);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
stateManager.messages_sort(persona.id);
|
|
201
|
+
eiInterface?.onMessageAdded?.(persona.id);
|
|
202
|
+
|
|
203
|
+
if (toAnalyze.length > 0 && !signal?.aborted) {
|
|
204
|
+
const allInState = stateManager.messages_get(persona.id);
|
|
205
|
+
const analyzeIds = new Set(toAnalyze.map((m) => m.id));
|
|
206
|
+
const analyzeStartIndex = allInState.findIndex((m) => analyzeIds.has(m.id));
|
|
207
|
+
const contextMsgs = analyzeStartIndex > 0 ? allInState.slice(0, analyzeStartIndex) : [];
|
|
208
|
+
|
|
209
|
+
const context: ExtractionContext = {
|
|
210
|
+
personaId: persona.id,
|
|
211
|
+
channelDisplayName: persona.display_name,
|
|
212
|
+
messages_context: contextMsgs,
|
|
213
|
+
messages_analyze: toAnalyze,
|
|
214
|
+
sources: [`pi:${getMachineId()}:${targetSession.id}`],
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
queuePersonRewritePhase(stateManager);
|
|
218
|
+
queueTopicRewritePhase(stateManager);
|
|
219
|
+
queueAllScans(context, stateManager, {
|
|
220
|
+
extraction_model: human.settings?.pi?.extraction_model,
|
|
221
|
+
external_filter: "only",
|
|
222
|
+
});
|
|
223
|
+
result.extractionScansQueued += 4;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
result.sessionsProcessed = 1;
|
|
227
|
+
updateProcessedState(stateManager, targetSession);
|
|
228
|
+
|
|
229
|
+
console.log(
|
|
230
|
+
`[Pi] Session complete: ${result.messagesImported} messages imported, ` +
|
|
231
|
+
`${result.extractionScansQueued} extraction scans queued`
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
return result;
|
|
235
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
IPiReader,
|
|
3
|
+
PiSession,
|
|
4
|
+
PiMessage,
|
|
5
|
+
PiMessageWindow,
|
|
6
|
+
PiMessageEntry,
|
|
7
|
+
PiGenericEntry,
|
|
8
|
+
PiContentBlock,
|
|
9
|
+
} from "./types.js";
|
|
10
|
+
|
|
11
|
+
const isBrowser = typeof document !== "undefined";
|
|
12
|
+
|
|
13
|
+
let _join: typeof import("path").join;
|
|
14
|
+
let _readdir: typeof import("fs/promises").readdir;
|
|
15
|
+
let _readFile: typeof import("fs/promises").readFile;
|
|
16
|
+
let _existsSync: typeof import("fs").existsSync;
|
|
17
|
+
let _nodeModulesLoaded = false;
|
|
18
|
+
|
|
19
|
+
async function ensureNodeModules(): Promise<boolean> {
|
|
20
|
+
if (isBrowser) return false;
|
|
21
|
+
if (_nodeModulesLoaded) return true;
|
|
22
|
+
|
|
23
|
+
const PATH_MODULE = "path";
|
|
24
|
+
const FS_MODULE = "fs/promises";
|
|
25
|
+
const FS_SYNC_MODULE = "fs";
|
|
26
|
+
|
|
27
|
+
const pathMod = await import(/* @vite-ignore */ PATH_MODULE);
|
|
28
|
+
const fsMod = await import(/* @vite-ignore */ FS_MODULE);
|
|
29
|
+
const fsSyncMod = await import(/* @vite-ignore */ FS_SYNC_MODULE);
|
|
30
|
+
|
|
31
|
+
_join = pathMod.join;
|
|
32
|
+
_readdir = fsMod.readdir;
|
|
33
|
+
_readFile = fsMod.readFile;
|
|
34
|
+
_existsSync = fsSyncMod.existsSync;
|
|
35
|
+
_nodeModulesLoaded = true;
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Pi encodes the cwd into the session directory name by replacing every "/"
|
|
41
|
+
* with "--". The root "/" becomes an empty leading segment, so paths end up
|
|
42
|
+
* looking like "--Users--flare576--Projects--Personal--ei--".
|
|
43
|
+
* This reverses that encoding.
|
|
44
|
+
*/
|
|
45
|
+
function decodeCwd(dirName: string): string {
|
|
46
|
+
return dirName.replace(/--/g, "/") || "/";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function titleFromCwd(cwd: string): string {
|
|
50
|
+
if (!cwd) return "Unknown";
|
|
51
|
+
const parts = cwd.replace(/\\/g, "/").split("/").filter(Boolean);
|
|
52
|
+
return parts[parts.length - 1] ?? cwd;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Extracts the session UUID from a Pi session filename.
|
|
57
|
+
* Filename format: "2026-05-20T14-57-03-205Z_019e45e3-e2e5-7174-8165-da221c147ebb.jsonl"
|
|
58
|
+
*/
|
|
59
|
+
function uuidFromFilename(filename: string): string | null {
|
|
60
|
+
const base = filename.replace(/\.jsonl$/, "");
|
|
61
|
+
const underscoreIdx = base.indexOf("_");
|
|
62
|
+
if (underscoreIdx === -1) return null;
|
|
63
|
+
return base.slice(underscoreIdx + 1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function extractAssistantText(content: PiContentBlock[] | string | undefined): string {
|
|
67
|
+
if (!content) return "";
|
|
68
|
+
if (typeof content === "string") return content.trim();
|
|
69
|
+
return content
|
|
70
|
+
.filter((b): b is { type: "text"; text: string } => b.type === "text")
|
|
71
|
+
.map((b) => b.text)
|
|
72
|
+
.join("\n\n")
|
|
73
|
+
.trim();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isPiMessageEntry(entry: PiGenericEntry): entry is PiMessageEntry {
|
|
77
|
+
return entry.type === "message" && typeof entry.message === "object" && entry.message !== null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export class PiReader implements IPiReader {
|
|
81
|
+
private readonly sessionsRoots: string[];
|
|
82
|
+
|
|
83
|
+
constructor(sessionsRoots?: string[]) {
|
|
84
|
+
this.sessionsRoots = sessionsRoots ?? [];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async isAvailable(): Promise<boolean> {
|
|
88
|
+
if (!(await ensureNodeModules())) return false;
|
|
89
|
+
const roots = this.sessionsRoots.length > 0 ? this.sessionsRoots : getDefaultSessionsRoots();
|
|
90
|
+
return roots.some((r) => _existsSync(r));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async getSessions(): Promise<PiSession[]> {
|
|
94
|
+
if (!(await ensureNodeModules())) return [];
|
|
95
|
+
|
|
96
|
+
const roots = this.sessionsRoots.length > 0 ? this.sessionsRoots : getDefaultSessionsRoots();
|
|
97
|
+
const sessions: PiSession[] = [];
|
|
98
|
+
|
|
99
|
+
for (const root of roots) {
|
|
100
|
+
if (!_existsSync(root)) continue;
|
|
101
|
+
|
|
102
|
+
let cwdDirs: string[];
|
|
103
|
+
try {
|
|
104
|
+
cwdDirs = await _readdir(root);
|
|
105
|
+
} catch {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const cwdDir of cwdDirs) {
|
|
110
|
+
if (cwdDir.startsWith(".")) continue;
|
|
111
|
+
const cwdPath = _join(root, cwdDir);
|
|
112
|
+
const cwd = decodeCwd(cwdDir);
|
|
113
|
+
|
|
114
|
+
let sessionFiles: string[];
|
|
115
|
+
try {
|
|
116
|
+
sessionFiles = await _readdir(cwdPath);
|
|
117
|
+
} catch {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
for (const filename of sessionFiles) {
|
|
122
|
+
if (!filename.endsWith(".jsonl")) continue;
|
|
123
|
+
|
|
124
|
+
const uuid = uuidFromFilename(filename);
|
|
125
|
+
if (!uuid) continue;
|
|
126
|
+
|
|
127
|
+
const filePath = _join(cwdPath, filename);
|
|
128
|
+
const session = await this.parseSession(uuid, cwd, filePath);
|
|
129
|
+
if (session) sessions.push(session);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return sessions.sort(
|
|
135
|
+
(a, b) => new Date(a.firstMessageAt).getTime() - new Date(b.firstMessageAt).getTime()
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async getMessageById(
|
|
140
|
+
sessionId: string,
|
|
141
|
+
messageId: string,
|
|
142
|
+
before = 0,
|
|
143
|
+
after = 0
|
|
144
|
+
): Promise<PiMessageWindow | null> {
|
|
145
|
+
if (!(await ensureNodeModules())) return null;
|
|
146
|
+
|
|
147
|
+
const allSessions = await this.getSessions();
|
|
148
|
+
const session = allSessions.find((s) => s.id === sessionId);
|
|
149
|
+
if (!session) return null;
|
|
150
|
+
|
|
151
|
+
const msgs = session.messages;
|
|
152
|
+
const idx = msgs.findIndex((m) => m.id === messageId || m.id.endsWith(`/${messageId}`));
|
|
153
|
+
if (idx === -1) return null;
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
message: msgs[idx],
|
|
157
|
+
before: msgs.slice(Math.max(0, idx - before), idx),
|
|
158
|
+
after: msgs.slice(idx + 1, idx + 1 + after),
|
|
159
|
+
session,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private async parseSession(
|
|
164
|
+
uuid: string,
|
|
165
|
+
cwd: string,
|
|
166
|
+
filePath: string
|
|
167
|
+
): Promise<PiSession | null> {
|
|
168
|
+
const entries = await this.readJsonl(filePath);
|
|
169
|
+
const messages: PiMessage[] = [];
|
|
170
|
+
let firstTs: string | null = null;
|
|
171
|
+
let lastTs: string | null = null;
|
|
172
|
+
|
|
173
|
+
for (const entry of entries) {
|
|
174
|
+
if (!isPiMessageEntry(entry)) continue;
|
|
175
|
+
|
|
176
|
+
const role = entry.message.role;
|
|
177
|
+
if (role !== "user" && role !== "assistant") continue;
|
|
178
|
+
|
|
179
|
+
let content = "";
|
|
180
|
+
if (role === "user") {
|
|
181
|
+
content = typeof entry.message.content === "string"
|
|
182
|
+
? entry.message.content.trim()
|
|
183
|
+
: extractAssistantText(entry.message.content);
|
|
184
|
+
} else {
|
|
185
|
+
content = extractAssistantText(entry.message.content);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (!content) continue;
|
|
189
|
+
|
|
190
|
+
const ts = entry.timestamp;
|
|
191
|
+
if (ts) {
|
|
192
|
+
if (!firstTs || ts < firstTs) firstTs = ts;
|
|
193
|
+
if (!lastTs || ts > lastTs) lastTs = ts;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
messages.push({
|
|
197
|
+
id: `${uuid}/${entry.id}`,
|
|
198
|
+
sessionId: uuid,
|
|
199
|
+
role,
|
|
200
|
+
content,
|
|
201
|
+
timestamp: ts ?? new Date(0).toISOString(),
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (!firstTs || !lastTs || messages.length === 0) return null;
|
|
206
|
+
|
|
207
|
+
messages.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
id: uuid,
|
|
211
|
+
title: titleFromCwd(cwd),
|
|
212
|
+
cwd,
|
|
213
|
+
firstMessageAt: firstTs,
|
|
214
|
+
lastMessageAt: lastTs,
|
|
215
|
+
messages,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private async readJsonl(filePath: string): Promise<PiGenericEntry[]> {
|
|
220
|
+
let text: string;
|
|
221
|
+
try {
|
|
222
|
+
text = await _readFile(filePath, "utf-8");
|
|
223
|
+
} catch {
|
|
224
|
+
return [];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const entries: PiGenericEntry[] = [];
|
|
228
|
+
for (const line of text.split("\n")) {
|
|
229
|
+
const trimmed = line.trim();
|
|
230
|
+
if (!trimmed) continue;
|
|
231
|
+
try {
|
|
232
|
+
entries.push(JSON.parse(trimmed) as PiGenericEntry);
|
|
233
|
+
} catch {
|
|
234
|
+
// skip malformed lines
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return entries;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function getDefaultSessionsRoots(): string[] {
|
|
242
|
+
const home = process.env.HOME ?? "~";
|
|
243
|
+
return [
|
|
244
|
+
_join(home, ".pi", "agent", "sessions"),
|
|
245
|
+
_join(home, ".omp", "agent", "sessions"),
|
|
246
|
+
];
|
|
247
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi Integration Types
|
|
3
|
+
*
|
|
4
|
+
* Pi (pi.dev / earendil-works/pi and the oh-my-pi fork) stores sessions as
|
|
5
|
+
* JSONL files under ~/.pi/agent/sessions/ (pi) or ~/.omp/agent/sessions/ (omp).
|
|
6
|
+
*
|
|
7
|
+
* Directory layout:
|
|
8
|
+
* <sessionsRoot>/
|
|
9
|
+
* <cwd-encoded>/ # cwd with "/" replaced by "--"
|
|
10
|
+
* <iso-ts>_<uuid>.jsonl # one file per session
|
|
11
|
+
*
|
|
12
|
+
* Each line in a session file is a JSON entry with a `type` field.
|
|
13
|
+
* We only care about `type: "message"` entries.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// ============================================================================
|
|
17
|
+
// Reader Interface
|
|
18
|
+
// ============================================================================
|
|
19
|
+
|
|
20
|
+
export interface PiMessageWindow {
|
|
21
|
+
message: PiMessage;
|
|
22
|
+
before: PiMessage[];
|
|
23
|
+
after: PiMessage[];
|
|
24
|
+
session: PiSession;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface IPiReader {
|
|
28
|
+
getSessions(): Promise<PiSession[]>;
|
|
29
|
+
getMessageById(
|
|
30
|
+
sessionId: string,
|
|
31
|
+
messageId: string,
|
|
32
|
+
before?: number,
|
|
33
|
+
after?: number
|
|
34
|
+
): Promise<PiMessageWindow | null>;
|
|
35
|
+
isAvailable(): Promise<boolean>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ============================================================================
|
|
39
|
+
// Raw JSONL Entry Types
|
|
40
|
+
// ============================================================================
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A `type: "message"` entry in a Pi session JSONL file.
|
|
44
|
+
* Other entry types (model-change, thinking-level, label, compaction,
|
|
45
|
+
* branch-summary, extension) are skipped during import.
|
|
46
|
+
*/
|
|
47
|
+
export interface PiMessageEntry {
|
|
48
|
+
type: "message";
|
|
49
|
+
id: string;
|
|
50
|
+
parentId?: string;
|
|
51
|
+
timestamp: string;
|
|
52
|
+
message: PiMessagePayload;
|
|
53
|
+
[key: string]: unknown;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface PiMessagePayload {
|
|
57
|
+
role: "user" | "assistant" | "toolResult" | "custom";
|
|
58
|
+
content?: PiContentBlock[] | string;
|
|
59
|
+
// assistant-only fields
|
|
60
|
+
model?: string;
|
|
61
|
+
provider?: string;
|
|
62
|
+
stopReason?: string;
|
|
63
|
+
usage?: unknown;
|
|
64
|
+
// toolResult-only fields
|
|
65
|
+
toolName?: string;
|
|
66
|
+
toolCallId?: string;
|
|
67
|
+
isError?: boolean;
|
|
68
|
+
// custom (extension-injected) fields
|
|
69
|
+
customType?: string;
|
|
70
|
+
display?: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type PiContentBlock =
|
|
74
|
+
| { type: "text"; text: string }
|
|
75
|
+
| { type: "thinking"; thinking: string; thinkingSignature?: string }
|
|
76
|
+
| { type: "toolCall"; id: string; name: string; arguments: unknown }
|
|
77
|
+
| { type: "image"; source: unknown }
|
|
78
|
+
| { type: string; [key: string]: unknown };
|
|
79
|
+
|
|
80
|
+
/** Union of all entry types we may encounter (we skip non-message ones). */
|
|
81
|
+
export interface PiGenericEntry {
|
|
82
|
+
type: string;
|
|
83
|
+
id?: string;
|
|
84
|
+
parentId?: string;
|
|
85
|
+
timestamp?: string;
|
|
86
|
+
[key: string]: unknown;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ============================================================================
|
|
90
|
+
// Cleaned Session / Message Types
|
|
91
|
+
// ============================================================================
|
|
92
|
+
|
|
93
|
+
export interface PiSession {
|
|
94
|
+
/** Full UUID from filename, e.g. "019e45e3-e2e5-7174-8165-da221c147ebb" */
|
|
95
|
+
id: string;
|
|
96
|
+
/** Human-readable title derived from the cwd directory name */
|
|
97
|
+
title: string;
|
|
98
|
+
/** Decoded working directory path */
|
|
99
|
+
cwd: string;
|
|
100
|
+
/** ISO timestamp of the earliest message */
|
|
101
|
+
firstMessageAt: string;
|
|
102
|
+
/** ISO timestamp of the most recent message */
|
|
103
|
+
lastMessageAt: string;
|
|
104
|
+
/** All user/assistant messages, sorted oldest-first */
|
|
105
|
+
messages: PiMessage[];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface PiMessage {
|
|
109
|
+
/** Synthetic stable id: "<sessionUuid>/<entryId>", e.g. "019e45e3.../e5c48339" */
|
|
110
|
+
id: string;
|
|
111
|
+
sessionId: string;
|
|
112
|
+
role: "user" | "assistant";
|
|
113
|
+
content: string;
|
|
114
|
+
timestamp: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ============================================================================
|
|
118
|
+
// Constants
|
|
119
|
+
// ============================================================================
|
|
120
|
+
|
|
121
|
+
/** Single persona name for all Pi/OMP sessions on a machine */
|
|
122
|
+
export const PI_PERSONA_NAME = "Pi";
|
|
123
|
+
|
|
124
|
+
/** Topic groups assigned to Pi session topics */
|
|
125
|
+
export const PI_TOPIC_GROUPS = ["General", "Coding", "Pi"];
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Minimum session age before import, while the tool is running.
|
|
129
|
+
* Mirrors the Claude Code / Codex 20-minute rule so active sessions settle.
|
|
130
|
+
*/
|
|
131
|
+
export { MIN_SESSION_AGE_MS } from "../constants.js";
|
|
132
|
+
|
|
133
|
+
// ============================================================================
|
|
134
|
+
// Human Settings Shape
|
|
135
|
+
// ============================================================================
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Stored under human.settings.pi
|
|
139
|
+
*
|
|
140
|
+
* WARNING: ADDING A NEW FIELD HERE?
|
|
141
|
+
* If it is runtime-managed (not user-editable), also preserve it in
|
|
142
|
+
* settingsFromYAML() in tui/src/util/yaml-settings.ts or /settings will wipe it.
|
|
143
|
+
*/
|
|
144
|
+
export interface PiSettings {
|
|
145
|
+
integration?: boolean;
|
|
146
|
+
polling_interval_ms?: number;
|
|
147
|
+
extraction_model?: string;
|
|
148
|
+
last_sync?: string;
|
|
149
|
+
extraction_point?: string;
|
|
150
|
+
processed_sessions?: Record<string, string>;
|
|
151
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ContextStatus, Message } from "../../core/types.js";
|
|
2
|
+
import { getMachineId } from "../machine-id.js";
|
|
3
|
+
|
|
4
|
+
export type QualifyFn = (machine: string, sessionId: string, nativeId: string) => string;
|
|
5
|
+
|
|
6
|
+
export interface ConvertibleMessage {
|
|
7
|
+
id: string;
|
|
8
|
+
role: "user" | "assistant";
|
|
9
|
+
content: string;
|
|
10
|
+
timestamp: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function convertToEiMessage(
|
|
14
|
+
msg: ConvertibleMessage,
|
|
15
|
+
sessionId: string,
|
|
16
|
+
qualify: QualifyFn
|
|
17
|
+
): Message {
|
|
18
|
+
return {
|
|
19
|
+
id: qualify(getMachineId(), sessionId, msg.id),
|
|
20
|
+
role: msg.role === "user" ? "human" : "system",
|
|
21
|
+
content: msg.content,
|
|
22
|
+
timestamp: msg.timestamp,
|
|
23
|
+
read: true,
|
|
24
|
+
context_status: "default" as ContextStatus,
|
|
25
|
+
external: true,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function convertToPreMarkedEiMessage(
|
|
30
|
+
msg: ConvertibleMessage,
|
|
31
|
+
sessionId: string,
|
|
32
|
+
qualify: QualifyFn
|
|
33
|
+
): Message {
|
|
34
|
+
return {
|
|
35
|
+
...convertToEiMessage(msg, sessionId, qualify),
|
|
36
|
+
f: true,
|
|
37
|
+
t: true,
|
|
38
|
+
p: true,
|
|
39
|
+
e: true,
|
|
40
|
+
};
|
|
41
|
+
}
|
package/tui/README.md
CHANGED
|
@@ -37,6 +37,7 @@ Enable any or all four in `/settings`. They work independently and feed into the
|
|
|
37
37
|
| Claude Code | `claudeCode.integration: true` | `~/.claude/projects/` (JSONL files) |
|
|
38
38
|
| Cursor | `cursor.integration: true` | `~/Library/Application Support/Cursor/User/` (macOS)<br>`%APPDATA%\Cursor\User\` (Windows)<br>`~/.config/Cursor/User/` (Linux) |
|
|
39
39
|
| Codex | `codex.integration: true` | `~/.codex/state_*.sqlite` + `~/.codex/sessions/` rollout JSONL files |
|
|
40
|
+
| Pi / OMP | `pi.integration: true` | `~/.pi/agent/sessions/` (Pi) or `~/.omp/agent/sessions/` (oh-my-pi) — JSONL files |
|
|
40
41
|
|
|
41
42
|
Sessions are processed oldest-first, one per queue cycle. On first run Ei works through your backlog gradually — it won't flood your LLM provider.
|
|
42
43
|
|