theslopmachine 1.0.7 → 1.0.8

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.
@@ -113,14 +113,12 @@ Packages the Claude project directory associated with a task root.
113
113
 
114
114
  Required:
115
115
  - `--task-root <task-root>`
116
- - `--output <zip-path>`
117
116
 
118
117
  Important options:
119
- - `--session-ids <comma-separated-session-ids>` narrows the expected tracked sessions
120
- - `--metadata-file <path>` overrides the default `<task-root>/metadata.json`
118
+ - `--output <zip-path>` writes the zip to a custom path; default is `<task-root>/claude-sessions.zip`
121
119
  - `--label <text>` adds reporting context
122
120
 
123
- The helper normalizes top-level JSONL transcripts, preserves the raw project directory structure, and writes a single zip.
121
+ The helper copies the Claude project/session directory as-is, removes `.DS_Store` files and top-level `.jsonl` transcripts under 25KB from the staged copy, zips the folder contents directly, and writes a single zip. It does not normalize or rewrite transcript files.
124
122
 
125
123
  ### `analyze_claude_project_dir.mjs`
126
124
 
@@ -184,10 +182,6 @@ Common usage:
184
182
  - `python3 convert_ai_session.py -i session.jsonl -o converted.json`
185
183
  - `python3 convert_ai_session.py -i session.jsonl --format claude`
186
184
 
187
- ### `normalize_claude_session.py`
188
-
189
- Normalizes Claude JSONL transcript structure for packaging and review.
190
-
191
185
  ### `strip_session_parent.py`
192
186
 
193
187
  Removes parent/session ancestry fields from exported session artifacts when needed for sanitized review.
@@ -8,25 +8,21 @@ import { spawn } from 'node:child_process'
8
8
  import { parseArgs, emitFailure, emitSuccess, printUsageAndExit } from './claude_worker_common.mjs'
9
9
 
10
10
  const argv = parseArgs(process.argv.slice(2))
11
+ const TINY_ROOT_TRANSCRIPT_MAX_BYTES = 25 * 1024
11
12
 
12
13
  if (argv.help === '1') {
13
14
  printUsageAndExit(`Usage:
14
- node ~/slopmachine/utils/package_claude_session.mjs --task-root <task-root> --output <zip-path> [options]
15
+ node ~/slopmachine/utils/package_claude_session.mjs --task-root <task-root> [options]
15
16
 
16
17
  Required:
17
18
  --task-root <task-root>
18
- --output <zip-path>
19
19
 
20
20
  Options:
21
+ --output <zip-path> Zip output path (default: <task-root>/claude-sessions.zip)
21
22
  --label <text> Optional package label for reporting
22
- --metadata-file <path> Metadata JSON containing the original prompt (default: ./metadata.json from task root)
23
23
  `)
24
24
  }
25
25
 
26
- const SMALL_PRE_ANCHOR_TRANSCRIPT_MAX_BYTES = 50 * 1024
27
- const TINY_TRANSCRIPT_PRUNE_MAX_BYTES = 25 * 1024
28
- const EARLY_USER_MESSAGE_LIMIT = 8
29
-
30
26
  async function pathExists(targetPath) {
31
27
  try {
32
28
  await fs.access(targetPath)
@@ -82,215 +78,48 @@ async function createZipArchive(sourceDir, outputPath) {
82
78
  throw lastError || new Error('No usable PowerShell shell was found for zip creation')
83
79
  }
84
80
 
85
- try {
86
- const result = await run('zip', ['-9', '-q', '-r', outputPath, '.'], sourceDir)
87
- if (result.code !== 0) {
88
- throw new Error((result.stderr || result.stdout).trim() || `zip failed with exit ${result.code}`)
89
- }
90
- } catch (error) {
91
- throw new Error(error instanceof Error ? error.message : 'zip command is required to package Claude sessions')
92
- }
93
- }
94
-
95
- async function normalizeClaudeJsonlFile(inputPath) {
96
- const normalizerScript = path.join(path.dirname(new URL(import.meta.url).pathname), 'normalize_claude_session.py')
97
- const outputPath = `${inputPath}.normalized`
98
- await fs.rm(outputPath, { force: true }).catch(() => {})
99
- const result = await run('python3', [normalizerScript, inputPath, '--output', outputPath], path.dirname(inputPath))
81
+ const result = await run('zip', ['-9', '-q', '-r', outputPath, '.'], sourceDir)
100
82
  if (result.code !== 0) {
101
- throw new Error(`Failed to normalize Claude session file ${path.basename(inputPath)}: ${(result.stderr || result.stdout).trim()}`)
102
- }
103
- await fs.rm(inputPath, { force: true })
104
- await fs.rename(outputPath, inputPath)
105
- }
106
-
107
- function normalizeForPromptMatch(value) {
108
- return String(value || '')
109
- .replace(/\r\n/g, '\n')
110
- .replace(/[ \t]+/g, ' ')
111
- .replace(/\n{3,}/g, '\n\n')
112
- .trim()
113
- }
114
-
115
- function buildPromptNeedles(prompt) {
116
- const normalized = normalizeForPromptMatch(prompt)
117
- if (!normalized) return []
118
- const needles = [normalized]
119
- const paragraphs = normalized
120
- .split(/\n\s*\n/)
121
- .map((part) => part.trim())
122
- .filter((part) => part.length >= 120)
123
-
124
- for (const paragraph of paragraphs.slice(0, 5)) {
125
- needles.push(paragraph)
83
+ throw new Error((result.stderr || result.stdout).trim() || `zip failed with exit ${result.code}`)
126
84
  }
127
-
128
- if (normalized.length > 2000) {
129
- needles.push(normalized.slice(0, 2000).trim())
130
- }
131
-
132
- return [...new Set(needles.filter((needle) => needle.length >= 80))]
133
- }
134
-
135
- function extractTextFromClaudeContent(content) {
136
- if (typeof content === 'string') return content
137
- if (Array.isArray(content)) {
138
- return content.map((item) => {
139
- if (typeof item === 'string') return item
140
- if (item && typeof item === 'object') {
141
- if (typeof item.text === 'string') return item.text
142
- if (typeof item.content === 'string') return item.content
143
- }
144
- return ''
145
- }).join('\n')
146
- }
147
- if (content && typeof content === 'object') {
148
- if (typeof content.text === 'string') return content.text
149
- if (typeof content.content === 'string') return content.content
150
- }
151
- return ''
152
- }
153
-
154
- function extractClaudeUserText(record) {
155
- if (!record || typeof record !== 'object') return ''
156
- const role = record.message?.role || record.role || record.type
157
- if (role !== 'user' && record.type !== 'user') return ''
158
- return extractTextFromClaudeContent(record.message?.content ?? record.content)
159
85
  }
160
86
 
161
- function extractClaudeTimestamp(record) {
162
- const candidates = [
163
- record?.timestamp,
164
- record?.created_at,
165
- record?.message?.timestamp,
166
- record?.message?.created_at,
167
- ]
168
- for (const candidate of candidates) {
169
- if (typeof candidate !== 'string' && typeof candidate !== 'number') continue
170
- const value = typeof candidate === 'number' ? candidate : Date.parse(candidate)
171
- if (Number.isFinite(value)) return value
172
- }
173
- return null
174
- }
175
-
176
- async function readOriginalPrompt({ cwd, metadataFile }) {
177
- const resolvedMetadataFile = metadataFile
178
- ? path.resolve(metadataFile)
179
- : path.resolve(cwd, 'metadata.json')
180
- if (!await pathExists(resolvedMetadataFile)) {
181
- return { prompt: '', metadataFile: resolvedMetadataFile, missing: true }
182
- }
183
- const metadata = JSON.parse(await fs.readFile(resolvedMetadataFile, 'utf8'))
184
- const prompt = typeof metadata?.prompt === 'string' ? metadata.prompt : ''
185
- return { prompt, metadataFile: resolvedMetadataFile, missing: false }
186
- }
87
+ async function removeDsStoreFiles(rootDir) {
88
+ const entries = await fs.readdir(rootDir, { withFileTypes: true })
187
89
 
188
- async function inspectTranscriptForPromptAnchor(transcriptPath, promptNeedles) {
189
- const stat = await fs.stat(transcriptPath)
190
- const text = await fs.readFile(transcriptPath, 'utf8')
191
- const lines = text.split('\n')
192
- let firstTimestamp = null
193
- let userMessagesSeen = 0
194
- let promptMatched = false
195
-
196
- for (const line of lines) {
197
- if (!line.trim()) continue
198
- let record = null
199
- try {
200
- record = JSON.parse(line)
201
- } catch {
90
+ for (const entry of entries) {
91
+ const absolutePath = path.join(rootDir, entry.name)
92
+ if (entry.isDirectory()) {
93
+ await removeDsStoreFiles(absolutePath)
202
94
  continue
203
95
  }
204
- if (firstTimestamp == null) {
205
- firstTimestamp = extractClaudeTimestamp(record)
206
- }
207
- const userText = extractClaudeUserText(record)
208
- if (!userText) continue
209
- userMessagesSeen += 1
210
- const normalizedUserText = normalizeForPromptMatch(userText)
211
- if (promptNeedles.some((needle) => normalizedUserText.includes(needle))) {
212
- promptMatched = true
213
- break
214
- }
215
- if (userMessagesSeen >= EARLY_USER_MESSAGE_LIMIT) break
216
- }
217
-
218
- return {
219
- path: transcriptPath,
220
- size: stat.size,
221
- mtimeMs: stat.mtimeMs,
222
- firstTimestamp: firstTimestamp ?? stat.mtimeMs,
223
- promptMatched,
224
- userMessagesSeen,
225
- }
226
- }
227
96
 
228
- async function filterPrePromptTinyTranscripts(transcriptTargets, { cwd, metadataFile }) {
229
- const { prompt, metadataFile: resolvedMetadataFile, missing } = await readOriginalPrompt({ cwd, metadataFile })
230
- const promptNeedles = buildPromptNeedles(prompt)
231
- if (missing || promptNeedles.length === 0) {
232
- return {
233
- anchor: null,
234
- removed: [],
235
- inspected: [],
236
- filter_applied: false,
237
- filter_reason: missing ? `metadata prompt file missing: ${resolvedMetadataFile}` : 'metadata prompt is empty or too short for safe matching',
238
- metadata_file: resolvedMetadataFile,
97
+ if (entry.isFile() && entry.name === '.DS_Store') {
98
+ await fs.rm(absolutePath, { force: true })
239
99
  }
240
100
  }
101
+ }
241
102
 
242
- const inspected = []
243
- for (const transcriptTarget of transcriptTargets) {
244
- inspected.push(await inspectTranscriptForPromptAnchor(transcriptTarget, promptNeedles))
245
- }
103
+ async function removeTinyRootTranscripts(rootDir) {
104
+ const entries = await fs.readdir(rootDir, { withFileTypes: true })
105
+ const removed = []
246
106
 
247
- inspected.sort((left, right) => left.firstTimestamp - right.firstTimestamp || left.path.localeCompare(right.path))
248
- const anchor = inspected.find((entry) => entry.promptMatched) || null
249
- if (!anchor) {
250
- return {
251
- anchor: null,
252
- removed: [],
253
- inspected,
254
- filter_applied: false,
255
- filter_reason: 'no top-level Claude transcript matched the original prompt in early user messages',
256
- metadata_file: resolvedMetadataFile,
107
+ for (const entry of entries) {
108
+ if (!entry.isFile() || !entry.name.endsWith('.jsonl')) {
109
+ continue
257
110
  }
258
- }
259
111
 
260
- const removed = []
261
- for (const entry of inspected) {
262
- if (entry.path === anchor.path) continue
263
- const tinyNoise = entry.size < TINY_TRANSCRIPT_PRUNE_MAX_BYTES
264
- const smallPreAnchor = entry.firstTimestamp < anchor.firstTimestamp && entry.size < SMALL_PRE_ANCHOR_TRANSCRIPT_MAX_BYTES
265
- if (!tinyNoise && !smallPreAnchor) continue
266
- await fs.rm(entry.path, { force: true })
267
- removed.push({
268
- ...entry,
269
- removalReason: tinyNoise
270
- ? `root-level transcript below ${TINY_TRANSCRIPT_PRUNE_MAX_BYTES} bytes`
271
- : `pre-anchor transcript below ${SMALL_PRE_ANCHOR_TRANSCRIPT_MAX_BYTES} bytes`,
272
- })
273
- }
112
+ const absolutePath = path.join(rootDir, entry.name)
113
+ const stat = await fs.stat(absolutePath)
114
+ if (stat.size >= TINY_ROOT_TRANSCRIPT_MAX_BYTES) {
115
+ continue
116
+ }
274
117
 
275
- return {
276
- anchor,
277
- removed,
278
- inspected,
279
- filter_applied: true,
280
- filter_reason: 'removed root-level transcripts under 25KB except the prompt anchor, and pre-anchor transcripts under 50KB',
281
- metadata_file: resolvedMetadataFile,
118
+ await fs.rm(absolutePath, { force: true })
119
+ removed.push({ file: entry.name, size: stat.size })
282
120
  }
283
- }
284
121
 
285
- async function preserveTranscriptTimes(transcriptTargets, inspected) {
286
- const inspectedByPath = new Map(inspected.map((entry) => [entry.path, entry]))
287
- for (const transcriptTarget of transcriptTargets) {
288
- if (!await pathExists(transcriptTarget)) continue
289
- const entry = inspectedByPath.get(transcriptTarget)
290
- if (!entry?.firstTimestamp || !Number.isFinite(entry.firstTimestamp)) continue
291
- const timestamp = new Date(entry.firstTimestamp)
292
- await fs.utimes(transcriptTarget, timestamp, timestamp).catch(() => {})
293
- }
122
+ return removed
294
123
  }
295
124
 
296
125
  async function listFilesRecursive(rootDir, relativePrefix = '') {
@@ -314,22 +143,6 @@ async function listFilesRecursive(rootDir, relativePrefix = '') {
314
143
  return files
315
144
  }
316
145
 
317
- async function removeDsStoreFiles(rootDir) {
318
- const entries = await fs.readdir(rootDir, { withFileTypes: true })
319
-
320
- for (const entry of entries) {
321
- const absolutePath = path.join(rootDir, entry.name)
322
- if (entry.isDirectory()) {
323
- await removeDsStoreFiles(absolutePath)
324
- continue
325
- }
326
-
327
- if (entry.isFile() && entry.name === '.DS_Store') {
328
- await fs.rm(absolutePath, { force: true })
329
- }
330
- }
331
- }
332
-
333
146
  async function resolveClaudeProjectDir(taskRoot) {
334
147
  const projectsRoot = path.join(os.homedir(), '.claude', 'projects')
335
148
  const resolvedTaskRoot = await fs.realpath(taskRoot).catch(() => path.resolve(taskRoot))
@@ -348,72 +161,31 @@ async function resolveClaudeProjectDir(taskRoot) {
348
161
  throw new Error(`Claude project directory not found for task root: ${resolvedTaskRoot}`)
349
162
  }
350
163
 
351
- async function stageClaudeProjectDir(sourceProjectDir, tempRoot) {
352
- const packageRoot = path.join(tempRoot, path.basename(sourceProjectDir))
353
- await fs.mkdir(tempRoot, { recursive: true })
354
- await fs.cp(sourceProjectDir, packageRoot, { recursive: true })
355
-
356
- const allFiles = await listFilesRecursive(packageRoot)
357
- const transcriptTargets = allFiles
358
- .filter((relativePath) => relativePath.endsWith('.jsonl') && !relativePath.includes(path.sep))
359
- .map((relativePath) => path.join(packageRoot, relativePath))
360
-
361
- return {
362
- packageRoot,
363
- transcriptTargets,
364
- }
365
- }
366
-
367
164
  try {
368
165
  const taskRoot = argv['task-root'] ? path.resolve(argv['task-root']) : null
369
166
  if (!taskRoot) {
370
167
  throw new Error('Missing --task-root')
371
168
  }
169
+
372
170
  const sourceProjectDir = await resolveClaudeProjectDir(taskRoot)
171
+ const outputPath = argv.output ? path.resolve(argv.output) : path.join(taskRoot, 'claude-sessions.zip')
373
172
  const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'slopmachine-claude-project-'))
374
- const { packageRoot, transcriptTargets } = await stageClaudeProjectDir(sourceProjectDir, tempRoot)
375
- await removeDsStoreFiles(packageRoot)
376
- const filterResult = await filterPrePromptTinyTranscripts(transcriptTargets, {
377
- cwd: taskRoot,
378
- metadataFile: argv['metadata-file'] || null,
379
- })
380
- for (const transcriptTargetPath of transcriptTargets) {
381
- if (!await pathExists(transcriptTargetPath)) continue
382
- await normalizeClaudeJsonlFile(transcriptTargetPath)
383
- }
384
- await preserveTranscriptTimes(transcriptTargets, filterResult.inspected)
385
- const included = (await listFilesRecursive(packageRoot)).sort((left, right) => left.localeCompare(right))
173
+ const packageRoot = path.join(tempRoot, path.basename(sourceProjectDir))
386
174
 
387
175
  try {
388
- await createZipArchive(packageRoot, argv.output)
176
+ await fs.cp(sourceProjectDir, packageRoot, { recursive: true })
177
+ await removeDsStoreFiles(packageRoot)
178
+ const tinyRootTranscriptsRemoved = await removeTinyRootTranscripts(packageRoot)
179
+ const included = (await listFilesRecursive(packageRoot)).sort((left, right) => left.localeCompare(right))
180
+ await createZipArchive(packageRoot, outputPath)
181
+
389
182
  emitSuccess(path.basename(sourceProjectDir), {
390
- output: argv.output,
183
+ output: outputPath,
391
184
  project_dir: sourceProjectDir,
392
185
  label: argv.label || null,
393
186
  included,
394
- packaging_mode: 'prompt_anchored_project_dir',
395
- normalized_transcripts_only: true,
396
- prompt_anchor: filterResult.anchor
397
- ? path.basename(filterResult.anchor.path)
398
- : null,
399
- pre_anchor_removed: filterResult.removed.map((entry) => ({
400
- file: path.basename(entry.path),
401
- size: entry.size,
402
- first_timestamp: new Date(entry.firstTimestamp).toISOString(),
403
- reason: entry.removalReason || null,
404
- })),
405
- prompt_anchor_filter_applied: filterResult.filter_applied,
406
- prompt_anchor_filter_reason: filterResult.filter_reason,
407
- prompt_anchor_metadata_file: filterResult.metadata_file,
408
- transcript_chronology: filterResult.inspected.map((entry) => ({
409
- file: path.basename(entry.path),
410
- size: entry.size,
411
- first_timestamp: new Date(entry.firstTimestamp).toISOString(),
412
- prompt_matched: entry.promptMatched,
413
- user_messages_scanned: entry.userMessagesSeen,
414
- included: !filterResult.removed.some((removed) => removed.path === entry.path),
415
- tiny_under_25kb: entry.size < TINY_TRANSCRIPT_PRUNE_MAX_BYTES,
416
- })),
187
+ tiny_root_transcripts_removed: tinyRootTranscriptsRemoved,
188
+ packaging_mode: 'raw_claude_project_dir',
417
189
  })
418
190
  } finally {
419
191
  await fs.rm(tempRoot, { recursive: true, force: true }).catch(() => {})
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "theslopmachine",
3
- "version": "1.0.7",
3
+ "version": "1.0.8",
4
4
  "description": "SlopMachine installer and project bootstrap CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/send-data.js CHANGED
@@ -430,14 +430,10 @@ async function exportClaudeProjectArtifacts(claudeSessions, taskRoot, stagingDir
430
430
  const utilsDir = path.join(buildPaths().slopmachineDir, 'utils')
431
431
  const packageClaudeSessionScript = path.join(utilsDir, 'package_claude_session.mjs')
432
432
  const outputPath = path.join(stagingDir, 'claude-sessions.zip')
433
- const trackedSessionIds = [...new Set(claudeSessions.map((session) => session.sessionId).filter(Boolean))]
434
-
435
433
  const packageResult = await runCommand(process.execPath, [
436
434
  packageClaudeSessionScript,
437
435
  '--task-root',
438
436
  taskRoot,
439
- '--session-ids',
440
- trackedSessionIds.join(','),
441
437
  '--label',
442
438
  'claude-sessions',
443
439
  '--output',