typeclaw 0.27.0 → 0.28.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.
Files changed (42) hide show
  1. package/package.json +1 -1
  2. package/scripts/generate-schema.ts +4 -6
  3. package/src/agent/index.ts +26 -4
  4. package/src/agent/multimodal/look-at.ts +1 -2
  5. package/src/agent/tools/channel-fetch-attachment.ts +1 -2
  6. package/src/agent/tools/channel-react.ts +9 -3
  7. package/src/agent/tools/channel-reply.ts +30 -1
  8. package/src/agent/tools/channel-send.ts +94 -1
  9. package/src/bundled-plugins/github-cli-auth/gh-review-detect.ts +175 -0
  10. package/src/bundled-plugins/github-cli-auth/index.ts +4 -0
  11. package/src/bundled-plugins/github-cli-auth/review-recorder.ts +93 -0
  12. package/src/bundled-plugins/guard/policies/managed-config.ts +1 -1
  13. package/src/bundled-plugins/memory/README.md +3 -21
  14. package/src/bundled-plugins/memory/index.ts +1 -149
  15. package/src/bundled-plugins/security/policies/cron-promotion.ts +2 -2
  16. package/src/channels/adapters/github/inbound.ts +103 -0
  17. package/src/channels/adapters/github/review-thread-resolver.ts +65 -5
  18. package/src/channels/github-false-receipt.ts +87 -0
  19. package/src/channels/github-review-claim.ts +91 -0
  20. package/src/channels/github-review-turn-ledger.ts +71 -0
  21. package/src/channels/persistence.ts +4 -102
  22. package/src/channels/router.ts +2 -0
  23. package/src/channels/schema.ts +20 -5
  24. package/src/cli/channel.ts +2 -1
  25. package/src/cli/init.ts +2 -1
  26. package/src/config/config.ts +19 -288
  27. package/src/container/start.ts +0 -2
  28. package/src/cron/index.ts +3 -44
  29. package/src/cron/schema.ts +2 -96
  30. package/src/init/gitignore.ts +1 -2
  31. package/src/secrets/defaults.ts +1 -18
  32. package/src/secrets/index.ts +0 -2
  33. package/src/secrets/schema.ts +4 -90
  34. package/src/secrets/storage.ts +0 -2
  35. package/src/server/index.ts +0 -4
  36. package/src/skills/typeclaw-config/SKILL.md +9 -11
  37. package/src/skills/typeclaw-permissions/SKILL.md +1 -1
  38. package/typeclaw.schema.json +1 -0
  39. package/src/agent/tools/normalize-ref.ts +0 -11
  40. package/src/bundled-plugins/memory/migration.ts +0 -633
  41. package/src/secrets/migrate-kakaotalk.ts +0 -82
  42. package/src/secrets/migrate.ts +0 -96
@@ -1,633 +0,0 @@
1
- import { existsSync } from 'node:fs'
2
- import { cp, mkdir, readdir, readFile, rename, rm, rmdir, unlink, writeFile } from 'node:fs/promises'
3
- import { join } from 'node:path'
4
-
5
- import { checkCitationSupersetAcrossShards, summarizeMissingCitations } from './citation-superset'
6
- import { normalizeCitation, parseCitations } from './citations'
7
- import { clearDreamedIds, loadDreamingState, saveDreamingState } from './dreaming-state'
8
- import { renderShard, type ShardFrontmatter } from './frontmatter'
9
- import {
10
- migratingTmpDir,
11
- PRE_SHARD_BACKUP_FILENAME,
12
- preShardBackupPath,
13
- streamFilePath,
14
- streamsDir,
15
- topicsDir,
16
- } from './paths'
17
- import { headingToSlug } from './slug'
18
- import { newEventId, type StreamEvent, streamEventSchema, timestampFromId } from './stream-events'
19
- import { writeEventsAtomic as defaultWriteEventsAtomic } from './stream-io'
20
- import { parseTopicsWithBodies } from './topics'
21
-
22
- export type MigrationResult = {
23
- migrated: string[]
24
- skipped: string[]
25
- legacyProseCount: number
26
- fragmentCount: number
27
- watermarkCount: number
28
- }
29
-
30
- export type MigrationLogger = {
31
- info: (message: string) => void
32
- warn: (message: string) => void
33
- error: (message: string) => void
34
- }
35
-
36
- export type MigrationGit = {
37
- spawn?: (args: string[], options: { cwd: string }) => Promise<{ exitCode: number; stdout: string; stderr: string }>
38
- }
39
-
40
- export type RunMigrationOptions = {
41
- agentDir: string
42
- logger: MigrationLogger
43
- git?: MigrationGit
44
- writeEventsAtomic?: (path: string, events: readonly StreamEvent[]) => Promise<void>
45
- }
46
-
47
- export type ShardingMigrationResult = {
48
- migrated: boolean
49
- topicCount: number
50
- streamCount: number
51
- legacy: MigrationResult
52
- error?: string
53
- }
54
-
55
- export type RunShardingMigrationOptions = RunMigrationOptions & {
56
- hooks?: {
57
- onAfterStageTopics?: () => Promise<void> | void
58
- onAfterStageStreams?: () => Promise<void> | void
59
- onAfterStageBackup?: () => Promise<void> | void
60
- }
61
- }
62
-
63
- const DAILY_MD_NAME = /^(\d{4}-\d{2}-\d{2})\.md$/
64
- const DAILY_JSONL_NAME = /^(\d{4}-\d{2}-\d{2})\.jsonl$/
65
- const LEGACY_FRAGMENT_RE =
66
- /<!-- fragment source=(\S+) entry=(\S+) -->\n## (.+)\n([\s\S]*?)(?=<!-- fragment |<!-- watermark |$)/g
67
- const LEGACY_WATERMARK_RE = /<!-- watermark source=(\S+) entry=(\S+) -->/g
68
-
69
- export async function runShardingMigration(options: RunShardingMigrationOptions): Promise<ShardingMigrationResult> {
70
- await recoverShardingMigration(options.agentDir, options.logger)
71
- const legacy = await runMigration(options)
72
- const empty = (extra?: Partial<ShardingMigrationResult>): ShardingMigrationResult => ({
73
- migrated: false,
74
- topicCount: 0,
75
- streamCount: 0,
76
- legacy,
77
- ...extra,
78
- })
79
-
80
- await recoverShardingOrphans(options.agentDir, options.logger, options.git)
81
-
82
- if (existsSync(topicsDir(options.agentDir)) || !existsSync(rootMemoryPath(options.agentDir))) {
83
- return empty()
84
- }
85
-
86
- const memoryDir = join(options.agentDir, 'memory')
87
- const tmpDir = migratingTmpDir(options.agentDir)
88
- await rm(tmpDir, { recursive: true, force: true })
89
- await mkdir(join(tmpDir, 'topics'), { recursive: true })
90
- await mkdir(join(tmpDir, 'streams'), { recursive: true })
91
-
92
- const rootContent = await readFile(rootMemoryPath(options.agentDir), 'utf8')
93
- const topics = parseTopicsWithBodies(rootContent)
94
- if (topics.length === 0) {
95
- await rm(tmpDir, { recursive: true, force: true })
96
- options.logger.warn('[memory:migration] MEMORY.md has no topics; skipping sharding migration')
97
- return empty()
98
- }
99
-
100
- const existingSlugs = new Set<string>()
101
- const orderedSlugs: string[] = []
102
- for (const topic of topics) {
103
- const slug = headingToSlug(topic.heading, existingSlugs)
104
- existingSlugs.add(slug)
105
- orderedSlugs.push(slug)
106
- const body = normalizeCitation(topic.body)
107
- const frontmatter = frontmatterForTopic(topic.heading, body)
108
- await writeFile(join(tmpDir, 'topics', `${slug}.md`), renderShard(frontmatter, body), 'utf8')
109
- }
110
- await options.hooks?.onAfterStageTopics?.()
111
-
112
- const streamDates = await collectFlatJsonlDates(memoryDir)
113
- for (const date of streamDates) {
114
- await cp(join(memoryDir, `${date}.jsonl`), join(tmpDir, 'streams', `${date}.jsonl`))
115
- }
116
- await options.hooks?.onAfterStageStreams?.()
117
-
118
- await cp(rootMemoryPath(options.agentDir), join(tmpDir, 'MEMORY.md.pre-shard.bak'))
119
- await options.hooks?.onAfterStageBackup?.()
120
-
121
- const newShardTexts = await readShardTexts(join(tmpDir, 'topics'))
122
- const verdict = checkCitationSupersetAcrossShards(new Map([['MEMORY.md', rootContent]]), newShardTexts)
123
- if (!verdict.ok) {
124
- const error = `citation superset violation: ${summarizeMissingCitations(verdict.missing)}`
125
- options.logger.error(`[memory:migration] ${error}`)
126
- return empty({ error })
127
- }
128
-
129
- const finalized = await finalizeShardingMigration(options.agentDir, streamDates, options.logger)
130
- if (!finalized.ok) return empty({ error: finalized.error })
131
-
132
- await commitShardingMigration(
133
- options.agentDir,
134
- { slugs: orderedSlugs, streamDates, hadRootMemory: true },
135
- options.logger,
136
- options.git,
137
- )
138
-
139
- options.logger.info(
140
- `[memory:migration] sharded MEMORY.md into ${topics.length} topic shard(s) and ${streamDates.length} stream file(s)`,
141
- )
142
- return { migrated: true, topicCount: topics.length, streamCount: streamDates.length, legacy }
143
- }
144
-
145
- export async function runMigration(options: RunMigrationOptions): Promise<MigrationResult> {
146
- const memoryDir = join(options.agentDir, 'memory')
147
- const result: MigrationResult = {
148
- migrated: [],
149
- skipped: [],
150
- legacyProseCount: 0,
151
- fragmentCount: 0,
152
- watermarkCount: 0,
153
- }
154
-
155
- let entries: string[]
156
- try {
157
- entries = await readdir(memoryDir)
158
- } catch {
159
- return result
160
- }
161
-
162
- const dates = collectDailyDates(entries)
163
- for (const date of dates) {
164
- const mdPath = join(memoryDir, `${date}.md`)
165
- const jsonlPath = join(memoryDir, `${date}.jsonl`)
166
- const hasMd = existsSync(mdPath)
167
- const hasJsonl = existsSync(jsonlPath)
168
-
169
- if (hasJsonl && !hasMd) {
170
- result.skipped.push(date)
171
- continue
172
- }
173
-
174
- if (hasJsonl && hasMd) {
175
- options.logger.warn(`[memory:migration] ${date}: skipped because both .md and .jsonl exist`)
176
- result.skipped.push(date)
177
- continue
178
- }
179
-
180
- if (!hasMd) continue
181
-
182
- const content = await readFile(mdPath, 'utf8')
183
- const events = parseLegacyMarkdown(content)
184
- const invalid = findInvalidEvent(events)
185
- if (invalid !== null) {
186
- options.logger.error(
187
- `[memory:migration] ${date}.md: event ${invalid.index + 1} failed validation: ${invalid.reason}`,
188
- )
189
- result.skipped.push(date)
190
- continue
191
- }
192
-
193
- const counts = countEvents(events)
194
- try {
195
- await (options.writeEventsAtomic ?? defaultWriteEventsAtomic)(jsonlPath, events)
196
- } catch (err) {
197
- options.logger.error(`[memory:migration] ${date}.md: failed to write JSONL: ${describeError(err)}`)
198
- result.skipped.push(date)
199
- continue
200
- }
201
- await unlink(mdPath)
202
-
203
- result.fragmentCount += counts.fragmentCount
204
- result.watermarkCount += counts.watermarkCount
205
- result.legacyProseCount += counts.legacyProseCount
206
- result.migrated.push(date)
207
- options.logger.info(
208
- `[memory:migration] ${date}: ${counts.fragmentCount} fragments, ${counts.watermarkCount} watermarks, ${counts.legacyProseCount} legacy_prose regions`,
209
- )
210
- }
211
-
212
- if (result.migrated.length > 0) {
213
- await resetDreamingWatermarks(options.agentDir, result.migrated)
214
- await commitMigration(options.agentDir, result.migrated, options.logger, options.git)
215
- }
216
-
217
- return result
218
- }
219
-
220
- function collectDailyDates(entries: readonly string[]): string[] {
221
- const dates = new Set<string>()
222
- for (const entry of entries) {
223
- const md = DAILY_MD_NAME.exec(entry)
224
- if (md?.[1] !== undefined) dates.add(md[1])
225
- const jsonl = DAILY_JSONL_NAME.exec(entry)
226
- if (jsonl?.[1] !== undefined) dates.add(jsonl[1])
227
- }
228
- return Array.from(dates).sort()
229
- }
230
-
231
- async function recoverShardingMigration(agentDir: string, logger: MigrationLogger): Promise<void> {
232
- const tmpDir = migratingTmpDir(agentDir)
233
- if (!existsSync(tmpDir)) return
234
-
235
- const hasTopics = existsSync(topicsDir(agentDir))
236
- await rm(tmpDir, { recursive: true, force: true })
237
- logger.info(
238
- hasTopics
239
- ? '[memory:migration] removed leftover sharding tmpdir after completed migration'
240
- : '[memory:migration] removed stale sharding tmpdir; retrying migration from originals',
241
- )
242
- }
243
-
244
- async function recoverShardingOrphans(
245
- agentDir: string,
246
- logger: MigrationLogger,
247
- git: MigrationGit | undefined,
248
- ): Promise<void> {
249
- if (existsSync(topicsDir(agentDir))) {
250
- let cleaned = false
251
- const memoryPath = rootMemoryPath(agentDir)
252
- if (existsSync(memoryPath)) {
253
- await unlink(memoryPath)
254
- cleaned = true
255
- }
256
-
257
- const memoryDir = join(agentDir, 'memory')
258
- const dates = await collectFlatJsonlDates(memoryDir)
259
- for (const date of dates) {
260
- if (!existsSync(streamFilePath(agentDir, date))) continue
261
- await unlink(join(memoryDir, `${date}.jsonl`))
262
- cleaned = true
263
- }
264
-
265
- if (cleaned) logger.info('[memory:migration] cleaned orphaned pre-shard memory files')
266
- }
267
-
268
- // Always called, even when nothing was cleaned this boot AND even when the
269
- // sharded layout never landed on this agent: pre-#315 migrations and
270
- // earlier runs of this function unlinked without committing, leaving
271
- // staged deletions that survive across reboots until cleared explicitly.
272
- // The earlier guard (`return` when topicsDir is absent) stranded any agent
273
- // whose pre-shard files were deleted but whose sharding never completed —
274
- // their staged deletions sat in the index forever.
275
- await commitPendingLegacyDeletions(agentDir, logger, git)
276
- }
277
-
278
- async function collectFlatJsonlDates(memoryDir: string): Promise<string[]> {
279
- let entries: string[]
280
- try {
281
- entries = await readdir(memoryDir)
282
- } catch {
283
- return []
284
- }
285
- return entries
286
- .map((entry) => DAILY_JSONL_NAME.exec(entry)?.[1])
287
- .filter((date): date is string => date !== undefined)
288
- .sort()
289
- }
290
-
291
- function frontmatterForTopic(heading: string, body: string): ShardFrontmatter {
292
- const citations = parseCitations(body)
293
- const dates = [...citations.keys()].sort()
294
- let cites = 0
295
- for (const ids of citations.values()) cites += ids.size
296
-
297
- return {
298
- heading,
299
- cites,
300
- days: dates.length,
301
- lastReinforced: dates.at(-1) ?? todayDate(),
302
- }
303
- }
304
-
305
- async function readShardTexts(dir: string): Promise<Map<string, string>> {
306
- const entries = (await readdir(dir)).filter((entry) => entry.endsWith('.md')).sort()
307
- const out = new Map<string, string>()
308
- for (const entry of entries) {
309
- out.set(entry, await readFile(join(dir, entry), 'utf8'))
310
- }
311
- return out
312
- }
313
-
314
- async function finalizeShardingMigration(
315
- agentDir: string,
316
- streamDates: readonly string[],
317
- logger: MigrationLogger,
318
- ): Promise<{ ok: true } | { ok: false; error: string }> {
319
- const tmpDir = migratingTmpDir(agentDir)
320
- const renames: Array<[string, string]> = [
321
- [join(tmpDir, 'topics'), topicsDir(agentDir)],
322
- [join(tmpDir, 'streams'), streamsDir(agentDir)],
323
- [join(tmpDir, 'MEMORY.md.pre-shard.bak'), preShardBackupPath(agentDir)],
324
- ]
325
-
326
- for (const [from, to] of renames) {
327
- try {
328
- await rename(from, to)
329
- } catch (err) {
330
- const error = `failed to finalize sharding migration: ${describeError(err)}`
331
- logger.error(`[memory:migration] ${error}`)
332
- return { ok: false, error }
333
- }
334
- }
335
-
336
- for (const date of streamDates) {
337
- try {
338
- await unlink(join(agentDir, 'memory', `${date}.jsonl`))
339
- } catch (err) {
340
- logger.warn(`[memory:migration] failed to remove flat stream ${date}.jsonl: ${describeError(err)}`)
341
- }
342
- }
343
-
344
- try {
345
- await unlink(rootMemoryPath(agentDir))
346
- } catch (err) {
347
- logger.warn(`[memory:migration] failed to remove root MEMORY.md: ${describeError(err)}`)
348
- }
349
-
350
- try {
351
- await rmdir(tmpDir)
352
- } catch (err) {
353
- logger.warn(`[memory:migration] failed to remove sharding tmpdir: ${describeError(err)}`)
354
- }
355
-
356
- return { ok: true }
357
- }
358
-
359
- function rootMemoryPath(agentDir: string): string {
360
- return join(agentDir, 'MEMORY.md')
361
- }
362
-
363
- function todayDate(): string {
364
- return new Date().toISOString().slice(0, 10)
365
- }
366
-
367
- function parseLegacyMarkdown(content: string): StreamEvent[] {
368
- const events: StreamEvent[] = []
369
- let cursor = 0
370
-
371
- while (cursor < content.length) {
372
- const fragment = nextMatch(LEGACY_FRAGMENT_RE, content, cursor)
373
- const watermark = nextMatch(LEGACY_WATERMARK_RE, content, cursor)
374
- const next = earliest(fragment, watermark)
375
- if (next === null) break
376
-
377
- addLegacyProse(events, content.slice(cursor, next.match.index))
378
- if (next.kind === 'fragment') {
379
- const id = newEventId()
380
- events.push({
381
- type: 'fragment',
382
- id,
383
- ts: timestampFromId(id),
384
- source: next.match[1]!,
385
- entry: next.match[2]!,
386
- topic: next.match[3]!,
387
- body: next.match[4]!,
388
- })
389
- } else {
390
- const id = newEventId()
391
- events.push({
392
- type: 'watermark',
393
- id,
394
- ts: timestampFromId(id),
395
- source: next.match[1]!,
396
- entry: next.match[2]!,
397
- })
398
- }
399
- cursor = next.match.index + next.match[0].length
400
- }
401
-
402
- addLegacyProse(events, content.slice(cursor))
403
- return events
404
- }
405
-
406
- function addLegacyProse(events: StreamEvent[], text: string): void {
407
- if (text.trim() === '') return
408
- events.push({ type: 'legacy_prose', ts: new Date().toISOString(), text, origin: 'migration' })
409
- }
410
-
411
- function nextMatch(regex: RegExp, content: string, cursor: number): RegExpExecArray | null {
412
- regex.lastIndex = cursor
413
- return regex.exec(content)
414
- }
415
-
416
- function earliest(
417
- fragment: RegExpExecArray | null,
418
- watermark: RegExpExecArray | null,
419
- ): { kind: 'fragment' | 'watermark'; match: RegExpExecArray } | null {
420
- if (fragment === null && watermark === null) return null
421
- if (fragment === null) return { kind: 'watermark', match: watermark! }
422
- if (watermark === null) return { kind: 'fragment', match: fragment }
423
- return fragment.index <= watermark.index
424
- ? { kind: 'fragment', match: fragment }
425
- : { kind: 'watermark', match: watermark }
426
- }
427
-
428
- function findInvalidEvent(events: readonly StreamEvent[]): { index: number; reason: string } | null {
429
- for (let i = 0; i < events.length; i++) {
430
- const parsed = streamEventSchema.safeParse(events[i])
431
- if (!parsed.success) {
432
- return { index: i, reason: parsed.error.issues.map((issue) => issue.message).join('; ') }
433
- }
434
- }
435
- return null
436
- }
437
-
438
- function countEvents(
439
- events: readonly StreamEvent[],
440
- ): Pick<MigrationResult, 'fragmentCount' | 'watermarkCount' | 'legacyProseCount'> {
441
- let fragmentCount = 0
442
- let watermarkCount = 0
443
- let legacyProseCount = 0
444
- for (const event of events) {
445
- if (event.type === 'fragment') fragmentCount++
446
- if (event.type === 'watermark') watermarkCount++
447
- if (event.type === 'legacy_prose') legacyProseCount++
448
- }
449
- return { fragmentCount, watermarkCount, legacyProseCount }
450
- }
451
-
452
- async function resetDreamingWatermarks(agentDir: string, dates: readonly string[]): Promise<void> {
453
- let state = await loadDreamingState(agentDir)
454
- const ts = new Date().toISOString()
455
- for (const date of dates) {
456
- state = clearDreamedIds(state, date, ts)
457
- }
458
- await saveDreamingState(agentDir, state)
459
- }
460
-
461
- async function commitMigration(
462
- agentDir: string,
463
- dates: readonly string[],
464
- logger: MigrationLogger,
465
- git: MigrationGit | undefined,
466
- ): Promise<void> {
467
- const spawn = git?.spawn ?? spawnGit
468
- const inside = await spawn(['rev-parse', '--is-inside-work-tree'], { cwd: agentDir })
469
- if (inside.exitCode !== 0) {
470
- logger.info('[memory:migration] not in a git repo; skipping git commit')
471
- return
472
- }
473
-
474
- const jsonlPaths = dates.map((date) => `memory/${date}.jsonl`)
475
- const addJsonl = await spawn(['add', '--', ...jsonlPaths], { cwd: agentDir })
476
- if (addJsonl.exitCode !== 0) {
477
- logger.warn(`[memory:migration] git add failed: ${addJsonl.stderr || addJsonl.stdout}`.trim())
478
- return
479
- }
480
-
481
- for (const date of dates) {
482
- const mdPath = `memory/${date}.md`
483
- const tracked = await spawn(['ls-files', '--error-unmatch', '--', mdPath], { cwd: agentDir })
484
- if (tracked.exitCode !== 0) continue
485
- const addDeletedMd = await spawn(['add', '-u', '--', mdPath], { cwd: agentDir })
486
- if (addDeletedMd.exitCode !== 0) {
487
- logger.warn(`[memory:migration] git add failed: ${addDeletedMd.stderr || addDeletedMd.stdout}`.trim())
488
- return
489
- }
490
- }
491
-
492
- const commit = await spawn(
493
- ['commit', '-m', `memory: migrate ${dates.length} daily stream(s) to JSONL`, '--no-edit'],
494
- {
495
- cwd: agentDir,
496
- },
497
- )
498
- if (commit.exitCode !== 0) {
499
- logger.warn(`[memory:migration] git commit failed: ${commit.stderr || commit.stdout}`.trim())
500
- }
501
- }
502
-
503
- async function commitShardingMigration(
504
- agentDir: string,
505
- details: { slugs: readonly string[]; streamDates: readonly string[]; hadRootMemory: boolean },
506
- logger: MigrationLogger,
507
- git: MigrationGit | undefined,
508
- ): Promise<void> {
509
- const spawn = git?.spawn ?? spawnGit
510
- const inside = await spawn(['rev-parse', '--is-inside-work-tree'], { cwd: agentDir })
511
- if (inside.exitCode !== 0) {
512
- logger.info('[memory:migration] not in a git repo; skipping git commit')
513
- return
514
- }
515
-
516
- const newPaths = [
517
- ...details.slugs.map((slug) => `memory/topics/${slug}.md`),
518
- ...details.streamDates.map((date) => `memory/streams/${date}.jsonl`),
519
- `memory/${PRE_SHARD_BACKUP_FILENAME}`,
520
- ]
521
- const addNew = await spawn(['add', '--', ...newPaths], { cwd: agentDir })
522
- if (addNew.exitCode !== 0) {
523
- logger.warn(`[memory:migration] git add failed: ${addNew.stderr || addNew.stdout}`.trim())
524
- return
525
- }
526
-
527
- const candidateDeletions = [...details.streamDates.map((date) => `memory/${date}.jsonl`)]
528
- if (details.hadRootMemory) candidateDeletions.push('MEMORY.md')
529
- const trackedDeletions: string[] = []
530
- for (const path of candidateDeletions) {
531
- const tracked = await spawn(['ls-files', '--error-unmatch', '--', path], { cwd: agentDir })
532
- if (tracked.exitCode === 0) trackedDeletions.push(path)
533
- }
534
- if (trackedDeletions.length > 0) {
535
- const addDeletions = await spawn(['add', '-u', '--', ...trackedDeletions], { cwd: agentDir })
536
- if (addDeletions.exitCode !== 0) {
537
- logger.warn(`[memory:migration] git add failed: ${addDeletions.stderr || addDeletions.stdout}`.trim())
538
- return
539
- }
540
- }
541
-
542
- const commitSharding = await spawn(
543
- [
544
- 'commit',
545
- '-m',
546
- `memory: shard MEMORY.md into ${details.slugs.length} topic(s) and ${details.streamDates.length} daily stream(s)`,
547
- '--no-edit',
548
- ],
549
- { cwd: agentDir },
550
- )
551
- if (commitSharding.exitCode !== 0) {
552
- logger.warn(`[memory:migration] git commit failed: ${commitSharding.stderr || commitSharding.stdout}`.trim())
553
- }
554
- }
555
-
556
- async function commitPendingLegacyDeletions(
557
- agentDir: string,
558
- logger: MigrationLogger,
559
- git: MigrationGit | undefined,
560
- ): Promise<void> {
561
- const spawn = git?.spawn ?? spawnGit
562
- const inside = await spawn(['rev-parse', '--is-inside-work-tree'], { cwd: agentDir })
563
- if (inside.exitCode !== 0) return
564
-
565
- const pending = await collectLegacyDeletions(agentDir, spawn)
566
- if (pending.all.length === 0) return
567
-
568
- // `git add -u` errors with "pathspec did not match" on paths whose deletion
569
- // is already in the index, so stage only the working-tree-only deletions.
570
- // The already-staged set is picked up by the commit directly.
571
- if (pending.workingTreeOnly.length > 0) {
572
- const addDeletions = await spawn(['add', '-u', '--', ...pending.workingTreeOnly], { cwd: agentDir })
573
- if (addDeletions.exitCode !== 0) {
574
- logger.warn(`[memory:migration] git add failed: ${addDeletions.stderr || addDeletions.stdout}`.trim())
575
- return
576
- }
577
- }
578
-
579
- const commit = await spawn(
580
- [
581
- 'commit',
582
- '-m',
583
- `memory: clean up ${pending.all.length} pre-shard file(s) orphaned by earlier migration`,
584
- '--no-edit',
585
- ],
586
- { cwd: agentDir },
587
- )
588
- if (commit.exitCode !== 0) {
589
- logger.warn(`[memory:migration] git commit failed: ${commit.stderr || commit.stdout}`.trim())
590
- }
591
- }
592
-
593
- async function collectLegacyDeletions(
594
- agentDir: string,
595
- spawn: NonNullable<MigrationGit['spawn']>,
596
- ): Promise<{ all: string[]; workingTreeOnly: string[] }> {
597
- const isLegacy = (line: string): boolean => line === 'MEMORY.md' || /^memory\/\d{4}-\d{2}-\d{2}\.jsonl$/.test(line)
598
- const parse = (out: string): string[] =>
599
- out
600
- .split('\n')
601
- .map((line) => line.trim())
602
- .filter(isLegacy)
603
-
604
- const allDiff = await spawn(['diff', 'HEAD', '--name-only', '--diff-filter=D', '--', 'memory/', 'MEMORY.md'], {
605
- cwd: agentDir,
606
- })
607
- if (allDiff.exitCode !== 0) return { all: [], workingTreeOnly: [] }
608
- const all = parse(allDiff.stdout)
609
- if (all.length === 0) return { all: [], workingTreeOnly: [] }
610
-
611
- const wtDiff = await spawn(['diff', '--name-only', '--diff-filter=D', '--', 'memory/', 'MEMORY.md'], {
612
- cwd: agentDir,
613
- })
614
- const workingTreeOnly = wtDiff.exitCode === 0 ? parse(wtDiff.stdout) : []
615
- return { all, workingTreeOnly }
616
- }
617
-
618
- async function spawnGit(
619
- args: string[],
620
- options: { cwd: string },
621
- ): Promise<{ exitCode: number; stdout: string; stderr: string }> {
622
- const proc = Bun.spawn({ cmd: ['git', ...args], cwd: options.cwd, stdout: 'pipe', stderr: 'pipe' })
623
- const [stdout, stderr, exitCode] = await Promise.all([
624
- new Response(proc.stdout).text(),
625
- new Response(proc.stderr).text(),
626
- proc.exited,
627
- ])
628
- return { exitCode, stdout, stderr }
629
- }
630
-
631
- function describeError(err: unknown): string {
632
- return err instanceof Error ? err.message : String(err)
633
- }