pi-session-memory 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +5 -0
- package/README.md +83 -11
- package/extensions/index.ts +248 -19
- package/package.json +1 -1
- package/spec.md +19 -163
- package/src/backfill.ts +102 -24
- package/src/db.ts +254 -4
- package/src/helper.ts +44 -0
- package/src/retriever.ts +208 -48
- package/src/session-migration.ts +188 -0
- package/src/writer.ts +3 -0
- package/tests/core.test.ts +223 -4
package/spec.md
CHANGED
|
@@ -1,172 +1,28 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Native Codex-to-Pi Project Session Migration — Spec
|
|
2
2
|
|
|
3
3
|
## Goal
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
`recall_memory` tool so the LLM can retrieve relevant past turns when the user
|
|
7
|
-
references previous discussions.
|
|
5
|
+
Allow a user to convert each historical Codex session for the active project into a separate, native Pi session that can be selected through Pi `/resume` and continued normally.
|
|
8
6
|
|
|
9
|
-
|
|
7
|
+
This is distinct from SQLite historical import and `recall_memory`:
|
|
10
8
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
▼
|
|
14
|
-
Writer (src/writer.ts)
|
|
15
|
-
- writes current pi turns
|
|
16
|
-
│
|
|
17
|
-
session_start ───────────┐ │
|
|
18
|
-
▼ ▼
|
|
19
|
-
Incremental source sync → SQLite ~/.pi/agent/memory.db
|
|
20
|
-
- per-file size + mtime quick check
|
|
21
|
-
- SHA-256 only for changed candidates
|
|
22
|
-
- Pi / Claude / Codex JSONL adapters
|
|
9
|
+
- Native migration writes Pi session JSONL files for direct continuation in Pi.
|
|
10
|
+
- `recall_memory` searches local SQLite excerpts and does not restore a client session.
|
|
23
11
|
|
|
24
|
-
|
|
25
|
-
│
|
|
26
|
-
▼
|
|
27
|
-
recall_memory tool
|
|
28
|
-
- LLM supplies entities[]
|
|
29
|
-
- LIKE substring match + hit-score ranking
|
|
30
|
-
- returns top-5 turns
|
|
31
|
-
```
|
|
12
|
+
## Design
|
|
32
13
|
|
|
33
|
-
|
|
14
|
+
- Export `migrateCodexProjectSessions(cwd)` from `src/session-migration.ts`.
|
|
15
|
+
- Scan Codex JSONL sessions and select only sessions with `session.cwd === cwd`.
|
|
16
|
+
- Create one Pi v3 session JSONL per Codex session under Pi's default session directory for that cwd.
|
|
17
|
+
- Write a `Migrated from Codex: <session-id>` session name so it is recognizable in `/resume`.
|
|
18
|
+
- Convert user and assistant textual messages only. Do not represent Codex system/developer prompts, tool calls, or tool results as Pi conversation messages.
|
|
19
|
+
- Use deterministic output file names and skip an already migrated Codex session, making reruns idempotent.
|
|
20
|
+
- Register `/project-session-migration` for users and `migrate_codex_project_sessions` for Pi agents. Both descriptions must state that this is native Pi continuation, not ordinary recall.
|
|
34
21
|
|
|
35
|
-
|
|
36
|
-
| column | type | note |
|
|
37
|
-
|------------|---------|-------------------------------|
|
|
38
|
-
| session_id | TEXT PK | native source session ID (UUID) |
|
|
39
|
-
| source | TEXT | `pi`, `claude`, or `codex` |
|
|
40
|
-
| cwd | TEXT | working directory |
|
|
41
|
-
| started_at | INTEGER | unix ms |
|
|
42
|
-
| model_id | TEXT | first model_change value |
|
|
43
|
-
| jsonl_path | TEXT | absolute path to source file |
|
|
22
|
+
## Acceptance criteria
|
|
44
23
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
| turn_index | INTEGER | display order within session; never used as identity |
|
|
51
|
-
| ts | INTEGER | user message timestamp (unix ms) |
|
|
52
|
-
| user_text | TEXT | user message content |
|
|
53
|
-
| reply_text | TEXT | assistant final text (all text blocks) |
|
|
54
|
-
| tool_names | TEXT | JSON array e.g. `["bash","read"]` |
|
|
55
|
-
|
|
56
|
-
There is intentionally no full-text virtual table. Retrieval uses escaped
|
|
57
|
-
SQLite `LIKE` against the complete `user_text` and `reply_text`, because literal
|
|
58
|
-
substring coverage and retrieval quality are prioritized over index performance.
|
|
59
|
-
|
|
60
|
-
## Write Path
|
|
61
|
-
|
|
62
|
-
Trigger: `agent_settled` event, after the agent run and any automatic
|
|
63
|
-
continuations have completed.
|
|
64
|
-
|
|
65
|
-
Steps:
|
|
66
|
-
1. Locate the latest user `SessionEntry` in `ctx.sessionManager.getBranch()` and
|
|
67
|
-
use its stable entry ID as `user_message_id`; do not use pi's transient `turnIndex`.
|
|
68
|
-
2. Extract `user_text` from that user entry's text content blocks.
|
|
69
|
-
3. Walk branch entries after that user entry to collect assistant text blocks →
|
|
70
|
-
`reply_text`, and toolCall names → `tool_names`.
|
|
71
|
-
4. Upsert source=`pi` session row (INSERT OR IGNORE).
|
|
72
|
-
5. Insert turn by stable ID (INSERT OR IGNORE — live writing and backfill target
|
|
73
|
-
the same row).
|
|
74
|
-
|
|
75
|
-
## Retrieval Path (recall_memory tool)
|
|
76
|
-
|
|
77
|
-
### source_files
|
|
78
|
-
| column | type | note |
|
|
79
|
-
|---|---|---|
|
|
80
|
-
| jsonl_path | TEXT PK | absolute source file path |
|
|
81
|
-
| source | TEXT | `pi`, `claude`, or `codex` |
|
|
82
|
-
| size | INTEGER | file size at last sync |
|
|
83
|
-
| mtime_ms | REAL | modification time at last sync |
|
|
84
|
-
| sha256 | TEXT | content hash for changed candidates |
|
|
85
|
-
|
|
86
|
-
### Tool Invocation Policy
|
|
87
|
-
|
|
88
|
-
- **Direct recall:** Call `recall_memory` immediately when the user explicitly
|
|
89
|
-
asks to review, remember, summarize, continue, or compare a prior discussion
|
|
90
|
-
about a topic.
|
|
91
|
-
- **Knowledge-gap recall:** When the user asks about a topic you cannot answer
|
|
92
|
-
confidently from the current conversation and your general knowledge, but it
|
|
93
|
-
may have been discussed in the user's past sessions, ask the user whether they
|
|
94
|
-
want you to search their conversation history. Call `recall_memory` only after
|
|
95
|
-
the user agrees.
|
|
96
|
-
- Do not search history merely because a question is difficult when the user has
|
|
97
|
-
not indicated that their own prior work or discussions are relevant.
|
|
98
|
-
|
|
99
|
-
Input: `{ entities: string[] }` — 2-5 key terms extracted by LLM from user query.
|
|
100
|
-
|
|
101
|
-
Steps:
|
|
102
|
-
1. Build per-entity LIKE hit score:
|
|
103
|
-
- `user_text` match = 2 points
|
|
104
|
-
- `reply_text` match = 1 point
|
|
105
|
-
2. `SELECT ... WHERE (LOWER(user_text) LIKE ? OR LOWER(reply_text) LIKE ?) OR ...`
|
|
106
|
-
3. Escape `%`, `_`, and `\\` in every entity, then use `LIKE ? ESCAPE '\\'` so
|
|
107
|
-
technical names containing LIKE wildcards remain literal substring matches.
|
|
108
|
-
4. `ORDER BY hits DESC, ts DESC LIMIT 5`
|
|
109
|
-
|
|
110
|
-
No FTS5 or write-time preprocessing. LIKE is a literal substring match after
|
|
111
|
-
escaping, so it does not lose substring matches through tokenization.
|
|
112
|
-
|
|
113
|
-
## Backfill
|
|
114
|
-
|
|
115
|
-
At `session_start`, `syncChangedHistory()` recursively enumerates the three
|
|
116
|
-
source roots. It records one row per source JSONL file in `source_files`:
|
|
117
|
-
`jsonl_path`, source, size, `mtime_ms`, and SHA-256. Unchanged size/mtime files
|
|
118
|
-
are skipped without reading; changed candidates are SHA-256 checked, and only
|
|
119
|
-
new content is parsed and imported.
|
|
120
|
-
|
|
121
|
-
`/memory-backfill` forces a full reparse of all historical records. All writes
|
|
122
|
-
use `INSERT OR IGNORE`, so both automatic sync and forced backfill are safe and
|
|
123
|
-
idempotent to run repeatedly.
|
|
124
|
-
|
|
125
|
-
| source | scan root | accepted user/assistant records | excluded records |
|
|
126
|
-
|---|---|---|---|
|
|
127
|
-
| pi | `~/.pi/agent/sessions/**/*.jsonl` | `message.role=user|assistant` | thinking, tool results, non-text content |
|
|
128
|
-
| claude | `~/.claude/projects/**/*.jsonl` | `type=user|assistant`, `message.role=user|assistant` | `isMeta`, sidechains, slash commands, local-command tags, continuation summaries, system/snapshot/attachments |
|
|
129
|
-
| codex | `~/.codex/sessions/**/*.jsonl` | `type=response_item`, `payload.type=message`, `role=user|assistant` | developer/system context, AGENTS.md and environment-context injection, IDE/image context, reasoning and tool events |
|
|
130
|
-
|
|
131
|
-
Each adapter outputs the common `ImportedSession` / `ImportedTurn` model.
|
|
132
|
-
Turns pair one accepted user message with all following accepted assistant text
|
|
133
|
-
until the next accepted user message. Each source adapter preserves the native
|
|
134
|
-
user-message ID (`pi entry.id`, Claude `uuid`, Codex `payload.id`) so rerunning
|
|
135
|
-
backfill never creates a duplicate of an already live-written pi turn.
|
|
136
|
-
|
|
137
|
-
## Acceptance Criteria
|
|
138
|
-
|
|
139
|
-
- A pi session with multiple user turns produces one distinct `turns` row per
|
|
140
|
-
user message; no row is dropped because `turnIndex` restarts.
|
|
141
|
-
- Running backfill after live pi writing does not duplicate those pi turns.
|
|
142
|
-
- Claude and Codex injected context records listed above are absent from
|
|
143
|
-
`turns.user_text`.
|
|
144
|
-
- Entity text containing `%`, `_`, or `\\` only matches its literal occurrence.
|
|
145
|
-
- Automated tests cover stable turn identity, LIKE escaping/ranking, and
|
|
146
|
-
cross-source backfill parsing.
|
|
147
|
-
|
|
148
|
-
## File Structure
|
|
149
|
-
|
|
150
|
-
```
|
|
151
|
-
pi-session-memory/
|
|
152
|
-
├── spec.md
|
|
153
|
-
├── package.json
|
|
154
|
-
├── tsconfig.json
|
|
155
|
-
├── extensions/
|
|
156
|
-
│ └── index.ts ← extension entry: tool + /memory-backfill
|
|
157
|
-
├── tests/
|
|
158
|
-
│ └── core.test.ts ← persistence identity and literal-LIKE tests
|
|
159
|
-
└── src/
|
|
160
|
-
├── db.ts ← DatabaseSync schema + upsert helpers
|
|
161
|
-
├── writer.ts ← live pi turn_end writer
|
|
162
|
-
├── retriever.ts ← LIKE query + hit-score ranking
|
|
163
|
-
└── backfill.ts ← Pi / Claude / Codex adapters and import runner
|
|
164
|
-
```
|
|
165
|
-
|
|
166
|
-
## Constraints
|
|
167
|
-
|
|
168
|
-
- Zero external dependencies (use `node:sqlite`, `node:fs`, `node:path`, `node:os`)
|
|
169
|
-
- Idempotent writes (INSERT OR IGNORE on turn_id)
|
|
170
|
-
- Cross-platform paths: storage is `join(homedir(), ".pi", "agent", "memory.db")`; source roots are derived with `join(homedir(), ...)`, never hard-coded POSIX paths.
|
|
171
|
-
- Requires a Pi-supported Node.js runtime that exposes `node:sqlite` (`DatabaseSync`).
|
|
172
|
-
- No fallback / silent failure — let errors surface
|
|
24
|
+
1. Each current-project Codex fixture produces one independently resumable Pi session with the expected user/assistant message sequence.
|
|
25
|
+
2. Another project's Codex session is not migrated.
|
|
26
|
+
3. A second migration skips existing output session files.
|
|
27
|
+
4. A malformed source file becomes an isolated issue and does not block other sessions.
|
|
28
|
+
5. `npm test` and `git diff --check` pass.
|
package/src/backfill.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
@@ -25,6 +25,12 @@ interface ImportedSession {
|
|
|
25
25
|
messages: ImportedMessage[];
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
export interface BackfillIssue {
|
|
29
|
+
source: Source;
|
|
30
|
+
jsonlPath: string | null;
|
|
31
|
+
error: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
28
34
|
export interface BackfillStats {
|
|
29
35
|
pi: number;
|
|
30
36
|
claude: number;
|
|
@@ -32,8 +38,16 @@ export interface BackfillStats {
|
|
|
32
38
|
turns: number;
|
|
33
39
|
scannedFiles: number;
|
|
34
40
|
skippedFiles: number;
|
|
41
|
+
issues: BackfillIssue[];
|
|
35
42
|
}
|
|
36
43
|
|
|
44
|
+
/** Versions whose Claude Code and Codex JSONL schemas this importer was verified against. */
|
|
45
|
+
export const HISTORY_SCHEMA_REFERENCE_VERSIONS = {
|
|
46
|
+
pi: "0.85.1",
|
|
47
|
+
claude: "2.1.234",
|
|
48
|
+
codex: "0.154.0",
|
|
49
|
+
} as const;
|
|
50
|
+
|
|
37
51
|
const SOURCES: Array<{ source: Source; root: string; parse: (path: string) => ImportedSession | undefined }> = [
|
|
38
52
|
{ source: "pi", root: join(homedir(), ".pi", "agent", "sessions"), parse: _parsePi },
|
|
39
53
|
{ source: "claude", root: join(homedir(), ".claude", "projects"), parse: _parseClaude },
|
|
@@ -50,35 +64,61 @@ export function syncChangedHistory(): BackfillStats {
|
|
|
50
64
|
return _syncHistory(false);
|
|
51
65
|
}
|
|
52
66
|
|
|
67
|
+
/** Synchronize all configured JSONL sources. */
|
|
53
68
|
function _syncHistory(force: boolean): BackfillStats {
|
|
54
|
-
const stats: BackfillStats = { pi: 0, claude: 0, codex: 0, turns: 0, scannedFiles: 0, skippedFiles: 0 };
|
|
69
|
+
const stats: BackfillStats = { pi: 0, claude: 0, codex: 0, turns: 0, scannedFiles: 0, skippedFiles: 0, issues: [] };
|
|
55
70
|
for (const definition of SOURCES) {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
71
|
+
if (!existsSync(definition.root)) continue;
|
|
72
|
+
try {
|
|
73
|
+
for (const jsonlPath of _jsonlFiles(definition.root)) _syncSourceFile(definition, jsonlPath, force, stats);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
stats.issues.push(_backfillIssue(definition.source, null, error));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return stats;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Synchronize one source file without allowing its failure to block other files or sources. */
|
|
82
|
+
function _syncSourceFile(definition: typeof SOURCES[number], jsonlPath: string, force: boolean, stats: BackfillStats): void {
|
|
83
|
+
try {
|
|
84
|
+
const metadata = statSync(jsonlPath);
|
|
85
|
+
const known = getSourceFile(jsonlPath);
|
|
86
|
+
if (!force && known?.size === metadata.size && known.mtime_ms === metadata.mtimeMs) {
|
|
87
|
+
stats.skippedFiles++;
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const sha256 = _sha256(jsonlPath);
|
|
92
|
+
if (!force && known?.sha256 === sha256) {
|
|
72
93
|
upsertSourceFile({ jsonl_path: jsonlPath, source: definition.source, size: metadata.size, mtime_ms: metadata.mtimeMs, sha256 });
|
|
73
|
-
stats.
|
|
74
|
-
|
|
94
|
+
stats.skippedFiles++;
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const session = definition.parse(jsonlPath);
|
|
99
|
+
if (session) {
|
|
75
100
|
stats[definition.source]++;
|
|
76
101
|
stats.turns += _persist(session);
|
|
102
|
+
upsertSourceFile({ jsonl_path: jsonlPath, source: definition.source, size: metadata.size, mtime_ms: metadata.mtimeMs, sha256 });
|
|
77
103
|
}
|
|
104
|
+
stats.scannedFiles++;
|
|
105
|
+
} catch (error) {
|
|
106
|
+
stats.issues.push(_backfillIssue(definition.source, jsonlPath, error));
|
|
78
107
|
}
|
|
79
|
-
return stats;
|
|
80
108
|
}
|
|
81
109
|
|
|
110
|
+
/** Format an isolated source failure with the reference version for schema comparison. */
|
|
111
|
+
function _backfillIssue(source: Source, jsonlPath: string | null, error: unknown): BackfillIssue {
|
|
112
|
+
const location = jsonlPath ? ` (${jsonlPath})` : "";
|
|
113
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
114
|
+
return {
|
|
115
|
+
source,
|
|
116
|
+
jsonlPath,
|
|
117
|
+
error: `${source} history import failed${location}: ${message}. Compare the local ${source} version with the supported reference ${HISTORY_SCHEMA_REFERENCE_VERSIONS[source]}; this may be a JSONL schema compatibility issue.`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Convert one normalized source session into paired, idempotently stored memory turns. */
|
|
82
122
|
function _persist(session: ImportedSession): number {
|
|
83
123
|
const sessionId = `${session.source}:${session.nativeSessionId}`;
|
|
84
124
|
upsertSession({
|
|
@@ -96,6 +136,7 @@ function _persist(session: ImportedSession): number {
|
|
|
96
136
|
const toolNames: string[] = [];
|
|
97
137
|
let persisted = 0;
|
|
98
138
|
|
|
139
|
+
/** Persist the current user-plus-assistant accumulation when a turn boundary is reached. */
|
|
99
140
|
const flush = () => {
|
|
100
141
|
if (!user || !replyText.trim()) return;
|
|
101
142
|
const inserted = insertTurn({
|
|
@@ -127,6 +168,7 @@ function _persist(session: ImportedSession): number {
|
|
|
127
168
|
return persisted;
|
|
128
169
|
}
|
|
129
170
|
|
|
171
|
+
/** Parse a Pi JSONL session into the source-neutral import representation. */
|
|
130
172
|
function _parsePi(jsonlPath: string): ImportedSession | undefined {
|
|
131
173
|
const entries = _readJsonl(jsonlPath);
|
|
132
174
|
const header = entries.find((entry) => entry.type === "session");
|
|
@@ -143,7 +185,13 @@ function _parsePi(jsonlPath: string): ImportedSession | undefined {
|
|
|
143
185
|
const toolNames = message.role === "assistant"
|
|
144
186
|
? message.content.filter((block: any) => block.type === "toolCall").map((block: any) => block.name)
|
|
145
187
|
: [];
|
|
146
|
-
messages.push({
|
|
188
|
+
messages.push({
|
|
189
|
+
id: _messageId("Pi entry.id", entry.id),
|
|
190
|
+
role: message.role,
|
|
191
|
+
text,
|
|
192
|
+
ts: message.timestamp,
|
|
193
|
+
toolNames,
|
|
194
|
+
});
|
|
147
195
|
}
|
|
148
196
|
|
|
149
197
|
return {
|
|
@@ -157,6 +205,7 @@ function _parsePi(jsonlPath: string): ImportedSession | undefined {
|
|
|
157
205
|
};
|
|
158
206
|
}
|
|
159
207
|
|
|
208
|
+
/** Parse non-meta Claude Code conversation records into normalized messages. */
|
|
160
209
|
function _parseClaude(jsonlPath: string): ImportedSession | undefined {
|
|
161
210
|
const entries = _readJsonl(jsonlPath);
|
|
162
211
|
const firstConversation = entries.find((entry) =>
|
|
@@ -174,7 +223,14 @@ function _parseClaude(jsonlPath: string): ImportedSession | undefined {
|
|
|
174
223
|
const toolNames = role === "assistant"
|
|
175
224
|
? entry.message.content.filter((block: any) => block.type === "tool_use").map((block: any) => block.name)
|
|
176
225
|
: [];
|
|
177
|
-
|
|
226
|
+
// Workaround: Claude Code JSONL schema differs by version; message IDs may be in uuid or id.
|
|
227
|
+
messages.push({
|
|
228
|
+
id: _messageId("Claude entry.uuid or entry.id", entry.uuid, entry.id),
|
|
229
|
+
role,
|
|
230
|
+
text,
|
|
231
|
+
ts: Date.parse(entry.timestamp),
|
|
232
|
+
toolNames,
|
|
233
|
+
});
|
|
178
234
|
}
|
|
179
235
|
|
|
180
236
|
return {
|
|
@@ -188,6 +244,7 @@ function _parseClaude(jsonlPath: string): ImportedSession | undefined {
|
|
|
188
244
|
};
|
|
189
245
|
}
|
|
190
246
|
|
|
247
|
+
/** Parse Codex session metadata and response-message envelopes into normalized messages. */
|
|
191
248
|
function _parseCodex(jsonlPath: string): ImportedSession | undefined {
|
|
192
249
|
const entries = _readJsonl(jsonlPath);
|
|
193
250
|
const meta = entries.find((entry) => entry.type === "session_meta")?.payload;
|
|
@@ -200,8 +257,14 @@ function _parseCodex(jsonlPath: string): ImportedSession | undefined {
|
|
|
200
257
|
if (payload?.type !== "message" || (payload.role !== "user" && payload.role !== "assistant")) continue;
|
|
201
258
|
const text = _codexText(payload);
|
|
202
259
|
if (!text || (payload.role === "user" && _isCodexInjectedContext(text))) continue;
|
|
260
|
+
// Workaround: Codex JSONL schema differs by version; legacy sessions store the ID as metadata.turn_id.
|
|
203
261
|
messages.push({
|
|
204
|
-
id:
|
|
262
|
+
id: _messageId(
|
|
263
|
+
"Codex payload.id, entry.id, or metadata.turn_id",
|
|
264
|
+
payload.id,
|
|
265
|
+
entry.id,
|
|
266
|
+
payload.internal_chat_message_metadata_passthrough?.turn_id,
|
|
267
|
+
),
|
|
205
268
|
role: payload.role,
|
|
206
269
|
text,
|
|
207
270
|
ts: Date.parse(entry.timestamp),
|
|
@@ -220,6 +283,14 @@ function _parseCodex(jsonlPath: string): ImportedSession | undefined {
|
|
|
220
283
|
};
|
|
221
284
|
}
|
|
222
285
|
|
|
286
|
+
/** Read a source message ID from a known schema field without inventing one for malformed records. */
|
|
287
|
+
function _messageId(field: string, ...values: unknown[]): string {
|
|
288
|
+
const id = values.find((value): value is string => typeof value === "string" && value.length > 0);
|
|
289
|
+
if (!id) throw new Error(`Invalid ${field}: expected a non-empty string`);
|
|
290
|
+
return id;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Extract Pi text content while excluding thinking and non-text blocks. */
|
|
223
294
|
function _piText(message: any): string {
|
|
224
295
|
if (typeof message.content === "string") return message.content.trim();
|
|
225
296
|
return message.content
|
|
@@ -229,6 +300,7 @@ function _piText(message: any): string {
|
|
|
229
300
|
.trim();
|
|
230
301
|
}
|
|
231
302
|
|
|
303
|
+
/** Extract Claude Code text content from either legacy strings or content blocks. */
|
|
232
304
|
function _claudeText(message: any): string {
|
|
233
305
|
if (typeof message.content === "string") return message.content.trim();
|
|
234
306
|
if (!Array.isArray(message.content)) return "";
|
|
@@ -239,6 +311,7 @@ function _claudeText(message: any): string {
|
|
|
239
311
|
.trim();
|
|
240
312
|
}
|
|
241
313
|
|
|
314
|
+
/** Extract user input and assistant output text from a Codex message payload. */
|
|
242
315
|
function _codexText(message: any): string {
|
|
243
316
|
return message.content
|
|
244
317
|
.filter((block: any) => block.type === "input_text" || block.type === "output_text")
|
|
@@ -247,6 +320,7 @@ function _codexText(message: any): string {
|
|
|
247
320
|
.trim();
|
|
248
321
|
}
|
|
249
322
|
|
|
323
|
+
/** Identify Claude Code client-injected text that must not become user memory. */
|
|
250
324
|
function _isClaudeInjectedContext(text: string): boolean {
|
|
251
325
|
return text.startsWith("<command-name>")
|
|
252
326
|
|| text.startsWith("<command-message>")
|
|
@@ -255,6 +329,7 @@ function _isClaudeInjectedContext(text: string): boolean {
|
|
|
255
329
|
|| text.startsWith("This session is being continued from a previous conversation");
|
|
256
330
|
}
|
|
257
331
|
|
|
332
|
+
/** Identify Codex environment or IDE context that must not become user memory. */
|
|
258
333
|
function _isCodexInjectedContext(text: string): boolean {
|
|
259
334
|
return text.startsWith("# AGENTS.md instructions")
|
|
260
335
|
|| text.startsWith("<environment_context>")
|
|
@@ -262,10 +337,12 @@ function _isCodexInjectedContext(text: string): boolean {
|
|
|
262
337
|
|| text.startsWith("<image name=");
|
|
263
338
|
}
|
|
264
339
|
|
|
340
|
+
/** Hash a source JSONL file so unchanged content can skip reparsing. */
|
|
265
341
|
function _sha256(jsonlPath: string): string {
|
|
266
342
|
return createHash("sha256").update(readFileSync(jsonlPath)).digest("hex");
|
|
267
343
|
}
|
|
268
344
|
|
|
345
|
+
/** Read every non-empty JSONL line into its ordered JSON record. */
|
|
269
346
|
function _readJsonl(jsonlPath: string): any[] {
|
|
270
347
|
return readFileSync(jsonlPath, "utf8")
|
|
271
348
|
.split("\n")
|
|
@@ -273,6 +350,7 @@ function _readJsonl(jsonlPath: string): any[] {
|
|
|
273
350
|
.map((line) => JSON.parse(line));
|
|
274
351
|
}
|
|
275
352
|
|
|
353
|
+
/** Recursively discover JSONL session files under a source root. */
|
|
276
354
|
function _jsonlFiles(root: string): string[] {
|
|
277
355
|
const files: string[] = [];
|
|
278
356
|
for (const entry of readdirSync(root, { withFileTypes: true })) {
|