pi-session-memory 0.1.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/.codegraph/.gitignore +5 -0
- package/extensions/index.ts +66 -0
- package/package.json +19 -0
- package/spec.md +153 -0
- package/src/backfill.ts +262 -0
- package/src/db.ts +99 -0
- package/src/retriever.ts +70 -0
- package/src/writer.ts +66 -0
- package/tests/core.test.ts +67 -0
- package/tsconfig.json +10 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { writeTurn } from "../src/writer.ts";
|
|
4
|
+
import { recallTurns, formatRecallResults } from "../src/retriever.ts";
|
|
5
|
+
import { backfillAll } from "../src/backfill.ts";
|
|
6
|
+
|
|
7
|
+
export default function (pi: ExtensionAPI) {
|
|
8
|
+
|
|
9
|
+
// ── Write: persist each completed agent run to SQLite ────────────────────
|
|
10
|
+
pi.on("agent_settled", async (_event, ctx) => {
|
|
11
|
+
try {
|
|
12
|
+
writeTurn(ctx);
|
|
13
|
+
} catch (err) {
|
|
14
|
+
ctx.ui.notify(`[session-memory] write failed: ${String(err)}`, "error");
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
pi.registerCommand("memory-backfill", {
|
|
19
|
+
description: "Import historical Pi, Claude Code, and Codex sessions into memory.db",
|
|
20
|
+
handler: async (_args, ctx) => {
|
|
21
|
+
const stats = backfillAll();
|
|
22
|
+
ctx.ui.notify(
|
|
23
|
+
`[session-memory] imported ${stats.turns} turns from ${stats.pi} Pi, ${stats.claude} Claude, ${stats.codex} Codex sessions`,
|
|
24
|
+
"info",
|
|
25
|
+
);
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// ── Read: recall_memory tool ──────────────────────────────────────────────
|
|
30
|
+
pi.registerTool({
|
|
31
|
+
name: "recall_memory",
|
|
32
|
+
label: "Recall Memory",
|
|
33
|
+
description: `Search this user's past conversation history across Pi, Claude Code, and Codex.
|
|
34
|
+
|
|
35
|
+
Invocation policy:
|
|
36
|
+
1. Call this tool immediately when the user explicitly asks to review, remember, summarize, continue, or compare a previous discussion about a topic. Examples:
|
|
37
|
+
- "我之前关于 xxx 的实践" / "I previously worked on xxx"
|
|
38
|
+
- "上次我们讨论过..." / "last time we discussed..."
|
|
39
|
+
- "你还记得那个 xxx 项目吗" / "remember that xxx project?"
|
|
40
|
+
- "之前那个方案怎么说的" / "what was that plan we had?"
|
|
41
|
+
2. When you cannot confidently answer from the current conversation and your general knowledge, but the user may have discussed the topic in prior sessions, first ask whether they want you to search their conversation history. Call this tool only after they agree.
|
|
42
|
+
3. Do not search history merely because a question is difficult when the user's prior discussions are not relevant.
|
|
43
|
+
|
|
44
|
+
Extract 2–5 specific entities from the user's topic: project names, tool names, technologies, domain terms, or identifiers.`,
|
|
45
|
+
|
|
46
|
+
parameters: Type.Object({
|
|
47
|
+
entities: Type.Array(
|
|
48
|
+
Type.String({ minLength: 1 }),
|
|
49
|
+
{
|
|
50
|
+
description: 'Key technical terms from the query. E.g. ["payroll", "LangGraph", "A2A"]',
|
|
51
|
+
minItems: 1,
|
|
52
|
+
maxItems: 8,
|
|
53
|
+
},
|
|
54
|
+
),
|
|
55
|
+
}),
|
|
56
|
+
|
|
57
|
+
async execute(_toolCallId, { entities }) {
|
|
58
|
+
const results = recallTurns(entities, 5);
|
|
59
|
+
const text = formatRecallResults(results);
|
|
60
|
+
return {
|
|
61
|
+
content: [{ type: "text" as const, text }],
|
|
62
|
+
details: { entities, resultCount: results.length },
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-session-memory",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi extension: persists conversation turns to SQLite and exposes recall_memory tool for cross-session retrieval",
|
|
5
|
+
"keywords": ["pi-package"],
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "tsx tests/core.test.ts"
|
|
9
|
+
},
|
|
10
|
+
"devDependencies": {
|
|
11
|
+
"tsx": "^4.23.13"
|
|
12
|
+
},
|
|
13
|
+
"pi": {
|
|
14
|
+
"extensions": ["./extensions"]
|
|
15
|
+
},
|
|
16
|
+
"peerDependencies": {
|
|
17
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
18
|
+
}
|
|
19
|
+
}
|
package/spec.md
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# pi-session-memory — Spec
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
A pi extension that persists every conversation turn to SQLite and exposes a
|
|
6
|
+
`recall_memory` tool so the LLM can retrieve relevant past turns when the user
|
|
7
|
+
references previous discussions.
|
|
8
|
+
|
|
9
|
+
## Architecture
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
pi turn_end event ──────────────────────────┐
|
|
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
|
+
```
|
|
30
|
+
|
|
31
|
+
## Tables
|
|
32
|
+
|
|
33
|
+
### sessions
|
|
34
|
+
| column | type | note |
|
|
35
|
+
|------------|---------|-------------------------------|
|
|
36
|
+
| session_id | TEXT PK | native source session ID (UUID) |
|
|
37
|
+
| source | TEXT | `pi`, `claude`, or `codex` |
|
|
38
|
+
| cwd | TEXT | working directory |
|
|
39
|
+
| started_at | INTEGER | unix ms |
|
|
40
|
+
| model_id | TEXT | first model_change value |
|
|
41
|
+
| jsonl_path | TEXT | absolute path to source file |
|
|
42
|
+
|
|
43
|
+
### turns
|
|
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"]` |
|
|
53
|
+
|
|
54
|
+
There is intentionally no full-text virtual table. Retrieval uses escaped
|
|
55
|
+
SQLite `LIKE` against the complete `user_text` and `reply_text`, because literal
|
|
56
|
+
substring coverage and retrieval quality are prioritized over index performance.
|
|
57
|
+
|
|
58
|
+
## Write Path
|
|
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
|
+
- DB path: `~/.pi/agent/memory.db`
|
|
153
|
+
- No fallback / silent failure — let errors surface
|
package/src/backfill.ts
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { insertTurn, upsertSession } from "./db.ts";
|
|
5
|
+
|
|
6
|
+
type Source = "pi" | "claude" | "codex";
|
|
7
|
+
type Role = "user" | "assistant";
|
|
8
|
+
|
|
9
|
+
interface ImportedMessage {
|
|
10
|
+
id: string;
|
|
11
|
+
role: Role;
|
|
12
|
+
text: string;
|
|
13
|
+
ts: number;
|
|
14
|
+
toolNames: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ImportedSession {
|
|
18
|
+
source: Source;
|
|
19
|
+
nativeSessionId: string;
|
|
20
|
+
cwd: string;
|
|
21
|
+
startedAt: number;
|
|
22
|
+
modelId: string | null;
|
|
23
|
+
jsonlPath: string;
|
|
24
|
+
messages: ImportedMessage[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface BackfillStats {
|
|
28
|
+
pi: number;
|
|
29
|
+
claude: number;
|
|
30
|
+
codex: number;
|
|
31
|
+
turns: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function backfillAll(): BackfillStats {
|
|
35
|
+
const stats: BackfillStats = { pi: 0, claude: 0, codex: 0, turns: 0 };
|
|
36
|
+
|
|
37
|
+
for (const jsonlPath of _jsonlFiles(join(homedir(), ".pi", "agent", "sessions"))) {
|
|
38
|
+
const session = _parsePi(jsonlPath);
|
|
39
|
+
if (session) {
|
|
40
|
+
stats.pi++;
|
|
41
|
+
stats.turns += _persist(session);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (const jsonlPath of _jsonlFiles(join(homedir(), ".claude", "projects"))) {
|
|
46
|
+
const session = _parseClaude(jsonlPath);
|
|
47
|
+
if (session) {
|
|
48
|
+
stats.claude++;
|
|
49
|
+
stats.turns += _persist(session);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
for (const jsonlPath of _jsonlFiles(join(homedir(), ".codex", "sessions"))) {
|
|
54
|
+
const session = _parseCodex(jsonlPath);
|
|
55
|
+
if (session) {
|
|
56
|
+
stats.codex++;
|
|
57
|
+
stats.turns += _persist(session);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return stats;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function _persist(session: ImportedSession): number {
|
|
65
|
+
const sessionId = `${session.source}:${session.nativeSessionId}`;
|
|
66
|
+
upsertSession({
|
|
67
|
+
session_id: sessionId,
|
|
68
|
+
source: session.source,
|
|
69
|
+
cwd: session.cwd,
|
|
70
|
+
started_at: session.startedAt,
|
|
71
|
+
model_id: session.modelId,
|
|
72
|
+
jsonl_path: session.jsonlPath,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
let turnIndex = 0;
|
|
76
|
+
let user: ImportedMessage | undefined;
|
|
77
|
+
let replyText = "";
|
|
78
|
+
const toolNames: string[] = [];
|
|
79
|
+
let persisted = 0;
|
|
80
|
+
|
|
81
|
+
const flush = () => {
|
|
82
|
+
if (!user || !replyText.trim()) return;
|
|
83
|
+
const inserted = insertTurn({
|
|
84
|
+
turn_id: `${sessionId}:${user.id}`,
|
|
85
|
+
session_id: sessionId,
|
|
86
|
+
turn_index: turnIndex++,
|
|
87
|
+
ts: user.ts,
|
|
88
|
+
user_text: user.text,
|
|
89
|
+
reply_text: replyText.trim(),
|
|
90
|
+
tool_names: toolNames.length ? JSON.stringify([...new Set(toolNames)]) : null,
|
|
91
|
+
user_message_id: user.id,
|
|
92
|
+
});
|
|
93
|
+
if (inserted) persisted++;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
for (const message of session.messages) {
|
|
97
|
+
if (message.role === "user") {
|
|
98
|
+
flush();
|
|
99
|
+
user = message;
|
|
100
|
+
replyText = "";
|
|
101
|
+
toolNames.length = 0;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (!user) continue;
|
|
105
|
+
if (message.text) replyText += (replyText ? "\n" : "") + message.text;
|
|
106
|
+
toolNames.push(...message.toolNames);
|
|
107
|
+
}
|
|
108
|
+
flush();
|
|
109
|
+
return persisted;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function _parsePi(jsonlPath: string): ImportedSession | undefined {
|
|
113
|
+
const entries = _readJsonl(jsonlPath);
|
|
114
|
+
const header = entries.find((entry) => entry.type === "session");
|
|
115
|
+
if (!header) return undefined;
|
|
116
|
+
const model = entries.find((entry) => entry.type === "model_change");
|
|
117
|
+
const messages: ImportedMessage[] = [];
|
|
118
|
+
|
|
119
|
+
for (const entry of entries) {
|
|
120
|
+
if (entry.type !== "message") continue;
|
|
121
|
+
const message = entry.message;
|
|
122
|
+
if (message?.role !== "user" && message?.role !== "assistant") continue;
|
|
123
|
+
const text = _piText(message);
|
|
124
|
+
if (!text) continue;
|
|
125
|
+
const toolNames = message.role === "assistant"
|
|
126
|
+
? message.content.filter((block: any) => block.type === "toolCall").map((block: any) => block.name)
|
|
127
|
+
: [];
|
|
128
|
+
messages.push({ id: entry.id, role: message.role, text, ts: message.timestamp, toolNames });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
source: "pi",
|
|
133
|
+
nativeSessionId: header.id,
|
|
134
|
+
cwd: header.cwd,
|
|
135
|
+
startedAt: Date.parse(header.timestamp),
|
|
136
|
+
modelId: model?.modelId ?? null,
|
|
137
|
+
jsonlPath,
|
|
138
|
+
messages,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function _parseClaude(jsonlPath: string): ImportedSession | undefined {
|
|
143
|
+
const entries = _readJsonl(jsonlPath);
|
|
144
|
+
const firstConversation = entries.find((entry) =>
|
|
145
|
+
(entry.type === "user" || entry.type === "assistant") && !entry.isMeta && !entry.isSidechain,
|
|
146
|
+
);
|
|
147
|
+
if (!firstConversation?.sessionId) return undefined;
|
|
148
|
+
|
|
149
|
+
const messages: ImportedMessage[] = [];
|
|
150
|
+
for (const entry of entries) {
|
|
151
|
+
if ((entry.type !== "user" && entry.type !== "assistant") || entry.isMeta || entry.isSidechain) continue;
|
|
152
|
+
const role = entry.message?.role;
|
|
153
|
+
if (role !== "user" && role !== "assistant") continue;
|
|
154
|
+
const text = _claudeText(entry.message);
|
|
155
|
+
if (!text || (role === "user" && _isClaudeInjectedContext(text))) continue;
|
|
156
|
+
const toolNames = role === "assistant"
|
|
157
|
+
? entry.message.content.filter((block: any) => block.type === "tool_use").map((block: any) => block.name)
|
|
158
|
+
: [];
|
|
159
|
+
messages.push({ id: entry.uuid, role, text, ts: Date.parse(entry.timestamp), toolNames });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
source: "claude",
|
|
164
|
+
nativeSessionId: firstConversation.sessionId,
|
|
165
|
+
cwd: firstConversation.cwd ?? "",
|
|
166
|
+
startedAt: Date.parse(firstConversation.timestamp),
|
|
167
|
+
modelId: null,
|
|
168
|
+
jsonlPath,
|
|
169
|
+
messages,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function _parseCodex(jsonlPath: string): ImportedSession | undefined {
|
|
174
|
+
const entries = _readJsonl(jsonlPath);
|
|
175
|
+
const meta = entries.find((entry) => entry.type === "session_meta")?.payload;
|
|
176
|
+
if (!meta?.session_id) return undefined;
|
|
177
|
+
|
|
178
|
+
const messages: ImportedMessage[] = [];
|
|
179
|
+
for (const entry of entries) {
|
|
180
|
+
if (entry.type !== "response_item") continue;
|
|
181
|
+
const payload = entry.payload;
|
|
182
|
+
if (payload?.type !== "message" || (payload.role !== "user" && payload.role !== "assistant")) continue;
|
|
183
|
+
const text = _codexText(payload);
|
|
184
|
+
if (!text || (payload.role === "user" && _isCodexInjectedContext(text))) continue;
|
|
185
|
+
messages.push({
|
|
186
|
+
id: payload.id,
|
|
187
|
+
role: payload.role,
|
|
188
|
+
text,
|
|
189
|
+
ts: Date.parse(entry.timestamp),
|
|
190
|
+
toolNames: [],
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
source: "codex",
|
|
196
|
+
nativeSessionId: meta.session_id,
|
|
197
|
+
cwd: meta.cwd ?? "",
|
|
198
|
+
startedAt: Date.parse(meta.timestamp),
|
|
199
|
+
modelId: meta.model_provider ?? null,
|
|
200
|
+
jsonlPath,
|
|
201
|
+
messages,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function _piText(message: any): string {
|
|
206
|
+
if (typeof message.content === "string") return message.content.trim();
|
|
207
|
+
return message.content
|
|
208
|
+
.filter((block: any) => block.type === "text")
|
|
209
|
+
.map((block: any) => block.text ?? "")
|
|
210
|
+
.join("\n")
|
|
211
|
+
.trim();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function _claudeText(message: any): string {
|
|
215
|
+
if (typeof message.content === "string") return message.content.trim();
|
|
216
|
+
if (!Array.isArray(message.content)) return "";
|
|
217
|
+
return message.content
|
|
218
|
+
.filter((block: any) => block.type === "text")
|
|
219
|
+
.map((block: any) => block.text ?? "")
|
|
220
|
+
.join("\n")
|
|
221
|
+
.trim();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function _codexText(message: any): string {
|
|
225
|
+
return message.content
|
|
226
|
+
.filter((block: any) => block.type === "input_text" || block.type === "output_text")
|
|
227
|
+
.map((block: any) => block.text ?? "")
|
|
228
|
+
.join("\n")
|
|
229
|
+
.trim();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function _isClaudeInjectedContext(text: string): boolean {
|
|
233
|
+
return text.startsWith("<command-name>")
|
|
234
|
+
|| text.startsWith("<command-message>")
|
|
235
|
+
|| text.startsWith("<local-command-")
|
|
236
|
+
|| text.startsWith("<task-notification>")
|
|
237
|
+
|| text.startsWith("This session is being continued from a previous conversation");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function _isCodexInjectedContext(text: string): boolean {
|
|
241
|
+
return text.startsWith("# AGENTS.md instructions")
|
|
242
|
+
|| text.startsWith("<environment_context>")
|
|
243
|
+
|| text.startsWith("# Context from my IDE setup:")
|
|
244
|
+
|| text.startsWith("<image name=");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function _readJsonl(jsonlPath: string): any[] {
|
|
248
|
+
return readFileSync(jsonlPath, "utf8")
|
|
249
|
+
.split("\n")
|
|
250
|
+
.filter(Boolean)
|
|
251
|
+
.map((line) => JSON.parse(line));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function _jsonlFiles(root: string): string[] {
|
|
255
|
+
const files: string[] = [];
|
|
256
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
257
|
+
const path = join(root, entry.name);
|
|
258
|
+
if (entry.isDirectory()) files.push(..._jsonlFiles(path));
|
|
259
|
+
if (entry.isFile() && entry.name.endsWith(".jsonl")) files.push(path);
|
|
260
|
+
}
|
|
261
|
+
return files;
|
|
262
|
+
}
|
package/src/db.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { mkdirSync } from "node:fs";
|
|
5
|
+
|
|
6
|
+
const DB_PATH = process.env.MEMORY_DB_PATH ?? join(homedir(), ".pi", "agent", "memory.db");
|
|
7
|
+
|
|
8
|
+
const SCHEMA = `
|
|
9
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
10
|
+
session_id TEXT PRIMARY KEY,
|
|
11
|
+
source TEXT NOT NULL DEFAULT 'pi',
|
|
12
|
+
cwd TEXT NOT NULL,
|
|
13
|
+
started_at INTEGER NOT NULL,
|
|
14
|
+
model_id TEXT,
|
|
15
|
+
jsonl_path TEXT NOT NULL
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_time ON sessions(started_at DESC);
|
|
19
|
+
|
|
20
|
+
CREATE TABLE IF NOT EXISTS turns (
|
|
21
|
+
turn_id TEXT PRIMARY KEY,
|
|
22
|
+
session_id TEXT NOT NULL REFERENCES sessions(session_id),
|
|
23
|
+
turn_index INTEGER NOT NULL,
|
|
24
|
+
ts INTEGER NOT NULL,
|
|
25
|
+
user_text TEXT NOT NULL,
|
|
26
|
+
reply_text TEXT NOT NULL,
|
|
27
|
+
tool_names TEXT,
|
|
28
|
+
user_message_id TEXT
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
CREATE INDEX IF NOT EXISTS idx_turns_session ON turns(session_id, turn_index);
|
|
32
|
+
CREATE INDEX IF NOT EXISTS idx_turns_ts ON turns(ts DESC);
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
let _db: DatabaseSync | undefined;
|
|
36
|
+
|
|
37
|
+
export function getDb(): DatabaseSync {
|
|
38
|
+
if (_db) return _db;
|
|
39
|
+
mkdirSync(join(homedir(), ".pi", "agent"), { recursive: true });
|
|
40
|
+
_db = new DatabaseSync(DB_PATH);
|
|
41
|
+
_db.exec("PRAGMA journal_mode = WAL");
|
|
42
|
+
_db.exec("PRAGMA synchronous = NORMAL");
|
|
43
|
+
_db.exec(SCHEMA);
|
|
44
|
+
_migrate(_db);
|
|
45
|
+
return _db;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function _migrate(db: DatabaseSync): void {
|
|
49
|
+
const sessionColumns = db.prepare("PRAGMA table_info(sessions)").all() as Array<{ name: string }>;
|
|
50
|
+
if (!sessionColumns.some((column) => column.name === "source")) {
|
|
51
|
+
db.exec("ALTER TABLE sessions ADD COLUMN source TEXT NOT NULL DEFAULT 'pi'");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const turnColumns = db.prepare("PRAGMA table_info(turns)").all() as Array<{ name: string }>;
|
|
55
|
+
if (!turnColumns.some((column) => column.name === "user_message_id")) {
|
|
56
|
+
db.exec("ALTER TABLE turns ADD COLUMN user_message_id TEXT");
|
|
57
|
+
db.exec("DELETE FROM turns WHERE user_message_id IS NULL");
|
|
58
|
+
db.exec("DELETE FROM sessions WHERE session_id NOT IN (SELECT DISTINCT session_id FROM turns)");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface SessionRow {
|
|
63
|
+
session_id: string;
|
|
64
|
+
source: "pi" | "claude" | "codex";
|
|
65
|
+
cwd: string;
|
|
66
|
+
started_at: number;
|
|
67
|
+
model_id: string | null;
|
|
68
|
+
jsonl_path: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface TurnRow {
|
|
72
|
+
turn_id: string;
|
|
73
|
+
session_id: string;
|
|
74
|
+
turn_index: number;
|
|
75
|
+
ts: number;
|
|
76
|
+
user_text: string;
|
|
77
|
+
reply_text: string;
|
|
78
|
+
tool_names: string | null;
|
|
79
|
+
user_message_id: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function upsertSession(row: SessionRow): void {
|
|
83
|
+
getDb().prepare(`
|
|
84
|
+
INSERT OR IGNORE INTO sessions (session_id, source, cwd, started_at, model_id, jsonl_path)
|
|
85
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
86
|
+
`).run(row.session_id, row.source, row.cwd, row.started_at, row.model_id, row.jsonl_path);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function insertTurn(row: TurnRow): boolean {
|
|
90
|
+
const result = getDb().prepare(`
|
|
91
|
+
INSERT OR IGNORE INTO turns
|
|
92
|
+
(turn_id, session_id, turn_index, ts, user_text, reply_text, tool_names, user_message_id)
|
|
93
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
94
|
+
`).run(
|
|
95
|
+
row.turn_id, row.session_id, row.turn_index, row.ts,
|
|
96
|
+
row.user_text, row.reply_text, row.tool_names, row.user_message_id,
|
|
97
|
+
);
|
|
98
|
+
return result.changes === 1;
|
|
99
|
+
}
|
package/src/retriever.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { getDb } from "./db.ts";
|
|
2
|
+
|
|
3
|
+
export interface RecallResult {
|
|
4
|
+
turn_id: string;
|
|
5
|
+
source: "pi" | "claude" | "codex";
|
|
6
|
+
ts: number;
|
|
7
|
+
user_text: string;
|
|
8
|
+
reply_text: string;
|
|
9
|
+
hits: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function recallTurns(entities: string[], topK = 5): RecallResult[] {
|
|
13
|
+
if (entities.length === 0) return [];
|
|
14
|
+
|
|
15
|
+
const scoreExpression = entities.map(() => `(
|
|
16
|
+
CASE WHEN LOWER(turns.user_text) LIKE ? ESCAPE '\\' THEN 2 ELSE 0 END +
|
|
17
|
+
CASE WHEN LOWER(turns.reply_text) LIKE ? ESCAPE '\\' THEN 1 ELSE 0 END
|
|
18
|
+
)`).join(" + ");
|
|
19
|
+
const scoreParameters = entities.flatMap((entity) => {
|
|
20
|
+
const pattern = _likePattern(entity);
|
|
21
|
+
return [pattern, pattern];
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const whereExpression = entities.map(() => `(
|
|
25
|
+
LOWER(turns.user_text) LIKE ? ESCAPE '\\' OR
|
|
26
|
+
LOWER(turns.reply_text) LIKE ? ESCAPE '\\'
|
|
27
|
+
)`).join(" OR ");
|
|
28
|
+
const whereParameters = entities.flatMap((entity) => {
|
|
29
|
+
const pattern = _likePattern(entity);
|
|
30
|
+
return [pattern, pattern];
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
return getDb().prepare(`
|
|
34
|
+
SELECT
|
|
35
|
+
turns.turn_id,
|
|
36
|
+
sessions.source,
|
|
37
|
+
turns.ts,
|
|
38
|
+
turns.user_text,
|
|
39
|
+
turns.reply_text,
|
|
40
|
+
(${scoreExpression}) AS hits
|
|
41
|
+
FROM turns
|
|
42
|
+
JOIN sessions ON sessions.session_id = turns.session_id
|
|
43
|
+
WHERE ${whereExpression}
|
|
44
|
+
ORDER BY hits DESC, turns.ts DESC
|
|
45
|
+
LIMIT ?
|
|
46
|
+
`).all(...scoreParameters, ...whereParameters, topK) as RecallResult[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function formatRecallResults(results: RecallResult[]): string {
|
|
50
|
+
if (results.length === 0) return "No relevant past conversations found.";
|
|
51
|
+
|
|
52
|
+
const lines = ["## Relevant past conversations\n"];
|
|
53
|
+
for (const result of results) {
|
|
54
|
+
const date = new Date(result.ts).toLocaleString();
|
|
55
|
+
lines.push(`### [${result.source} · ${date}]`);
|
|
56
|
+
lines.push(`**You:** ${result.user_text}`);
|
|
57
|
+
if (result.reply_text) {
|
|
58
|
+
const preview = result.reply_text.length > 500
|
|
59
|
+
? `${result.reply_text.slice(0, 500)}…`
|
|
60
|
+
: result.reply_text;
|
|
61
|
+
lines.push(`**Assistant:** ${preview}`);
|
|
62
|
+
}
|
|
63
|
+
lines.push("");
|
|
64
|
+
}
|
|
65
|
+
return lines.join("\n");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function _likePattern(entity: string): string {
|
|
69
|
+
return `%${entity.toLowerCase().replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_")}%`;
|
|
70
|
+
}
|
package/src/writer.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
3
|
+
import { upsertSession, insertTurn } from "./db.ts";
|
|
4
|
+
|
|
5
|
+
export function writeTurn(ctx: ExtensionContext): void {
|
|
6
|
+
const sessionManager = ctx.sessionManager;
|
|
7
|
+
const sessionId = `pi:${sessionManager.getSessionId()}`;
|
|
8
|
+
const branch = sessionManager.getBranch();
|
|
9
|
+
const userEntry = [...branch].reverse().find((entry) =>
|
|
10
|
+
entry.type === "message" && entry.message.role === "user",
|
|
11
|
+
);
|
|
12
|
+
if (!userEntry) throw new Error("Current session branch has no user message");
|
|
13
|
+
|
|
14
|
+
const userText = _extractText(userEntry.message);
|
|
15
|
+
if (!userText) return;
|
|
16
|
+
|
|
17
|
+
let replyText = "";
|
|
18
|
+
const toolNames: string[] = [];
|
|
19
|
+
const userIndex = branch.indexOf(userEntry);
|
|
20
|
+
for (const entry of branch.slice(userIndex + 1)) {
|
|
21
|
+
if (entry.type !== "message" || entry.message.role !== "assistant") continue;
|
|
22
|
+
const message = entry.message as AssistantMessage;
|
|
23
|
+
for (const block of message.content) {
|
|
24
|
+
if (block.type === "text" && block.text.trim()) {
|
|
25
|
+
replyText += (replyText ? "\n" : "") + block.text.trim();
|
|
26
|
+
}
|
|
27
|
+
if (block.type === "toolCall") toolNames.push(block.name);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const header = sessionManager.getHeader();
|
|
32
|
+
upsertSession({
|
|
33
|
+
session_id: sessionId,
|
|
34
|
+
source: "pi",
|
|
35
|
+
cwd: sessionManager.getCwd(),
|
|
36
|
+
started_at: header ? Date.parse(header.timestamp) : userEntry.message.timestamp,
|
|
37
|
+
model_id: null,
|
|
38
|
+
jsonl_path: sessionManager.getSessionFile() ?? "",
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
insertTurn({
|
|
42
|
+
turn_id: `${sessionId}:${userEntry.id}`,
|
|
43
|
+
session_id: sessionId,
|
|
44
|
+
turn_index: _userTurnIndex(branch, userIndex),
|
|
45
|
+
ts: userEntry.message.timestamp,
|
|
46
|
+
user_text: userText,
|
|
47
|
+
reply_text: replyText,
|
|
48
|
+
tool_names: toolNames.length ? JSON.stringify([...new Set(toolNames)]) : null,
|
|
49
|
+
user_message_id: userEntry.id,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function _extractText(message: { content: string | Array<{ type: string; text?: string }> }): string {
|
|
54
|
+
if (typeof message.content === "string") return message.content.trim();
|
|
55
|
+
return message.content
|
|
56
|
+
.filter((block) => block.type === "text")
|
|
57
|
+
.map((block) => block.text)
|
|
58
|
+
.join("\n")
|
|
59
|
+
.trim();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function _userTurnIndex(branch: ReturnType<ExtensionContext["sessionManager"]["getBranch"]>, userIndex: number): number {
|
|
63
|
+
return branch.slice(0, userIndex + 1)
|
|
64
|
+
.filter((entry) => entry.type === "message" && entry.message.role === "user")
|
|
65
|
+
.length - 1;
|
|
66
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { existsSync, rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
const dbPath = join("/tmp", `pi-session-memory-${process.pid}.db`);
|
|
6
|
+
process.env.MEMORY_DB_PATH = dbPath;
|
|
7
|
+
|
|
8
|
+
const { getDb, insertTurn, upsertSession } = await import("../src/db.ts");
|
|
9
|
+
const { recallTurns } = await import("../src/retriever.ts");
|
|
10
|
+
|
|
11
|
+
function cleanup(): void {
|
|
12
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
13
|
+
const path = `${dbPath}${suffix}`;
|
|
14
|
+
if (existsSync(path)) rmSync(path);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
cleanup();
|
|
19
|
+
|
|
20
|
+
upsertSession({
|
|
21
|
+
session_id: "pi:test",
|
|
22
|
+
source: "pi",
|
|
23
|
+
cwd: "/tmp",
|
|
24
|
+
started_at: 1,
|
|
25
|
+
model_id: null,
|
|
26
|
+
jsonl_path: "/tmp/test.jsonl",
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
assert.equal(insertTurn({
|
|
30
|
+
turn_id: "pi:test:user-1",
|
|
31
|
+
session_id: "pi:test",
|
|
32
|
+
turn_index: 0,
|
|
33
|
+
ts: 1,
|
|
34
|
+
user_text: "literal 100% and a_b",
|
|
35
|
+
reply_text: "first reply",
|
|
36
|
+
tool_names: null,
|
|
37
|
+
user_message_id: "user-1",
|
|
38
|
+
}), true);
|
|
39
|
+
|
|
40
|
+
assert.equal(insertTurn({
|
|
41
|
+
turn_id: "pi:test:user-2",
|
|
42
|
+
session_id: "pi:test",
|
|
43
|
+
turn_index: 1,
|
|
44
|
+
ts: 2,
|
|
45
|
+
user_text: "wildcard 100x and acb",
|
|
46
|
+
reply_text: "second reply",
|
|
47
|
+
tool_names: null,
|
|
48
|
+
user_message_id: "user-2",
|
|
49
|
+
}), true);
|
|
50
|
+
|
|
51
|
+
assert.equal(insertTurn({
|
|
52
|
+
turn_id: "pi:test:user-1",
|
|
53
|
+
session_id: "pi:test",
|
|
54
|
+
turn_index: 0,
|
|
55
|
+
ts: 1,
|
|
56
|
+
user_text: "duplicate",
|
|
57
|
+
reply_text: "duplicate",
|
|
58
|
+
tool_names: null,
|
|
59
|
+
user_message_id: "user-1",
|
|
60
|
+
}), false);
|
|
61
|
+
|
|
62
|
+
assert.deepEqual(recallTurns(["100%"], 10).map((result) => result.turn_id), ["pi:test:user-1"]);
|
|
63
|
+
assert.deepEqual(recallTurns(["a_b"], 10).map((result) => result.turn_id), ["pi:test:user-1"]);
|
|
64
|
+
assert.equal(getDb().prepare("SELECT count(*) AS count FROM turns").get().count, 2);
|
|
65
|
+
|
|
66
|
+
cleanup();
|
|
67
|
+
console.log("core.test.ts: passed");
|