pi-session-memory 0.1.3 → 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 +90 -10
- package/extensions/index.ts +255 -16
- package/package.json +1 -1
- package/spec.md +19 -145
- package/src/backfill.ts +121 -21
- package/src/db.ts +290 -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,154 +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
|
-
/backfill command ────────┐ │
|
|
18
|
-
▼ ▼
|
|
19
|
-
Source adapters → SQLite ~/.pi/agent/memory.db
|
|
20
|
-
- Pi JSONL sessions + turns
|
|
21
|
-
- Claude JSONL
|
|
22
|
-
- Codex JSONL
|
|
23
|
-
│
|
|
24
|
-
▼
|
|
25
|
-
recall_memory tool
|
|
26
|
-
- LLM supplies entities[]
|
|
27
|
-
- LIKE substring match + hit-score ranking
|
|
28
|
-
- returns top-5 turns
|
|
29
|
-
```
|
|
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.
|
|
30
11
|
|
|
31
|
-
##
|
|
12
|
+
## Design
|
|
32
13
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
| model_id | TEXT | first model_change value |
|
|
41
|
-
| jsonl_path | TEXT | absolute path to source file |
|
|
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.
|
|
42
21
|
|
|
43
|
-
|
|
44
|
-
| column | type | note |
|
|
45
|
-
|-------------|---------|-------------------------------------------|
|
|
46
|
-
| turn_id | TEXT PK | `{session_id}:{user_message_id}` — stable across live write and backfill |
|
|
47
|
-
| session_id | TEXT FK | |
|
|
48
|
-
| turn_index | INTEGER | display order within session; never used as identity |
|
|
49
|
-
| ts | INTEGER | user message timestamp (unix ms) |
|
|
50
|
-
| user_text | TEXT | user message content |
|
|
51
|
-
| reply_text | TEXT | assistant final text (all text blocks) |
|
|
52
|
-
| tool_names | TEXT | JSON array e.g. `["bash","read"]` |
|
|
22
|
+
## Acceptance criteria
|
|
53
23
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
Trigger: `agent_settled` event, after the agent run and any automatic
|
|
61
|
-
continuations have completed.
|
|
62
|
-
|
|
63
|
-
Steps:
|
|
64
|
-
1. Locate the latest user `SessionEntry` in `ctx.sessionManager.getBranch()` and
|
|
65
|
-
use its stable entry ID as `user_message_id`; do not use pi's transient `turnIndex`.
|
|
66
|
-
2. Extract `user_text` from that user entry's text content blocks.
|
|
67
|
-
3. Walk branch entries after that user entry to collect assistant text blocks →
|
|
68
|
-
`reply_text`, and toolCall names → `tool_names`.
|
|
69
|
-
4. Upsert source=`pi` session row (INSERT OR IGNORE).
|
|
70
|
-
5. Insert turn by stable ID (INSERT OR IGNORE — live writing and backfill target
|
|
71
|
-
the same row).
|
|
72
|
-
|
|
73
|
-
## Retrieval Path (recall_memory tool)
|
|
74
|
-
|
|
75
|
-
### Tool Invocation Policy
|
|
76
|
-
|
|
77
|
-
- **Direct recall:** Call `recall_memory` immediately when the user explicitly
|
|
78
|
-
asks to review, remember, summarize, continue, or compare a prior discussion
|
|
79
|
-
about a topic.
|
|
80
|
-
- **Knowledge-gap recall:** When the user asks about a topic you cannot answer
|
|
81
|
-
confidently from the current conversation and your general knowledge, but it
|
|
82
|
-
may have been discussed in the user's past sessions, ask the user whether they
|
|
83
|
-
want you to search their conversation history. Call `recall_memory` only after
|
|
84
|
-
the user agrees.
|
|
85
|
-
- Do not search history merely because a question is difficult when the user has
|
|
86
|
-
not indicated that their own prior work or discussions are relevant.
|
|
87
|
-
|
|
88
|
-
Input: `{ entities: string[] }` — 2-5 key terms extracted by LLM from user query.
|
|
89
|
-
|
|
90
|
-
Steps:
|
|
91
|
-
1. Build per-entity LIKE hit score:
|
|
92
|
-
- `user_text` match = 2 points
|
|
93
|
-
- `reply_text` match = 1 point
|
|
94
|
-
2. `SELECT ... WHERE (LOWER(user_text) LIKE ? OR LOWER(reply_text) LIKE ?) OR ...`
|
|
95
|
-
3. Escape `%`, `_`, and `\\` in every entity, then use `LIKE ? ESCAPE '\\'` so
|
|
96
|
-
technical names containing LIKE wildcards remain literal substring matches.
|
|
97
|
-
4. `ORDER BY hits DESC, ts DESC LIMIT 5`
|
|
98
|
-
|
|
99
|
-
No FTS5 or write-time preprocessing. LIKE is a literal substring match after
|
|
100
|
-
escaping, so it does not lose substring matches through tokenization.
|
|
101
|
-
|
|
102
|
-
## Backfill
|
|
103
|
-
|
|
104
|
-
`/memory-backfill` scans and imports all historical records. All writes use
|
|
105
|
-
`INSERT OR IGNORE`, so it is safe and idempotent to run repeatedly.
|
|
106
|
-
|
|
107
|
-
| source | scan root | accepted user/assistant records | excluded records |
|
|
108
|
-
|---|---|---|---|
|
|
109
|
-
| pi | `~/.pi/agent/sessions/**/*.jsonl` | `message.role=user|assistant` | thinking, tool results, non-text content |
|
|
110
|
-
| claude | `~/.claude/projects/**/*.jsonl` | `type=user|assistant`, `message.role=user|assistant` | `isMeta`, sidechains, slash commands, local-command tags, continuation summaries, system/snapshot/attachments |
|
|
111
|
-
| 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 |
|
|
112
|
-
|
|
113
|
-
Each adapter outputs the common `ImportedSession` / `ImportedTurn` model.
|
|
114
|
-
Turns pair one accepted user message with all following accepted assistant text
|
|
115
|
-
until the next accepted user message. Each source adapter preserves the native
|
|
116
|
-
user-message ID (`pi entry.id`, Claude `uuid`, Codex `payload.id`) so rerunning
|
|
117
|
-
backfill never creates a duplicate of an already live-written pi turn.
|
|
118
|
-
|
|
119
|
-
## Acceptance Criteria
|
|
120
|
-
|
|
121
|
-
- A pi session with multiple user turns produces one distinct `turns` row per
|
|
122
|
-
user message; no row is dropped because `turnIndex` restarts.
|
|
123
|
-
- Running backfill after live pi writing does not duplicate those pi turns.
|
|
124
|
-
- Claude and Codex injected context records listed above are absent from
|
|
125
|
-
`turns.user_text`.
|
|
126
|
-
- Entity text containing `%`, `_`, or `\\` only matches its literal occurrence.
|
|
127
|
-
- Automated tests cover stable turn identity, LIKE escaping/ranking, and
|
|
128
|
-
cross-source backfill parsing.
|
|
129
|
-
|
|
130
|
-
## File Structure
|
|
131
|
-
|
|
132
|
-
```
|
|
133
|
-
pi-session-memory/
|
|
134
|
-
├── spec.md
|
|
135
|
-
├── package.json
|
|
136
|
-
├── tsconfig.json
|
|
137
|
-
├── extensions/
|
|
138
|
-
│ └── index.ts ← extension entry: tool + /memory-backfill
|
|
139
|
-
├── tests/
|
|
140
|
-
│ └── core.test.ts ← persistence identity and literal-LIKE tests
|
|
141
|
-
└── src/
|
|
142
|
-
├── db.ts ← DatabaseSync schema + upsert helpers
|
|
143
|
-
├── writer.ts ← live pi turn_end writer
|
|
144
|
-
├── retriever.ts ← LIKE query + hit-score ranking
|
|
145
|
-
└── backfill.ts ← Pi / Claude / Codex adapters and import runner
|
|
146
|
-
```
|
|
147
|
-
|
|
148
|
-
## Constraints
|
|
149
|
-
|
|
150
|
-
- Zero external dependencies (use `node:sqlite`, `node:fs`, `node:path`, `node:os`)
|
|
151
|
-
- Idempotent writes (INSERT OR IGNORE on turn_id)
|
|
152
|
-
- Cross-platform paths: storage is `join(homedir(), ".pi", "agent", "memory.db")`; source roots are derived with `join(homedir(), ...)`, never hard-coded POSIX paths.
|
|
153
|
-
- Requires a Pi-supported Node.js runtime that exposes `node:sqlite` (`DatabaseSync`).
|
|
154
|
-
- 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,7 +1,8 @@
|
|
|
1
|
-
import { readdirSync, readFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import { homedir } from "node:os";
|
|
3
4
|
import { join } from "node:path";
|
|
4
|
-
import { insertTurn, upsertSession } from "./db.ts";
|
|
5
|
+
import { getSourceFile, insertTurn, upsertSession, upsertSourceFile } from "./db.ts";
|
|
5
6
|
|
|
6
7
|
type Source = "pi" | "claude" | "codex";
|
|
7
8
|
type Role = "user" | "assistant";
|
|
@@ -24,43 +25,100 @@ interface ImportedSession {
|
|
|
24
25
|
messages: ImportedMessage[];
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
export interface BackfillIssue {
|
|
29
|
+
source: Source;
|
|
30
|
+
jsonlPath: string | null;
|
|
31
|
+
error: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
27
34
|
export interface BackfillStats {
|
|
28
35
|
pi: number;
|
|
29
36
|
claude: number;
|
|
30
37
|
codex: number;
|
|
31
38
|
turns: number;
|
|
39
|
+
scannedFiles: number;
|
|
40
|
+
skippedFiles: number;
|
|
41
|
+
issues: BackfillIssue[];
|
|
32
42
|
}
|
|
33
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
|
+
|
|
51
|
+
const SOURCES: Array<{ source: Source; root: string; parse: (path: string) => ImportedSession | undefined }> = [
|
|
52
|
+
{ source: "pi", root: join(homedir(), ".pi", "agent", "sessions"), parse: _parsePi },
|
|
53
|
+
{ source: "claude", root: join(homedir(), ".claude", "projects"), parse: _parseClaude },
|
|
54
|
+
{ source: "codex", root: join(homedir(), ".codex", "sessions"), parse: _parseCodex },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
/** Reparse every known source file. Intended for the explicit /memory-backfill command. */
|
|
34
58
|
export function backfillAll(): BackfillStats {
|
|
35
|
-
|
|
59
|
+
return _syncHistory(true);
|
|
60
|
+
}
|
|
36
61
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
62
|
+
/** Parse only source files that are new or whose metadata/content changed. */
|
|
63
|
+
export function syncChangedHistory(): BackfillStats {
|
|
64
|
+
return _syncHistory(false);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Synchronize all configured JSONL sources. */
|
|
68
|
+
function _syncHistory(force: boolean): BackfillStats {
|
|
69
|
+
const stats: BackfillStats = { pi: 0, claude: 0, codex: 0, turns: 0, scannedFiles: 0, skippedFiles: 0, issues: [] };
|
|
70
|
+
for (const definition of SOURCES) {
|
|
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));
|
|
42
76
|
}
|
|
43
77
|
}
|
|
78
|
+
return stats;
|
|
79
|
+
}
|
|
44
80
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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) {
|
|
93
|
+
upsertSourceFile({ jsonl_path: jsonlPath, source: definition.source, size: metadata.size, mtime_ms: metadata.mtimeMs, sha256 });
|
|
94
|
+
stats.skippedFiles++;
|
|
95
|
+
return;
|
|
50
96
|
}
|
|
51
|
-
}
|
|
52
97
|
|
|
53
|
-
|
|
54
|
-
const session = _parseCodex(jsonlPath);
|
|
98
|
+
const session = definition.parse(jsonlPath);
|
|
55
99
|
if (session) {
|
|
56
|
-
stats.
|
|
100
|
+
stats[definition.source]++;
|
|
57
101
|
stats.turns += _persist(session);
|
|
102
|
+
upsertSourceFile({ jsonl_path: jsonlPath, source: definition.source, size: metadata.size, mtime_ms: metadata.mtimeMs, sha256 });
|
|
58
103
|
}
|
|
104
|
+
stats.scannedFiles++;
|
|
105
|
+
} catch (error) {
|
|
106
|
+
stats.issues.push(_backfillIssue(definition.source, jsonlPath, error));
|
|
59
107
|
}
|
|
108
|
+
}
|
|
60
109
|
|
|
61
|
-
|
|
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
|
+
};
|
|
62
119
|
}
|
|
63
120
|
|
|
121
|
+
/** Convert one normalized source session into paired, idempotently stored memory turns. */
|
|
64
122
|
function _persist(session: ImportedSession): number {
|
|
65
123
|
const sessionId = `${session.source}:${session.nativeSessionId}`;
|
|
66
124
|
upsertSession({
|
|
@@ -78,6 +136,7 @@ function _persist(session: ImportedSession): number {
|
|
|
78
136
|
const toolNames: string[] = [];
|
|
79
137
|
let persisted = 0;
|
|
80
138
|
|
|
139
|
+
/** Persist the current user-plus-assistant accumulation when a turn boundary is reached. */
|
|
81
140
|
const flush = () => {
|
|
82
141
|
if (!user || !replyText.trim()) return;
|
|
83
142
|
const inserted = insertTurn({
|
|
@@ -109,6 +168,7 @@ function _persist(session: ImportedSession): number {
|
|
|
109
168
|
return persisted;
|
|
110
169
|
}
|
|
111
170
|
|
|
171
|
+
/** Parse a Pi JSONL session into the source-neutral import representation. */
|
|
112
172
|
function _parsePi(jsonlPath: string): ImportedSession | undefined {
|
|
113
173
|
const entries = _readJsonl(jsonlPath);
|
|
114
174
|
const header = entries.find((entry) => entry.type === "session");
|
|
@@ -125,7 +185,13 @@ function _parsePi(jsonlPath: string): ImportedSession | undefined {
|
|
|
125
185
|
const toolNames = message.role === "assistant"
|
|
126
186
|
? message.content.filter((block: any) => block.type === "toolCall").map((block: any) => block.name)
|
|
127
187
|
: [];
|
|
128
|
-
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
|
+
});
|
|
129
195
|
}
|
|
130
196
|
|
|
131
197
|
return {
|
|
@@ -139,6 +205,7 @@ function _parsePi(jsonlPath: string): ImportedSession | undefined {
|
|
|
139
205
|
};
|
|
140
206
|
}
|
|
141
207
|
|
|
208
|
+
/** Parse non-meta Claude Code conversation records into normalized messages. */
|
|
142
209
|
function _parseClaude(jsonlPath: string): ImportedSession | undefined {
|
|
143
210
|
const entries = _readJsonl(jsonlPath);
|
|
144
211
|
const firstConversation = entries.find((entry) =>
|
|
@@ -156,7 +223,14 @@ function _parseClaude(jsonlPath: string): ImportedSession | undefined {
|
|
|
156
223
|
const toolNames = role === "assistant"
|
|
157
224
|
? entry.message.content.filter((block: any) => block.type === "tool_use").map((block: any) => block.name)
|
|
158
225
|
: [];
|
|
159
|
-
|
|
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
|
+
});
|
|
160
234
|
}
|
|
161
235
|
|
|
162
236
|
return {
|
|
@@ -170,6 +244,7 @@ function _parseClaude(jsonlPath: string): ImportedSession | undefined {
|
|
|
170
244
|
};
|
|
171
245
|
}
|
|
172
246
|
|
|
247
|
+
/** Parse Codex session metadata and response-message envelopes into normalized messages. */
|
|
173
248
|
function _parseCodex(jsonlPath: string): ImportedSession | undefined {
|
|
174
249
|
const entries = _readJsonl(jsonlPath);
|
|
175
250
|
const meta = entries.find((entry) => entry.type === "session_meta")?.payload;
|
|
@@ -182,8 +257,14 @@ function _parseCodex(jsonlPath: string): ImportedSession | undefined {
|
|
|
182
257
|
if (payload?.type !== "message" || (payload.role !== "user" && payload.role !== "assistant")) continue;
|
|
183
258
|
const text = _codexText(payload);
|
|
184
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.
|
|
185
261
|
messages.push({
|
|
186
|
-
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
|
+
),
|
|
187
268
|
role: payload.role,
|
|
188
269
|
text,
|
|
189
270
|
ts: Date.parse(entry.timestamp),
|
|
@@ -202,6 +283,14 @@ function _parseCodex(jsonlPath: string): ImportedSession | undefined {
|
|
|
202
283
|
};
|
|
203
284
|
}
|
|
204
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. */
|
|
205
294
|
function _piText(message: any): string {
|
|
206
295
|
if (typeof message.content === "string") return message.content.trim();
|
|
207
296
|
return message.content
|
|
@@ -211,6 +300,7 @@ function _piText(message: any): string {
|
|
|
211
300
|
.trim();
|
|
212
301
|
}
|
|
213
302
|
|
|
303
|
+
/** Extract Claude Code text content from either legacy strings or content blocks. */
|
|
214
304
|
function _claudeText(message: any): string {
|
|
215
305
|
if (typeof message.content === "string") return message.content.trim();
|
|
216
306
|
if (!Array.isArray(message.content)) return "";
|
|
@@ -221,6 +311,7 @@ function _claudeText(message: any): string {
|
|
|
221
311
|
.trim();
|
|
222
312
|
}
|
|
223
313
|
|
|
314
|
+
/** Extract user input and assistant output text from a Codex message payload. */
|
|
224
315
|
function _codexText(message: any): string {
|
|
225
316
|
return message.content
|
|
226
317
|
.filter((block: any) => block.type === "input_text" || block.type === "output_text")
|
|
@@ -229,6 +320,7 @@ function _codexText(message: any): string {
|
|
|
229
320
|
.trim();
|
|
230
321
|
}
|
|
231
322
|
|
|
323
|
+
/** Identify Claude Code client-injected text that must not become user memory. */
|
|
232
324
|
function _isClaudeInjectedContext(text: string): boolean {
|
|
233
325
|
return text.startsWith("<command-name>")
|
|
234
326
|
|| text.startsWith("<command-message>")
|
|
@@ -237,6 +329,7 @@ function _isClaudeInjectedContext(text: string): boolean {
|
|
|
237
329
|
|| text.startsWith("This session is being continued from a previous conversation");
|
|
238
330
|
}
|
|
239
331
|
|
|
332
|
+
/** Identify Codex environment or IDE context that must not become user memory. */
|
|
240
333
|
function _isCodexInjectedContext(text: string): boolean {
|
|
241
334
|
return text.startsWith("# AGENTS.md instructions")
|
|
242
335
|
|| text.startsWith("<environment_context>")
|
|
@@ -244,6 +337,12 @@ function _isCodexInjectedContext(text: string): boolean {
|
|
|
244
337
|
|| text.startsWith("<image name=");
|
|
245
338
|
}
|
|
246
339
|
|
|
340
|
+
/** Hash a source JSONL file so unchanged content can skip reparsing. */
|
|
341
|
+
function _sha256(jsonlPath: string): string {
|
|
342
|
+
return createHash("sha256").update(readFileSync(jsonlPath)).digest("hex");
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Read every non-empty JSONL line into its ordered JSON record. */
|
|
247
346
|
function _readJsonl(jsonlPath: string): any[] {
|
|
248
347
|
return readFileSync(jsonlPath, "utf8")
|
|
249
348
|
.split("\n")
|
|
@@ -251,6 +350,7 @@ function _readJsonl(jsonlPath: string): any[] {
|
|
|
251
350
|
.map((line) => JSON.parse(line));
|
|
252
351
|
}
|
|
253
352
|
|
|
353
|
+
/** Recursively discover JSONL session files under a source root. */
|
|
254
354
|
function _jsonlFiles(root: string): string[] {
|
|
255
355
|
const files: string[] = [];
|
|
256
356
|
for (const entry of readdirSync(root, { withFileTypes: true })) {
|