sessioner 0.1.0 → 0.1.1

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
@@ -32,8 +32,6 @@ Claude Code supports native forking from specific points, but loses context like
32
32
  npm i sessioner
33
33
  ```
34
34
 
35
- > Requires **Node.js >= 22**.
36
-
37
35
  ---
38
36
 
39
37
  ## 🚀 Quick Start
@@ -114,9 +112,10 @@ sessioner
114
112
  - Analyzes the session and reports what can be removed:
115
113
  - **Tool blocks**: `tool_use` and `tool_result` entries
116
114
  - **Empty messages**: no text content after tools are stripped
117
- - **Short messages**: text under 50 characters
118
115
  - **System/IDE tags**: `<system-reminder>`, `<ide_selection>`, `<ide_opened_file>`
119
- - Checkbox selection for what to remove (all pre-selected by default)
116
+ - **Old custom titles**: duplicate `custom-title` entries (keeps the most recent)
117
+ - **Short messages**: text under 50 characters (unselected by default)
118
+ - Checkbox selection for what to remove (all pre-selected by default, except short messages)
120
119
  - Repairs the parent-child UUID chain after pruning
121
120
  - Shows "Nothing to prune" when the session is already clean
122
121
 
@@ -135,11 +134,7 @@ sessioner
135
134
  ### Rename
136
135
 
137
136
  - Text input for the new title (<kbd>Esc</kbd> to cancel)
138
- - Updates three locations:
139
- - Session file (prepends a `session_title` line)
140
- - Sessions index (`sessions-index.json`)
141
- - First user message (injects title into metadata)
142
- - Tracks renames in a local cache (`.cache/renames.json`)
137
+ - Writes a `custom-title` entry to the session file (same format used by the Claude Code extension)
143
138
 
144
139
  ---
145
140
 
@@ -148,7 +143,7 @@ sessioner
148
143
  - Shows exactly what will be deleted:
149
144
  - Session `.jsonl` file
150
145
  - Subagents directory (with file count)
151
- - Removes the entry from the sessions index and rename cache
146
+ - Removes the entry from the sessions index
152
147
  - Confirmation prompt before applying
153
148
 
154
149
  ---
@@ -1,6 +1,5 @@
1
1
  import { readFile, rm, unlink, writeFile } from 'node:fs/promises';
2
2
  import { basename, dirname, join } from 'node:path';
3
- import { readCache, writeCache } from '../helpers/rename-cache.js';
4
3
  import { countSubagents } from '../sessions/count-subagents.js';
5
4
  export const targets = async (session) => {
6
5
  const name = basename(session.file);
@@ -23,16 +22,6 @@ const removeFromIndex = async (session) => {
23
22
  }
24
23
  catch { }
25
24
  };
26
- const removeFromCache = async (session) => {
27
- try {
28
- const cache = await readCache();
29
- if (!(session.file in cache))
30
- return;
31
- delete cache[session.file];
32
- await writeCache(cache);
33
- }
34
- catch { }
35
- };
36
25
  export const cleanEmpty = async (files) => {
37
26
  for (const file of files) {
38
27
  const id = basename(file, '.jsonl');
@@ -53,5 +42,4 @@ export const remove = async (session) => {
53
42
  }
54
43
  catch { }
55
44
  await removeFromIndex(session);
56
- await removeFromCache(session);
57
45
  };
@@ -9,7 +9,7 @@ const buildUuidMap = (lines) => {
9
9
  continue;
10
10
  try {
11
11
  const parsed = JSON.parse(line);
12
- if (parsed.type === 'session_title')
12
+ if (parsed.type === 'session_title' || parsed.type === 'custom-title')
13
13
  continue;
14
14
  if (parsed.uuid)
15
15
  uuidMap.set(parsed.uuid, randomUUID());
@@ -26,7 +26,7 @@ const remapSession = (lines, uuidMap, sessionId, previousLastUuid) => {
26
26
  continue;
27
27
  try {
28
28
  const parsed = JSON.parse(line);
29
- if (parsed.type === 'session_title')
29
+ if (parsed.type === 'session_title' || parsed.type === 'custom-title')
30
30
  continue;
31
31
  if (parsed.uuid) {
32
32
  const newUuid = uuidMap.get(parsed.uuid);
@@ -29,7 +29,7 @@ export const preview = (session, limit = PREVIEW_LIMIT) => new Promise((resolve)
29
29
  const entry = parseLine(line);
30
30
  if (!entry)
31
31
  return;
32
- if (entry.type === 'session_title')
32
+ if (entry.type === 'session_title' || entry.type === 'custom-title')
33
33
  return;
34
34
  const texts = entry.message?.content ?? [];
35
35
  for (const block of texts) {
@@ -75,11 +75,25 @@ export const prune = async (session, options) => {
75
75
  const lines = raw.split('\n');
76
76
  const kept = [];
77
77
  const survivingUuids = new Set();
78
- for (const line of lines) {
78
+ let lastTitleIndex = -1;
79
+ if (options.customTitles) {
80
+ for (let index = 0; index < lines.length; index++) {
81
+ try {
82
+ const parsed = JSON.parse(lines[index] ?? '');
83
+ if (parsed.type === 'custom-title')
84
+ lastTitleIndex = index;
85
+ }
86
+ catch { }
87
+ }
88
+ }
89
+ for (let index = 0; index < lines.length; index++) {
90
+ const line = lines[index] ?? '';
79
91
  if (!line.trim())
80
92
  continue;
81
93
  try {
82
94
  const parsed = JSON.parse(line);
95
+ if (parsed.type === 'custom-title' && index !== lastTitleIndex)
96
+ continue;
83
97
  if (hasContent(parsed)) {
84
98
  const content = filterContent(parsed.message.content, options);
85
99
  if (shouldSkipMessage(parsed.type, content, options))
@@ -1,87 +1,9 @@
1
- import { readFile, writeFile } from 'node:fs/promises';
2
- import { dirname, join } from 'node:path';
3
- import { readCache, writeCache } from '../helpers/rename-cache.js';
4
- const updateIndex = async (session, title) => {
5
- const path = join(dirname(session.file), 'sessions-index.json');
6
- try {
7
- const raw = await readFile(path, 'utf-8');
8
- const index = JSON.parse(raw);
9
- const entry = index.entries.find((item) => item.sessionId === session.id);
10
- if (!entry)
11
- return;
12
- entry.firstPrompt = title;
13
- await writeFile(path, JSON.stringify(index, null, 2));
14
- }
15
- catch { }
16
- };
17
- const stripExistingTitle = (content) => {
18
- const newline = content.indexOf('\n');
19
- if (newline === -1)
20
- return content;
21
- try {
22
- const parsed = JSON.parse(content.slice(0, newline));
23
- if (parsed.type === 'session_title')
24
- return content.slice(newline + 1);
25
- }
26
- catch { }
27
- return content;
28
- };
29
- const isUserLine = (value) => typeof value === 'object' &&
30
- value !== null &&
31
- value.type === 'user' &&
32
- Array.isArray(value.message?.content);
33
- const isTextBlock = (block) => block.type === 'text' &&
34
- typeof block.text === 'string' &&
35
- !block.text.startsWith('<');
36
- const updateUserMessage = async (filePath, title) => {
37
- const cache = await readCache();
38
- const previous = cache[filePath];
39
- const raw = await readFile(filePath, 'utf-8');
40
- const lines = raw.split('\n');
41
- for (let index = 0; index < lines.length; index++) {
42
- const current = lines[index];
43
- if (!current?.trim())
44
- continue;
45
- let parsed;
46
- try {
47
- parsed = JSON.parse(current);
48
- }
49
- catch {
50
- continue;
51
- }
52
- if (!isUserLine(parsed))
53
- continue;
54
- const content = parsed.message.content;
55
- for (const block of content) {
56
- delete block._renamed;
57
- }
58
- let replaced = false;
59
- if (previous) {
60
- const existing = content.findIndex((block) => block.type === 'text' && block.text === previous);
61
- const match = content[existing];
62
- if (existing !== -1 && match) {
63
- match.text = title;
64
- replaced = true;
65
- }
66
- }
67
- if (!replaced) {
68
- const target = content.findIndex(isTextBlock);
69
- if (target === -1)
70
- break;
71
- content.splice(target, 0, { type: 'text', text: title });
72
- }
73
- lines[index] = JSON.stringify(parsed);
74
- await writeFile(filePath, lines.join('\n'));
75
- cache[filePath] = title;
76
- await writeCache(cache);
77
- return;
78
- }
79
- };
1
+ import { appendFile } from 'node:fs/promises';
80
2
  export const rename = async (session, title) => {
81
- const raw = await readFile(session.file, 'utf-8');
82
- const content = stripExistingTitle(raw);
83
- const line = JSON.stringify({ type: 'session_title', title });
84
- await writeFile(session.file, `${line}\n${content}`);
85
- await updateIndex(session, title);
86
- await updateUserMessage(session.file, title);
3
+ const entry = JSON.stringify({
4
+ type: 'custom-title',
5
+ sessionId: session.id,
6
+ customTitle: title,
7
+ });
8
+ await appendFile(session.file, `\n${entry}`);
87
9
  };
package/lib/bin/index.js CHANGED
@@ -171,6 +171,7 @@ menu: while (true) {
171
171
  shortMessages: selected.has('shortMessages'),
172
172
  emptyMessages: selected.has('emptyMessages'),
173
173
  systemTags: selected.has('systemTags'),
174
+ customTitles: selected.has('customTitles'),
174
175
  });
175
176
  previewLines = await preview(session);
176
177
  subagentCount = await countSubagents(session);
@@ -52,11 +52,16 @@ export const analyze = async (session) => {
52
52
  let shortMessages = 0;
53
53
  let emptyMessages = 0;
54
54
  let systemTags = 0;
55
+ let titleLines = 0;
55
56
  for (const line of lines) {
56
57
  if (!line.trim())
57
58
  continue;
58
59
  try {
59
60
  const parsed = JSON.parse(line);
61
+ if (parsed.type === 'custom-title') {
62
+ titleLines++;
63
+ continue;
64
+ }
60
65
  if (!hasContent(parsed))
61
66
  continue;
62
67
  const content = parsed.message.content;
@@ -73,5 +78,6 @@ export const analyze = async (session) => {
73
78
  }
74
79
  catch { }
75
80
  }
76
- return { toolBlocks, shortMessages, emptyMessages, systemTags };
81
+ const customTitles = Math.max(0, titleLines - 1);
82
+ return { toolBlocks, shortMessages, emptyMessages, systemTags, customTitles };
77
83
  };
@@ -2,9 +2,12 @@ import { createReadStream } from 'node:fs';
2
2
  import { createInterface } from 'node:readline';
3
3
  import { parseLine } from './parse-line.js';
4
4
  const MIN_TEXT_LENGTH = 5;
5
+ const normalizeText = (value) => value.replace(/\s+/g, ' ').trim();
5
6
  export const extractFirstUserMessage = (filePath) => new Promise((resolve) => {
6
7
  const stream = createReadStream(filePath, 'utf-8');
7
8
  const reader = createInterface({ input: stream, crlfDelay: Infinity });
9
+ let customTitle = '';
10
+ let fallback = '';
8
11
  const close = (value) => {
9
12
  resolve(value);
10
13
  reader.close();
@@ -16,27 +19,33 @@ export const extractFirstUserMessage = (filePath) => new Promise((resolve) => {
16
19
  const entry = parseLine(line);
17
20
  if (!entry)
18
21
  return;
22
+ if (entry.type === 'custom-title' && 'customTitle' in entry) {
23
+ customTitle = normalizeText(String(entry.customTitle));
24
+ return;
25
+ }
19
26
  if (entry.type === 'session_title' && 'title' in entry) {
20
- close(String(entry.title)
21
- .replace(/\s+/g, ' ')
22
- .trim());
27
+ if (!fallback) {
28
+ fallback = normalizeText(String(entry.title));
29
+ }
23
30
  return;
24
31
  }
32
+ if (fallback)
33
+ return;
25
34
  if (entry.type !== 'user')
26
35
  return;
27
36
  const texts = entry.message?.content ?? [];
28
37
  for (const block of texts) {
29
38
  if (block.type !== 'text' || !block.text)
30
39
  continue;
31
- const text = block.text.replace(/\s+/g, ' ').trim();
40
+ const text = normalizeText(block.text);
32
41
  if (text.startsWith('<'))
33
42
  continue;
34
43
  if (text.length <= MIN_TEXT_LENGTH)
35
44
  continue;
36
- close(text);
45
+ fallback = text;
37
46
  return;
38
47
  }
39
48
  });
40
- reader.on('close', () => resolve(''));
49
+ reader.on('close', () => resolve(customTitle || fallback));
41
50
  stream.on('error', () => close(''));
42
51
  });
package/lib/types.d.ts CHANGED
@@ -51,19 +51,11 @@ export type SessionIndex = {
51
51
  entries: IndexEntry[];
52
52
  [key: string]: unknown;
53
53
  };
54
- export type RenameCache = Record<string, string>;
55
54
  export type ContentBlock = {
56
55
  type: string;
57
56
  text?: string;
58
57
  [key: string]: unknown;
59
58
  };
60
- export type UserLine = {
61
- type: 'user';
62
- message: {
63
- content: ContentBlock[];
64
- };
65
- [key: string]: unknown;
66
- };
67
59
  export type ModelName = 'opus' | 'sonnet' | 'haiku' | 'unknown';
68
60
  export type TokenUsage = {
69
61
  input: number;
@@ -91,10 +83,12 @@ export type PruneStats = {
91
83
  shortMessages: number;
92
84
  emptyMessages: number;
93
85
  systemTags: number;
86
+ customTitles: number;
94
87
  };
95
88
  export type PruneOptions = {
96
89
  toolBlocks: boolean;
97
90
  shortMessages: boolean;
98
91
  emptyMessages: boolean;
99
92
  systemTags: boolean;
93
+ customTitles: boolean;
100
94
  };
package/lib/ui/select.js CHANGED
@@ -299,21 +299,31 @@ export const selectPruneOptions = async (stats, fields) => {
299
299
  entries.push({
300
300
  key: 'toolBlocks',
301
301
  label: `${stats.toolBlocks} tool blocks`,
302
+ defaultSelected: true,
302
303
  });
303
304
  if (stats.emptyMessages > 0)
304
305
  entries.push({
305
306
  key: 'emptyMessages',
306
307
  label: `${stats.emptyMessages} empty messages`,
307
- });
308
- if (stats.shortMessages > 0)
309
- entries.push({
310
- key: 'shortMessages',
311
- label: `${stats.shortMessages} short messages (<50 chars)`,
308
+ defaultSelected: true,
312
309
  });
313
310
  if (stats.systemTags > 0)
314
311
  entries.push({
315
312
  key: 'systemTags',
316
313
  label: `${stats.systemTags} system/IDE tags`,
314
+ defaultSelected: true,
315
+ });
316
+ if (stats.customTitles > 0)
317
+ entries.push({
318
+ key: 'customTitles',
319
+ label: `${stats.customTitles} old custom titles`,
320
+ defaultSelected: true,
321
+ });
322
+ if (stats.shortMessages > 0)
323
+ entries.push({
324
+ key: 'shortMessages',
325
+ label: `${stats.shortMessages} short messages (<50 chars)`,
326
+ defaultSelected: false,
317
327
  });
318
328
  if (entries.length === 0) {
319
329
  showHeader(fields);
@@ -332,8 +342,10 @@ export const selectPruneOptions = async (stats, fields) => {
332
342
  }
333
343
  const selected = new Set();
334
344
  let cursor;
335
- for (let index = 0; index < entries.length; index++)
336
- selected.add(index);
345
+ for (let index = 0; index < entries.length; index++) {
346
+ if (entries[index]?.defaultSelected)
347
+ selected.add(index);
348
+ }
337
349
  toggle: while (true) {
338
350
  showHeader(fields);
339
351
  const options = entries.map((entry, index) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sessioner",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "✨ An interactive CLI to navigate and manage Claude Code sessions from the terminal.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,7 +27,9 @@
27
27
  "start": "tsx src/bin/index.ts",
28
28
  "lint": "prettier --check .",
29
29
  "lint:fix": "prettier --write .",
30
- "typecheck": "tsc --noEmit"
30
+ "typecheck": "tsc --noEmit",
31
+ "patch": "npm version patch --no-git-tag-version",
32
+ "prepublish": "npm run build"
31
33
  },
32
34
  "dependencies": {
33
35
  "@clack/core": "^1.0.1",
@@ -1,3 +0,0 @@
1
- import type { RenameCache } from '../types.js';
2
- export declare const readCache: () => Promise<RenameCache>;
3
- export declare const writeCache: (cache: RenameCache) => Promise<void>;
@@ -1,18 +0,0 @@
1
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
- import { dirname, join } from 'node:path';
3
- import { fileURLToPath } from 'node:url';
4
- const cacheDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '.cache');
5
- const cacheFile = join(cacheDir, 'renames.json');
6
- export const readCache = async () => {
7
- try {
8
- const raw = await readFile(cacheFile, 'utf-8');
9
- return JSON.parse(raw);
10
- }
11
- catch {
12
- return {};
13
- }
14
- };
15
- export const writeCache = async (cache) => {
16
- await mkdir(cacheDir, { recursive: true });
17
- await writeFile(cacheFile, JSON.stringify(cache, null, 2));
18
- };