chat-logbook 0.10.0 → 0.12.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/api/dist/index.js CHANGED
@@ -9,7 +9,7 @@ import fs6 from "fs";
9
9
  import os from "os";
10
10
  import path4 from "path";
11
11
  import { exec } from "child_process";
12
- import { fileURLToPath as fileURLToPath3 } from "url";
12
+ import { fileURLToPath as fileURLToPath2 } from "url";
13
13
 
14
14
  // ../node_modules/.pnpm/@hono+node-server@1.19.11_hono@4.12.9/node_modules/@hono/node-server/dist/index.mjs
15
15
  import { createServer as createServerHTTP } from "http";
@@ -2860,6 +2860,207 @@ var serveStatic = (options = { root: "" }) => {
2860
2860
  };
2861
2861
  };
2862
2862
 
2863
+ // src/archive/chat-id.ts
2864
+ import crypto3 from "crypto";
2865
+ var CROCKFORD_ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
2866
+ var CHAT_ID_LENGTH = 6;
2867
+ var MAX_RETRIES = 5;
2868
+ var CHAT_ID_PREFIX = "clog_";
2869
+ var WIRE_FORM_RE = new RegExp(
2870
+ `^${CHAT_ID_PREFIX}[${CROCKFORD_ALPHABET}]{${CHAT_ID_LENGTH}}$`
2871
+ );
2872
+ function formatChatId(code) {
2873
+ return `${CHAT_ID_PREFIX}${code}`;
2874
+ }
2875
+ function parseChatId(wire) {
2876
+ if (!WIRE_FORM_RE.test(wire)) return null;
2877
+ return wire.slice(CHAT_ID_PREFIX.length);
2878
+ }
2879
+ function generateChatId({
2880
+ isTaken,
2881
+ randomIndex = () => crypto3.randomInt(CROCKFORD_ALPHABET.length)
2882
+ }) {
2883
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
2884
+ let code = "";
2885
+ for (let i = 0; i < CHAT_ID_LENGTH; i++) {
2886
+ code += CROCKFORD_ALPHABET[randomIndex()];
2887
+ }
2888
+ if (!isTaken(code)) {
2889
+ return code;
2890
+ }
2891
+ }
2892
+ throw new Error(
2893
+ `Failed to generate unique chat_id after ${MAX_RETRIES + 1} attempts`
2894
+ );
2895
+ }
2896
+
2897
+ // src/visibility.ts
2898
+ function loadChatVisibility(metadata2, opts) {
2899
+ const deleted = metadata2.listDeleted();
2900
+ const trashed = new Set(deleted.map((r) => r.id));
2901
+ const deletedAtById = new Map(
2902
+ deleted.map((r) => [r.id, r.deletedAt?.getTime() ?? null])
2903
+ );
2904
+ const showTrashed = opts.includeTrashed === true;
2905
+ return {
2906
+ isTrashed: (internalId) => trashed.has(internalId),
2907
+ isVisible: (internalId) => showTrashed || !trashed.has(internalId),
2908
+ deletedAt: (internalId) => deletedAtById.get(internalId) ?? null
2909
+ };
2910
+ }
2911
+
2912
+ // src/chat-reader.ts
2913
+ function toApiBlock(block) {
2914
+ if (block.type === "tool_result") {
2915
+ return {
2916
+ type: "tool_result",
2917
+ tool_use_id: String(block.toolUseId ?? ""),
2918
+ content: block.content
2919
+ };
2920
+ }
2921
+ return block;
2922
+ }
2923
+ function createChatReader({
2924
+ archive: archive2,
2925
+ metadata: metadata2
2926
+ }) {
2927
+ function findChat(id) {
2928
+ const code = parseChatId(id);
2929
+ if (code === null) return null;
2930
+ return archive2.read.findChatByChatId(code);
2931
+ }
2932
+ function deriveTitle(customTitle, firstUserText) {
2933
+ if (customTitle && customTitle.trim()) return customTitle;
2934
+ const text2 = firstUserText?.trim().split("\n")[0]?.trim();
2935
+ return text2 && text2.length > 0 ? text2 : "Untitled";
2936
+ }
2937
+ function key(agent, sourceId) {
2938
+ return [agent, sourceId].join("\0");
2939
+ }
2940
+ function listChats({
2941
+ includeTrashed
2942
+ }) {
2943
+ const visibility = loadChatVisibility(metadata2, { includeTrashed });
2944
+ const rows = archive2.read.listChatRows();
2945
+ const tsRangeByKey = new Map(
2946
+ archive2.read.listChatTsRanges().map((r) => [key(r.agent, r.sourceId), r])
2947
+ );
2948
+ const sourcePathByKey = new Map(
2949
+ archive2.read.listLatestRawSourcePaths().map((r) => [key(r.agent, r.sourceId), r.sourcePath])
2950
+ );
2951
+ const firstUserTextByKey = new Map(
2952
+ archive2.read.listFirstUserTexts().map((r) => [key(r.agent, r.sourceId), r.text])
2953
+ );
2954
+ const customTitleById = metadata2.listCustomTitles();
2955
+ const chats2 = [];
2956
+ for (const row of rows) {
2957
+ if (!visibility.isVisible(row.id)) continue;
2958
+ const isDeleted = visibility.isTrashed(row.id);
2959
+ const rowKey = key(row.agent, row.sourceId);
2960
+ const tsRange = tsRangeByKey.get(rowKey);
2961
+ const firstSeenAtMs = row.firstSeenAt.getTime();
2962
+ const chat = {
2963
+ id: formatChatId(row.chatId),
2964
+ sourceId: row.sourceId,
2965
+ agent: row.agent,
2966
+ title: deriveTitle(
2967
+ customTitleById.get(row.id),
2968
+ firstUserTextByKey.get(rowKey)
2969
+ ),
2970
+ project: row.project ?? "",
2971
+ projectPath: row.projectPath ?? null,
2972
+ sourceFilePath: sourcePathByKey.get(rowKey) ?? null,
2973
+ createdAt: tsRange?.minTs ?? firstSeenAtMs,
2974
+ updatedAt: tsRange?.maxTs ?? firstSeenAtMs,
2975
+ deletedAt: visibility.deletedAt(row.id)
2976
+ };
2977
+ if (isDeleted) chat.isDeleted = true;
2978
+ chats2.push(chat);
2979
+ }
2980
+ return chats2;
2981
+ }
2982
+ function getMessages(id, { includeTrashed }) {
2983
+ const row = findChat(id);
2984
+ if (!row) return null;
2985
+ const visibility = loadChatVisibility(metadata2, { includeTrashed });
2986
+ if (!visibility.isVisible(row.id)) return null;
2987
+ const rows = archive2.read.listMessagesByChat(row.agent, row.sourceId);
2988
+ return rows.map((m) => ({
2989
+ role: m.role,
2990
+ content: m.blocks.map(toApiBlock),
2991
+ timestamp: m.ts.toISOString()
2992
+ }));
2993
+ }
2994
+ return { listChats, getMessages, findChat };
2995
+ }
2996
+
2997
+ // src/app.ts
2998
+ function createApp({ archive: archive2, metadata: metadata2, webDistDir: webDistDir2 }) {
2999
+ const app2 = new Hono2();
3000
+ const reader = createChatReader({ archive: archive2, metadata: metadata2 });
3001
+ app2.get("/api/chats", (c) => {
3002
+ const chats2 = reader.listChats({
3003
+ includeTrashed: c.req.query("includeTrashed") === "true"
3004
+ });
3005
+ return c.json({ chats: chats2 });
3006
+ });
3007
+ app2.delete("/api/chats/:id", (c) => {
3008
+ const row = reader.findChat(c.req.param("id"));
3009
+ if (!row) {
3010
+ return c.json({ error: "Chat not found" }, 404);
3011
+ }
3012
+ metadata2.softDelete(row.id);
3013
+ return c.body(null, 204);
3014
+ });
3015
+ app2.post("/api/chats/:id/restore", (c) => {
3016
+ const row = reader.findChat(c.req.param("id"));
3017
+ if (!row) {
3018
+ return c.json({ error: "Chat not found" }, 404);
3019
+ }
3020
+ metadata2.restore(row.id);
3021
+ return c.body(null, 204);
3022
+ });
3023
+ app2.patch("/api/chats/:id/title", async (c) => {
3024
+ const row = reader.findChat(c.req.param("id"));
3025
+ if (!row) {
3026
+ return c.json({ error: "Chat not found" }, 404);
3027
+ }
3028
+ let body;
3029
+ try {
3030
+ body = await c.req.json();
3031
+ } catch {
3032
+ return c.json({ error: "Invalid JSON body" }, 400);
3033
+ }
3034
+ if (!body || typeof body !== "object" || typeof body.title !== "string") {
3035
+ return c.json({ error: "Invalid title" }, 400);
3036
+ }
3037
+ const raw2 = body.title;
3038
+ if (raw2.length > 200) {
3039
+ return c.json({ error: "Title too long" }, 400);
3040
+ }
3041
+ const trimmed = raw2.trim();
3042
+ metadata2.setCustomTitle(row.id, trimmed.length > 0 ? trimmed : null);
3043
+ return c.body(null, 204);
3044
+ });
3045
+ app2.get("/api/chats/:id", (c) => {
3046
+ const messages2 = reader.getMessages(c.req.param("id"), {
3047
+ includeTrashed: c.req.query("includeTrashed") === "true"
3048
+ });
3049
+ if (messages2 === null) {
3050
+ return c.json({ error: "Chat not found" }, 404);
3051
+ }
3052
+ return c.json({ messages: messages2 });
3053
+ });
3054
+ if (webDistDir2) {
3055
+ app2.use("*", serveStatic({ root: webDistDir2 }));
3056
+ app2.use("*", serveStatic({ root: webDistDir2, path: "index.html" }));
3057
+ }
3058
+ return app2;
3059
+ }
3060
+
3061
+ // src/archive/repository.ts
3062
+ import crypto5 from "crypto";
3063
+
2863
3064
  // ../node_modules/.pnpm/drizzle-orm@0.45.2_@types+better-sqlite3@7.6.13_better-sqlite3@12.9.0/node_modules/drizzle-orm/entity.js
2864
3065
  var entityKind = /* @__PURE__ */ Symbol.for("drizzle:entityKind");
2865
3066
  function is(value, type) {
@@ -4754,17 +4955,82 @@ function mapRelationalRow(tablesConfig, tableConfig, row, buildQueryResultSelect
4754
4955
  return result;
4755
4956
  }
4756
4957
 
4757
- // src/archive/schema.ts
4758
- var schema_exports = {};
4759
- __export(schema_exports, {
4760
- archiveMeta: () => archiveMeta,
4761
- chatScanState: () => chatScanState,
4762
- chats: () => chats,
4763
- ingestionEvents: () => ingestionEvents,
4764
- messages: () => messages,
4765
- rawMessages: () => rawMessages,
4766
- schemaVersion: () => schemaVersion
4767
- });
4958
+ // src/storage/openStore.ts
4959
+ import fs2 from "fs";
4960
+ import path from "path";
4961
+ import { fileURLToPath } from "url";
4962
+ import Database from "better-sqlite3";
4963
+
4964
+ // ../node_modules/.pnpm/drizzle-orm@0.45.2_@types+better-sqlite3@7.6.13_better-sqlite3@12.9.0/node_modules/drizzle-orm/better-sqlite3/driver.js
4965
+ import Client from "better-sqlite3";
4966
+
4967
+ // ../node_modules/.pnpm/drizzle-orm@0.45.2_@types+better-sqlite3@7.6.13_better-sqlite3@12.9.0/node_modules/drizzle-orm/selection-proxy.js
4968
+ var SelectionProxyHandler = class _SelectionProxyHandler {
4969
+ static [entityKind] = "SelectionProxyHandler";
4970
+ config;
4971
+ constructor(config) {
4972
+ this.config = { ...config };
4973
+ }
4974
+ get(subquery, prop) {
4975
+ if (prop === "_") {
4976
+ return {
4977
+ ...subquery["_"],
4978
+ selectedFields: new Proxy(
4979
+ subquery._.selectedFields,
4980
+ this
4981
+ )
4982
+ };
4983
+ }
4984
+ if (prop === ViewBaseConfig) {
4985
+ return {
4986
+ ...subquery[ViewBaseConfig],
4987
+ selectedFields: new Proxy(
4988
+ subquery[ViewBaseConfig].selectedFields,
4989
+ this
4990
+ )
4991
+ };
4992
+ }
4993
+ if (typeof prop === "symbol") {
4994
+ return subquery[prop];
4995
+ }
4996
+ const columns = is(subquery, Subquery) ? subquery._.selectedFields : is(subquery, View) ? subquery[ViewBaseConfig].selectedFields : subquery;
4997
+ const value = columns[prop];
4998
+ if (is(value, SQL.Aliased)) {
4999
+ if (this.config.sqlAliasedBehavior === "sql" && !value.isSelectionField) {
5000
+ return value.sql;
5001
+ }
5002
+ const newValue = value.clone();
5003
+ newValue.isSelectionField = true;
5004
+ return newValue;
5005
+ }
5006
+ if (is(value, SQL)) {
5007
+ if (this.config.sqlBehavior === "sql") {
5008
+ return value;
5009
+ }
5010
+ throw new Error(
5011
+ `You tried to reference "${prop}" field from a subquery, which is a raw SQL field, but it doesn't have an alias declared. Please add an alias to the field using ".as('alias')" method.`
5012
+ );
5013
+ }
5014
+ if (is(value, Column)) {
5015
+ if (this.config.alias) {
5016
+ return new Proxy(
5017
+ value,
5018
+ new ColumnAliasProxyHandler(
5019
+ new Proxy(
5020
+ value.table,
5021
+ new TableAliasProxyHandler(this.config.alias, this.config.replaceOriginalName ?? false)
5022
+ )
5023
+ )
5024
+ );
5025
+ }
5026
+ return value;
5027
+ }
5028
+ if (typeof value !== "object" || value === null) {
5029
+ return value;
5030
+ }
5031
+ return new Proxy(value, new _SelectionProxyHandler(this.config));
5032
+ }
5033
+ };
4768
5034
 
4769
5035
  // ../node_modules/.pnpm/drizzle-orm@0.45.2_@types+better-sqlite3@7.6.13_better-sqlite3@12.9.0/node_modules/drizzle-orm/sqlite-core/foreign-keys.js
4770
5036
  var ForeignKeyBuilder2 = class {
@@ -5318,74 +5584,6 @@ function text(a, b = {}) {
5318
5584
  return new SQLiteTextBuilder(name, config);
5319
5585
  }
5320
5586
 
5321
- // ../node_modules/.pnpm/drizzle-orm@0.45.2_@types+better-sqlite3@7.6.13_better-sqlite3@12.9.0/node_modules/drizzle-orm/selection-proxy.js
5322
- var SelectionProxyHandler = class _SelectionProxyHandler {
5323
- static [entityKind] = "SelectionProxyHandler";
5324
- config;
5325
- constructor(config) {
5326
- this.config = { ...config };
5327
- }
5328
- get(subquery, prop) {
5329
- if (prop === "_") {
5330
- return {
5331
- ...subquery["_"],
5332
- selectedFields: new Proxy(
5333
- subquery._.selectedFields,
5334
- this
5335
- )
5336
- };
5337
- }
5338
- if (prop === ViewBaseConfig) {
5339
- return {
5340
- ...subquery[ViewBaseConfig],
5341
- selectedFields: new Proxy(
5342
- subquery[ViewBaseConfig].selectedFields,
5343
- this
5344
- )
5345
- };
5346
- }
5347
- if (typeof prop === "symbol") {
5348
- return subquery[prop];
5349
- }
5350
- const columns = is(subquery, Subquery) ? subquery._.selectedFields : is(subquery, View) ? subquery[ViewBaseConfig].selectedFields : subquery;
5351
- const value = columns[prop];
5352
- if (is(value, SQL.Aliased)) {
5353
- if (this.config.sqlAliasedBehavior === "sql" && !value.isSelectionField) {
5354
- return value.sql;
5355
- }
5356
- const newValue = value.clone();
5357
- newValue.isSelectionField = true;
5358
- return newValue;
5359
- }
5360
- if (is(value, SQL)) {
5361
- if (this.config.sqlBehavior === "sql") {
5362
- return value;
5363
- }
5364
- throw new Error(
5365
- `You tried to reference "${prop}" field from a subquery, which is a raw SQL field, but it doesn't have an alias declared. Please add an alias to the field using ".as('alias')" method.`
5366
- );
5367
- }
5368
- if (is(value, Column)) {
5369
- if (this.config.alias) {
5370
- return new Proxy(
5371
- value,
5372
- new ColumnAliasProxyHandler(
5373
- new Proxy(
5374
- value.table,
5375
- new TableAliasProxyHandler(this.config.alias, this.config.replaceOriginalName ?? false)
5376
- )
5377
- )
5378
- );
5379
- }
5380
- return value;
5381
- }
5382
- if (typeof value !== "object" || value === null) {
5383
- return value;
5384
- }
5385
- return new Proxy(value, new _SelectionProxyHandler(this.config));
5386
- }
5387
- };
5388
-
5389
5587
  // ../node_modules/.pnpm/drizzle-orm@0.45.2_@types+better-sqlite3@7.6.13_better-sqlite3@12.9.0/node_modules/drizzle-orm/sqlite-core/columns/all.js
5390
5588
  function getSQLiteColumnBuilders() {
5391
5589
  return {
@@ -8169,257 +8367,6 @@ var SQLiteTransaction = class extends BaseSQLiteDatabase {
8169
8367
  }
8170
8368
  };
8171
8369
 
8172
- // src/archive/schema.ts
8173
- var archiveMeta = sqliteTable("archive_meta", {
8174
- id: integer("id").primaryKey(),
8175
- archiveUuid: text("archive_uuid").notNull(),
8176
- createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull()
8177
- });
8178
- var schemaVersion = sqliteTable("schema_version", {
8179
- version: integer("version").primaryKey(),
8180
- appliedAt: integer("applied_at", { mode: "timestamp_ms" }).notNull()
8181
- });
8182
- var chats = sqliteTable(
8183
- "chats",
8184
- {
8185
- id: text("id").primaryKey(),
8186
- chatId: text("chat_id").notNull().unique(),
8187
- agent: text("agent").notNull(),
8188
- sourceId: text("source_id").notNull(),
8189
- firstSeenAt: integer("first_seen_at", { mode: "timestamp_ms" }).notNull(),
8190
- project: text("project"),
8191
- projectPath: text("project_path")
8192
- },
8193
- (t) => [uniqueIndex("chats_agent_source_idx").on(t.agent, t.sourceId)]
8194
- );
8195
- var rawMessages = sqliteTable(
8196
- "raw_messages",
8197
- {
8198
- id: integer("id").primaryKey({ autoIncrement: true }),
8199
- agent: text("agent").notNull(),
8200
- sourceId: text("source_id").notNull(),
8201
- sourcePath: text("source_path").notNull(),
8202
- sourceLocator: text("source_locator").notNull(),
8203
- rawPayload: text("raw_payload").notNull(),
8204
- payloadHash: text("payload_hash").notNull(),
8205
- ingestedAt: integer("ingested_at", { mode: "timestamp_ms" }).notNull()
8206
- },
8207
- (t) => [
8208
- uniqueIndex("raw_messages_idem_idx").on(t.agent, t.sourceId, t.payloadHash)
8209
- ]
8210
- );
8211
- var messages = sqliteTable(
8212
- "messages",
8213
- {
8214
- id: integer("id").primaryKey({ autoIncrement: true }),
8215
- agent: text("agent").notNull(),
8216
- sourceId: text("source_id").notNull(),
8217
- messageId: text("message_id").notNull(),
8218
- role: text("role").notNull(),
8219
- ts: integer("ts", { mode: "timestamp_ms" }).notNull(),
8220
- text: text("text").notNull(),
8221
- blocks: text("blocks", { mode: "json" }).notNull(),
8222
- rawId: integer("raw_id").notNull().references(() => rawMessages.id)
8223
- },
8224
- (t) => [
8225
- uniqueIndex("messages_canonical_idx").on(t.agent, t.sourceId, t.messageId)
8226
- ]
8227
- );
8228
- var chatScanState = sqliteTable(
8229
- "session_scan_state",
8230
- {
8231
- id: integer("id").primaryKey({ autoIncrement: true }),
8232
- agent: text("agent").notNull(),
8233
- sourceId: text("source_id").notNull(),
8234
- sourcePath: text("source_path").notNull(),
8235
- lastMtimeMs: integer("last_mtime_ms").notNull(),
8236
- lastSizeBytes: integer("last_size_bytes").notNull(),
8237
- lastScannedAt: integer("last_scanned_at", {
8238
- mode: "timestamp_ms"
8239
- }).notNull()
8240
- },
8241
- (t) => [uniqueIndex("session_scan_state_idx").on(t.agent, t.sourceId)]
8242
- );
8243
- var ingestionEvents = sqliteTable("ingestion_events", {
8244
- id: integer("id").primaryKey({ autoIncrement: true }),
8245
- agent: text("agent").notNull(),
8246
- sourceId: text("source_id").notNull(),
8247
- sourcePath: text("source_path").notNull(),
8248
- eventType: text("event_type").notNull(),
8249
- detail: text("detail", { mode: "json" }).notNull(),
8250
- observedAt: integer("observed_at", { mode: "timestamp_ms" }).notNull()
8251
- });
8252
-
8253
- // src/visibility.ts
8254
- function loadChatVisibility(metadata2, opts) {
8255
- const deleted = metadata2.listDeleted();
8256
- const trashed = new Set(deleted.map((r) => r.id));
8257
- const deletedAtById = new Map(
8258
- deleted.map((r) => [r.id, r.deletedAt?.getTime() ?? null])
8259
- );
8260
- const showTrashed = opts.includeTrashed === true;
8261
- return {
8262
- isTrashed: (internalId) => trashed.has(internalId),
8263
- isVisible: (internalId) => showTrashed || !trashed.has(internalId),
8264
- deletedAt: (internalId) => deletedAtById.get(internalId) ?? null
8265
- };
8266
- }
8267
-
8268
- // src/app.ts
8269
- function toApiBlock(block) {
8270
- if (block.type === "tool_result") {
8271
- return {
8272
- type: "tool_result",
8273
- tool_use_id: String(block.toolUseId ?? ""),
8274
- content: block.content
8275
- };
8276
- }
8277
- return block;
8278
- }
8279
- var CLAUDE_CODE_AGENT = "claude-code";
8280
- function createApp({ archive: archive2, metadata: metadata2, webDistDir: webDistDir2 }) {
8281
- const app2 = new Hono2();
8282
- function findArchiveChatBySourceId(sourceId) {
8283
- return archive2.db.select().from(chats).where(eq(chats.sourceId, sourceId)).get();
8284
- }
8285
- app2.get("/api/chats", (c) => {
8286
- const visibility = loadChatVisibility(metadata2, {
8287
- includeTrashed: c.req.query("includeTrashed") === "true"
8288
- });
8289
- const rows = archive2.db.select().from(chats).all();
8290
- const chats2 = [];
8291
- for (const row of rows) {
8292
- if (!visibility.isVisible(row.id)) continue;
8293
- const isDeleted = visibility.isTrashed(row.id);
8294
- const latestRaw = archive2.db.select({ sourcePath: rawMessages.sourcePath }).from(rawMessages).where(
8295
- and(
8296
- eq(rawMessages.agent, CLAUDE_CODE_AGENT),
8297
- eq(rawMessages.sourceId, row.sourceId)
8298
- )
8299
- ).orderBy(desc(rawMessages.ingestedAt)).limit(1).get();
8300
- const tsRange = archive2.db.select({
8301
- minTs: sql`min(${messages.ts})`,
8302
- maxTs: sql`max(${messages.ts})`
8303
- }).from(messages).where(
8304
- and(
8305
- eq(messages.agent, CLAUDE_CODE_AGENT),
8306
- eq(messages.sourceId, row.sourceId)
8307
- )
8308
- ).get();
8309
- const firstSeenAtMs = row.firstSeenAt.getTime();
8310
- const chat = {
8311
- id: row.sourceId,
8312
- chatId: row.chatId,
8313
- agent: row.agent,
8314
- title: deriveTitle(archive2, metadata2, row),
8315
- project: row.project ?? "",
8316
- projectPath: row.projectPath ?? null,
8317
- sourceFilePath: latestRaw?.sourcePath ?? null,
8318
- createdAt: tsRange?.minTs ?? firstSeenAtMs,
8319
- updatedAt: tsRange?.maxTs ?? firstSeenAtMs,
8320
- deletedAt: visibility.deletedAt(row.id)
8321
- };
8322
- if (isDeleted) chat.isDeleted = true;
8323
- chats2.push(chat);
8324
- }
8325
- return c.json({ chats: chats2 });
8326
- });
8327
- app2.delete("/api/chats/:id", (c) => {
8328
- const id = c.req.param("id");
8329
- const row = findArchiveChatBySourceId(id);
8330
- if (!row) {
8331
- return c.json({ error: "Chat not found" }, 404);
8332
- }
8333
- metadata2.softDelete(row.id);
8334
- return c.body(null, 204);
8335
- });
8336
- app2.post("/api/chats/:id/restore", (c) => {
8337
- const id = c.req.param("id");
8338
- const row = findArchiveChatBySourceId(id);
8339
- if (!row) {
8340
- return c.json({ error: "Chat not found" }, 404);
8341
- }
8342
- metadata2.restore(row.id);
8343
- return c.body(null, 204);
8344
- });
8345
- app2.patch("/api/chats/:id/title", async (c) => {
8346
- const id = c.req.param("id");
8347
- const row = findArchiveChatBySourceId(id);
8348
- if (!row) {
8349
- return c.json({ error: "Chat not found" }, 404);
8350
- }
8351
- let body;
8352
- try {
8353
- body = await c.req.json();
8354
- } catch {
8355
- return c.json({ error: "Invalid JSON body" }, 400);
8356
- }
8357
- if (!body || typeof body !== "object" || typeof body.title !== "string") {
8358
- return c.json({ error: "Invalid title" }, 400);
8359
- }
8360
- const raw2 = body.title;
8361
- if (raw2.length > 200) {
8362
- return c.json({ error: "Title too long" }, 400);
8363
- }
8364
- const trimmed = raw2.trim();
8365
- metadata2.setCustomTitle(row.id, trimmed.length > 0 ? trimmed : null);
8366
- return c.body(null, 204);
8367
- });
8368
- app2.get("/api/chats/:id", (c) => {
8369
- const id = c.req.param("id");
8370
- const row = findArchiveChatBySourceId(id);
8371
- if (!row) {
8372
- return c.json({ error: "Chat not found" }, 404);
8373
- }
8374
- const visibility = loadChatVisibility(metadata2, {
8375
- includeTrashed: c.req.query("includeTrashed") === "true"
8376
- });
8377
- if (!visibility.isVisible(row.id)) {
8378
- return c.json({ error: "Chat not found" }, 404);
8379
- }
8380
- const rows = archive2.db.select().from(messages).where(
8381
- and(
8382
- eq(messages.agent, CLAUDE_CODE_AGENT),
8383
- eq(messages.sourceId, id)
8384
- )
8385
- ).orderBy(asc(messages.ts)).all();
8386
- const messages2 = rows.map((m) => ({
8387
- role: m.role,
8388
- content: m.blocks.map(toApiBlock),
8389
- timestamp: m.ts.toISOString()
8390
- }));
8391
- return c.json({ messages: messages2 });
8392
- });
8393
- function deriveTitle(archive3, metadata3, row) {
8394
- const custom = metadata3.getCustomTitle(row.id);
8395
- if (custom && custom.trim()) return custom;
8396
- const firstUser = archive3.db.select({ text: messages.text }).from(messages).where(
8397
- and(
8398
- eq(messages.agent, CLAUDE_CODE_AGENT),
8399
- eq(messages.sourceId, row.sourceId),
8400
- eq(messages.role, "user")
8401
- )
8402
- ).orderBy(asc(messages.ts)).limit(1).get();
8403
- const text2 = firstUser?.text?.trim().split("\n")[0]?.trim();
8404
- return text2 && text2.length > 0 ? text2 : "Untitled";
8405
- }
8406
- if (webDistDir2) {
8407
- app2.use("*", serveStatic({ root: webDistDir2 }));
8408
- app2.use("*", serveStatic({ root: webDistDir2, path: "index.html" }));
8409
- }
8410
- return app2;
8411
- }
8412
-
8413
- // src/archive/repository.ts
8414
- import crypto5 from "crypto";
8415
- import fs2 from "fs";
8416
- import path from "path";
8417
- import { fileURLToPath } from "url";
8418
- import Database from "better-sqlite3";
8419
-
8420
- // ../node_modules/.pnpm/drizzle-orm@0.45.2_@types+better-sqlite3@7.6.13_better-sqlite3@12.9.0/node_modules/drizzle-orm/better-sqlite3/driver.js
8421
- import Client from "better-sqlite3";
8422
-
8423
8370
  // ../node_modules/.pnpm/drizzle-orm@0.45.2_@types+better-sqlite3@7.6.13_better-sqlite3@12.9.0/node_modules/drizzle-orm/better-sqlite3/session.js
8424
8371
  var BetterSQLiteSession = class extends SQLiteSession {
8425
8372
  constructor(client, dialect, schema, options = {}) {
@@ -8579,7 +8526,7 @@ function drizzle(...params) {
8579
8526
  })(drizzle || (drizzle = {}));
8580
8527
 
8581
8528
  // ../node_modules/.pnpm/drizzle-orm@0.45.2_@types+better-sqlite3@7.6.13_better-sqlite3@12.9.0/node_modules/drizzle-orm/migrator.js
8582
- import crypto3 from "crypto";
8529
+ import crypto4 from "crypto";
8583
8530
  import fs from "fs";
8584
8531
  function readMigrationFiles(config) {
8585
8532
  const migrationFolderTo = config.migrationsFolder;
@@ -8601,7 +8548,7 @@ function readMigrationFiles(config) {
8601
8548
  sql: result,
8602
8549
  bps: journalEntry.breakpoints,
8603
8550
  folderMillis: journalEntry.when,
8604
- hash: crypto3.createHash("sha256").update(query).digest("hex")
8551
+ hash: crypto4.createHash("sha256").update(query).digest("hex")
8605
8552
  });
8606
8553
  } catch {
8607
8554
  throw new Error(`No file ${migrationPath} found in ${migrationFolderTo} folder`);
@@ -8616,49 +8563,184 @@ function migrate(db, config) {
8616
8563
  db.dialect.migrate(migrations, db.session, config);
8617
8564
  }
8618
8565
 
8619
- // src/archive/chat-id.ts
8620
- import crypto4 from "crypto";
8621
- var CROCKFORD_ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
8622
- var CHAT_ID_LENGTH = 6;
8623
- var MAX_RETRIES = 5;
8624
- function generateChatId({
8625
- isTaken,
8626
- randomIndex = () => crypto4.randomInt(CROCKFORD_ALPHABET.length)
8627
- }) {
8628
- for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
8629
- let code = "";
8630
- for (let i = 0; i < CHAT_ID_LENGTH; i++) {
8631
- code += CROCKFORD_ALPHABET[randomIndex()];
8632
- }
8633
- if (!isTaken(code)) {
8634
- return code;
8635
- }
8636
- }
8637
- throw new Error(
8638
- `Failed to generate unique chat_id after ${MAX_RETRIES + 1} attempts`
8639
- );
8640
- }
8641
-
8642
- // src/archive/repository.ts
8643
- function resolveMigrationsFolder() {
8644
- const here = path.dirname(fileURLToPath(import.meta.url));
8566
+ // src/storage/openStore.ts
8567
+ function resolveMigrationsFolder(callerUrl, migrationsSubdir) {
8568
+ const here = path.dirname(fileURLToPath(callerUrl));
8645
8569
  const candidates = [
8646
- path.join(here, "../../drizzle/archive"),
8647
- path.join(here, "./drizzle/archive")
8570
+ path.join(here, "../..", migrationsSubdir),
8571
+ path.join(here, ".", migrationsSubdir)
8648
8572
  ];
8649
8573
  const found = candidates.find((p) => fs2.existsSync(p));
8650
8574
  if (!found) {
8651
- throw new Error("Could not locate archive drizzle migrations folder");
8575
+ throw new Error(`Could not locate migrations folder "${migrationsSubdir}"`);
8652
8576
  }
8653
8577
  return found;
8654
8578
  }
8579
+ function openStore({
8580
+ dataDir: dataDir2,
8581
+ dbFile,
8582
+ callerUrl,
8583
+ migrationsSubdir,
8584
+ schema
8585
+ }) {
8586
+ const migrationsFolder = resolveMigrationsFolder(callerUrl, migrationsSubdir);
8587
+ fs2.mkdirSync(dataDir2, { recursive: true });
8588
+ const sqlite = new Database(path.join(dataDir2, dbFile));
8589
+ const db = drizzle(sqlite, { schema });
8590
+ migrate(db, { migrationsFolder });
8591
+ return { db, sqlite };
8592
+ }
8593
+
8594
+ // src/archive/schema.ts
8595
+ var schema_exports = {};
8596
+ __export(schema_exports, {
8597
+ archiveMeta: () => archiveMeta,
8598
+ chats: () => chats,
8599
+ ingestionEvents: () => ingestionEvents,
8600
+ messages: () => messages,
8601
+ rawMessages: () => rawMessages,
8602
+ schemaVersion: () => schemaVersion
8603
+ });
8604
+ var archiveMeta = sqliteTable("archive_meta", {
8605
+ id: integer("id").primaryKey(),
8606
+ archiveUuid: text("archive_uuid").notNull(),
8607
+ createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull()
8608
+ });
8609
+ var schemaVersion = sqliteTable("schema_version", {
8610
+ version: integer("version").primaryKey(),
8611
+ appliedAt: integer("applied_at", { mode: "timestamp_ms" }).notNull()
8612
+ });
8613
+ var chats = sqliteTable(
8614
+ "chats",
8615
+ {
8616
+ id: text("id").primaryKey(),
8617
+ chatId: text("chat_id").notNull().unique(),
8618
+ agent: text("agent").notNull(),
8619
+ sourceId: text("source_id").notNull(),
8620
+ firstSeenAt: integer("first_seen_at", { mode: "timestamp_ms" }).notNull(),
8621
+ project: text("project"),
8622
+ projectPath: text("project_path")
8623
+ },
8624
+ (t) => [uniqueIndex("chats_agent_source_idx").on(t.agent, t.sourceId)]
8625
+ );
8626
+ var rawMessages = sqliteTable(
8627
+ "raw_messages",
8628
+ {
8629
+ id: integer("id").primaryKey({ autoIncrement: true }),
8630
+ agent: text("agent").notNull(),
8631
+ sourceId: text("source_id").notNull(),
8632
+ sourcePath: text("source_path").notNull(),
8633
+ sourceLocator: text("source_locator").notNull(),
8634
+ rawPayload: text("raw_payload").notNull(),
8635
+ payloadHash: text("payload_hash").notNull(),
8636
+ ingestedAt: integer("ingested_at", { mode: "timestamp_ms" }).notNull()
8637
+ },
8638
+ (t) => [
8639
+ uniqueIndex("raw_messages_idem_idx").on(t.agent, t.sourceId, t.payloadHash)
8640
+ ]
8641
+ );
8642
+ var messages = sqliteTable(
8643
+ "messages",
8644
+ {
8645
+ id: integer("id").primaryKey({ autoIncrement: true }),
8646
+ agent: text("agent").notNull(),
8647
+ sourceId: text("source_id").notNull(),
8648
+ messageId: text("message_id").notNull(),
8649
+ role: text("role").notNull(),
8650
+ ts: integer("ts", { mode: "timestamp_ms" }).notNull(),
8651
+ text: text("text").notNull(),
8652
+ blocks: text("blocks", { mode: "json" }).notNull(),
8653
+ rawId: integer("raw_id").notNull().references(() => rawMessages.id)
8654
+ },
8655
+ (t) => [
8656
+ uniqueIndex("messages_canonical_idx").on(t.agent, t.sourceId, t.messageId)
8657
+ ]
8658
+ );
8659
+ var ingestionEvents = sqliteTable("ingestion_events", {
8660
+ id: integer("id").primaryKey({ autoIncrement: true }),
8661
+ agent: text("agent").notNull(),
8662
+ sourceId: text("source_id").notNull(),
8663
+ sourcePath: text("source_path").notNull(),
8664
+ eventType: text("event_type").notNull(),
8665
+ detail: text("detail", { mode: "json" }).notNull(),
8666
+ observedAt: integer("observed_at", { mode: "timestamp_ms" }).notNull()
8667
+ });
8668
+
8669
+ // src/archive/read-seam.ts
8670
+ function createArchiveReadSeam(db) {
8671
+ return {
8672
+ listChatRows() {
8673
+ return db.select().from(chats).all();
8674
+ },
8675
+ findChatBySourceId(sourceId) {
8676
+ return db.select().from(chats).where(eq(chats.sourceId, sourceId)).get() ?? null;
8677
+ },
8678
+ findChatByChatId(chatId) {
8679
+ return db.select().from(chats).where(eq(chats.chatId, chatId)).get() ?? null;
8680
+ },
8681
+ listMessagesByChat(agent, sourceId) {
8682
+ return db.select().from(messages).where(and(eq(messages.agent, agent), eq(messages.sourceId, sourceId))).orderBy(asc(messages.ts)).all();
8683
+ },
8684
+ listChatTsRanges() {
8685
+ return db.select({
8686
+ agent: messages.agent,
8687
+ sourceId: messages.sourceId,
8688
+ minTs: sql`min(${messages.ts})`,
8689
+ maxTs: sql`max(${messages.ts})`
8690
+ }).from(messages).groupBy(messages.agent, messages.sourceId).all();
8691
+ },
8692
+ listLatestRawSourcePaths() {
8693
+ const ranked = db.select({
8694
+ agent: rawMessages.agent,
8695
+ sourceId: rawMessages.sourceId,
8696
+ sourcePath: rawMessages.sourcePath,
8697
+ rn: sql`row_number() over (partition by ${rawMessages.agent}, ${rawMessages.sourceId} order by ${rawMessages.ingestedAt} desc)`.as(
8698
+ "rn"
8699
+ )
8700
+ }).from(rawMessages).as("ranked_raw");
8701
+ return db.select({
8702
+ agent: ranked.agent,
8703
+ sourceId: ranked.sourceId,
8704
+ sourcePath: ranked.sourcePath
8705
+ }).from(ranked).where(eq(ranked.rn, 1)).all();
8706
+ },
8707
+ listFirstUserTexts() {
8708
+ const ranked = db.select({
8709
+ agent: messages.agent,
8710
+ sourceId: messages.sourceId,
8711
+ text: messages.text,
8712
+ rn: sql`row_number() over (partition by ${messages.agent}, ${messages.sourceId} order by ${messages.ts} asc)`.as(
8713
+ "rn"
8714
+ )
8715
+ }).from(messages).where(eq(messages.role, "user")).as("ranked_user");
8716
+ return db.select({
8717
+ agent: ranked.agent,
8718
+ sourceId: ranked.sourceId,
8719
+ text: ranked.text
8720
+ }).from(ranked).where(eq(ranked.rn, 1)).all();
8721
+ },
8722
+ listIngestionEvents() {
8723
+ return db.select().from(ingestionEvents).all();
8724
+ }
8725
+ };
8726
+ }
8727
+
8728
+ // src/archive/repository.ts
8729
+ function parseTs(ts) {
8730
+ const d = new Date(ts);
8731
+ if (Number.isNaN(d.getTime())) return /* @__PURE__ */ new Date(0);
8732
+ return d;
8733
+ }
8655
8734
  function createArchiveRepository({
8656
8735
  dataDir: dataDir2
8657
8736
  }) {
8658
- fs2.mkdirSync(dataDir2, { recursive: true });
8659
- const sqlite = new Database(path.join(dataDir2, "archive.db"));
8660
- const db = drizzle(sqlite, { schema: schema_exports });
8661
- migrate(db, { migrationsFolder: resolveMigrationsFolder() });
8737
+ const { db, sqlite } = openStore({
8738
+ dataDir: dataDir2,
8739
+ dbFile: "archive.db",
8740
+ callerUrl: import.meta.url,
8741
+ migrationsSubdir: "drizzle/archive",
8742
+ schema: schema_exports
8743
+ });
8662
8744
  const drizzleMigrations = sqlite.prepare("SELECT id, created_at FROM __drizzle_migrations ORDER BY id ASC").all();
8663
8745
  for (const m of drizzleMigrations) {
8664
8746
  db.insert(schemaVersion).values({ version: m.id, appliedAt: new Date(m.created_at) }).onConflictDoNothing().run();
@@ -8671,8 +8753,13 @@ function createArchiveRepository({
8671
8753
  createdAt: /* @__PURE__ */ new Date()
8672
8754
  }).run();
8673
8755
  }
8756
+ function nextChatId() {
8757
+ return generateChatId({
8758
+ isTaken: (candidate) => db.select({ id: chats.id }).from(chats).where(eq(chats.chatId, candidate)).get() !== void 0
8759
+ });
8760
+ }
8674
8761
  return {
8675
- db,
8762
+ read: createArchiveReadSeam(db),
8676
8763
  getArchiveUuid() {
8677
8764
  const row = db.select().from(archiveMeta).get();
8678
8765
  if (!row) {
@@ -8684,9 +8771,178 @@ function createArchiveRepository({
8684
8771
  return db.select().from(schemaVersion).all().map((r) => ({ version: r.version, appliedAt: r.appliedAt }));
8685
8772
  },
8686
8773
  generateChatId() {
8687
- return generateChatId({
8688
- isTaken: (candidate) => db.select({ id: chats.id }).from(chats).where(eq(chats.chatId, candidate)).get() !== void 0
8689
- });
8774
+ return nextChatId();
8775
+ },
8776
+ ensureChat(agent, sourceId, firstSeenAt, project, projectPath) {
8777
+ const existingChat = db.select().from(chats).where(and(eq(chats.agent, agent), eq(chats.sourceId, sourceId))).get();
8778
+ if (existingChat) {
8779
+ const updates = {};
8780
+ if (project && existingChat.project !== project) {
8781
+ updates.project = project;
8782
+ }
8783
+ if (projectPath && existingChat.projectPath !== projectPath) {
8784
+ updates.projectPath = projectPath;
8785
+ }
8786
+ if (Object.keys(updates).length > 0) {
8787
+ db.update(chats).set(updates).where(eq(chats.id, existingChat.id)).run();
8788
+ }
8789
+ return existingChat.id;
8790
+ }
8791
+ const id = crypto5.randomUUID();
8792
+ db.insert(chats).values({
8793
+ id,
8794
+ chatId: nextChatId(),
8795
+ agent,
8796
+ sourceId,
8797
+ firstSeenAt,
8798
+ project: project ?? null,
8799
+ projectPath: projectPath ?? null
8800
+ }).run();
8801
+ return id;
8802
+ },
8803
+ insertRawMessage(input) {
8804
+ const payloadJson = JSON.stringify(input.payload);
8805
+ const payloadHash = crypto5.createHash("sha256").update(payloadJson).digest("hex");
8806
+ const existing2 = db.select({ id: rawMessages.id }).from(rawMessages).where(
8807
+ and(
8808
+ eq(rawMessages.agent, input.agent),
8809
+ eq(rawMessages.sourceId, input.sourceId),
8810
+ eq(rawMessages.payloadHash, payloadHash)
8811
+ )
8812
+ ).get();
8813
+ if (existing2) return { id: existing2.id, inserted: false };
8814
+ const inserted = db.insert(rawMessages).values({
8815
+ agent: input.agent,
8816
+ sourceId: input.sourceId,
8817
+ sourcePath: input.sourcePath,
8818
+ sourceLocator: input.sourceLocator,
8819
+ rawPayload: payloadJson,
8820
+ payloadHash,
8821
+ ingestedAt: input.ingestedAt
8822
+ }).returning({ id: rawMessages.id }).get();
8823
+ return { id: inserted.id, inserted: true };
8824
+ },
8825
+ upsertNormalizedMessage({ agent, sourceId, message, rawId }) {
8826
+ const existing2 = db.select().from(messages).where(
8827
+ and(
8828
+ eq(messages.agent, agent),
8829
+ eq(messages.sourceId, sourceId),
8830
+ eq(messages.messageId, message.messageId)
8831
+ )
8832
+ ).get();
8833
+ const ts = parseTs(message.ts);
8834
+ if (!existing2) {
8835
+ db.insert(messages).values({
8836
+ agent,
8837
+ sourceId,
8838
+ messageId: message.messageId,
8839
+ role: message.role,
8840
+ ts,
8841
+ text: message.text,
8842
+ blocks: message.blocks,
8843
+ rawId
8844
+ }).run();
8845
+ return true;
8846
+ }
8847
+ if (ts.getTime() >= existing2.ts.getTime()) {
8848
+ db.update(messages).set({
8849
+ role: message.role,
8850
+ ts,
8851
+ text: message.text,
8852
+ blocks: message.blocks,
8853
+ rawId
8854
+ }).where(eq(messages.id, existing2.id)).run();
8855
+ return true;
8856
+ }
8857
+ return false;
8858
+ },
8859
+ recordIngestionEvent(event) {
8860
+ db.insert(ingestionEvents).values({
8861
+ agent: event.agent,
8862
+ sourceId: event.sourceId,
8863
+ sourcePath: event.sourcePath,
8864
+ eventType: event.eventType,
8865
+ detail: event.detail,
8866
+ observedAt: event.observedAt ?? /* @__PURE__ */ new Date()
8867
+ }).run();
8868
+ },
8869
+ close() {
8870
+ sqlite.close();
8871
+ }
8872
+ };
8873
+ }
8874
+
8875
+ // src/checkpoint/schema.ts
8876
+ var schema_exports2 = {};
8877
+ __export(schema_exports2, {
8878
+ chatScanState: () => chatScanState
8879
+ });
8880
+ var chatScanState = sqliteTable(
8881
+ "chat_scan_state",
8882
+ {
8883
+ id: integer("id").primaryKey({ autoIncrement: true }),
8884
+ agent: text("agent").notNull(),
8885
+ sourceId: text("source_id").notNull(),
8886
+ sourcePath: text("source_path").notNull(),
8887
+ lastMtimeMs: integer("last_mtime_ms").notNull(),
8888
+ lastSizeBytes: integer("last_size_bytes").notNull(),
8889
+ lastScannedAt: integer("last_scanned_at", {
8890
+ mode: "timestamp_ms"
8891
+ }).notNull()
8892
+ },
8893
+ (t) => [uniqueIndex("chat_scan_state_idx").on(t.agent, t.sourceId)]
8894
+ );
8895
+
8896
+ // src/checkpoint/repository.ts
8897
+ function createCheckpointRepository({
8898
+ dataDir: dataDir2
8899
+ }) {
8900
+ const { db, sqlite } = openStore({
8901
+ dataDir: dataDir2,
8902
+ dbFile: "checkpoint.db",
8903
+ callerUrl: import.meta.url,
8904
+ migrationsSubdir: "drizzle/checkpoint",
8905
+ schema: schema_exports2
8906
+ });
8907
+ return {
8908
+ db,
8909
+ getScanState(agent, sourceId) {
8910
+ const row = db.select({
8911
+ lastMtimeMs: chatScanState.lastMtimeMs,
8912
+ lastSizeBytes: chatScanState.lastSizeBytes,
8913
+ lastScannedAt: chatScanState.lastScannedAt
8914
+ }).from(chatScanState).where(
8915
+ and(
8916
+ eq(chatScanState.agent, agent),
8917
+ eq(chatScanState.sourceId, sourceId)
8918
+ )
8919
+ ).get();
8920
+ return row ?? void 0;
8921
+ },
8922
+ recordScanState(agent, sourceId, sourcePath, stat4, scannedAt) {
8923
+ const existing = db.select({ id: chatScanState.id }).from(chatScanState).where(
8924
+ and(
8925
+ eq(chatScanState.agent, agent),
8926
+ eq(chatScanState.sourceId, sourceId)
8927
+ )
8928
+ ).get();
8929
+ if (existing) {
8930
+ db.update(chatScanState).set({
8931
+ sourcePath,
8932
+ lastMtimeMs: stat4.mtimeMs,
8933
+ lastSizeBytes: stat4.sizeBytes,
8934
+ lastScannedAt: scannedAt
8935
+ }).where(eq(chatScanState.id, existing.id)).run();
8936
+ } else {
8937
+ db.insert(chatScanState).values({
8938
+ agent,
8939
+ sourceId,
8940
+ sourcePath,
8941
+ lastMtimeMs: stat4.mtimeMs,
8942
+ lastSizeBytes: stat4.sizeBytes,
8943
+ lastScannedAt: scannedAt
8944
+ }).run();
8945
+ }
8690
8946
  },
8691
8947
  close() {
8692
8948
  sqlite.close();
@@ -8697,10 +8953,12 @@ function createArchiveRepository({
8697
8953
  // src/metadata/repository.ts
8698
8954
  import fs3 from "fs";
8699
8955
  import path2 from "path";
8700
- import { fileURLToPath as fileURLToPath2 } from "url";
8701
- import Database2 from "better-sqlite3";
8702
8956
 
8703
8957
  // src/metadata/schema.ts
8958
+ var schema_exports3 = {};
8959
+ __export(schema_exports3, {
8960
+ chatsMeta: () => chatsMeta
8961
+ });
8704
8962
  var chatsMeta = sqliteTable("chats_meta", {
8705
8963
  id: text("id").primaryKey(),
8706
8964
  isDeleted: integer("is_deleted", { mode: "boolean" }).notNull().default(false),
@@ -8713,30 +8971,31 @@ var chatsMeta = sqliteTable("chats_meta", {
8713
8971
  });
8714
8972
 
8715
8973
  // src/metadata/repository.ts
8716
- function resolveMigrationsFolder2() {
8717
- const here = path2.dirname(fileURLToPath2(import.meta.url));
8718
- const candidates = [
8719
- path2.join(here, "../../drizzle"),
8720
- path2.join(here, "./drizzle")
8721
- ];
8722
- const found = candidates.find((p) => fs3.existsSync(p));
8723
- if (!found) {
8724
- throw new Error("Could not locate drizzle migrations folder");
8725
- }
8726
- return found;
8974
+ var DB_FILE = "metadata.db";
8975
+ var LEGACY_DB_FILE = "data.db";
8976
+ function migrateLegacyDbFile(dataDir2) {
8977
+ const legacy = path2.join(dataDir2, LEGACY_DB_FILE);
8978
+ const current = path2.join(dataDir2, DB_FILE);
8979
+ if (!fs3.existsSync(legacy)) return;
8980
+ if (fs3.existsSync(current)) return;
8981
+ fs3.renameSync(legacy, current);
8727
8982
  }
8728
- var CLAUDE_CODE_AGENT2 = "claude-code";
8983
+ var CLAUDE_CODE_AGENT = "claude-code";
8729
8984
  function createMetadataRepository({
8730
8985
  dataDir: dataDir2,
8731
8986
  lookupInternalId,
8732
- ensureChat: ensureChat2
8987
+ ensureChat
8733
8988
  }) {
8734
- fs3.mkdirSync(dataDir2, { recursive: true });
8735
- const sqlite = new Database2(path2.join(dataDir2, "data.db"));
8736
- const db = drizzle(sqlite);
8737
- migrate(db, { migrationsFolder: resolveMigrationsFolder2() });
8989
+ migrateLegacyDbFile(dataDir2);
8990
+ const { db, sqlite } = openStore({
8991
+ dataDir: dataDir2,
8992
+ dbFile: DB_FILE,
8993
+ callerUrl: import.meta.url,
8994
+ migrationsSubdir: "drizzle",
8995
+ schema: schema_exports3
8996
+ });
8738
8997
  if (lookupInternalId) {
8739
- rekeyLegacyRows(sqlite, db, lookupInternalId, ensureChat2);
8998
+ rekeyLegacyRows(sqlite, db, lookupInternalId, ensureChat);
8740
8999
  }
8741
9000
  return {
8742
9001
  softDelete(internalId) {
@@ -8772,6 +9031,14 @@ function createMetadataRepository({
8772
9031
  const row = db.select({ customTitle: chatsMeta.customTitle }).from(chatsMeta).where(eq(chatsMeta.id, internalId)).get();
8773
9032
  return row?.customTitle ?? null;
8774
9033
  },
9034
+ listCustomTitles() {
9035
+ const rows = db.select({ id: chatsMeta.id, customTitle: chatsMeta.customTitle }).from(chatsMeta).where(isNotNull(chatsMeta.customTitle)).all();
9036
+ const byId = /* @__PURE__ */ new Map();
9037
+ for (const row of rows) {
9038
+ if (row.customTitle !== null) byId.set(row.id, row.customTitle);
9039
+ }
9040
+ return byId;
9041
+ },
8775
9042
  setCustomTitle(internalId, title) {
8776
9043
  const now = /* @__PURE__ */ new Date();
8777
9044
  db.insert(chatsMeta).values({
@@ -8787,15 +9054,15 @@ function createMetadataRepository({
8787
9054
  };
8788
9055
  }
8789
9056
  var REKEY_USER_VERSION = 4;
8790
- function rekeyLegacyRows(sqlite, db, lookupInternalId, ensureChat2) {
9057
+ function rekeyLegacyRows(sqlite, db, lookupInternalId, ensureChat) {
8791
9058
  const current = sqlite.pragma("user_version", { simple: true });
8792
9059
  if (current >= REKEY_USER_VERSION) return;
8793
9060
  const rows = db.select({ id: chatsMeta.id }).from(chatsMeta).all();
8794
9061
  for (const row of rows) {
8795
- let target = lookupInternalId(CLAUDE_CODE_AGENT2, row.id);
9062
+ let target = lookupInternalId(CLAUDE_CODE_AGENT, row.id);
8796
9063
  if (target === null) {
8797
- if (!ensureChat2) continue;
8798
- target = ensureChat2(CLAUDE_CODE_AGENT2, row.id);
9064
+ if (!ensureChat) continue;
9065
+ target = ensureChat(CLAUDE_CODE_AGENT, row.id);
8799
9066
  }
8800
9067
  if (target === row.id) continue;
8801
9068
  db.update(chatsMeta).set({ id: target, updatedAt: /* @__PURE__ */ new Date() }).where(eq(chatsMeta.id, row.id)).run();
@@ -8804,28 +9071,21 @@ function rekeyLegacyRows(sqlite, db, lookupInternalId, ensureChat2) {
8804
9071
  }
8805
9072
 
8806
9073
  // src/ingestion/ingest.ts
8807
- import crypto6 from "crypto";
8808
9074
  import fs4 from "fs";
8809
9075
  async function runIngestion(opts) {
8810
9076
  const now = opts.now ?? (() => /* @__PURE__ */ new Date());
8811
9077
  const result = {
8812
9078
  scanned: 0,
8813
9079
  rawInserted: 0,
8814
- canonicalUpserted: 0,
9080
+ normalizedUpserted: 0,
8815
9081
  skippedByMtime: 0
8816
9082
  };
8817
9083
  for (const plugin of opts.plugins) {
8818
9084
  for await (const ref of plugin.discover(opts.env)) {
8819
9085
  result.scanned += 1;
8820
9086
  const stat4 = safeStat(ref.sourcePath);
8821
- const prior = opts.archive.db.select().from(chatScanState).where(
8822
- and(
8823
- eq(chatScanState.agent, plugin.id),
8824
- eq(chatScanState.sourceId, ref.sourceId)
8825
- )
8826
- ).get();
8827
- ensureChat(
8828
- opts.archive,
9087
+ const prior = opts.checkpoint.getScanState(plugin.id, ref.sourceId);
9088
+ opts.archive.ensureChat(
8829
9089
  plugin.id,
8830
9090
  ref.sourceId,
8831
9091
  now(),
@@ -8837,49 +9097,31 @@ async function runIngestion(opts) {
8837
9097
  continue;
8838
9098
  }
8839
9099
  for await (const raw2 of plugin.extractRaw(ref)) {
8840
- const payloadJson = JSON.stringify(raw2.payload);
8841
- const payloadHash = crypto6.createHash("sha256").update(payloadJson).digest("hex");
8842
- const existing = opts.archive.db.select({ id: rawMessages.id }).from(rawMessages).where(
8843
- and(
8844
- eq(rawMessages.agent, plugin.id),
8845
- eq(rawMessages.sourceId, ref.sourceId),
8846
- eq(rawMessages.payloadHash, payloadHash)
8847
- )
8848
- ).get();
8849
- let rawId;
8850
- if (existing) {
8851
- rawId = existing.id;
8852
- } else {
8853
- const inserted = opts.archive.db.insert(rawMessages).values({
8854
- agent: plugin.id,
8855
- sourceId: ref.sourceId,
8856
- sourcePath: raw2.sourcePath,
8857
- sourceLocator: raw2.sourceLocator,
8858
- rawPayload: payloadJson,
8859
- payloadHash,
8860
- ingestedAt: now()
8861
- }).returning({ id: rawMessages.id }).get();
8862
- rawId = inserted.id;
8863
- result.rawInserted += 1;
8864
- }
8865
- const canonical = plugin.normalize(raw2);
8866
- if (!canonical) continue;
8867
- const upserted = upsertCanonical(
8868
- opts.archive,
8869
- plugin.id,
8870
- ref.sourceId,
8871
- canonical,
9100
+ const { id: rawId, inserted } = opts.archive.insertRawMessage({
9101
+ agent: plugin.id,
9102
+ sourceId: ref.sourceId,
9103
+ sourcePath: raw2.sourcePath,
9104
+ sourceLocator: raw2.sourceLocator,
9105
+ payload: raw2.payload,
9106
+ ingestedAt: now()
9107
+ });
9108
+ if (inserted) result.rawInserted += 1;
9109
+ const normalized = plugin.normalize(raw2);
9110
+ if (!normalized) continue;
9111
+ const upserted = opts.archive.upsertNormalizedMessage({
9112
+ agent: plugin.id,
9113
+ sourceId: ref.sourceId,
9114
+ message: normalized,
8872
9115
  rawId
8873
- );
8874
- if (upserted) result.canonicalUpserted += 1;
9116
+ });
9117
+ if (upserted) result.normalizedUpserted += 1;
8875
9118
  }
8876
9119
  if (stat4) {
8877
- recordScanState(
8878
- opts.archive,
9120
+ opts.checkpoint.recordScanState(
8879
9121
  plugin.id,
8880
9122
  ref.sourceId,
8881
9123
  ref.sourcePath,
8882
- stat4,
9124
+ { mtimeMs: stat4.mtimeMs, sizeBytes: stat4.size },
8883
9125
  now()
8884
9126
  );
8885
9127
  }
@@ -8894,93 +9136,6 @@ function safeStat(p) {
8894
9136
  return null;
8895
9137
  }
8896
9138
  }
8897
- function recordScanState(archive2, agent, sourceId, sourcePath, stat4, scannedAt) {
8898
- const existing = archive2.db.select({ id: chatScanState.id }).from(chatScanState).where(
8899
- and(eq(chatScanState.agent, agent), eq(chatScanState.sourceId, sourceId))
8900
- ).get();
8901
- if (existing) {
8902
- archive2.db.update(chatScanState).set({
8903
- sourcePath,
8904
- lastMtimeMs: stat4.mtimeMs,
8905
- lastSizeBytes: stat4.size,
8906
- lastScannedAt: scannedAt
8907
- }).where(eq(chatScanState.id, existing.id)).run();
8908
- } else {
8909
- archive2.db.insert(chatScanState).values({
8910
- agent,
8911
- sourceId,
8912
- sourcePath,
8913
- lastMtimeMs: stat4.mtimeMs,
8914
- lastSizeBytes: stat4.size,
8915
- lastScannedAt: scannedAt
8916
- }).run();
8917
- }
8918
- }
8919
- function ensureChat(archive2, agent, sourceId, firstSeenAt, project, projectPath) {
8920
- const existing = archive2.db.select().from(chats).where(and(eq(chats.agent, agent), eq(chats.sourceId, sourceId))).get();
8921
- if (existing) {
8922
- const updates = {};
8923
- if (project && existing.project !== project) updates.project = project;
8924
- if (projectPath && existing.projectPath !== projectPath) {
8925
- updates.projectPath = projectPath;
8926
- }
8927
- if (Object.keys(updates).length > 0) {
8928
- archive2.db.update(chats).set(updates).where(eq(chats.id, existing.id)).run();
8929
- }
8930
- return existing.id;
8931
- }
8932
- const id = crypto6.randomUUID();
8933
- const chatId = archive2.generateChatId();
8934
- archive2.db.insert(chats).values({
8935
- id,
8936
- chatId,
8937
- agent,
8938
- sourceId,
8939
- firstSeenAt,
8940
- project: project ?? null,
8941
- projectPath: projectPath ?? null
8942
- }).run();
8943
- return id;
8944
- }
8945
- function upsertCanonical(archive2, agent, sourceId, msg, rawId) {
8946
- const existing = archive2.db.select().from(messages).where(
8947
- and(
8948
- eq(messages.agent, agent),
8949
- eq(messages.sourceId, sourceId),
8950
- eq(messages.messageId, msg.messageId)
8951
- )
8952
- ).get();
8953
- const ts = parseTs(msg.ts);
8954
- if (!existing) {
8955
- archive2.db.insert(messages).values({
8956
- agent,
8957
- sourceId,
8958
- messageId: msg.messageId,
8959
- role: msg.role,
8960
- ts,
8961
- text: msg.text,
8962
- blocks: msg.blocks,
8963
- rawId
8964
- }).run();
8965
- return true;
8966
- }
8967
- if (ts.getTime() >= existing.ts.getTime()) {
8968
- archive2.db.update(messages).set({
8969
- role: msg.role,
8970
- ts,
8971
- text: msg.text,
8972
- blocks: msg.blocks,
8973
- rawId
8974
- }).where(eq(messages.id, existing.id)).run();
8975
- return true;
8976
- }
8977
- return false;
8978
- }
8979
- function parseTs(ts) {
8980
- const d = new Date(ts);
8981
- if (Number.isNaN(d.getTime())) return /* @__PURE__ */ new Date(0);
8982
- return d;
8983
- }
8984
9139
 
8985
9140
  // src/ingestion/background.ts
8986
9141
  function startIngestionInBackground(opts, bg = {}) {
@@ -10767,14 +10922,14 @@ function startWatcher(opts) {
10767
10922
  const binding = pathBindings.get(removedPath);
10768
10923
  if (!binding) return;
10769
10924
  try {
10770
- opts.archive.db.insert(ingestionEvents).values({
10925
+ opts.archive.recordIngestionEvent({
10771
10926
  agent: binding.plugin.id,
10772
10927
  sourceId: binding.ref.sourceId,
10773
10928
  sourcePath: removedPath,
10774
10929
  eventType: "unlink_observed",
10775
10930
  detail: { path: removedPath },
10776
10931
  observedAt: /* @__PURE__ */ new Date()
10777
- }).run();
10932
+ });
10778
10933
  } catch (err) {
10779
10934
  onError(err);
10780
10935
  }
@@ -10788,13 +10943,22 @@ function startWatcher(opts) {
10788
10943
  void runIngestion({
10789
10944
  plugins: opts.plugins,
10790
10945
  archive: opts.archive,
10946
+ checkpoint: opts.checkpoint,
10791
10947
  env: opts.env
10792
- }).catch((err) => onError(err));
10948
+ }).then((result) => opts.onIngest?.(result)).catch((err) => onError(err));
10793
10949
  }, debounceMs);
10794
10950
  pendingTimers.set(changedPath, timer);
10795
10951
  }
10796
10952
  return {
10797
10953
  ready,
10954
+ notifyChange(path5) {
10955
+ if (watcher2) watcher2.emit("change", path5);
10956
+ else scheduleIngest(path5);
10957
+ },
10958
+ notifyUnlink(path5) {
10959
+ if (watcher2) watcher2.emit("unlink", path5);
10960
+ else recordUnlink(path5);
10961
+ },
10798
10962
  async close() {
10799
10963
  closed = true;
10800
10964
  for (const t of pendingTimers.values()) clearTimeout(t);
@@ -10992,7 +11156,7 @@ Environment:
10992
11156
  `;
10993
11157
 
10994
11158
  // src/index.ts
10995
- var __dirname = path4.dirname(fileURLToPath3(import.meta.url));
11159
+ var __dirname = path4.dirname(fileURLToPath2(import.meta.url));
10996
11160
  var pkgPath = path4.join(__dirname, "../../package.json");
10997
11161
  var pkg = JSON.parse(fs6.readFileSync(pkgPath, "utf-8"));
10998
11162
  var action = parseCliArgs(process.argv.slice(2), {
@@ -11015,16 +11179,19 @@ var dataDir = path4.join(os.homedir(), ".chat-logbook");
11015
11179
  var webDistDir = path4.join(__dirname, "../../web/dist");
11016
11180
  var port = action.port;
11017
11181
  var archive = createArchiveRepository({ dataDir });
11182
+ var checkpoint = createCheckpointRepository({ dataDir });
11018
11183
  var metadata = createMetadataRepository({ dataDir });
11019
11184
  var app = createApp({ archive, metadata, webDistDir });
11020
11185
  var initialIngest = startIngestionInBackground({
11021
11186
  plugins,
11022
11187
  archive,
11188
+ checkpoint,
11023
11189
  env: { homeDir: os.homedir() }
11024
11190
  });
11025
11191
  var watcher = startWatcher({
11026
11192
  plugins,
11027
11193
  archive,
11194
+ checkpoint,
11028
11195
  env: { homeDir: os.homedir() }
11029
11196
  });
11030
11197
  void initialIngest.done.then(() => watcher.ready).catch(() => {