diff --git a/integrations.json b/integrations.json
index 806ca2b1..25742de5 100644
--- a/integrations.json
+++ b/integrations.json
@@ -35,7 +35,8 @@
"threadSave": {
"method": "cli-native",
"runtime": "claude-code",
- "command": "nmem t save --from claude-code"
+ "command": "nmem t save --from claude-code",
+ "historicalCommand": "nmem t sync --from claude-code --all-projects"
},
"autonomy": {
"bootstrap": "automatic",
@@ -45,7 +46,8 @@
"bestResultRequires": [
"Install the native connector",
"Keep nmem available on this machine",
- "Restart Claude Code after install or update"
+ "Restart Claude Code after install or update",
+ "Run nmem t sync --from claude-code --all-projects --apply only when you want to import older Claude Code sessions"
]
},
"install": {
@@ -135,7 +137,8 @@
"threadSave": {
"method": "cli-native",
"runtime": "gemini-cli",
- "command": "nmem t save --from gemini-cli"
+ "command": "nmem t save --from gemini-cli",
+ "historicalCommand": "nmem t sync --from gemini-cli --all-projects"
},
"autonomy": {
"bootstrap": "automatic",
@@ -146,7 +149,8 @@
"Install the Gemini extension",
"Keep the bundled MCP endpoint local, or use nmem config mcp show --host gemini-cli to override mcpServers.nowledge-mem for remote Mem",
"Keep nmem available on this machine",
- "Start a fresh Gemini CLI session after install"
+ "Start a fresh Gemini CLI session after install",
+ "Run nmem t sync --from gemini-cli --all-projects --apply only when you want to import older Gemini CLI sessions"
]
},
"install": {
@@ -188,7 +192,8 @@
"threadSave": {
"method": "hook+cli-native",
"runtime": "codex",
- "command": "nmem t save --from codex"
+ "command": "nmem t save --from codex",
+ "historicalCommand": "nmem t sync --from codex --all-projects"
},
"autonomy": {
"bootstrap": "guided",
@@ -200,6 +205,7 @@
"Run the bundled hook setup after install or update so Codex enables lifecycle hooks, enables plugin-bundled hooks, keeps the Nowledge Mem Stop hook enabled in /hooks, and can install a managed MCP override from nmem config when needed",
"Use the bundled local MCP server, or use nmem config mcp show --host codex to override mcp_servers.nowledge-mem for remote Mem or authenticated localhost",
"Keep nmem available on this machine",
+ "Run nmem t sync --from codex --all-projects --apply only when you want to import older Codex sessions",
"Merge the package AGENTS.md into the project for stronger follow-through"
]
},
@@ -672,7 +678,7 @@
"name": "Pi",
"category": "coding",
"type": "plugin",
- "version": "0.8.0",
+ "version": "0.8.1",
"directory": "nowledge-mem-pi-package",
"transport": "plugin+cli",
"capabilities": {
@@ -686,7 +692,8 @@
},
"threadSave": {
"method": "plugin-capture",
- "note": "The Pi extension syncs completed session branches as Mem threads; save-thread remains available for curated handoff summaries."
+ "historicalCommand": "nmem t sync --from pi",
+ "note": "The Pi extension syncs completed session branches as Mem threads; nmem t sync --from pi can backfill older Pi session files; nowledge-mem-pi-sync remains an older-CLI fallback; save-thread remains available for curated handoff summaries."
},
"autonomy": {
"bootstrap": "guided",
@@ -697,6 +704,7 @@
"Install the Pi package",
"Keep nmem available on this machine",
"Restart Pi after install or update so the extension is loaded",
+ "Run nmem t sync --from pi --apply only when you want to import older Pi sessions",
"Use save-thread only when you want an extra curated handoff summary"
]
},
@@ -786,6 +794,7 @@
},
"threadSave": {
"method": "sdk-extract",
+ "historicalCommand": "nmem t sync --from opencode --all-projects",
"note": "save_thread reads session messages via OpenCode SDK and posts to Nowledge Mem thread API over HTTP; save_handoff available as lighter alternative"
},
"autonomy": {
@@ -797,6 +806,7 @@
"Install the native OpenCode plugin",
"Use local mode for real session auto-sync",
"Keep nmem available for memory operations and handoff flows",
+ "Run nmem t sync --from opencode --all-projects --apply only when you want to import older OpenCode sessions",
"Run nmem config client set url/api-key once when Mem is remote"
]
},
diff --git a/nowledge-mem-claude-code-plugin/README.md b/nowledge-mem-claude-code-plugin/README.md
index 223ff7d8..82fe4d27 100644
--- a/nowledge-mem-claude-code-plugin/README.md
+++ b/nowledge-mem-claude-code-plugin/README.md
@@ -96,6 +96,22 @@ That writes the shared local client config used by `nmem` and the plugin. You ca
In remote mode, the Stop and PreCompact hooks still read Claude session files locally through `nmem t save --from claude-code` on the machine where Claude Code is running, then upload the normalized messages to Mem. The remote Mem server does not need direct access to your `~/.claude` directory.
+### Import older sessions
+
+Automatic capture starts after the plugin and hooks are installed. To backfill older Claude Code sessions, preview first:
+
+```bash
+nmem t sync --from claude-code --all-projects --limit 20
+```
+
+Then import:
+
+```bash
+nmem t sync --from claude-code --all-projects --apply
+```
+
+Use `-p /path/to/project` instead of `--all-projects` when you only want one project. The command reads local Claude Code session files and writes to the Mem server configured in `nmem`.
+
## Spaces
Spaces are optional. If one Claude Code process naturally belongs to one project or agent lane, launch Claude Code with:
diff --git a/nowledge-mem-codex-plugin/README.md b/nowledge-mem-codex-plugin/README.md
index 74e2eced..02b9ee0e 100644
--- a/nowledge-mem-codex-plugin/README.md
+++ b/nowledge-mem-codex-plugin/README.md
@@ -140,6 +140,20 @@ if ($null -eq $HookSetup) {
This enables Codex lifecycle hooks and plugin-bundled hooks, keeps the Nowledge Mem packaged Stop hook enabled in `/hooks`, then installs a small host-level Stop hook for Codex builds that still need `~/.codex/hooks.json`. The Stop hook shells out to `nmem t save --from codex`, so local mode and remote Mem mode use the same `nmem` client configuration. If both the bundled hook and the host-level fallback are visible, the hook runtime suppresses the duplicate save for the same transcript state.
+To backfill older Codex sessions, preview first:
+
+```bash
+nmem t sync --from codex --all-projects --limit 20
+```
+
+Then import:
+
+```bash
+nmem t sync --from codex --all-projects --apply
+```
+
+Use `-p /path/to/project` instead of `--all-projects` when you only want one project. The command reads local Codex rollout files and writes to the Mem server configured in `nmem`.
+
On current Codex builds, `plugin_hooks = true` is the separate gate that lets Codex load `hooks/hooks.json` from installed plugins. If Codex shows the Nowledge Mem Stop hook in `/hooks`, it should be enabled.
The same setup also asks `nmem` for a Codex MCP config. If `nmem` has a saved API key or a non-default endpoint, the script writes a managed `mcp_servers.nowledge-mem` block into `~/.codex/config.toml`. This is the safest path for remote Mem and for localhost setups that require auth.
diff --git a/nowledge-mem-gemini-cli b/nowledge-mem-gemini-cli
index 6482de7e..8e5e93ec 160000
--- a/nowledge-mem-gemini-cli
+++ b/nowledge-mem-gemini-cli
@@ -1 +1 @@
-Subproject commit 6482de7e9947e6a727b2fe71b7d98ab99fc381ef
+Subproject commit 8e5e93ec0df1e9c526ce4f149476504c068fa525
diff --git a/nowledge-mem-opencode-plugin/README.md b/nowledge-mem-opencode-plugin/README.md
index e10b09c7..7940a44e 100644
--- a/nowledge-mem-opencode-plugin/README.md
+++ b/nowledge-mem-opencode-plugin/README.md
@@ -86,6 +86,20 @@ Nowledge Mem captures OpenCode sessions in three complementary ways:
**Remote mode note:** Background auto-sync (1) reads OpenCode's local SQLite database, so it only works when both tools run on the same machine. The plugin tools (2, 3) work in both local and remote mode.
+To backfill older OpenCode sessions, preview first:
+
+```bash
+nmem t sync --from opencode --all-projects --limit 20
+```
+
+Then import:
+
+```bash
+nmem t sync --from opencode --all-projects --apply
+```
+
+Use `-p /path/to/project` instead of `--all-projects` when you only want one project. The command reads OpenCode's local session database or legacy JSON storage and writes to the Mem server configured in `nmem`.
+
## Hooks
The plugin uses two OpenCode hooks:
diff --git a/nowledge-mem-pi-package/CHANGELOG.md b/nowledge-mem-pi-package/CHANGELOG.md
index a5b8cba0..db287f95 100644
--- a/nowledge-mem-pi-package/CHANGELOG.md
+++ b/nowledge-mem-pi-package/CHANGELOG.md
@@ -5,6 +5,12 @@ All notable changes to the Nowledge Mem Pi package will be documented in this fi
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [0.8.1] - 2026-06-10
+
+### Fixed
+
+- Exposed `nowledge-mem-pi-sync` as the package binary so older `nmem` installs can still backfill historical Pi sessions.
+
## [0.8.0] - 2026-06-10
### Added
@@ -12,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added a native Pi extension that automatically syncs completed Pi sessions into Nowledge Mem threads.
- Session capture is idempotent: the plugin writes stable Pi entry IDs and appends only new transcript messages.
- Added final flushes for session switches, compaction, and shutdown so `/new`, resume, and exit keep thread history complete.
+- Added `nowledge-mem-pi-sync`, a preview-first historical import command for older Pi session files.
## [0.7.1] - 2026-06-07
diff --git a/nowledge-mem-pi-package/README.md b/nowledge-mem-pi-package/README.md
index 5c685d4a..5718d87b 100644
--- a/nowledge-mem-pi-package/README.md
+++ b/nowledge-mem-pi-package/README.md
@@ -71,6 +71,39 @@ Then have a short Pi exchange and check recent threads:
nmem t list --source pi -n 5
```
+## Import Older Pi Sessions
+
+The extension keeps new Pi conversations synced automatically. To backfill sessions that happened before you installed the package, run the history sync command.
+
+Preview first. This scans Pi session files and makes no changes:
+
+```bash
+nmem t sync --from pi --limit 20
+```
+
+Import after the preview looks right:
+
+```bash
+nmem t sync --from pi --apply
+```
+
+The command uses the same local or remote Mem configuration as the extension: `~/.nowledge-mem/config.json`, `NMEM_API_URL`, `NMEM_API_KEY`, and optional `NMEM_SPACE`. It is safe to rerun: thread IDs come from Pi session IDs, and messages use stable Pi entry IDs with backend deduplication.
+
+Useful options:
+
+```bash
+nmem t sync --from pi --session-dir ~/.pi/agent/sessions --limit 20
+nmem t sync --from pi --space work --apply
+```
+
+When you pass `--session-dir`, only that directory is scanned. Without it, the command uses Pi's standard session locations.
+
+If your installed `nmem` does not have `t sync` yet, use the package fallback:
+
+```bash
+npx -p nowledge-mem-pi nowledge-mem-pi-sync --apply
+```
+
## Update
```bash
diff --git a/nowledge-mem-pi-package/extensions/nowledge-mem.ts b/nowledge-mem-pi-package/extensions/nowledge-mem.ts
index 4f0f2a27..60e74433 100644
--- a/nowledge-mem-pi-package/extensions/nowledge-mem.ts
+++ b/nowledge-mem-pi-package/extensions/nowledge-mem.ts
@@ -4,7 +4,7 @@ import { basename } from "node:path";
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
const SOURCE_APP = "pi";
-const PLUGIN_VERSION = "0.8.0";
+const PLUGIN_VERSION = "0.8.1";
const DEFAULT_API_URL = "http://127.0.0.1:14242";
const CONFIG_PATH = `${homedir()}/.nowledge-mem/config.json`;
const MAX_MESSAGE_CHARS = 20_000;
diff --git a/nowledge-mem-pi-package/package.json b/nowledge-mem-pi-package/package.json
index a8c7eaef..e4011ca7 100644
--- a/nowledge-mem-pi-package/package.json
+++ b/nowledge-mem-pi-package/package.json
@@ -1,10 +1,13 @@
{
"name": "nowledge-mem-pi",
- "version": "0.8.0",
+ "version": "0.8.1",
"description": "Cross-tool memory for Pi. Recall past decisions, search knowledge from every AI tool, and save what matters.",
"keywords": ["pi-package"],
"author": "Nowledge Labs",
"license": "MIT",
+ "bin": {
+ "nowledge-mem-pi-sync": "./scripts/sync-history.mjs"
+ },
"pi": {
"extensions": ["./extensions/nowledge-mem.ts"],
"skills": ["./skills"]
diff --git a/nowledge-mem-pi-package/scripts/sync-history.mjs b/nowledge-mem-pi-package/scripts/sync-history.mjs
new file mode 100755
index 00000000..6bea99e9
--- /dev/null
+++ b/nowledge-mem-pi-package/scripts/sync-history.mjs
@@ -0,0 +1,554 @@
+#!/usr/bin/env node
+
+import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
+import { homedir } from "node:os";
+import { basename, dirname, join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const SOURCE_APP = "pi";
+const DEFAULT_API_URL = "http://127.0.0.1:14242";
+const CONFIG_PATH = join(homedir(), ".nowledge-mem", "config.json");
+const DEFAULT_MAX_MESSAGE_CHARS = 20_000;
+const DEFAULT_TIMEOUT_MS = 15_000;
+const VERSION = readPackageVersion();
+
+function readPackageVersion() {
+ try {
+ const here = dirname(fileURLToPath(import.meta.url));
+ const pkg = JSON.parse(readFileSync(resolve(here, "..", "package.json"), "utf8"));
+ return typeof pkg.version === "string" ? pkg.version : "unknown";
+ } catch {
+ return "unknown";
+ }
+}
+
+function usage() {
+ return `Nowledge Mem Pi historical thread sync
+
+Preview Pi sessions:
+ nowledge-mem-pi-sync
+ nowledge-mem-pi-sync --limit 20 --json
+
+Import Pi sessions:
+ nowledge-mem-pi-sync --apply
+ nowledge-mem-pi-sync --apply --space work
+
+Options:
+ --apply Import sessions. Without this, the command only previews.
+ --json Print machine-readable JSON.
+ --session-dir
Scan a Pi session directory. May be repeated.
+ --limit Limit imported/previewed sessions after filtering.
+ --since Include sessions modified at or after this date.
+ --until Include sessions modified before this date.
+ --project Include sessions whose cwd contains this text.
+ --space Route imported threads to a Mem space.
+ --api-url Override NMEM_API_URL or ~/.nowledge-mem/config.json.
+ --api-key Override NMEM_API_KEY or ~/.nowledge-mem/config.json.
+ --agent-id Attribute messages to an AI Identity.
+ --host-agent-id Attribute messages to a host-local agent identity.
+ --max-message-chars Truncate individual messages after n characters.
+ --help Show this help.
+`;
+}
+
+function parseArgs(argv) {
+ const args = {
+ apply: false,
+ json: false,
+ sessionDirs: [],
+ limit: undefined,
+ since: undefined,
+ until: undefined,
+ project: undefined,
+ space: undefined,
+ apiUrl: undefined,
+ apiKey: undefined,
+ agentId: undefined,
+ hostAgentId: undefined,
+ maxMessageChars: DEFAULT_MAX_MESSAGE_CHARS,
+ };
+ for (let index = 0; index < argv.length; index += 1) {
+ const arg = argv[index];
+ const value = () => {
+ index += 1;
+ if (index >= argv.length) throw new Error(`${arg} requires a value`);
+ return argv[index];
+ };
+ if (arg === "--apply") args.apply = true;
+ else if (arg === "--json") args.json = true;
+ else if (arg === "--session-dir") args.sessionDirs.push(resolve(value()));
+ else if (arg === "--limit") args.limit = positiveInt(value(), "--limit");
+ else if (arg === "--since") args.since = parseDate(value(), "--since");
+ else if (arg === "--until") args.until = parseDate(value(), "--until");
+ else if (arg === "--project") args.project = value().toLowerCase();
+ else if (arg === "--space") args.space = value().trim() || undefined;
+ else if (arg === "--api-url") args.apiUrl = trimTrailingSlash(value());
+ else if (arg === "--api-key") args.apiKey = value().trim() || undefined;
+ else if (arg === "--agent-id") args.agentId = value().trim() || undefined;
+ else if (arg === "--host-agent-id") args.hostAgentId = value().trim() || undefined;
+ else if (arg === "--max-message-chars") args.maxMessageChars = positiveInt(value(), "--max-message-chars");
+ else if (arg === "--help" || arg === "-h") {
+ console.log(usage());
+ process.exit(0);
+ } else {
+ throw new Error(`Unknown option: ${arg}`);
+ }
+ }
+ return args;
+}
+
+function positiveInt(raw, name) {
+ const parsed = Number.parseInt(raw, 10);
+ if (!Number.isFinite(parsed) || parsed < 1) throw new Error(`${name} must be a positive integer`);
+ return parsed;
+}
+
+function parseDate(raw, name) {
+ const date = new Date(raw);
+ if (Number.isNaN(date.getTime())) throw new Error(`${name} must be a valid date`);
+ return date;
+}
+
+function trimTrailingSlash(value) {
+ return value.trim().replace(/\/+$/, "");
+}
+
+function readSharedConfig() {
+ try {
+ if (!existsSync(CONFIG_PATH)) return {};
+ const parsed = JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
+ } catch {
+ return {};
+ }
+}
+
+function stringValue(value) {
+ return typeof value === "string" ? value.trim() || undefined : undefined;
+}
+
+function readConfigValue(args, config, optionName, envName, ...configKeys) {
+ if (args[optionName]) return args[optionName];
+ const envValue = process.env[envName]?.trim();
+ if (envValue) return envValue;
+ for (const key of configKeys) {
+ const value = stringValue(config[key]);
+ if (value) return value;
+ }
+ return undefined;
+}
+
+function resolveConfig(args) {
+ const config = readSharedConfig();
+ return {
+ apiUrl: trimTrailingSlash(args.apiUrl || process.env.NMEM_API_URL || stringValue(config.apiUrl) || stringValue(config.api_url) || DEFAULT_API_URL),
+ apiKey: readConfigValue(args, config, "apiKey", "NMEM_API_KEY", "apiKey", "api_key"),
+ space:
+ args.space ||
+ process.env.NMEM_SPACE?.trim() ||
+ process.env.NMEM_SPACE_ID?.trim() ||
+ stringValue(config.space) ||
+ stringValue(config.spaceId) ||
+ stringValue(config.space_id),
+ agentId: readConfigValue(args, config, "agentId", "NMEM_AGENT_ID", "agentId", "agent_id"),
+ hostAgentId: readConfigValue(args, config, "hostAgentId", "NMEM_HOST_AGENT_ID", "hostAgentId", "host_agent_id"),
+ };
+}
+
+function candidateSessionDirs(args) {
+ const dirs = [];
+ const add = (candidate) => {
+ if (!candidate) return;
+ const resolved = resolve(candidate);
+ if (!dirs.includes(resolved)) dirs.push(resolved);
+ };
+ for (const dir of args.sessionDirs) add(dir);
+ if (args.sessionDirs.length > 0) return dirs;
+ add(process.env.PI_CODING_AGENT_SESSION_DIR);
+ if (process.env.PI_CODING_AGENT_DIR) add(join(process.env.PI_CODING_AGENT_DIR, "sessions"));
+ add(join(homedir(), ".pi", "agent", "sessions"));
+ add(join(homedir(), ".pi", "agent"));
+ return dirs;
+}
+
+function discoverSessionFiles(args) {
+ const seen = new Set();
+ const files = [];
+ for (const dir of candidateSessionDirs(args)) {
+ if (!existsSync(dir)) continue;
+ const stats = statSync(dir);
+ if (stats.isFile() && dir.endsWith(".jsonl")) {
+ if (!seen.has(dir)) {
+ seen.add(dir);
+ files.push(dir);
+ }
+ continue;
+ }
+ if (!stats.isDirectory()) continue;
+ for (const file of walkJsonl(dir)) {
+ if (!seen.has(file)) {
+ seen.add(file);
+ files.push(file);
+ }
+ }
+ }
+ return files;
+}
+
+function walkJsonl(root) {
+ const files = [];
+ const visit = (dir) => {
+ for (const name of readdirSync(dir)) {
+ const path = join(dir, name);
+ let stats;
+ try {
+ stats = statSync(path);
+ } catch {
+ continue;
+ }
+ if (stats.isDirectory()) visit(path);
+ else if (stats.isFile() && name.endsWith(".jsonl")) files.push(path);
+ }
+ };
+ visit(root);
+ return files;
+}
+
+function parseSessionFile(file) {
+ const raw = readFileSync(file, "utf8");
+ const entries = [];
+ const errors = [];
+ const lines = raw.split(/\r?\n/);
+ for (let index = 0; index < lines.length; index += 1) {
+ const line = lines[index].trim();
+ if (!line) continue;
+ try {
+ const value = JSON.parse(line);
+ if (value && typeof value === "object" && !Array.isArray(value)) entries.push(value);
+ } catch (error) {
+ errors.push(`line ${index + 1}: ${error instanceof Error ? error.message : String(error)}`);
+ }
+ }
+ const header = entries.find((entry) => entry.type === "session") || {};
+ const body = entries.filter((entry) => entry.type !== "session");
+ return { file, header, entries: body, errors };
+}
+
+function branchEntries(entries) {
+ const withIds = entries.filter((entry) => typeof entry.id === "string" && entry.id);
+ if (!withIds.length) return entries;
+ const byId = new Map(withIds.map((entry) => [entry.id, entry]));
+ const leaf = withIds[withIds.length - 1];
+ const branch = [];
+ const seen = new Set();
+ let current = leaf;
+ while (current && !seen.has(current.id)) {
+ branch.unshift(current);
+ seen.add(current.id);
+ const parentId = typeof current.parentId === "string" ? current.parentId : undefined;
+ current = parentId ? byId.get(parentId) : undefined;
+ }
+ return branch.length ? branch : entries;
+}
+
+function contentToText(content) {
+ if (typeof content === "string") return content;
+ if (Array.isArray(content)) return content.map(partToText).filter(Boolean).join("\n");
+ if (content && typeof content === "object") return partToText(content);
+ return "";
+}
+
+function partToText(part) {
+ if (typeof part === "string") return part;
+ if (!part || typeof part !== "object") return "";
+ const type = stringValue(part.type) || "part";
+ if (type === "text") return stringValue(part.text) || stringValue(part.content) || "";
+ if (type === "image") return "[Image]";
+ if (type === "toolUse" || type === "tool" || type === "toolCall") {
+ return `[Tool: ${stringValue(part.name) || stringValue(part.tool) || "tool"}]`;
+ }
+ if (type === "file") return `[File: ${stringValue(part.filename) || stringValue(part.path) || "attachment"}]`;
+ return stringValue(part.text) || stringValue(part.content) || `[${type}]`;
+}
+
+function messageToText(message) {
+ const role = stringValue(message.role);
+ if (role === "bashExecution") {
+ const command = stringValue(message.command) || "";
+ const output = stringValue(message.output) || "(no output)";
+ const suffix = typeof message.exitCode === "number" && message.exitCode !== 0 ? `\n\nCommand exited with code ${message.exitCode}` : "";
+ return `Ran \`${command}\`\n\`\`\`\n${output}\n\`\`\`${suffix}`;
+ }
+ if (role === "branchSummary") return `Pi branch summary:\n${stringValue(message.summary) || ""}`;
+ if (role === "compactionSummary") return `Pi compaction summary:\n${stringValue(message.summary) || ""}`;
+ return contentToText(message.content);
+}
+
+function normalizeRole(role) {
+ if (role === "user" || role === "bashExecution") return "user";
+ if (role === "assistant" || role === "toolResult" || role === "branchSummary" || role === "compactionSummary") return "assistant";
+ return undefined;
+}
+
+function truncate(text, maxChars) {
+ if (text.length <= maxChars) return text;
+ return `${text.slice(0, maxChars)}\n\n[Pi message truncated by Nowledge Mem history sync]`;
+}
+
+function entryToMessage(entry, index, ambient, maxMessageChars) {
+ if (entry.type === "message") {
+ const message = entry.message;
+ if (!message || typeof message !== "object") return undefined;
+ if (message.role === "custom") return undefined;
+ const role = normalizeRole(message.role);
+ if (!role) return undefined;
+ const content = truncate(messageToText(message).trim(), maxMessageChars);
+ if (!content) return undefined;
+ return {
+ role,
+ content,
+ timestamp: stringValue(entry.timestamp),
+ metadata: {
+ external_id: `pi-entry-${stringValue(entry.id) || index}`,
+ pi_entry_id: stringValue(entry.id),
+ pi_entry_type: entry.type,
+ pi_message_role: stringValue(message.role),
+ ...ambient,
+ },
+ };
+ }
+ if (entry.type === "compaction" || entry.type === "branch_summary") {
+ const label = entry.type === "compaction" ? "Pi compaction summary" : "Pi branch summary";
+ const content = truncate(`${label}:\n${stringValue(entry.summary) || ""}`.trim(), maxMessageChars);
+ if (!content) return undefined;
+ return {
+ role: "assistant",
+ content,
+ timestamp: stringValue(entry.timestamp),
+ metadata: {
+ external_id: `pi-entry-${stringValue(entry.id) || index}`,
+ pi_entry_id: stringValue(entry.id),
+ pi_entry_type: entry.type,
+ ...ambient,
+ },
+ };
+ }
+ return undefined;
+}
+
+function sessionIdFor(parsed) {
+ return stringValue(parsed.header.id) || basename(parsed.file).replace(/\.jsonl$/i, "");
+}
+
+function threadIdFor(sessionId) {
+ return `pi-${sessionId}`.toLowerCase().replace(/[^a-z0-9._-]+/g, "-");
+}
+
+function sessionName(entries) {
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
+ const entry = entries[index];
+ if (entry.type === "session_info" && stringValue(entry.name)) return stringValue(entry.name);
+ }
+ return undefined;
+}
+
+function buildTitle(parsed, messages, branch) {
+ const name = sessionName(branch);
+ if (name) return name;
+ const firstUser = messages.find((message) => message.role === "user")?.content.trim();
+ if (firstUser) return firstUser.slice(0, 120);
+ const cwd = stringValue(parsed.header.cwd);
+ return cwd ? `Pi session - ${basename(cwd)}` : `Pi session - ${basename(parsed.file, ".jsonl")}`;
+}
+
+function shouldSync(messages) {
+ return messages.some((message) => message.role === "user") && messages.some((message) => message.role === "assistant");
+}
+
+function normalizeSession(parsed, args, config) {
+ const sessionId = sessionIdFor(parsed);
+ const threadId = threadIdFor(sessionId);
+ const cwd = stringValue(parsed.header.cwd);
+ const ambient = {
+ source_app: SOURCE_APP,
+ ...(config.agentId ? { agent_id: config.agentId } : {}),
+ ...(config.hostAgentId ? { host_agent_id: config.hostAgentId } : {}),
+ };
+ const branch = branchEntries(parsed.entries);
+ const messages = branch
+ .map((entry, index) => entryToMessage(entry, index, ambient, args.maxMessageChars))
+ .filter(Boolean);
+ const importable = shouldSync(messages);
+ const body = {
+ thread_id: threadId,
+ title: buildTitle(parsed, messages, branch),
+ messages,
+ source: SOURCE_APP,
+ project: cwd,
+ workspace: cwd,
+ tool_version: VERSION,
+ metadata: {
+ pi_session_id: sessionId,
+ pi_session_file: parsed.file,
+ sync_reason: "history_sync",
+ historical_import: true,
+ analysis: "searchable-now-distill-on-demand",
+ branch_entry_count: branch.length,
+ total_entry_count: parsed.entries.length,
+ ...(config.agentId ? { agent_id: config.agentId } : {}),
+ ...(config.hostAgentId ? { host_agent_id: config.hostAgentId } : {}),
+ },
+ };
+ return {
+ file: parsed.file,
+ sessionId,
+ threadId,
+ cwd,
+ modifiedAt: statSync(parsed.file).mtime.toISOString(),
+ parseErrors: parsed.errors,
+ messageCount: messages.length,
+ importable,
+ skipReason: importable ? undefined : "needs at least one user and one assistant message on the active branch",
+ body,
+ };
+}
+
+function filterSessions(sessions, args) {
+ let result = sessions;
+ if (args.since) result = result.filter((session) => new Date(session.modifiedAt) >= args.since);
+ if (args.until) result = result.filter((session) => new Date(session.modifiedAt) < args.until);
+ if (args.project) result = result.filter((session) => (session.cwd || "").toLowerCase().includes(args.project));
+ result = result.sort((a, b) => new Date(b.modifiedAt) - new Date(a.modifiedAt));
+ if (args.limit) result = result.slice(0, args.limit);
+ return result;
+}
+
+function withSpace(body, space) {
+ if (!space || Object.prototype.hasOwnProperty.call(body, "space_id")) return body;
+ return { ...body, space_id: space };
+}
+
+function remoteApiFallbackUrls(url) {
+ const urls = [];
+ const add = (candidate) => {
+ if (!urls.includes(candidate)) urls.push(candidate);
+ };
+ const parsed = new URL(url);
+ const path = parsed.pathname || "";
+ if (path === "/remote-api") {
+ parsed.pathname = "/";
+ add(parsed.toString());
+ } else if (path.startsWith("/remote-api/")) {
+ parsed.pathname = path.slice("/remote-api".length);
+ add(parsed.toString());
+ }
+ return urls;
+}
+
+async function postJson(config, path, body) {
+ const headers = { "Content-Type": "application/json" };
+ if (config.apiKey) {
+ headers.Authorization = `Bearer ${config.apiKey}`;
+ headers["X-NMEM-API-Key"] = config.apiKey;
+ }
+ let urls;
+ try {
+ urls = [`${config.apiUrl}${path}`, ...remoteApiFallbackUrls(`${config.apiUrl}${path}`)];
+ } catch {
+ return { ok: false, status: 0, data: { error: "Invalid API URL" } };
+ }
+ const requestBody = JSON.stringify(withSpace(body, config.space));
+ let last = { ok: false, status: 0, data: { error: "request was not sent" } };
+ for (const url of urls) {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
+ try {
+ const response = await fetch(url, { method: "POST", headers, body: requestBody, signal: controller.signal });
+ const data = await response.json().catch(() => ({}));
+ last = { ok: response.ok, status: response.status, data };
+ if (response.ok) return last;
+ } catch (error) {
+ last = { ok: false, status: 0, data: { error: error instanceof Error ? error.message : String(error) } };
+ } finally {
+ clearTimeout(timeout);
+ }
+ }
+ return last;
+}
+
+async function syncSession(session, config) {
+ let result = await postJson(config, "/threads", session.body);
+ if (result.ok) return { ...session, action: "created", ok: true, status: result.status, response: result.data };
+ result = await postJson(config, `/threads/${encodeURIComponent(session.threadId)}/append`, {
+ messages: session.body.messages,
+ deduplicate: true,
+ idempotency_key: `pi:history:${session.sessionId}:${session.messageCount}`,
+ historical_import: true,
+ analysis: "searchable-now-distill-on-demand",
+ });
+ if (result.ok) return { ...session, action: "appended", ok: true, status: result.status, response: result.data };
+ return { ...session, action: "failed", ok: false, status: result.status, response: result.data };
+}
+
+function summarize(results, apply) {
+ const summary = {
+ apply,
+ found: results.length,
+ importable: results.filter((session) => session.importable).length,
+ skipped: results.filter((session) => !session.importable).length,
+ created: results.filter((session) => session.action === "created").length,
+ appended: results.filter((session) => session.action === "appended").length,
+ failed: results.filter((session) => session.action === "failed").length,
+ parseWarnings: results.filter((session) => session.parseErrors.length).length,
+ };
+ return { summary, sessions: results };
+}
+
+function printHuman(report) {
+ const { summary, sessions } = report;
+ const mode = summary.apply ? "Import" : "Preview";
+ console.log(`${mode}: ${summary.importable}/${summary.found} Pi sessions importable`);
+ if (summary.skipped) console.log(`Skipped: ${summary.skipped}`);
+ if (summary.parseWarnings) console.log(`Parse warnings: ${summary.parseWarnings}`);
+ if (summary.apply) console.log(`Created: ${summary.created} Appended: ${summary.appended} Failed: ${summary.failed}`);
+ if (!summary.apply) console.log("No changes made. Re-run with --apply to import.");
+ for (const session of sessions.slice(0, 20)) {
+ const status = session.action || (session.importable ? "ready" : "skipped");
+ const reason = session.skipReason ? ` (${session.skipReason})` : "";
+ console.log(`- ${status}: ${session.threadId} messages=${session.messageCount} ${session.body.title}${reason}`);
+ console.log(` ${session.file}`);
+ }
+ if (sessions.length > 20) console.log(`... ${sessions.length - 20} more sessions hidden; use --json for full output.`);
+}
+
+async function main() {
+ const args = parseArgs(process.argv.slice(2));
+ const config = resolveConfig(args);
+ const files = discoverSessionFiles(args);
+ const parsed = files.map((file) => parseSessionFile(file));
+ let sessions = parsed.map((session) => normalizeSession(session, args, config));
+ sessions = filterSessions(sessions, args);
+ if (!args.apply) {
+ const report = summarize(sessions, false);
+ if (args.json) console.log(JSON.stringify(report, null, 2));
+ else printHuman(report);
+ return;
+ }
+ const results = [];
+ for (const session of sessions) {
+ if (!session.importable) {
+ results.push({ ...session, action: "skipped", ok: true });
+ continue;
+ }
+ results.push(await syncSession(session, config));
+ }
+ const report = summarize(results, true);
+ if (args.json) console.log(JSON.stringify(report, null, 2));
+ else printHuman(report);
+ if (report.summary.failed > 0) process.exitCode = 1;
+}
+
+main().catch((error) => {
+ console.error(error instanceof Error ? error.message : String(error));
+ process.exit(1);
+});
diff --git a/tests/plugin_e2e/test_key_plugins_e2e.py b/tests/plugin_e2e/test_key_plugins_e2e.py
index 3594366e..698b9509 100644
--- a/tests/plugin_e2e/test_key_plugins_e2e.py
+++ b/tests/plugin_e2e/test_key_plugins_e2e.py
@@ -4,9 +4,11 @@
import os
import shutil
import subprocess
+import threading
import time
import uuid
from dataclasses import dataclass
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from textwrap import dedent
from typing import Any
@@ -248,6 +250,18 @@ def _codex_transcript_meta(transcript: Path) -> dict[str, Any]:
def test_key_plugin_static_contracts_are_declared():
+ registry = _read_json(COMMUNITY_ROOT / "integrations.json")
+ historical_commands = {
+ item["id"]: item["threadSave"].get("historicalCommand")
+ for item in registry["integrations"]
+ if item.get("id")
+ }
+ assert historical_commands["claude-code"] == "nmem t sync --from claude-code --all-projects"
+ assert historical_commands["codex-cli"] == "nmem t sync --from codex --all-projects"
+ assert historical_commands["gemini-cli"] == "nmem t sync --from gemini-cli --all-projects"
+ assert historical_commands["opencode"] == "nmem t sync --from opencode --all-projects"
+ assert historical_commands["pi"] == "nmem t sync --from pi"
+
claude_manifest = _read_json(CLAUDE_PLUGIN / ".claude-plugin" / "plugin.json")
claude_hooks = _read_json(CLAUDE_PLUGIN / "hooks" / "hooks.json")["hooks"]
assert claude_manifest["name"] == "nowledge-mem"
@@ -379,9 +393,11 @@ def test_key_plugin_static_contracts_are_declared():
pi_pkg = _read_json(PI_PLUGIN / "package.json")
pi_extension = (PI_PLUGIN / "extensions" / "nowledge-mem.ts").read_text(encoding="utf-8")
- assert pi_pkg["version"] == "0.8.0"
+ pi_history_sync = (PI_PLUGIN / "scripts" / "sync-history.mjs").read_text(encoding="utf-8")
+ assert pi_pkg["version"] == "0.8.1"
assert "./extensions/nowledge-mem.ts" in pi_pkg["pi"]["extensions"]
assert "./skills" in pi_pkg["pi"]["skills"]
+ assert pi_pkg["bin"]["nowledge-mem-pi-sync"] == "./scripts/sync-history.mjs"
assert 'pi.on("agent_end"' in pi_extension
assert 'pi.on("session_shutdown"' in pi_extension
assert 'pi.on("session_before_switch"' in pi_extension
@@ -393,6 +409,158 @@ def test_key_plugin_static_contracts_are_declared():
assert "NMEM_AGENT_ID" in pi_extension
assert "NMEM_HOST_AGENT_ID" in pi_extension
assert "custom" in pi_extension
+ assert "PI_CODING_AGENT_SESSION_DIR" in pi_history_sync
+ assert "--apply" in pi_history_sync
+ assert "historical_import: true" in pi_history_sync
+ assert "deduplicate: true" in pi_history_sync
+ assert "branchEntries" in pi_history_sync
+
+
+def test_pi_history_sync_script_previews_and_appends_idempotently(tmp_path: Path):
+ if shutil.which("node") is None:
+ pytest.skip("Pi history sync script smoke requires node on PATH")
+
+ session_dir = tmp_path / "pi-sessions"
+ session_dir.mkdir()
+ session_file = session_dir / "session-one.jsonl"
+ session_file.write_text(
+ "\n".join(
+ [
+ json.dumps(
+ {
+ "type": "session",
+ "version": 3,
+ "id": "History Session/One",
+ "timestamp": "2026-06-10T00:00:00Z",
+ "cwd": "/tmp/pi-history-project",
+ }
+ ),
+ json.dumps(
+ {
+ "type": "message",
+ "id": "u1",
+ "parentId": None,
+ "timestamp": "2026-06-10T00:00:01Z",
+ "message": {"role": "user", "content": "history user message"},
+ }
+ ),
+ json.dumps(
+ {
+ "type": "message",
+ "id": "old-assistant",
+ "parentId": "u1",
+ "timestamp": "2026-06-10T00:00:02Z",
+ "message": {"role": "assistant", "content": "abandoned branch should not import"},
+ }
+ ),
+ json.dumps(
+ {
+ "type": "message",
+ "id": "ctx",
+ "parentId": "u1",
+ "timestamp": "2026-06-10T00:00:03Z",
+ "message": {"role": "custom", "content": "Context Bundle should not sync"},
+ }
+ ),
+ json.dumps(
+ {
+ "type": "message",
+ "id": "a1",
+ "parentId": "ctx",
+ "timestamp": "2026-06-10T00:00:04Z",
+ "message": {"role": "assistant", "content": "history assistant message"},
+ }
+ ),
+ ]
+ )
+ + "\n",
+ encoding="utf-8",
+ )
+
+ script = PI_PLUGIN / "scripts" / "sync-history.mjs"
+ preview = _run(["node", str(script), "--session-dir", str(session_dir), "--json"], timeout=30)
+ preview_json = json.loads(preview.stdout)
+ assert preview_json["summary"]["apply"] is False
+ assert preview_json["summary"]["found"] == 1
+ assert preview_json["summary"]["importable"] == 1
+ assert preview_json["sessions"][0]["threadId"] == "pi-history-session-one"
+ assert preview_json["sessions"][0]["messageCount"] == 2
+ preview_text = json.dumps(preview_json)
+ assert "Context Bundle should not sync" not in preview_text
+ assert "abandoned branch should not import" not in preview_text
+
+ calls: list[dict[str, Any]] = []
+
+ class Handler(BaseHTTPRequestHandler):
+ def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
+ length = int(self.headers.get("content-length", "0"))
+ body = self.rfile.read(length).decode("utf-8")
+ calls.append(
+ {
+ "path": self.path,
+ "headers": dict(self.headers),
+ "body": json.loads(body) if body else {},
+ }
+ )
+ if self.path == "/threads":
+ self.send_response(409)
+ self.send_header("content-type", "application/json")
+ self.end_headers()
+ self.wfile.write(b'{"detail":"thread already exists"}')
+ return
+ self.send_response(200)
+ self.send_header("content-type", "application/json")
+ self.end_headers()
+ self.wfile.write(b'{"ok":true,"messages_added":0}')
+
+ def log_message(self, format: str, *args: Any) -> None:
+ return
+
+ server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ try:
+ env = os.environ.copy()
+ env["NMEM_API_URL"] = f"http://127.0.0.1:{server.server_address[1]}"
+ env["NMEM_SPACE"] = "pi-history-space"
+ env["NMEM_AGENT_ID"] = "PiHistoryAgent"
+ env["NMEM_HOST_AGENT_ID"] = "slock:PiHistoryAgent"
+ result = _run(
+ ["node", str(script), "--session-dir", str(session_dir), "--json", "--apply"],
+ env=env,
+ timeout=30,
+ )
+ finally:
+ server.shutdown()
+ thread.join(timeout=5)
+
+ applied = json.loads(result.stdout)
+ assert applied["summary"]["created"] == 0
+ assert applied["summary"]["appended"] == 1
+ assert applied["summary"]["failed"] == 0
+ assert [call["path"] for call in calls] == ["/threads", "/threads/pi-history-session-one/append"]
+
+ create_body = calls[0]["body"]
+ append_body = calls[1]["body"]
+ assert create_body["source"] == "pi"
+ assert create_body["space_id"] == "pi-history-space"
+ assert create_body["metadata"]["historical_import"] is True
+ assert create_body["metadata"]["analysis"] == "searchable-now-distill-on-demand"
+ assert create_body["metadata"]["sync_reason"] == "history_sync"
+ assert create_body["metadata"]["agent_id"] == "PiHistoryAgent"
+ assert create_body["metadata"]["host_agent_id"] == "slock:PiHistoryAgent"
+ assert create_body["project"] == "/tmp/pi-history-project"
+ assert create_body["workspace"] == "/tmp/pi-history-project"
+ assert [message["content"] for message in create_body["messages"]] == [
+ "history user message",
+ "history assistant message",
+ ]
+ assert all(message["metadata"]["source_app"] == "pi" for message in create_body["messages"])
+ assert append_body["deduplicate"] is True
+ assert append_body["space_id"] == "pi-history-space"
+ assert append_body["historical_import"] is True
+ assert append_body["analysis"] == "searchable-now-distill-on-demand"
+ assert append_body["idempotency_key"] == "pi:history:History Session/One:2"
def test_registry_connect_contract_points_agent_prompts_to_universal_skill():
@@ -428,7 +596,7 @@ def test_registry_connect_contract_points_agent_prompts_to_universal_skill():
assert by_id["openclaw"]["version"] == "0.8.26"
assert by_id["proma"]["version"] == "0.1.1"
assert by_id["opencode"]["version"] == "0.3.4"
- assert by_id["pi"]["version"] == "0.8.0"
+ assert by_id["pi"]["version"] == "0.8.1"
assert by_id["pi"]["threadSave"]["method"] == "plugin-capture"
assert by_id["pi"]["capabilities"]["autoCapture"] is True
assert by_id["pi"]["autonomy"]["threads"] == "automatic-capture"