chat-logbook 0.12.0 → 0.13.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/README.md CHANGED
@@ -42,6 +42,8 @@ The full problem statement, user stories, and direction live in the [PRD](docs/P
42
42
  - **Local archive.** Every conversation chat-logbook reads is copied into `~/.chat-logbook/archive.db`. The UI reads from the archive, so a chat stays visible even after the source JSONL is gone.
43
43
  - **Live updates.** While Claude Code is actively writing to a chat, new messages appear in chat-logbook within seconds — no restart needed.
44
44
  - **Chat metadata at a glance.** A ⓘ button on the conversation header opens a popover showing when the chat started, when it was last updated, the agent, the project working directory, and the chat's IDs — with one-click copy.
45
+ - **Tags.** Create tags, give them colors, and assign them to chats. They show as colored chips on the chat and in the list. Rename or delete them anytime.
46
+ - **Filter by tag or project.** Narrow the chat list to a project, or to chats that carry all of several selected tags.
45
47
 
46
48
  ## Quick start
47
49
 
@@ -109,13 +111,9 @@ No. chat-logbook reads files those tools write on your machine. It doesn't talk
109
111
 
110
112
  ## Roadmap
111
113
 
112
- Two phases of work are in flight, in this order.
113
-
114
- **Custom titles and tags.** Rename a chat to something you'll recognize, attach tags with custom colors, and filter the list by tag or by project. This is the path from "I have a lot of conversations" to "I can find the one I need."
115
-
116
114
  **Spotlight search.** A keyboard-driven overlay (`⌘K` or `/`) that searches across chats, messages, tags, and projects from one input. A match lands you on the exact message with the term highlighted, and you can jump between every match without leaving the overlay.
117
115
 
118
- Beyond these three, several smaller things are on the list without firm timing — live updates while an AI tool is writing, annotations and highlights for marking what matters, editing your own past messages, CLI commands for working with conversations from a terminal. Some will ship; some are still being weighed.
116
+ Beyond this, several smaller things are on the list without firm timing — live updates while an AI tool is writing, annotations and highlights for marking what matters, editing your own past messages, CLI commands for working with conversations from a terminal. Some will ship; some are still being weighed.
119
117
 
120
118
  Explicit non-goals: semantic / vector search, cloud sync inside the OSS core, modification of source directories like `~/.claude/`, and bulk export to bespoke formats (the archive itself is the export). See the [PRD](docs/PRD.md) for the full out-of-scope list.
121
119
 
@@ -0,0 +1,16 @@
1
+ CREATE TABLE `tags` (
2
+ `id` text PRIMARY KEY NOT NULL,
3
+ `name` text NOT NULL,
4
+ `color` text NOT NULL,
5
+ `created_at` integer NOT NULL,
6
+ `updated_at` integer NOT NULL
7
+ );
8
+ --> statement-breakpoint
9
+ CREATE TABLE `chat_tags` (
10
+ `chat_id` text NOT NULL,
11
+ `tag_id` text NOT NULL,
12
+ PRIMARY KEY(`chat_id`, `tag_id`),
13
+ FOREIGN KEY (`tag_id`) REFERENCES `tags`(`id`) ON UPDATE no action ON DELETE cascade
14
+ );
15
+ --> statement-breakpoint
16
+ CREATE INDEX `chat_tags_tag_id_idx` ON `chat_tags` (`tag_id`);
@@ -0,0 +1 @@
1
+ CREATE INDEX `chats_project_idx` ON `chats` (`project`);
@@ -57,6 +57,13 @@
57
57
  "when": 1779200000000,
58
58
  "tag": "0007_drop_session_scan_state",
59
59
  "breakpoints": true
60
+ },
61
+ {
62
+ "idx": 8,
63
+ "version": "6",
64
+ "when": 1779300000000,
65
+ "tag": "0008_add_chats_project_idx",
66
+ "breakpoints": true
60
67
  }
61
68
  ]
62
69
  }
@@ -29,6 +29,13 @@
29
29
  "when": 1779500000000,
30
30
  "tag": "0003_add_deleted_at",
31
31
  "breakpoints": true
32
+ },
33
+ {
34
+ "idx": 4,
35
+ "version": "6",
36
+ "when": 1780000000000,
37
+ "tag": "0004_add_tags",
38
+ "breakpoints": true
32
39
  }
33
40
  ]
34
41
  }
package/api/dist/index.js CHANGED
@@ -588,13 +588,13 @@ import updateNotifier from "update-notifier";
588
588
  // ../node_modules/.pnpm/hono@4.12.9/node_modules/hono/dist/compose.js
589
589
  var compose = (middleware, onError, onNotFound) => {
590
590
  return (context, next) => {
591
- let index = -1;
591
+ let index2 = -1;
592
592
  return dispatch(0);
593
593
  async function dispatch(i) {
594
- if (i <= index) {
594
+ if (i <= index2) {
595
595
  throw new Error("next() called multiple times");
596
596
  }
597
- index = i;
597
+ index2 = i;
598
598
  let res;
599
599
  let isError = false;
600
600
  let handler;
@@ -692,8 +692,8 @@ var handleParsingNestedValues = (form, key, value) => {
692
692
  }
693
693
  let nestedForm = form;
694
694
  const keys = key.split(".");
695
- keys.forEach((key2, index) => {
696
- if (index === keys.length - 1) {
695
+ keys.forEach((key2, index2) => {
696
+ if (index2 === keys.length - 1) {
697
697
  nestedForm[key2] = value;
698
698
  } else {
699
699
  if (!nestedForm[key2] || typeof nestedForm[key2] !== "object" || Array.isArray(nestedForm[key2]) || nestedForm[key2] instanceof File) {
@@ -719,8 +719,8 @@ var splitRoutingPath = (routePath) => {
719
719
  };
720
720
  var extractGroupsFromPath = (path5) => {
721
721
  const groups = [];
722
- path5 = path5.replace(/\{[^}]+\}/g, (match2, index) => {
723
- const mark = `@${index}`;
722
+ path5 = path5.replace(/\{[^}]+\}/g, (match2, index2) => {
723
+ const mark = `@${index2}`;
724
724
  groups.push([mark, match2]);
725
725
  return mark;
726
726
  });
@@ -2021,8 +2021,8 @@ function match(method, path5) {
2021
2021
  if (!match3) {
2022
2022
  return [[], emptyParam];
2023
2023
  }
2024
- const index = match3.indexOf("", 1);
2025
- return [matcher[1][index], match3];
2024
+ const index2 = match3.indexOf("", 1);
2025
+ return [matcher[1][index2], match3];
2026
2026
  });
2027
2027
  this.match = match2;
2028
2028
  return match2(method, path5);
@@ -2057,7 +2057,7 @@ var Node = class _Node {
2057
2057
  #index;
2058
2058
  #varIndex;
2059
2059
  #children = /* @__PURE__ */ Object.create(null);
2060
- insert(tokens, index, paramMap, context, pathErrorCheckOnly) {
2060
+ insert(tokens, index2, paramMap, context, pathErrorCheckOnly) {
2061
2061
  if (tokens.length === 0) {
2062
2062
  if (this.#index !== void 0) {
2063
2063
  throw PATH_ERROR;
@@ -2065,7 +2065,7 @@ var Node = class _Node {
2065
2065
  if (pathErrorCheckOnly) {
2066
2066
  return;
2067
2067
  }
2068
- this.#index = index;
2068
+ this.#index = index2;
2069
2069
  return;
2070
2070
  }
2071
2071
  const [token, ...restTokens] = tokens;
@@ -2115,7 +2115,7 @@ var Node = class _Node {
2115
2115
  node = this.#children[token] = new _Node();
2116
2116
  }
2117
2117
  }
2118
- node.insert(restTokens, index, paramMap, context, pathErrorCheckOnly);
2118
+ node.insert(restTokens, index2, paramMap, context, pathErrorCheckOnly);
2119
2119
  }
2120
2120
  buildRegExpStr() {
2121
2121
  const childKeys = Object.keys(this.#children).sort(compareKey);
@@ -2140,7 +2140,7 @@ var Node = class _Node {
2140
2140
  var Trie = class {
2141
2141
  #context = { varIndex: 0 };
2142
2142
  #root = new Node();
2143
- insert(path5, index, pathErrorCheckOnly) {
2143
+ insert(path5, index2, pathErrorCheckOnly) {
2144
2144
  const paramAssoc = [];
2145
2145
  const groups = [];
2146
2146
  for (let i = 0; ; ) {
@@ -2166,7 +2166,7 @@ var Trie = class {
2166
2166
  }
2167
2167
  }
2168
2168
  }
2169
- this.#root.insert(tokens, index, paramAssoc, this.#context, pathErrorCheckOnly);
2169
+ this.#root.insert(tokens, index2, paramAssoc, this.#context, pathErrorCheckOnly);
2170
2170
  return paramAssoc;
2171
2171
  }
2172
2172
  buildRegExp() {
@@ -2860,6 +2860,21 @@ var serveStatic = (options = { root: "" }) => {
2860
2860
  };
2861
2861
  };
2862
2862
 
2863
+ // src/metadata/tag-colors.ts
2864
+ var TAG_COLORS = [
2865
+ "yellow",
2866
+ "orange",
2867
+ "red",
2868
+ "magenta",
2869
+ "violet",
2870
+ "blue",
2871
+ "cyan",
2872
+ "green"
2873
+ ];
2874
+ function isColorToken(value) {
2875
+ return typeof value === "string" && TAG_COLORS.includes(value);
2876
+ }
2877
+
2863
2878
  // src/archive/chat-id.ts
2864
2879
  import crypto3 from "crypto";
2865
2880
  var CROCKFORD_ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
@@ -2922,7 +2937,8 @@ function toApiBlock(block) {
2922
2937
  }
2923
2938
  function createChatReader({
2924
2939
  archive: archive2,
2925
- metadata: metadata2
2940
+ metadata: metadata2,
2941
+ tags: tags3
2926
2942
  }) {
2927
2943
  function findChat(id) {
2928
2944
  const code = parseChatId(id);
@@ -2938,10 +2954,24 @@ function createChatReader({
2938
2954
  return [agent, sourceId].join("\0");
2939
2955
  }
2940
2956
  function listChats({
2941
- includeTrashed
2957
+ includeTrashed,
2958
+ projects,
2959
+ tags: tagSelection
2942
2960
  }) {
2943
2961
  const visibility = loadChatVisibility(metadata2, { includeTrashed });
2944
- const rows = archive2.read.listChatRows();
2962
+ const rows = archive2.read.listChatRows(projects ? { projects } : void 0);
2963
+ let passesTagFilter = null;
2964
+ if (tagSelection) {
2965
+ const realTagIds = tagSelection.filter((t) => t !== "");
2966
+ const wantUntagged = tagSelection.includes("");
2967
+ const allowedByTags = realTagIds.length > 0 ? new Set(tags3.listChatIdsWithAllTags(realTagIds)) : null;
2968
+ const taggedChatIds = wantUntagged ? new Set(tags3.listTagsByChat().keys()) : null;
2969
+ passesTagFilter = (chatInternalId) => {
2970
+ if (allowedByTags && !allowedByTags.has(chatInternalId)) return false;
2971
+ if (taggedChatIds && taggedChatIds.has(chatInternalId)) return false;
2972
+ return true;
2973
+ };
2974
+ }
2945
2975
  const tsRangeByKey = new Map(
2946
2976
  archive2.read.listChatTsRanges().map((r) => [key(r.agent, r.sourceId), r])
2947
2977
  );
@@ -2952,9 +2982,11 @@ function createChatReader({
2952
2982
  archive2.read.listFirstUserTexts().map((r) => [key(r.agent, r.sourceId), r.text])
2953
2983
  );
2954
2984
  const customTitleById = metadata2.listCustomTitles();
2985
+ const tagsByChatId = tags3.listTagsByChat();
2955
2986
  const chats2 = [];
2956
2987
  for (const row of rows) {
2957
2988
  if (!visibility.isVisible(row.id)) continue;
2989
+ if (passesTagFilter && !passesTagFilter(row.id)) continue;
2958
2990
  const isDeleted = visibility.isTrashed(row.id);
2959
2991
  const rowKey = key(row.agent, row.sourceId);
2960
2992
  const tsRange = tsRangeByKey.get(rowKey);
@@ -2972,7 +3004,8 @@ function createChatReader({
2972
3004
  sourceFilePath: sourcePathByKey.get(rowKey) ?? null,
2973
3005
  createdAt: tsRange?.minTs ?? firstSeenAtMs,
2974
3006
  updatedAt: tsRange?.maxTs ?? firstSeenAtMs,
2975
- deletedAt: visibility.deletedAt(row.id)
3007
+ deletedAt: visibility.deletedAt(row.id),
3008
+ tags: tagsByChatId.get(row.id) ?? []
2976
3009
  };
2977
3010
  if (isDeleted) chat.isDeleted = true;
2978
3011
  chats2.push(chat);
@@ -2995,12 +3028,17 @@ function createChatReader({
2995
3028
  }
2996
3029
 
2997
3030
  // src/app.ts
2998
- function createApp({ archive: archive2, metadata: metadata2, webDistDir: webDistDir2 }) {
3031
+ function createApp({ archive: archive2, metadata: metadata2, tags: tags3, webDistDir: webDistDir2 }) {
2999
3032
  const app2 = new Hono2();
3000
- const reader = createChatReader({ archive: archive2, metadata: metadata2 });
3033
+ const reader = createChatReader({ archive: archive2, metadata: metadata2, tags: tags3 });
3001
3034
  app2.get("/api/chats", (c) => {
3035
+ const projects = c.req.queries("project");
3036
+ const tagsParam = c.req.query("tags");
3037
+ const tags4 = tagsParam === void 0 ? void 0 : tagsParam.split(",");
3002
3038
  const chats2 = reader.listChats({
3003
- includeTrashed: c.req.query("includeTrashed") === "true"
3039
+ includeTrashed: c.req.query("includeTrashed") === "true",
3040
+ projects,
3041
+ tags: tags4
3004
3042
  });
3005
3043
  return c.json({ chats: chats2 });
3006
3044
  });
@@ -3051,6 +3089,85 @@ function createApp({ archive: archive2, metadata: metadata2, webDistDir: webDist
3051
3089
  }
3052
3090
  return c.json({ messages: messages2 });
3053
3091
  });
3092
+ app2.get("/api/tags", (c) => {
3093
+ return c.json({ tags: tags3.listTags() });
3094
+ });
3095
+ app2.post("/api/tags", async (c) => {
3096
+ let body;
3097
+ try {
3098
+ body = await c.req.json();
3099
+ } catch {
3100
+ return c.json({ error: "Invalid JSON body" }, 400);
3101
+ }
3102
+ const name = body && typeof body === "object" ? body.name : void 0;
3103
+ const color = body && typeof body === "object" ? body.color : void 0;
3104
+ if (typeof name !== "string" || name.trim().length === 0) {
3105
+ return c.json({ error: "Invalid name" }, 400);
3106
+ }
3107
+ if (!isColorToken(color)) {
3108
+ return c.json({ error: "Invalid color" }, 400);
3109
+ }
3110
+ const tag = tags3.createTag(name.trim(), color);
3111
+ return c.json({ tag }, 201);
3112
+ });
3113
+ app2.patch("/api/tags/:id", async (c) => {
3114
+ const id = c.req.param("id");
3115
+ if (!tags3.getTag(id)) {
3116
+ return c.json({ error: "Tag not found" }, 404);
3117
+ }
3118
+ let body;
3119
+ try {
3120
+ body = await c.req.json();
3121
+ } catch {
3122
+ return c.json({ error: "Invalid JSON body" }, 400);
3123
+ }
3124
+ const name = body && typeof body === "object" ? body.name : void 0;
3125
+ const color = body && typeof body === "object" ? body.color : void 0;
3126
+ if (name !== void 0) {
3127
+ if (typeof name !== "string" || name.trim().length === 0) {
3128
+ return c.json({ error: "Invalid name" }, 400);
3129
+ }
3130
+ }
3131
+ if (color !== void 0 && !isColorToken(color)) {
3132
+ return c.json({ error: "Invalid color" }, 400);
3133
+ }
3134
+ if (typeof name === "string") tags3.renameTag(id, name.trim());
3135
+ if (isColorToken(color)) tags3.recolorTag(id, color);
3136
+ return c.body(null, 204);
3137
+ });
3138
+ app2.delete("/api/tags/:id", (c) => {
3139
+ if (!tags3.getTag(c.req.param("id"))) {
3140
+ return c.json({ error: "Tag not found" }, 404);
3141
+ }
3142
+ const result = tags3.deleteTag(c.req.param("id"));
3143
+ return c.json(result);
3144
+ });
3145
+ app2.post("/api/chats/:id/tags", async (c) => {
3146
+ const row = reader.findChat(c.req.param("id"));
3147
+ if (!row) {
3148
+ return c.json({ error: "Chat not found" }, 404);
3149
+ }
3150
+ let body;
3151
+ try {
3152
+ body = await c.req.json();
3153
+ } catch {
3154
+ return c.json({ error: "Invalid JSON body" }, 400);
3155
+ }
3156
+ const tagId = body && typeof body === "object" ? body.tagId : void 0;
3157
+ if (typeof tagId !== "string" || !tags3.getTag(tagId)) {
3158
+ return c.json({ error: "Tag not found" }, 404);
3159
+ }
3160
+ tags3.assignTag(row.id, tagId);
3161
+ return c.body(null, 204);
3162
+ });
3163
+ app2.delete("/api/chats/:id/tags/:tagId", (c) => {
3164
+ const row = reader.findChat(c.req.param("id"));
3165
+ if (!row) {
3166
+ return c.json({ error: "Chat not found" }, 404);
3167
+ }
3168
+ tags3.removeTag(row.id, c.req.param("tagId"));
3169
+ return c.body(null, 204);
3170
+ });
3054
3171
  if (webDistDir2) {
3055
3172
  app2.use("*", serveStatic({ root: webDistDir2 }));
3056
3173
  app2.use("*", serveStatic({ root: webDistDir2, path: "index.html" }));
@@ -4440,8 +4557,8 @@ function haveSameKeys(left, right) {
4440
4557
  if (leftKeys.length !== rightKeys.length) {
4441
4558
  return false;
4442
4559
  }
4443
- for (const [index, key] of leftKeys.entries()) {
4444
- if (key !== rightKeys[index]) {
4560
+ for (const [index2, key] of leftKeys.entries()) {
4561
+ if (key !== rightKeys[index2]) {
4445
4562
  return false;
4446
4563
  }
4447
4564
  }
@@ -4821,19 +4938,19 @@ function extractTablesRelationalConfig(schema, configHelpers) {
4821
4938
  const relations2 = value.config(
4822
4939
  configHelpers(value.table)
4823
4940
  );
4824
- let primaryKey;
4941
+ let primaryKey2;
4825
4942
  for (const [relationName, relation] of Object.entries(relations2)) {
4826
4943
  if (tableName) {
4827
4944
  const tableConfig = tablesConfig[tableName];
4828
4945
  tableConfig.relations[relationName] = relation;
4829
- if (primaryKey) {
4830
- tableConfig.primaryKey.push(...primaryKey);
4946
+ if (primaryKey2) {
4947
+ tableConfig.primaryKey.push(...primaryKey2);
4831
4948
  }
4832
4949
  } else {
4833
4950
  if (!(dbName in relationsBuffer)) {
4834
4951
  relationsBuffer[dbName] = {
4835
4952
  relations: {},
4836
- primaryKey
4953
+ primaryKey: primaryKey2
4837
4954
  };
4838
4955
  }
4839
4956
  relationsBuffer[dbName].relations[relationName] = relation;
@@ -5677,10 +5794,49 @@ var Index = class {
5677
5794
  this.config = { ...config, table };
5678
5795
  }
5679
5796
  };
5797
+ function index(name) {
5798
+ return new IndexBuilderOn(name, false);
5799
+ }
5680
5800
  function uniqueIndex(name) {
5681
5801
  return new IndexBuilderOn(name, true);
5682
5802
  }
5683
5803
 
5804
+ // ../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/primary-keys.js
5805
+ function primaryKey(...config) {
5806
+ if (config[0].columns) {
5807
+ return new PrimaryKeyBuilder2(config[0].columns, config[0].name);
5808
+ }
5809
+ return new PrimaryKeyBuilder2(config);
5810
+ }
5811
+ var PrimaryKeyBuilder2 = class {
5812
+ static [entityKind] = "SQLitePrimaryKeyBuilder";
5813
+ /** @internal */
5814
+ columns;
5815
+ /** @internal */
5816
+ name;
5817
+ constructor(columns, name) {
5818
+ this.columns = columns;
5819
+ this.name = name;
5820
+ }
5821
+ /** @internal */
5822
+ build(table) {
5823
+ return new PrimaryKey2(table, this.columns, this.name);
5824
+ }
5825
+ };
5826
+ var PrimaryKey2 = class {
5827
+ constructor(table, columns, name) {
5828
+ this.table = table;
5829
+ this.columns = columns;
5830
+ this.name = name;
5831
+ }
5832
+ static [entityKind] = "SQLitePrimaryKey";
5833
+ columns;
5834
+ name;
5835
+ getName() {
5836
+ return this.name ?? `${this.table[SQLiteTable.Symbol.Name]}_${this.columns.map((column) => column.name).join("_")}_pk`;
5837
+ }
5838
+ };
5839
+
5684
5840
  // ../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/utils.js
5685
5841
  function extractUsedTable(table) {
5686
5842
  if (is(table, SQLiteTable)) {
@@ -6029,8 +6185,8 @@ var SQLiteDialect = class {
6029
6185
  }
6030
6186
  const joinsArray = [];
6031
6187
  if (joins) {
6032
- for (const [index, joinMeta] of joins.entries()) {
6033
- if (index === 0) {
6188
+ for (const [index2, joinMeta] of joins.entries()) {
6189
+ if (index2 === 0) {
6034
6190
  joinsArray.push(sql` `);
6035
6191
  }
6036
6192
  const table = joinMeta.table;
@@ -6050,7 +6206,7 @@ var SQLiteDialect = class {
6050
6206
  sql`${sql.raw(joinMeta.joinType)} join ${table}${onSql}`
6051
6207
  );
6052
6208
  }
6053
- if (index < joins.length - 1) {
6209
+ if (index2 < joins.length - 1) {
6054
6210
  joinsArray.push(sql` `);
6055
6211
  }
6056
6212
  }
@@ -6063,9 +6219,9 @@ var SQLiteDialect = class {
6063
6219
  buildOrderBy(orderBy) {
6064
6220
  const orderByList = [];
6065
6221
  if (orderBy) {
6066
- for (const [index, orderByValue] of orderBy.entries()) {
6222
+ for (const [index2, orderByValue] of orderBy.entries()) {
6067
6223
  orderByList.push(orderByValue);
6068
- if (index < orderBy.length - 1) {
6224
+ if (index2 < orderBy.length - 1) {
6069
6225
  orderByList.push(sql`, `);
6070
6226
  }
6071
6227
  }
@@ -6118,9 +6274,9 @@ var SQLiteDialect = class {
6118
6274
  const havingSql = having ? sql` having ${having}` : void 0;
6119
6275
  const groupByList = [];
6120
6276
  if (groupBy) {
6121
- for (const [index, groupByValue] of groupBy.entries()) {
6277
+ for (const [index2, groupByValue] of groupBy.entries()) {
6122
6278
  groupByList.push(groupByValue);
6123
- if (index < groupBy.length - 1) {
6279
+ if (index2 < groupBy.length - 1) {
6124
6280
  groupByList.push(sql`, `);
6125
6281
  }
6126
6282
  }
@@ -8621,7 +8777,11 @@ var chats = sqliteTable(
8621
8777
  project: text("project"),
8622
8778
  projectPath: text("project_path")
8623
8779
  },
8624
- (t) => [uniqueIndex("chats_agent_source_idx").on(t.agent, t.sourceId)]
8780
+ (t) => [
8781
+ uniqueIndex("chats_agent_source_idx").on(t.agent, t.sourceId),
8782
+ // Backs the server-side Project filter (`WHERE coalesce(project,'') IN …`).
8783
+ index("chats_project_idx").on(t.project)
8784
+ ]
8625
8785
  );
8626
8786
  var rawMessages = sqliteTable(
8627
8787
  "raw_messages",
@@ -8669,7 +8829,11 @@ var ingestionEvents = sqliteTable("ingestion_events", {
8669
8829
  // src/archive/read-seam.ts
8670
8830
  function createArchiveReadSeam(db) {
8671
8831
  return {
8672
- listChatRows() {
8832
+ listChatRows(opts) {
8833
+ const projects = opts?.projects;
8834
+ if (projects) {
8835
+ return db.select().from(chats).where(inArray(sql`coalesce(${chats.project}, '')`, projects)).all();
8836
+ }
8673
8837
  return db.select().from(chats).all();
8674
8838
  },
8675
8839
  findChatBySourceId(sourceId) {
@@ -8957,7 +9121,9 @@ import path2 from "path";
8957
9121
  // src/metadata/schema.ts
8958
9122
  var schema_exports3 = {};
8959
9123
  __export(schema_exports3, {
8960
- chatsMeta: () => chatsMeta
9124
+ chatTags: () => chatTags,
9125
+ chatsMeta: () => chatsMeta,
9126
+ tags: () => tags
8961
9127
  });
8962
9128
  var chatsMeta = sqliteTable("chats_meta", {
8963
9129
  id: text("id").primaryKey(),
@@ -8969,6 +9135,24 @@ var chatsMeta = sqliteTable("chats_meta", {
8969
9135
  // view's independent "Deleted time" sort axis.
8970
9136
  deletedAt: integer("deleted_at", { mode: "timestamp_ms" })
8971
9137
  });
9138
+ var tags = sqliteTable("tags", {
9139
+ id: text("id").primaryKey(),
9140
+ name: text("name").notNull(),
9141
+ color: text("color").notNull(),
9142
+ createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
9143
+ updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull()
9144
+ });
9145
+ var chatTags = sqliteTable(
9146
+ "chat_tags",
9147
+ {
9148
+ chatId: text("chat_id").notNull(),
9149
+ tagId: text("tag_id").notNull().references(() => tags.id, { onDelete: "cascade" })
9150
+ },
9151
+ (table) => [
9152
+ primaryKey({ columns: [table.chatId, table.tagId] }),
9153
+ index("chat_tags_tag_id_idx").on(table.tagId)
9154
+ ]
9155
+ );
8972
9156
 
8973
9157
  // src/metadata/repository.ts
8974
9158
  var DB_FILE = "metadata.db";
@@ -9070,6 +9254,96 @@ function rekeyLegacyRows(sqlite, db, lookupInternalId, ensureChat) {
9070
9254
  sqlite.pragma(`user_version = ${REKEY_USER_VERSION}`);
9071
9255
  }
9072
9256
 
9257
+ // src/metadata/tags.ts
9258
+ import { randomUUID } from "crypto";
9259
+ var DB_FILE2 = "metadata.db";
9260
+ function createTagRepository({
9261
+ dataDir: dataDir2
9262
+ }) {
9263
+ const { db } = openStore({
9264
+ dataDir: dataDir2,
9265
+ dbFile: DB_FILE2,
9266
+ callerUrl: import.meta.url,
9267
+ migrationsSubdir: "drizzle",
9268
+ schema: schema_exports3
9269
+ });
9270
+ function toTag(row) {
9271
+ return { id: row.id, name: row.name, color: row.color };
9272
+ }
9273
+ function assertColor(color) {
9274
+ if (!isColorToken(color)) {
9275
+ throw new Error(`Invalid tag color token: ${String(color)}`);
9276
+ }
9277
+ }
9278
+ return {
9279
+ createTag(name, color) {
9280
+ assertColor(color);
9281
+ const now = /* @__PURE__ */ new Date();
9282
+ const row = {
9283
+ id: randomUUID(),
9284
+ name,
9285
+ color,
9286
+ createdAt: now,
9287
+ updatedAt: now
9288
+ };
9289
+ db.insert(tags).values(row).run();
9290
+ return toTag(row);
9291
+ },
9292
+ listTags() {
9293
+ return db.select({ id: tags.id, name: tags.name, color: tags.color }).from(tags).all().map(toTag);
9294
+ },
9295
+ getTag(id) {
9296
+ const row = db.select({ id: tags.id, name: tags.name, color: tags.color }).from(tags).where(eq(tags.id, id)).get();
9297
+ return row ? toTag(row) : null;
9298
+ },
9299
+ renameTag(id, name) {
9300
+ db.update(tags).set({ name, updatedAt: /* @__PURE__ */ new Date() }).where(eq(tags.id, id)).run();
9301
+ },
9302
+ recolorTag(id, color) {
9303
+ assertColor(color);
9304
+ db.update(tags).set({ color, updatedAt: /* @__PURE__ */ new Date() }).where(eq(tags.id, id)).run();
9305
+ },
9306
+ deleteTag(id) {
9307
+ return db.transaction((tx) => {
9308
+ const removedFromChats = tx.select({ chatId: chatTags.chatId }).from(chatTags).where(eq(chatTags.tagId, id)).all().length;
9309
+ tx.delete(chatTags).where(eq(chatTags.tagId, id)).run();
9310
+ tx.delete(tags).where(eq(tags.id, id)).run();
9311
+ return { removedFromChats };
9312
+ });
9313
+ },
9314
+ assignTag(chatId, tagId) {
9315
+ db.insert(chatTags).values({ chatId, tagId }).onConflictDoNothing().run();
9316
+ },
9317
+ removeTag(chatId, tagId) {
9318
+ db.delete(chatTags).where(and(eq(chatTags.chatId, chatId), eq(chatTags.tagId, tagId))).run();
9319
+ },
9320
+ listTagsForChat(chatId) {
9321
+ return db.select({ id: tags.id, name: tags.name, color: tags.color }).from(chatTags).innerJoin(tags, eq(chatTags.tagId, tags.id)).where(eq(chatTags.chatId, chatId)).all().map(toTag);
9322
+ },
9323
+ listTagsByChat(chatIds) {
9324
+ const base = db.select({
9325
+ chatId: chatTags.chatId,
9326
+ id: tags.id,
9327
+ name: tags.name,
9328
+ color: tags.color
9329
+ }).from(chatTags).innerJoin(tags, eq(chatTags.tagId, tags.id));
9330
+ const rows = chatIds === void 0 ? base.all() : chatIds.length === 0 ? [] : base.where(inArray(chatTags.chatId, chatIds)).all();
9331
+ const byChat = /* @__PURE__ */ new Map();
9332
+ for (const row of rows) {
9333
+ const list = byChat.get(row.chatId) ?? [];
9334
+ list.push(toTag(row));
9335
+ byChat.set(row.chatId, list);
9336
+ }
9337
+ return byChat;
9338
+ },
9339
+ listChatIdsWithAllTags(tagIds) {
9340
+ const distinct = [...new Set(tagIds)];
9341
+ if (distinct.length === 0) return [];
9342
+ return db.select({ chatId: chatTags.chatId }).from(chatTags).where(inArray(chatTags.tagId, distinct)).groupBy(chatTags.chatId).having(sql`count(${chatTags.tagId}) = ${distinct.length}`).all().map((r) => r.chatId);
9343
+ }
9344
+ };
9345
+ }
9346
+
9073
9347
  // src/ingestion/ingest.ts
9074
9348
  import fs4 from "fs";
9075
9349
  async function runIngestion(opts) {
@@ -10200,8 +10474,8 @@ function normalizePath(path5) {
10200
10474
  }
10201
10475
  function matchPatterns(patterns, testString, stats) {
10202
10476
  const path5 = normalizePath(testString);
10203
- for (let index = 0; index < patterns.length; index++) {
10204
- const pattern = patterns[index];
10477
+ for (let index2 = 0; index2 < patterns.length; index2++) {
10478
+ const pattern = patterns[index2];
10205
10479
  if (pattern(path5, stats)) {
10206
10480
  return true;
10207
10481
  }
@@ -10549,8 +10823,8 @@ var FSWatcher = class extends EventEmitter {
10549
10823
  const watchList = {};
10550
10824
  this._watched.forEach((entry, dir) => {
10551
10825
  const key = this.options.cwd ? sp2.relative(this.options.cwd, dir) : dir;
10552
- const index = key || ONE_DOT;
10553
- watchList[index] = entry.getChildren().sort();
10826
+ const index2 = key || ONE_DOT;
10827
+ watchList[index2] = entry.getChildren().sort();
10554
10828
  });
10555
10829
  return watchList;
10556
10830
  }
@@ -11181,7 +11455,8 @@ var port = action.port;
11181
11455
  var archive = createArchiveRepository({ dataDir });
11182
11456
  var checkpoint = createCheckpointRepository({ dataDir });
11183
11457
  var metadata = createMetadataRepository({ dataDir });
11184
- var app = createApp({ archive, metadata, webDistDir });
11458
+ var tags2 = createTagRepository({ dataDir });
11459
+ var app = createApp({ archive, metadata, tags: tags2, webDistDir });
11185
11460
  var initialIngest = startIngestionInBackground({
11186
11461
  plugins,
11187
11462
  archive,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chat-logbook",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "A local-first conversation manager for Claude Code",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",