# Legacy sealseek Chats Import — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Reconstruct legacy named chats by joining `chats.json` (session metadata) with `dialog/<date>.jsonl` (the actual message log), filter heartbeat/bootstrap pollution, and import each chat as a self-contained sealclaw session.

**Architecture:** Refactor the existing `scripts/migrate-legacy-sessions.mjs` in place. Keep the proven pure helpers (`parseLegacyTimestamp`, `buildStoreEntry`, `mergeSessionsStore`, `loadManifest`, `recordImport`, `atomicWriteJson`, `readJsonIfExists`). Replace the source-file-walk with a chats+dialog pipeline: load chats, scan dialog, attribute by time window, drop heartbeats + their assistant replies, convert each chat to a JSONL transcript, merge into `sessions.json`, write manifest.

**Tech Stack:** Node 22 ESM, only `node:*` built-ins. Vitest + TS for tests under `test/scripts/`. Same file `scripts/migrate-legacy-sessions.mjs`; same test file `test/scripts/migrate-legacy-sessions.test.ts`.

**Spec:** [docs/superpowers/specs/2026-05-25-legacy-chats-import-design.md](../specs/2026-05-25-legacy-chats-import-design.md)

**State at start of this plan:** Branch `main`. HEAD = `bb769adb942c67aabe25776dc5ef639c421d684e`. 18 tests passing in `test/scripts/migrate-legacy-sessions.test.ts`. The current `runCli` walks `sessions/default_*.json` — this plan replaces that walk.

---

## File Structure

- **Modify:** `scripts/migrate-legacy-sessions.mjs`
  - Remove: `convertLegacyMessages` and the current body of `runCli`.
  - Keep: every other exported helper and the internal `atomicWriteJson` / `readJsonIfExists`.
  - Add (exports): `loadChats`, `scanDialog`, `isHeartbeatMessage`, `attributeMessagesToChats`, `convertChatToEvents`.
  - Replace: `runCli` with a chats-pipeline orchestrator.
- **Modify:** `test/scripts/migrate-legacy-sessions.test.ts`
  - Remove: `describe("convertLegacyMessages", ...)` block (5 tests) and `describe("runCli end-to-end", ...)` block (3 tests).
  - Keep: `parseLegacyTimestamp` (4), `buildStoreEntry` (1), `mergeSessionsStore` (3), `manifest helpers` (2). Total kept = 10.
  - Add: `loadChats`, `scanDialog`, `isHeartbeatMessage`, `attributeMessagesToChats`, `convertChatToEvents`, plus a new `runCli` integration block.

No new files. No new dependencies.

---

## Task 1: Refactor — drop old convertLegacyMessages + old runCli body

**Files:**
- Modify: `scripts/migrate-legacy-sessions.mjs`
- Modify: `test/scripts/migrate-legacy-sessions.test.ts`

- [ ] **Step 1: Delete the obsolete tests**

In `test/scripts/migrate-legacy-sessions.test.ts`:
- Delete the entire `describe("convertLegacyMessages", ...)` block (the constant `legacy` fixture lives inside that describe; remove it with the block).
- Delete the entire `describe("runCli end-to-end", ...)` block (and its `setup()` helper, since that helper lives inside it).
- Update the script import — remove `convertLegacyMessages` and `runCli` from the import list (we'll re-add `runCli` in Task 5).

After this, the import block should look like:

```typescript
import {
  buildStoreEntry,
  loadManifest,
  mergeSessionsStore,
  parseLegacyTimestamp,
  recordImport,
} from "../../scripts/migrate-legacy-sessions.mjs";
```

The `tmp()` helper at module scope stays — it's used by the merge and manifest blocks. The `fs/os/path/describe/expect/it` imports stay.

- [ ] **Step 2: Delete the obsolete code from the script**

In `scripts/migrate-legacy-sessions.mjs`:

- Delete the entire `export function convertLegacyMessages(legacyJson, opts) { ... }` block.
- Delete the `ALLOWED_ROLES` constant (it's only used by `convertLegacyMessages`).
- Delete the entire current `export async function runCli({ argv, env }) { ... }` body — replace with a stub:

```javascript
export async function runCli(_args) {
  throw new Error("not implemented");
}
```

- Delete the `parseArgs` and `resolveStateDir` file-local helpers — they will be rewritten in Task 5 with different defaults.

Keep everything else: imports, `parseLegacyTimestamp`, `LEGACY_TS_RE`, internal helpers, `buildStoreEntry`, `mergeSessionsStore`, `loadManifest`, `recordImport`, the CLI guard at the bottom.

- [ ] **Step 3: Verify the kept tests still pass**

Run: `pnpm test test/scripts/migrate-legacy-sessions.test.ts`

Expected: 10 tests pass (4 parseLegacyTimestamp + 1 buildStoreEntry + 3 mergeSessionsStore + 2 manifest helpers). No failures.

- [ ] **Step 4: Commit**

```bash
scripts/committer "refactor(scripts): drop sessions/-walk in favor of chats.json+dialog pipeline (wip)" scripts/migrate-legacy-sessions.mjs test/scripts/migrate-legacy-sessions.test.ts
```

---

## Task 2: TDD loadChats + scanDialog + isHeartbeatMessage

Three small pure helpers, grouped because each is too small for its own task.

**Files:**
- Modify: `scripts/migrate-legacy-sessions.mjs`
- Modify: `test/scripts/migrate-legacy-sessions.test.ts`

- [ ] **Step 1: Append failing tests**

Add these three imports to the import-from-script line at the top of the test file:

```typescript
import {
  buildStoreEntry,
  isHeartbeatMessage,
  loadChats,
  loadManifest,
  mergeSessionsStore,
  parseLegacyTimestamp,
  recordImport,
  scanDialog,
} from "../../scripts/migrate-legacy-sessions.mjs";
```

Append three describe blocks AFTER the existing `describe("manifest helpers", ...)` block:

```typescript
describe("loadChats", () => {
  it("parses chats.json into sorted array of normalized chat records", () => {
    const dir = tmp("legacy-chats-a-");
    const chatsPath = path.join(dir, "chats.json");
    fs.writeFileSync(
      chatsPath,
      JSON.stringify({
        version: 1,
        chats: [
          {
            channel: "console",
            created_at: "2026-04-16T04:36:08.442097Z",
            id: "id-b",
            meta: {},
            name: "second",
            session_id: "200",
            updated_at: "2026-04-16T05:00:00.000Z",
            user_id: "default",
          },
          {
            channel: "console",
            created_at: "2026-04-15T18:01:09.130744Z",
            id: "id-a",
            meta: {},
            name: "first",
            session_id: "100",
            updated_at: "2026-04-16T00:00:00.000Z",
            user_id: "default",
          },
        ],
      }),
    );
    const chats = loadChats(chatsPath);
    expect(chats).toHaveLength(2);
    expect(chats[0]).toMatchObject({
      sessionId: "100",
      name: "first",
      channel: "console",
      createdAtMs: Date.parse("2026-04-15T18:01:09.130744Z"),
      updatedAtMs: Date.parse("2026-04-16T00:00:00.000Z"),
    });
    expect(chats[1].sessionId).toBe("200");
  });

  it("returns empty array when chats.json is missing", () => {
    const dir = tmp("legacy-chats-b-");
    expect(loadChats(path.join(dir, "nope.json"))).toEqual([]);
  });

  it("returns empty array when chats.json has no chats array", () => {
    const dir = tmp("legacy-chats-c-");
    const p = path.join(dir, "chats.json");
    fs.writeFileSync(p, JSON.stringify({ version: 1 }));
    expect(loadChats(p)).toEqual([]);
  });
});

describe("scanDialog", () => {
  it("reads all *.jsonl files and returns messages sorted by timestamp ms", () => {
    const dir = tmp("legacy-dialog-a-");
    fs.writeFileSync(
      path.join(dir, "2026-04-16.jsonl"),
      [
        JSON.stringify({
          id: "m1",
          role: "user",
          content: [{ type: "text", text: "B" }],
          timestamp: "2026-04-16 10:00:00.000",
        }),
        JSON.stringify({
          id: "m2",
          role: "assistant",
          content: [{ type: "text", text: "C" }],
          timestamp: "2026-04-16 11:00:00.000",
        }),
      ].join("\n"),
    );
    fs.writeFileSync(
      path.join(dir, "2026-04-15.jsonl"),
      JSON.stringify({
        id: "m0",
        role: "user",
        content: [{ type: "text", text: "A" }],
        timestamp: "2026-04-15 09:00:00.000",
      }),
    );
    const msgs = scanDialog(dir);
    expect(msgs.map((m) => m.text)).toEqual(["A", "B", "C"]);
    expect(msgs[0].tsMs).toBe(Date.UTC(2026, 3, 15, 9, 0, 0, 0));
    expect(msgs[0].role).toBe("user");
  });

  it("returns empty array when dialog dir is missing", () => {
    const dir = tmp("legacy-dialog-b-");
    expect(scanDialog(path.join(dir, "missing"))).toEqual([]);
  });

  it("skips invalid lines but keeps valid ones", () => {
    const dir = tmp("legacy-dialog-c-");
    fs.writeFileSync(
      path.join(dir, "2026-04-16.jsonl"),
      [
        "not-json",
        JSON.stringify({
          id: "ok",
          role: "user",
          content: [{ type: "text", text: "OK" }],
          timestamp: "2026-04-16 10:00:00.000",
        }),
      ].join("\n"),
    );
    const msgs = scanDialog(dir);
    expect(msgs).toHaveLength(1);
    expect(msgs[0].text).toBe("OK");
  });

  it("joins multiple text blocks with double newline", () => {
    const dir = tmp("legacy-dialog-d-");
    fs.writeFileSync(
      path.join(dir, "2026-04-16.jsonl"),
      JSON.stringify({
        id: "m",
        role: "assistant",
        content: [
          { type: "text", text: "alpha" },
          { type: "text", text: "beta" },
        ],
        timestamp: "2026-04-16 10:00:00.000",
      }),
    );
    expect(scanDialog(dir)[0].text).toBe("alpha\n\nbeta");
  });
});

describe("isHeartbeatMessage", () => {
  it("flags messages that start with the heartbeat-mode prefix", () => {
    expect(
      isHeartbeatMessage({ role: "user", text: "你现在处于心跳巡检模式，请依次...", tsMs: 0 }),
    ).toBe(true);
  });

  it("flags messages that start with `# 心跳巡检`", () => {
    expect(isHeartbeatMessage({ role: "user", text: "# 心跳巡检\n\nfoo", tsMs: 0 })).toBe(true);
  });

  it("flags messages that start with `# 引导模式`", () => {
    expect(isHeartbeatMessage({ role: "user", text: "# 引导模式\n\nfoo", tsMs: 0 })).toBe(true);
  });

  it("returns false for normal user messages", () => {
    expect(isHeartbeatMessage({ role: "user", text: "你好", tsMs: 0 })).toBe(false);
  });

  it("returns false for assistant messages even with matching prefix", () => {
    expect(
      isHeartbeatMessage({ role: "assistant", text: "你现在处于心跳巡检模式", tsMs: 0 }),
    ).toBe(false);
  });

  it("ignores leading whitespace before the prefix", () => {
    expect(isHeartbeatMessage({ role: "user", text: "  \n你现在处于心跳巡检模式", tsMs: 0 })).toBe(
      true,
    );
  });
});
```

- [ ] **Step 2: Run, expect fail**

Run: `pnpm test test/scripts/migrate-legacy-sessions.test.ts`

Expected: 13 new tests FAIL (function not exported / not defined). The 10 prior tests still pass.

- [ ] **Step 3: Implement the three helpers**

In `scripts/migrate-legacy-sessions.mjs`, add these three exports AFTER `recordImport` and BEFORE `runCli`:

```javascript
const HEARTBEAT_PREFIXES = ["你现在处于心跳巡检模式", "# 心跳巡检", "# 引导模式"];

export function loadChats(chatsJsonPath) {
  const raw = readJsonIfExists(chatsJsonPath);
  if (!raw || !Array.isArray(raw.chats)) return [];
  return raw.chats
    .map((c) => ({
      sessionId: String(c.session_id),
      name: typeof c.name === "string" ? c.name : "",
      channel: typeof c.channel === "string" ? c.channel : "",
      createdAtMs: Date.parse(c.created_at),
      updatedAtMs: Date.parse(c.updated_at),
    }))
    .filter((c) => Number.isFinite(c.createdAtMs) && Number.isFinite(c.updatedAtMs))
    .sort((a, b) => a.createdAtMs - b.createdAtMs);
}

export function scanDialog(dialogDir) {
  let files;
  try {
    files = fs.readdirSync(dialogDir);
  } catch (err) {
    if (err.code === "ENOENT") return [];
    throw err;
  }
  const out = [];
  for (const f of files) {
    if (!f.endsWith(".jsonl")) continue;
    const lines = fs.readFileSync(path.join(dialogDir, f), "utf8").split("\n");
    for (const line of lines) {
      const trimmed = line.trim();
      if (!trimmed) continue;
      let obj;
      try {
        obj = JSON.parse(trimmed);
      } catch {
        continue;
      }
      const text = Array.isArray(obj?.content)
        ? obj.content
            .filter((b) => b && b.type === "text" && typeof b.text === "string")
            .map((b) => b.text)
            .join("\n\n")
        : "";
      const tsMs = parseLegacyTimestamp(obj?.timestamp, Number.NaN);
      if (!Number.isFinite(tsMs)) continue;
      out.push({ role: obj.role, text, tsMs });
    }
  }
  out.sort((a, b) => a.tsMs - b.tsMs);
  return out;
}

export function isHeartbeatMessage(msg) {
  if (msg?.role !== "user") return false;
  const trimmed = (msg.text ?? "").replace(/^\s+/, "");
  return HEARTBEAT_PREFIXES.some((p) => trimmed.startsWith(p));
}
```

- [ ] **Step 4: Run, expect pass**

Run: `pnpm test test/scripts/migrate-legacy-sessions.test.ts`

Expected: 23 tests PASS (10 prior + 3 loadChats + 4 scanDialog + 6 isHeartbeatMessage).

- [ ] **Step 5: Commit**

```bash
scripts/committer "wip: add loadChats, scanDialog, isHeartbeatMessage helpers" scripts/migrate-legacy-sessions.mjs test/scripts/migrate-legacy-sessions.test.ts
```

---

## Task 3: TDD attributeMessagesToChats

The trickiest helper: time-window attribution + heartbeat filter + drop the assistant reply that follows a dropped heartbeat.

**Files:**
- Modify: `scripts/migrate-legacy-sessions.mjs`
- Modify: `test/scripts/migrate-legacy-sessions.test.ts`

- [ ] **Step 1: Append failing tests**

Add `attributeMessagesToChats` to the import list.

Append AFTER the `isHeartbeatMessage` block:

```typescript
describe("attributeMessagesToChats", () => {
  const chats = [
    { sessionId: "A", name: "a", channel: "c", createdAtMs: 1_000, updatedAtMs: 2_000 },
    { sessionId: "B", name: "b", channel: "c", createdAtMs: 3_000, updatedAtMs: 5_000 },
  ];

  it("assigns each message to the chat whose window contains its timestamp", () => {
    const messages = [
      { role: "user", text: "in A", tsMs: 1_500 },
      { role: "assistant", text: "reply A", tsMs: 1_800 },
      { role: "user", text: "in B", tsMs: 3_500 },
    ];
    const result = attributeMessagesToChats(chats, messages);
    expect(result.get("A")?.map((m) => m.text)).toEqual(["in A", "reply A"]);
    expect(result.get("B")?.map((m) => m.text)).toEqual(["in B"]);
  });

  it("drops orphan messages outside every window", () => {
    const messages = [
      { role: "user", text: "before A", tsMs: 500 },
      { role: "user", text: "between", tsMs: 2_500 },
      { role: "user", text: "after B", tsMs: 9_999 },
    ];
    const result = attributeMessagesToChats(chats, messages);
    expect(result.get("A") ?? []).toEqual([]);
    expect(result.get("B") ?? []).toEqual([]);
  });

  it("attributes to the chat with the latest createdAtMs when windows overlap", () => {
    const overlap = [
      { sessionId: "X", name: "x", channel: "c", createdAtMs: 1_000, updatedAtMs: 10_000 },
      { sessionId: "Y", name: "y", channel: "c", createdAtMs: 5_000, updatedAtMs: 10_000 },
    ];
    const messages = [
      { role: "user", text: "early", tsMs: 2_000 },
      { role: "user", text: "after Y opens", tsMs: 6_000 },
    ];
    const result = attributeMessagesToChats(overlap, messages);
    expect(result.get("X")?.map((m) => m.text)).toEqual(["early"]);
    expect(result.get("Y")?.map((m) => m.text)).toEqual(["after Y opens"]);
  });

  it("drops heartbeat user messages and the immediately following assistant reply", () => {
    const messages = [
      { role: "user", text: "你现在处于心跳巡检模式", tsMs: 1_100 },
      { role: "assistant", text: "OK heartbeat done", tsMs: 1_200 },
      { role: "user", text: "real question", tsMs: 1_300 },
      { role: "assistant", text: "real reply", tsMs: 1_400 },
    ];
    const result = attributeMessagesToChats(chats, messages);
    expect(result.get("A")?.map((m) => m.text)).toEqual(["real question", "real reply"]);
  });

  it("drops only the immediately following assistant — not later assistants", () => {
    const messages = [
      { role: "user", text: "# 心跳巡检", tsMs: 1_050 },
      { role: "assistant", text: "drop me", tsMs: 1_100 },
      { role: "user", text: "kept", tsMs: 1_200 },
      { role: "assistant", text: "kept reply", tsMs: 1_300 },
    ];
    const result = attributeMessagesToChats(chats, messages);
    expect(result.get("A")?.map((m) => m.text)).toEqual(["kept", "kept reply"]);
  });

  it("if a heartbeat is the last message, nothing extra to drop", () => {
    const messages = [
      { role: "user", text: "kept", tsMs: 1_100 },
      { role: "user", text: "# 引导模式", tsMs: 1_900 },
    ];
    const result = attributeMessagesToChats(chats, messages);
    expect(result.get("A")?.map((m) => m.text)).toEqual(["kept"]);
  });

  it("returns an empty Map when no chats are given", () => {
    expect(attributeMessagesToChats([], [{ role: "user", text: "x", tsMs: 1 }]).size).toBe(0);
  });
});
```

- [ ] **Step 2: Run, expect fail**

Run: `pnpm test test/scripts/migrate-legacy-sessions.test.ts -t attributeMessagesToChats`

Expected: 7 new tests FAIL.

- [ ] **Step 3: Implement**

In `scripts/migrate-legacy-sessions.mjs`, add AFTER `isHeartbeatMessage` and BEFORE `runCli`:

```javascript
export function attributeMessagesToChats(chats, messages) {
  const result = new Map();
  for (const c of chats) result.set(c.sessionId, []);
  if (chats.length === 0) return result;

  // First pass: assign every message to the chat with latest createdAtMs <= msg.tsMs and msg.tsMs <= updatedAtMs.
  // Track each chat's message list as a flat array (still in source order — `messages` is pre-sorted by caller).
  for (const m of messages) {
    let pick = null;
    for (const c of chats) {
      if (c.createdAtMs <= m.tsMs && m.tsMs <= c.updatedAtMs) {
        if (pick === null || c.createdAtMs > pick.createdAtMs) pick = c;
      }
    }
    if (pick) result.get(pick.sessionId).push(m);
  }

  // Second pass: per chat, drop heartbeat user messages and the assistant reply that immediately follows (if any).
  for (const [sid, list] of result) {
    const kept = [];
    let dropNextAssistant = false;
    for (const m of list) {
      if (dropNextAssistant && m.role === "assistant") {
        dropNextAssistant = false;
        continue;
      }
      dropNextAssistant = false;
      if (isHeartbeatMessage(m)) {
        dropNextAssistant = true;
        continue;
      }
      kept.push(m);
    }
    result.set(sid, kept);
  }

  return result;
}
```

- [ ] **Step 4: Run, expect pass**

Run: `pnpm test test/scripts/migrate-legacy-sessions.test.ts`

Expected: 30 tests PASS (23 + 7).

- [ ] **Step 5: Commit**

```bash
scripts/committer "wip: attribute dialog messages to chats by time window + heartbeat filter" scripts/migrate-legacy-sessions.mjs test/scripts/migrate-legacy-sessions.test.ts
```

---

## Task 4: TDD convertChatToEvents

**Files:**
- Modify: `scripts/migrate-legacy-sessions.mjs`
- Modify: `test/scripts/migrate-legacy-sessions.test.ts`

- [ ] **Step 1: Append failing tests**

Add `convertChatToEvents` to the import list.

Append AFTER the `attributeMessagesToChats` block:

```typescript
describe("convertChatToEvents", () => {
  const chat = {
    sessionId: "100",
    name: "你好",
    channel: "console",
    createdAtMs: 1_000,
    updatedAtMs: 5_000,
  };
  const messages = [
    { role: "user", text: "Q1", tsMs: 1_100 },
    { role: "assistant", text: "A1", tsMs: 1_200 },
    { role: "user", text: "Q2", tsMs: 1_300 },
  ];

  it("emits session header + one message event per attributed message with linear parentId chain", () => {
    const result = convertChatToEvents({
      chat,
      sessionId: "sid-1",
      messages,
      cwd: "/work",
      randomHex: (() => {
        let i = 0;
        return () => String(++i).padStart(8, "0");
      })(),
    });
    expect(result.events).toHaveLength(4);
    expect(result.events[0]).toMatchObject({ type: "session", version: 3, id: "sid-1", cwd: "/work" });
    expect(result.events[1]).toMatchObject({ id: "00000001", parentId: "sid-1", message: { role: "user", content: [{ type: "text", text: "Q1" }] } });
    expect(result.events[2]).toMatchObject({ id: "00000002", parentId: "00000001" });
    expect(result.events[3]).toMatchObject({ id: "00000003", parentId: "00000002" });
  });

  it("reports messageCount, firstMsgMs, lastMsgMs", () => {
    const result = convertChatToEvents({
      chat,
      sessionId: "sid",
      messages,
      cwd: "/w",
      randomHex: () => "deadbeef",
    });
    expect(result.messageCount).toBe(3);
    expect(result.firstMsgMs).toBe(1_100);
    expect(result.lastMsgMs).toBe(1_300);
  });

  it("returns events: [session-only] and messageCount=0 when messages is empty", () => {
    const result = convertChatToEvents({
      chat,
      sessionId: "sid",
      messages: [],
      cwd: "/w",
      randomHex: () => "deadbeef",
    });
    expect(result.events).toHaveLength(1);
    expect(result.events[0].type).toBe("session");
    expect(result.messageCount).toBe(0);
    expect(result.firstMsgMs).toBe(chat.createdAtMs);
    expect(result.lastMsgMs).toBe(chat.createdAtMs);
  });

  it("skips messages with unknown roles but keeps parentId chain monotonic", () => {
    const result = convertChatToEvents({
      chat,
      sessionId: "sid",
      messages: [
        { role: "user", text: "ok", tsMs: 1_100 },
        { role: "tool", text: "skip", tsMs: 1_200 },
        { role: "assistant", text: "reply", tsMs: 1_300 },
      ],
      cwd: "/w",
      randomHex: (() => {
        let i = 0;
        return () => String(++i).padStart(8, "0");
      })(),
    });
    expect(result.events).toHaveLength(3); // session + 2 messages
    expect(result.messageCount).toBe(2);
    expect(result.events[2].parentId).toBe(result.events[1].id);
  });
});
```

- [ ] **Step 2: Run, expect fail**

Run: `pnpm test test/scripts/migrate-legacy-sessions.test.ts -t convertChatToEvents`

Expected: 4 new tests FAIL.

- [ ] **Step 3: Implement**

Add AFTER `attributeMessagesToChats`:

```javascript
const CHAT_ALLOWED_ROLES = new Set(["user", "assistant"]);

export function convertChatToEvents({ chat, sessionId, messages, cwd, randomHex }) {
  const events = [];
  const headerTs = messages.length > 0 ? messages[0].tsMs : chat.createdAtMs;
  events.push({
    type: "session",
    version: 3,
    id: sessionId,
    timestamp: new Date(headerTs).toISOString(),
    cwd,
  });

  let prevId = sessionId;
  let firstMsgMs = Number.POSITIVE_INFINITY;
  let lastMsgMs = Number.NEGATIVE_INFINITY;
  for (const m of messages) {
    if (!CHAT_ALLOWED_ROLES.has(m.role)) continue;
    const id = randomHex();
    events.push({
      type: "message",
      id,
      parentId: prevId,
      timestamp: new Date(m.tsMs).toISOString(),
      message: {
        role: m.role,
        content: [{ type: "text", text: m.text }],
        timestamp: m.tsMs,
      },
    });
    prevId = id;
    if (m.tsMs < firstMsgMs) firstMsgMs = m.tsMs;
    if (m.tsMs > lastMsgMs) lastMsgMs = m.tsMs;
  }

  if (firstMsgMs === Number.POSITIVE_INFINITY) {
    firstMsgMs = chat.createdAtMs;
    lastMsgMs = chat.createdAtMs;
  }

  return {
    events,
    messageCount: events.length - 1,
    firstMsgMs,
    lastMsgMs,
  };
}
```

- [ ] **Step 4: Run, expect pass**

Run: `pnpm test test/scripts/migrate-legacy-sessions.test.ts`

Expected: 34 tests PASS (30 + 4).

- [ ] **Step 5: Commit**

```bash
scripts/committer "wip: convert per-chat message list into Pi transcript events" scripts/migrate-legacy-sessions.mjs test/scripts/migrate-legacy-sessions.test.ts
```

---

## Task 5: New runCli + end-to-end integration test

**Files:**
- Modify: `scripts/migrate-legacy-sessions.mjs`
- Modify: `test/scripts/migrate-legacy-sessions.test.ts`

- [ ] **Step 1: Append failing integration tests**

Add `runCli` to the import list.

Append AFTER the `convertChatToEvents` block:

```typescript
describe("runCli end-to-end (chats pipeline)", () => {
  function setup() {
    const root = tmp("legacy-cli-chats-");
    const workspace = path.join(root, "ws");
    const stateDir = path.join(root, ".sealseek");
    fs.mkdirSync(path.join(workspace, "dialog"), { recursive: true });

    fs.writeFileSync(
      path.join(workspace, "chats.json"),
      JSON.stringify({
        version: 1,
        chats: [
          {
            channel: "console",
            created_at: "2026-04-16T10:00:00.000Z",
            id: "id-1",
            meta: {},
            name: "Real Chat",
            session_id: "100",
            updated_at: "2026-04-16T11:00:00.000Z",
            user_id: "default",
          },
        ],
      }),
    );

    fs.writeFileSync(
      path.join(workspace, "dialog", "2026-04-16.jsonl"),
      [
        // heartbeat -> drop, plus its assistant reply
        JSON.stringify({
          role: "user",
          content: [{ type: "text", text: "你现在处于心跳巡检模式" }],
          timestamp: "2026-04-16 10:05:00.000",
        }),
        JSON.stringify({
          role: "assistant",
          content: [{ type: "text", text: "heartbeat done" }],
          timestamp: "2026-04-16 10:06:00.000",
        }),
        // real conversation
        JSON.stringify({
          role: "user",
          content: [{ type: "text", text: "你好" }],
          timestamp: "2026-04-16 10:30:00.000",
        }),
        JSON.stringify({
          role: "assistant",
          content: [{ type: "text", text: "你好，我是 Friday" }],
          timestamp: "2026-04-16 10:30:30.000",
        }),
      ].join("\n"),
    );

    return { root, workspace, stateDir };
  }

  it("imports a chat by joining chats.json with dialog, dropping heartbeats", async () => {
    const { workspace, stateDir } = setup();
    const code = await runCli({
      argv: ["--workspace", workspace, "--state-dir", stateDir],
      env: {},
    });
    expect(code).toBe(0);

    const sessionsDir = path.join(stateDir, "agents", "main", "sessions");
    const jsonl = fs
      .readdirSync(sessionsDir)
      .filter((f) => f.endsWith(".jsonl") && !f.includes("trajectory"));
    expect(jsonl).toHaveLength(1);

    const lines = fs.readFileSync(path.join(sessionsDir, jsonl[0]), "utf8").trim().split("\n");
    expect(lines).toHaveLength(3); // session header + 2 kept messages
    const parsed = lines.map((l) => JSON.parse(l));
    expect(parsed[1].message.content[0].text).toBe("你好");
    expect(parsed[2].message.content[0].text).toBe("你好，我是 Friday");

    const store = JSON.parse(fs.readFileSync(path.join(sessionsDir, "sessions.json"), "utf8"));
    expect(store["agent:main:legacy-100"].label).toBe("Real Chat");

    const manifest = JSON.parse(
      fs.readFileSync(path.join(sessionsDir, ".legacy-import-manifest.json"), "utf8"),
    );
    expect(manifest.imported["100"]).toBeDefined();
    expect(manifest.imported["100"].messageCount).toBe(2);
  });

  it("is idempotent: a second run imports nothing", async () => {
    const { workspace, stateDir } = setup();
    await runCli({ argv: ["--workspace", workspace, "--state-dir", stateDir], env: {} });
    const before = fs.readdirSync(path.join(stateDir, "agents", "main", "sessions")).sort();
    const code = await runCli({ argv: ["--workspace", workspace, "--state-dir", stateDir], env: {} });
    const after = fs.readdirSync(path.join(stateDir, "agents", "main", "sessions")).sort();
    expect(code).toBe(0);
    expect(after).toEqual(before);
  });

  it("--dry-run writes nothing", async () => {
    const { workspace, stateDir } = setup();
    const code = await runCli({
      argv: ["--workspace", workspace, "--state-dir", stateDir, "--dry-run"],
      env: {},
    });
    expect(code).toBe(0);
    expect(fs.existsSync(path.join(stateDir, "agents"))).toBe(false);
  });

  it("returns 1 when chats.json is missing", async () => {
    const root = tmp("legacy-cli-missing-");
    const code = await runCli({
      argv: ["--workspace", path.join(root, "nope"), "--state-dir", path.join(root, ".sealseek")],
      env: {},
    });
    expect(code).toBe(1);
  });

  it("drops chats with zero surviving messages (empty count, not failed)", async () => {
    const root = tmp("legacy-cli-empty-");
    const workspace = path.join(root, "ws");
    const stateDir = path.join(root, ".sealseek");
    fs.mkdirSync(path.join(workspace, "dialog"), { recursive: true });
    fs.writeFileSync(
      path.join(workspace, "chats.json"),
      JSON.stringify({
        version: 1,
        chats: [
          {
            channel: "console",
            created_at: "2026-04-16T10:00:00.000Z",
            id: "id-1",
            meta: {},
            name: "Heartbeat-only",
            session_id: "100",
            updated_at: "2026-04-16T11:00:00.000Z",
            user_id: "default",
          },
        ],
      }),
    );
    fs.writeFileSync(
      path.join(workspace, "dialog", "2026-04-16.jsonl"),
      JSON.stringify({
        role: "user",
        content: [{ type: "text", text: "你现在处于心跳巡检模式" }],
        timestamp: "2026-04-16 10:30:00.000",
      }),
    );
    const code = await runCli({
      argv: ["--workspace", workspace, "--state-dir", stateDir],
      env: {},
    });
    expect(code).toBe(0);
    const sessionsDir = path.join(stateDir, "agents", "main", "sessions");
    expect(fs.existsSync(sessionsDir) ? fs.readdirSync(sessionsDir).filter((f) => f.endsWith(".jsonl")) : []).toEqual([]);
  });
});
```

- [ ] **Step 2: Run, expect fail**

Run: `pnpm test test/scripts/migrate-legacy-sessions.test.ts -t "runCli end-to-end"`

Expected: 5 new tests FAIL with "not implemented".

- [ ] **Step 3: Implement parseArgs, resolveStateDir, and runCli**

In `scripts/migrate-legacy-sessions.mjs`, add IMMEDIATELY BEFORE the `export async function runCli(_args)` stub:

```javascript
function parseArgs(argv) {
  const out = {
    workspace: null,
    agent: "main",
    stateDir: null,
    dryRun: false,
    force: false,
    verbose: false,
  };
  for (let i = 0; i < argv.length; i++) {
    const a = argv[i];
    switch (a) {
      case "--workspace":
        out.workspace = argv[++i];
        break;
      case "--agent":
        out.agent = argv[++i];
        break;
      case "--state-dir":
        out.stateDir = argv[++i];
        break;
      case "--dry-run":
        out.dryRun = true;
        break;
      case "--force":
        out.force = true;
        break;
      case "--verbose":
        out.verbose = true;
        break;
      default:
        throw new Error(`unknown arg: ${a}`);
    }
  }
  return out;
}

function resolveStateDir(env, override) {
  if (override) return override;
  const explicit = env.OPENCLAW_STATE_DIR;
  if (explicit) return explicit;
  const home = env.HOME || env.USERPROFILE || os.homedir();
  return path.join(home, ".sealseek");
}
```

Then replace the `runCli` stub with:

```javascript
export async function runCli({ argv, env }) {
  const args = parseArgs(argv);
  const stateDir = resolveStateDir(env, args.stateDir);
  const workspace = args.workspace ?? path.join(stateDir, "workspaces", "default");
  const chatsPath = path.join(workspace, "chats.json");
  const dialogDir = path.join(workspace, "dialog");
  const sessionsDir = path.join(stateDir, "agents", args.agent, "sessions");
  const storePath = path.join(sessionsDir, "sessions.json");
  const manifestPath = path.join(sessionsDir, ".legacy-import-manifest.json");

  if (!fs.existsSync(chatsPath)) {
    console.error(`chats.json not found at ${chatsPath}`);
    return 1;
  }

  const chats = loadChats(chatsPath);
  const messages = scanDialog(dialogDir);
  const attributed = attributeMessagesToChats(chats, messages);

  const chatsJsonMtime = fs.statSync(chatsPath).mtimeMs;
  const manifest = loadManifest(manifestPath);

  const additions = {};
  const newManifestRecords = [];
  let imported = 0;
  let skipped = 0;
  let empty = 0;
  let failed = 0;

  for (const chat of chats) {
    const existing = manifest.imported[chat.sessionId];
    if (!args.force && existing && existing.chatsJsonMtime === chatsJsonMtime) {
      if (args.verbose) console.log(`skip ${chat.sessionId} (already imported)`);
      skipped++;
      continue;
    }

    const msgs = attributed.get(chat.sessionId) ?? [];
    if (msgs.length === 0) {
      if (args.verbose) console.log(`empty ${chat.sessionId} (${chat.name || "<no name>"})`);
      empty++;
      continue;
    }

    const sessionId = crypto.randomUUID();
    const sessionFile = path.join(sessionsDir, `${sessionId}.jsonl`);
    let result;
    try {
      result = convertChatToEvents({
        chat,
        sessionId,
        messages: msgs,
        cwd: path.join(stateDir, "workspace"),
        randomHex: () => crypto.randomBytes(4).toString("hex"),
      });
    } catch (err) {
      console.error(`fail ${chat.sessionId}: ${err.message}`);
      failed++;
      continue;
    }

    if (!args.dryRun) {
      fs.mkdirSync(sessionsDir, { recursive: true });
      const tmpPath = `${sessionFile}.tmp`;
      fs.writeFileSync(tmpPath, result.events.map((e) => JSON.stringify(e)).join("\n") + "\n");
      fs.renameSync(tmpPath, sessionFile);
    }

    additions[`agent:${args.agent}:legacy-${chat.sessionId}`] = buildStoreEntry({
      sessionId,
      sessionFile,
      firstMsgMs: result.firstMsgMs,
      lastMsgMs: result.lastMsgMs,
      label: chat.name || "(legacy chat)",
    });
    newManifestRecords.push({
      sessionId: chat.sessionId,
      record: {
        newSessionId: sessionId,
        sessionKey: `legacy-${chat.sessionId}`,
        importedAt: Date.now(),
        chatsJsonMtime,
        messageCount: result.messageCount,
      },
    });
    imported++;
    if (args.verbose) console.log(`import ${chat.sessionId} -> ${sessionId} (${result.messageCount} msgs)`);
  }

  if (!args.dryRun && imported > 0) {
    mergeSessionsStore({ storePath, additions, force: args.force });
    let mani = loadManifest(manifestPath);
    for (const r of newManifestRecords) mani.imported[r.sessionId] = r.record;
    // Write manifest in one atomic call by routing through recordImport-style flow:
    for (const r of newManifestRecords) {
      recordImport({ manifestPath, sourcePath: r.sessionId, record: r.record });
    }
  }

  console.log(
    `chats=${chats.length} imported=${imported} skipped=${skipped} empty=${empty} failed=${failed} dry=${args.dryRun ? "true" : "false"}`,
  );
  return 0;
}
```

Note: `recordImport`'s second parameter is named `sourcePath` for the previous design, but the manifest is just a `Record<string,record>` keyed by whatever string. Passing `chat.sessionId` as the key works without changing `recordImport`. The `mani` local at the top of the merge block is dead — remove it; that's left from a previous draft. Final code should NOT include the `let mani = loadManifest(...)` line or the redundant `mani.imported[...]` assignment loop. Use only the `for (const r ...) recordImport(...)` loop.

Corrected merge block:

```javascript
  if (!args.dryRun && imported > 0) {
    mergeSessionsStore({ storePath, additions, force: args.force });
    for (const r of newManifestRecords) {
      recordImport({ manifestPath, sourcePath: r.sessionId, record: r.record });
    }
  }
```

- [ ] **Step 4: Run, expect pass**

Run: `pnpm test test/scripts/migrate-legacy-sessions.test.ts`

Expected: 39 tests PASS (34 + 5).

- [ ] **Step 5: Format the touched files**

```bash
pnpm exec oxfmt --write --threads=1 scripts/migrate-legacy-sessions.mjs test/scripts/migrate-legacy-sessions.test.ts
pnpm test test/scripts/migrate-legacy-sessions.test.ts
```

Expected: still 39 passing after format.

- [ ] **Step 6: Commit**

```bash
scripts/committer "feat(scripts): pivot legacy importer to chats.json+dialog pipeline" scripts/migrate-legacy-sessions.mjs test/scripts/migrate-legacy-sessions.test.ts
```

---

## Task 6: Verify against real legacy data

- [ ] **Step 1: Dry-run**

```bash
node scripts/migrate-legacy-sessions.mjs --workspace ~/.sealseek/workspaces/default --state-dir ~/.sealseek --dry-run --verbose
```

Expected: `chats=62 imported=I empty=E failed=0 dry=true` with I + E = 62. I should be roughly 30-60 (most named chats should have surviving non-heartbeat messages).

If `failed > 0`, capture the per-chat error lines and fix root cause before continuing.

- [ ] **Step 2: Spot-check 3 chats from the verbose output**

Pick 3 chats with `messageCount` > 5 from the dry-run log. For each, manually compute the expected count:

```bash
node -e "
const fs = require('fs');
const path = require('path');
const j = JSON.parse(fs.readFileSync(process.env.HOME + '/.sealseek/workspaces/default/chats.json','utf8'));
const c = j.chats.find(c => c.session_id === 'SESSION_ID_HERE');
const start = Date.parse(c.created_at), end = Date.parse(c.updated_at);
const dir = process.env.HOME + '/.sealseek/workspaces/default/dialog';
let total = 0;
for (const f of fs.readdirSync(dir)) {
  if (!f.endsWith('.jsonl')) continue;
  for (const ln of fs.readFileSync(path.join(dir,f),'utf8').split('\n').filter(Boolean)) {
    try {
      const o = JSON.parse(ln);
      const m = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?$/.exec(o.timestamp);
      if (!m) continue;
      const ts = Date.UTC(+m[1],+m[2]-1,+m[3],+m[4],+m[5],+m[6],m[7]?+m[7].padEnd(3,'0'):0);
      if (ts >= start && ts <= end) total++;
    } catch{}
  }
}
console.log('total in window:', total);
"
```

The total in window may be a bit higher than messageCount (heartbeats are filtered out). Confirm the difference is plausibly the heartbeat count.

- [ ] **Step 3: Snapshot sessions.json before real run**

```bash
cp ~/.sealseek/agents/main/sessions/sessions.json ~/.sealseek/agents/main/sessions/sessions.json.pre-legacy-chats-import.bak
```

- [ ] **Step 4: Real run**

```bash
node scripts/migrate-legacy-sessions.mjs --workspace ~/.sealseek/workspaces/default --state-dir ~/.sealseek --verbose
```

Expected: same numbers as dry-run, `dry=false`.

- [ ] **Step 5: Sanity-check sessions.json**

```bash
node -e "
const fs = require('fs');
const j = JSON.parse(fs.readFileSync(process.env.HOME + '/.sealseek/agents/main/sessions/sessions.json','utf8'));
const keys = Object.keys(j);
console.log('total keys:', keys.length);
console.log('legacy keys:', keys.filter(k => k.includes(':legacy-')).length);
"
```

Expected: `legacy keys` matches `imported` count from step 4.

- [ ] **Step 6: Idempotency check**

```bash
node scripts/migrate-legacy-sessions.mjs --workspace ~/.sealseek/workspaces/default --state-dir ~/.sealseek
```

Expected: `imported=0 skipped=I empty=E failed=0 dry=false` — every previously-imported chat is now in skipped.

- [ ] **Step 7: UI smoke check (manual — controller hands back to user)**

Launch the sealclaw desktop app. In the session list:
- Confirm legacy chats appear with names from `chats.json`.
- Sort by updatedAt — legacy rows interleave with live sessions.
- Open one chat with 10+ messages; confirm dialogue reads in order, no heartbeat pollution.
- Open one chat with 1-2 messages; confirm it doesn't error.

If anything fails the UI check, capture the error, write a failing test for the underlying helper, fix, and re-run from Task 5 Step 4 onward.

- [ ] **Step 8: Final commit only if fixes were needed**

```bash
scripts/committer "fix(scripts): <specific issue> in legacy chats importer" scripts/migrate-legacy-sessions.mjs test/scripts/migrate-legacy-sessions.test.ts
```

---

## Self-Review Notes

- **Spec coverage:** every spec section maps to a task. Filtering rules → Task 2 + 3. Attribution rule → Task 3. Mapping → Task 4. CLI + summary line → Task 5. Verification plan → Task 6.
- **Placeholder check:** every step has complete code or exact command.
- **Type consistency:** `loadChats` returns `{sessionId, name, channel, createdAtMs, updatedAtMs}` — those names flow through `attributeMessagesToChats` and `convertChatToEvents` unchanged. `scanDialog` returns `{role, text, tsMs}` — same shape consumed by `isHeartbeatMessage`, `attributeMessagesToChats`, and `convertChatToEvents`.
- **Reuse:** `parseLegacyTimestamp` (consumed by `scanDialog`), `buildStoreEntry`, `mergeSessionsStore`, `loadManifest`, `recordImport`, `atomicWriteJson`, `readJsonIfExists` are unchanged from the superseded work — no rewrites.
- **Removed code:** `convertLegacyMessages` and `ALLOWED_ROLES` from the script; `convertLegacyMessages` (5 tests) and old `runCli end-to-end` (3 tests) from the test file. Total: 8 tests removed, 5 + 4 + 7 + 4 + 5 = 25 tests added. Final count: 10 kept + 25 new = 35 tests... wait, that's 35 not 39. Let me recount: `parseLegacyTimestamp` 4, `buildStoreEntry` 1, `mergeSessionsStore` 3, `manifest helpers` 2 (= 10 kept) + `loadChats` 3, `scanDialog` 4, `isHeartbeatMessage` 6, `attributeMessagesToChats` 7, `convertChatToEvents` 4, `runCli end-to-end (chats)` 5 (= 29 new) = **39 total**. Step 4 expectations updated to match (Task 2 step 4 = 23, Task 3 step 4 = 30, Task 4 step 4 = 34, Task 5 step 4 = 39).
