Skip to content

Commit e75aaaf

Browse files
feat(memory): import vendor history into AgentMemory
Co-Authored-By: First Fluke <our.first.fluke@gmail.com>
1 parent 80d7e38 commit e75aaaf

19 files changed

Lines changed: 1229 additions & 15 deletions

cli/commands/memory/command.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
resolveJsonMode,
55
runAction,
66
} from "../../utils/cli-framework.js";
7+
import { printAgentMemoryImport } from "./import.js";
78
import {
89
initMemory,
910
printAgentMemoryDaemon,
@@ -186,4 +187,37 @@ export function registerMemory(program: Command): void {
186187
{ supportsJsonOutput: true },
187188
),
188189
);
190+
191+
addOutputOptions(
192+
program
193+
.command("memory:import")
194+
.description("Import vendor conversation history into AgentMemory")
195+
.option(
196+
"--source <source>",
197+
"Import source: all, claude, codex, cursor, gemini, qwen, retry",
198+
"all",
199+
)
200+
.option(
201+
"--since <since>",
202+
"Import window start: 24h, 7d, 30d, or YYYY-MM-DD",
203+
"30d",
204+
)
205+
.option("--dry-run", "Preview import without writing to AgentMemory")
206+
.option(
207+
"--force-partial",
208+
"Accept partial source coverage for locked or best-effort stores",
209+
),
210+
).action(
211+
runAction(
212+
async (options) => {
213+
await printAgentMemoryImport(resolveJsonMode(options), {
214+
source: options.source,
215+
since: options.since,
216+
dryRun: options.dryRun,
217+
forcePartial: options.forcePartial,
218+
});
219+
},
220+
{ supportsJsonOutput: true },
221+
),
222+
);
189223
}

cli/commands/memory/import.test.ts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import {
2+
mkdirSync,
3+
mkdtempSync,
4+
readFileSync,
5+
rmSync,
6+
writeFileSync,
7+
} from "node:fs";
8+
import { tmpdir } from "node:os";
9+
import { dirname, join } from "node:path";
10+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
11+
import { retryObservePath } from "../../state/events.js";
12+
import type {
13+
MemoryObservePayload,
14+
MemoryProvider,
15+
MemoryProviderStatus,
16+
MemoryRawTurn,
17+
} from "../../types/memory.js";
18+
import { importAgentMemory } from "./import.js";
19+
20+
function providerStub(args: {
21+
status?: MemoryProviderStatus;
22+
observe?: (payload: MemoryObservePayload) => Promise<boolean> | boolean;
23+
}): MemoryProvider {
24+
return {
25+
name: args.status?.provider ?? "agentmemory",
26+
async status() {
27+
return (
28+
args.status ?? {
29+
provider: "agentmemory",
30+
reachable: true,
31+
endpoint: "http://127.0.0.1:1234",
32+
}
33+
);
34+
},
35+
async observe(payload) {
36+
return args.observe?.(payload) ?? true;
37+
},
38+
};
39+
}
40+
41+
function turn(overrides: Partial<MemoryRawTurn> = {}): MemoryRawTurn {
42+
return {
43+
vendor: "codex",
44+
role: "user",
45+
text: "hello",
46+
timestamp: Date.now(),
47+
vendorSessionId: "codex-1",
48+
idempotencyKey: "codex:codex-1:user:hello",
49+
...overrides,
50+
};
51+
}
52+
53+
function eventLine(eventId: string, sid = "oma-test"): string {
54+
return JSON.stringify({
55+
eventId,
56+
ts: "2026-05-27T00:00:00.000Z",
57+
sid,
58+
kind: "decision.made",
59+
writerPid: 1,
60+
});
61+
}
62+
63+
describe("memory import", () => {
64+
let projectDir: string;
65+
66+
beforeEach(() => {
67+
projectDir = mkdtempSync(join(tmpdir(), "oma-memory-import-"));
68+
});
69+
70+
afterEach(() => {
71+
rmSync(projectDir, { recursive: true, force: true });
72+
});
73+
74+
it("previews raw turn imports without observing AgentMemory", async () => {
75+
let observeCount = 0;
76+
const result = await importAgentMemory({
77+
source: "codex",
78+
since: "1d",
79+
dryRun: true,
80+
provider: providerStub({
81+
observe() {
82+
observeCount += 1;
83+
return true;
84+
},
85+
}),
86+
async rawTurnLoader() {
87+
return [turn(), turn({ role: "assistant", text: "hi" })];
88+
},
89+
});
90+
91+
expect(result).toMatchObject({
92+
source: "codex",
93+
total: 2,
94+
imported: 0,
95+
failed: 0,
96+
dryRun: true,
97+
});
98+
expect(observeCount).toBe(0);
99+
});
100+
101+
it("imports raw turns through the memory provider", async () => {
102+
const observed: MemoryObservePayload[] = [];
103+
const result = await importAgentMemory({
104+
source: "codex",
105+
provider: providerStub({
106+
observe(payload) {
107+
observed.push(payload);
108+
return true;
109+
},
110+
}),
111+
async rawTurnLoader() {
112+
return [turn()];
113+
},
114+
});
115+
116+
expect(result).toMatchObject({
117+
total: 1,
118+
imported: 1,
119+
failed: 0,
120+
});
121+
expect(observed[0]?.source).toBe("oma-memory-import:codex");
122+
expect(JSON.parse(observed[0]?.content ?? "{}")).toMatchObject({
123+
idempotencyKey: "codex:codex-1:user:hello",
124+
});
125+
});
126+
127+
it("drains retry queue when source is retry", async () => {
128+
const retryPath = retryObservePath(projectDir);
129+
mkdirSync(dirname(retryPath), { recursive: true });
130+
writeFileSync(retryPath, `${eventLine("ok", "sid-ok")}\n`, "utf-8");
131+
132+
const result = await importAgentMemory({
133+
source: "retry",
134+
projectDir,
135+
provider: providerStub({}),
136+
});
137+
138+
expect(result).toMatchObject({
139+
source: "retry",
140+
total: 1,
141+
imported: 1,
142+
failed: 0,
143+
});
144+
expect(readFileSync(retryPath, "utf-8")).toBe("");
145+
});
146+
147+
it("rejects retry mixed with vendor imports", async () => {
148+
await expect(
149+
importAgentMemory({ source: "retry,codex", dryRun: true }),
150+
).rejects.toThrow("cannot be combined");
151+
});
152+
153+
it("reports cursor imports as partial unless forced", async () => {
154+
const result = await importAgentMemory({
155+
source: "cursor",
156+
dryRun: true,
157+
async rawTurnLoader() {
158+
return [];
159+
},
160+
});
161+
162+
expect(result.partial).toBe(true);
163+
expect(result.warnings[0]).toContain("cursor import");
164+
});
165+
});

0 commit comments

Comments
 (0)