@towles/tool 0.0.118 → 0.0.120

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@towles/tool",
3
- "version": "0.0.118",
3
+ "version": "0.0.120",
4
4
  "description": "One off quality of life scripts that I use on a daily basis.",
5
5
  "homepage": "https://github.com/ChrisTowles/towles-tool#readme",
6
6
  "bugs": {
@@ -25,6 +25,7 @@ import { JOURNAL_IDLE_TIMEOUT_MS } from "../../shared";
25
25
  interface ContentItem {
26
26
  type?: string;
27
27
  text?: string;
28
+ name?: string;
28
29
  }
29
30
 
30
31
  interface JournalEntry {
@@ -61,7 +62,7 @@ export function determineStatus(entry: JournalEntry): AgentStatus | null {
61
62
  if (msg.role === "assistant") {
62
63
  const toolUses = items.filter((c) => c.type === "tool_use");
63
64
  if (toolUses.length === 0) return "done";
64
- const allAsking = toolUses.every((c) => (c as any).name === "AskUserQuestion");
65
+ const allAsking = toolUses.every((c) => c.name === "AskUserQuestion");
65
66
  return allAsking ? "question" : "running";
66
67
  }
67
68
 
@@ -133,18 +133,36 @@ export function startServer(
133
133
  const map = getDirSessionMap();
134
134
  const direct = map.get(projectDir);
135
135
  if (direct) return direct;
136
+ // Find the most specific (longest) matching session dir.
137
+ // Without this, a project dir like /a/b could match /a/b/c (session1)
138
+ // before /a/b/c/d (session2), assigning it to the wrong session.
139
+ let bestMatch: string | null = null;
140
+ let bestLen = 0;
136
141
  for (const [dir, name] of map) {
137
- if (projectDir.startsWith(dir + "/") || dir.startsWith(projectDir + "/")) return name;
142
+ if (projectDir.startsWith(dir + "/") || dir.startsWith(projectDir + "/")) {
143
+ if (dir.length > bestLen) {
144
+ bestLen = dir.length;
145
+ bestMatch = name;
146
+ }
147
+ }
138
148
  }
149
+ if (bestMatch) return bestMatch;
139
150
  // Fallback: the decoded projectDir may be wrong due to dash ambiguity.
140
151
  // Re-encode each known session dir and check if the encoded form matches
141
152
  // as a prefix of the (still-encoded) input.
142
153
  const encoded = encodeProjectDir(projectDir);
154
+ bestMatch = null;
155
+ bestLen = 0;
143
156
  for (const [dir, name] of map) {
144
157
  const encodedDir = encodeProjectDir(dir);
145
- if (encoded.startsWith(encodedDir) || encodedDir.startsWith(encoded)) return name;
158
+ if (encoded.startsWith(encodedDir) || encodedDir.startsWith(encoded)) {
159
+ if (encodedDir.length > bestLen) {
160
+ bestLen = encodedDir.length;
161
+ bestMatch = name;
162
+ }
163
+ }
146
164
  }
147
- return null;
165
+ return bestMatch;
148
166
  },
149
167
  emit(event: AgentEvent) {
150
168
  tracker.applyEvent(event, { seed: !watchersSeeded });
@@ -83,7 +83,7 @@ describe("runClaude (injected spawnFn)", () => {
83
83
  type: "stream_event",
84
84
  event: {
85
85
  type: "content_block_start",
86
- content_block: { type: "thinking" },
86
+ content_block: { type: "thinking", thinking: "Let me consider this" },
87
87
  },
88
88
  };
89
89
  const toolEvent = {
@@ -263,7 +263,7 @@ describe("parseStreamLine", () => {
263
263
  }
264
264
  });
265
265
 
266
- it("handles empty thinking", () => {
266
+ it("rejects thinking block without string thinking field", () => {
267
267
  const line = JSON.stringify({
268
268
  type: "stream_event",
269
269
  event: {
@@ -272,10 +272,7 @@ describe("parseStreamLine", () => {
272
272
  },
273
273
  });
274
274
 
275
- const event = parseStreamLine(line);
276
- if (event?.kind === "thinking") {
277
- expect(event.summary).toBe("");
278
- }
275
+ expect(parseStreamLine(line)).toBeNull();
279
276
  });
280
277
  });
281
278
 
@@ -1,3 +1,25 @@
1
+ import type {
2
+ TextBlock,
3
+ ThinkingBlock,
4
+ ToolUseBlock,
5
+ } from "@anthropic-ai/sdk/resources/messages/messages";
6
+
7
+ function isToolUseBlock(
8
+ block: Record<string, unknown>,
9
+ ): block is ToolUseBlock & Record<string, unknown> {
10
+ return block.type === "tool_use" && typeof block.name === "string";
11
+ }
12
+
13
+ function isThinkingBlock(
14
+ block: Record<string, unknown>,
15
+ ): block is ThinkingBlock & Record<string, unknown> {
16
+ return block.type === "thinking" && typeof block.thinking === "string";
17
+ }
18
+
19
+ function isTextBlock(block: Record<string, unknown>): block is TextBlock & Record<string, unknown> {
20
+ return block.type === "text" && typeof block.text === "string";
21
+ }
22
+
1
23
  export interface AgentToolEvent {
2
24
  kind: "tool_use";
3
25
  name: string;
@@ -57,7 +79,7 @@ function toolDetail(block: Record<string, unknown>): string {
57
79
  }
58
80
 
59
81
  function parseContentBlock(block: Record<string, unknown>): AgentActivityEvent | null {
60
- if (block.type === "tool_use" && typeof block.name === "string") {
82
+ if (isToolUseBlock(block)) {
61
83
  return {
62
84
  kind: "tool_use",
63
85
  name: block.name,
@@ -69,15 +91,14 @@ function parseContentBlock(block: Record<string, unknown>): AgentActivityEvent |
69
91
  };
70
92
  }
71
93
 
72
- if (block.type === "thinking") {
73
- const text = typeof block.thinking === "string" ? block.thinking : "";
94
+ if (isThinkingBlock(block)) {
74
95
  return {
75
96
  kind: "thinking",
76
- summary: truncate(text.split("\n")[0].trim(), 120),
97
+ summary: truncate(block.thinking.split("\n")[0].trim(), 120),
77
98
  };
78
99
  }
79
100
 
80
- if (block.type === "text" && typeof block.text === "string") {
101
+ if (isTextBlock(block)) {
81
102
  return {
82
103
  kind: "text",
83
104
  content: block.text,
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import type { ContentBlock, JournalEntry } from "./types";
3
+ import type { Usage } from "@anthropic-ai/sdk/resources/messages/messages";
3
4
  import {
4
5
  aggregateSessionTools,
5
6
  analyzeSession,
@@ -12,6 +13,26 @@ import { extractToolData, extractToolDetail, sanitizeString, truncateDetail } fr
12
13
 
13
14
  // ── Helpers ──
14
15
 
16
+ function textBlock(text: string): ContentBlock {
17
+ return { type: "text" as const, text, citations: null };
18
+ }
19
+
20
+ function toolUseBlock(name: string, input: Record<string, unknown>): ContentBlock {
21
+ return { type: "tool_use" as const, id: "tool-stub", name, input };
22
+ }
23
+
24
+ function makeUsage(overrides: Partial<Usage> = {}): Usage {
25
+ return {
26
+ input_tokens: 0,
27
+ output_tokens: 0,
28
+ cache_read_input_tokens: null,
29
+ cache_creation_input_tokens: null,
30
+ server_tool_use: null,
31
+ service_tier: null,
32
+ ...overrides,
33
+ };
34
+ }
35
+
15
36
  function makeEntry(overrides: Partial<JournalEntry> = {}): JournalEntry {
16
37
  return {
17
38
  type: "assistant",
@@ -33,8 +54,8 @@ function makeAssistantEntry(
33
54
  message: {
34
55
  role: "assistant",
35
56
  model,
36
- usage: { input_tokens: inputTokens, output_tokens: outputTokens },
37
- content: content ?? [{ type: "text", text: "response" }],
57
+ usage: makeUsage({ input_tokens: inputTokens, output_tokens: outputTokens }),
58
+ content: content ?? [textBlock("response")],
38
59
  ...extra,
39
60
  },
40
61
  });
@@ -76,8 +97,8 @@ describe("analyzeSession", () => {
76
97
  message: {
77
98
  role: "assistant",
78
99
  model: "claude-opus-4",
79
- usage: { input_tokens: 1000, output_tokens: 0, cache_read_input_tokens: 800 },
80
- content: [{ type: "text", text: "hi" }],
100
+ usage: makeUsage({ input_tokens: 1000, output_tokens: 0, cache_read_input_tokens: 800 }),
101
+ content: [textBlock("hi")],
81
102
  },
82
103
  }),
83
104
  ];
@@ -87,9 +108,9 @@ describe("analyzeSession", () => {
87
108
 
88
109
  it("counts repeated file reads", () => {
89
110
  const content: ContentBlock[] = [
90
- { type: "tool_use", name: "Read", input: { file_path: "/a.ts" } },
91
- { type: "tool_use", name: "Read", input: { file_path: "/a.ts" } },
92
- { type: "tool_use", name: "Read", input: { file_path: "/b.ts" } },
111
+ toolUseBlock("Read", { file_path: "/a.ts" }),
112
+ toolUseBlock("Read", { file_path: "/a.ts" }),
113
+ toolUseBlock("Read", { file_path: "/b.ts" }),
93
114
  ];
94
115
  const entries = [makeAssistantEntry("claude-opus-4", 100, 50, content)];
95
116
  const result = analyzeSession(entries);
@@ -145,7 +166,7 @@ describe("extractSessionLabel", () => {
145
166
  type: "user",
146
167
  message: {
147
168
  role: "user",
148
- content: [{ type: "text", text: "Array content message" }],
169
+ content: [textBlock("Array content message")],
149
170
  },
150
171
  }),
151
172
  ];
@@ -158,7 +179,7 @@ describe("extractSessionLabel", () => {
158
179
  type: "assistant",
159
180
  message: {
160
181
  role: "assistant",
161
- content: [{ type: "text", text: "I'll help you with that" }],
182
+ content: [textBlock("I'll help you with that")],
162
183
  },
163
184
  }),
164
185
  ];
@@ -314,14 +335,14 @@ describe("extractToolData", () => {
314
335
  });
315
336
 
316
337
  it("returns empty when no tool_use blocks", () => {
317
- const content: ContentBlock[] = [{ type: "text", text: "hello" }];
338
+ const content: ContentBlock[] = [textBlock("hello")];
318
339
  expect(extractToolData(content, 100, 50)).toEqual([]);
319
340
  });
320
341
 
321
342
  it("extracts tool calls and distributes tokens", () => {
322
343
  const content: ContentBlock[] = [
323
- { type: "tool_use", name: "Read", input: { file_path: "/a.ts" } },
324
- { type: "tool_use", name: "Bash", input: { command: "ls" } },
344
+ toolUseBlock("Read", { file_path: "/a.ts" }),
345
+ toolUseBlock("Bash", { command: "ls" }),
325
346
  ];
326
347
  const result = extractToolData(content, 200, 100);
327
348
  expect(result).toHaveLength(2);
@@ -342,12 +363,10 @@ describe("aggregateSessionTools", () => {
342
363
 
343
364
  it("aggregates tools across entries", () => {
344
365
  const entries = [
345
- makeAssistantEntry("claude-opus-4", 100, 50, [
346
- { type: "tool_use", name: "Read", input: { file_path: "/a.ts" } },
347
- ]),
366
+ makeAssistantEntry("claude-opus-4", 100, 50, [toolUseBlock("Read", { file_path: "/a.ts" })]),
348
367
  makeAssistantEntry("claude-opus-4", 200, 100, [
349
- { type: "tool_use", name: "Read", input: { file_path: "/b.ts" } },
350
- { type: "tool_use", name: "Bash", input: { command: "ls" } },
368
+ toolUseBlock("Read", { file_path: "/b.ts" }),
369
+ toolUseBlock("Bash", { command: "ls" }),
351
370
  ]),
352
371
  ];
353
372
  const result = aggregateSessionTools(entries);
@@ -358,11 +377,9 @@ describe("aggregateSessionTools", () => {
358
377
 
359
378
  it("sorts by total token usage descending", () => {
360
379
  const entries = [
361
- makeAssistantEntry("claude-opus-4", 100, 50, [
362
- { type: "tool_use", name: "Bash", input: { command: "ls" } },
363
- ]),
380
+ makeAssistantEntry("claude-opus-4", 100, 50, [toolUseBlock("Bash", { command: "ls" })]),
364
381
  makeAssistantEntry("claude-opus-4", 1000, 500, [
365
- { type: "tool_use", name: "Read", input: { file_path: "/a.ts" } },
382
+ toolUseBlock("Read", { file_path: "/a.ts" }),
366
383
  ]),
367
384
  ];
368
385
  const result = aggregateSessionTools(entries);
@@ -68,7 +68,7 @@ export function extractToolData(
68
68
  const toolBlocks: Array<{ name: string; detail?: string }> = [];
69
69
  for (const block of content) {
70
70
  if (block.type === "tool_use" && block.name) {
71
- const detail = extractToolDetail(block.name, block.input);
71
+ const detail = extractToolDetail(block.name, block.input as Record<string, unknown>);
72
72
  toolBlocks.push({ name: block.name, detail });
73
73
  }
74
74
  }
@@ -1,11 +1,7 @@
1
1
  // Types for parsing Claude Code session JSONL files
2
- export interface ContentBlock {
3
- type: string;
4
- text?: string;
5
- id?: string;
6
- name?: string;
7
- input?: Record<string, unknown>;
8
- }
2
+ import type { ContentBlock, Usage } from "@anthropic-ai/sdk/resources/messages/messages";
3
+
4
+ export type { ContentBlock };
9
5
 
10
6
  export interface JournalEntry {
11
7
  type: string;
@@ -14,12 +10,7 @@ export interface JournalEntry {
14
10
  message?: {
15
11
  role: "user" | "assistant";
16
12
  model?: string;
17
- usage?: {
18
- input_tokens?: number;
19
- output_tokens?: number;
20
- cache_read_input_tokens?: number;
21
- cache_creation_input_tokens?: number;
22
- };
13
+ usage?: Usage;
23
14
  content?: ContentBlock[] | string;
24
15
  };
25
16
  uuid?: string;
@@ -2,8 +2,21 @@
2
2
  * Tests for graph command --days filtering and bar chart data
3
3
  */
4
4
  import { describe, it, expect } from "vitest";
5
+ import type { Usage } from "@anthropic-ai/sdk/resources/messages/messages";
5
6
  import { analyzeSession, calculateCutoffMs, filterByDays } from "./graph/index.js";
6
7
 
8
+ function makeUsage(overrides: Partial<Usage> = {}): Usage {
9
+ return {
10
+ input_tokens: 0,
11
+ output_tokens: 0,
12
+ cache_read_input_tokens: null,
13
+ cache_creation_input_tokens: null,
14
+ server_tool_use: null,
15
+ service_tier: null,
16
+ ...overrides,
17
+ };
18
+ }
19
+
7
20
  describe("graph --days filtering", () => {
8
21
  describe("calculateCutoffMs", () => {
9
22
  it("returns 0 when days <= 0", () => {
@@ -98,7 +111,7 @@ describe("analyzeSession (bar chart token aggregation)", () => {
98
111
  message: {
99
112
  role: "assistant" as const,
100
113
  model,
101
- usage: { input_tokens: inputTokens, output_tokens: outputTokens },
114
+ usage: makeUsage({ input_tokens: inputTokens, output_tokens: outputTokens }),
102
115
  },
103
116
  };
104
117
  }