typeclaw 0.2.0 → 0.3.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.
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto'
1
2
  import { existsSync } from 'node:fs'
2
3
  import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
3
4
  import { dirname, join } from 'node:path'
@@ -7,15 +8,17 @@ import { z } from 'zod'
7
8
  import { lsTool, readTool, type Subagent, writeTool } from '@/plugin'
8
9
  import { formatLocalDate, formatLocalDateTime } from '@/shared'
9
10
 
11
+ import { parseCitations } from './citations'
10
12
  import {
13
+ addDreamedIds,
11
14
  DREAMING_STATE_FILE,
12
15
  type DreamingState,
13
- getDreamedLines,
16
+ getDreamedIds,
14
17
  loadDreamingState,
15
18
  saveDreamingState,
16
- setDreamedLines,
17
19
  } from './dreaming-state'
18
- import { readEvents } from './stream-io'
20
+ import type { StreamEvent } from './stream-events'
21
+ import { readEvents, writeEventsAtomic } from './stream-io'
19
22
 
20
23
  const STREAM_FILE_PATTERN = /^(\d{4}-\d{2}-\d{2})\.jsonl$/
21
24
 
@@ -46,18 +49,16 @@ const consoleLogger: DreamingLogger = {
46
49
  type StreamSnapshot = {
47
50
  date: string
48
51
  filename: string
49
- totalLines: number
50
- dreamedLines: number
52
+ undreamedIds: string[]
51
53
  }
52
54
 
53
55
  type StreamSnapshots = {
54
- all: StreamSnapshot[]
55
56
  undreamed: StreamSnapshot[]
56
57
  }
57
58
 
58
59
  async function collectStreamSnapshots(agentDir: string, state: DreamingState): Promise<StreamSnapshots> {
59
60
  const memoryDir = join(agentDir, 'memory')
60
- if (!existsSync(memoryDir)) return { all: [], undreamed: [] }
61
+ if (!existsSync(memoryDir)) return { undreamed: [] }
61
62
 
62
63
  const names = await readdir(memoryDir)
63
64
  const dated = names
@@ -66,42 +67,155 @@ async function collectStreamSnapshots(agentDir: string, state: DreamingState): P
66
67
  .map(({ name, match }) => ({ name, date: match[1]! }))
67
68
  .sort((a, b) => a.date.localeCompare(b.date))
68
69
 
69
- const all = await Promise.all(
70
+ const snapshots = await Promise.all(
70
71
  dated.map(async ({ name, date }): Promise<StreamSnapshot> => {
71
- const totalLines = await countLines(join(memoryDir, name))
72
- const dreamedLines = getDreamedLines(state, date)
73
- return { date, filename: name, totalLines, dreamedLines }
72
+ const events = await readEvents(join(memoryDir, name))
73
+ const dreamedIds = getDreamedIds(state, date)
74
+ const undreamedIds = collectUndreamedFragmentIds(events, dreamedIds)
75
+ return { date, filename: name, undreamedIds }
74
76
  }),
75
77
  )
76
78
 
77
- // A hand-edited stream that shrank below its watermark is "fully dreamed":
78
- // the locked-in design says trust the user's edit and keep the watermark.
79
- // The locked-out lines are presumed already consolidated into MEMORY.md.
80
- const undreamed = all.filter((s) => s.totalLines > s.dreamedLines)
81
- return { all, undreamed }
79
+ return { undreamed: snapshots.filter((s) => s.undreamedIds.length > 0) }
82
80
  }
83
81
 
84
- async function countLines(path: string): Promise<number> {
85
- try {
86
- const raw = await readFile(path, 'utf8')
87
- if (raw.length === 0) return 0
88
- // A trailing newline is a separator, not a line. So `"a\nb\n"` is 2 lines,
89
- // matching `wc -l` semantics and how an editor displays line numbers.
90
- return raw.endsWith('\n') ? raw.split('\n').length - 1 : raw.split('\n').length
91
- } catch {
92
- return 0
82
+ function collectUndreamedFragmentIds(events: readonly StreamEvent[], dreamedIds: ReadonlySet<string>): string[] {
83
+ const ids: string[] = []
84
+ for (const event of events) {
85
+ if (event.type !== 'fragment') continue
86
+ if (dreamedIds.has(event.id)) continue
87
+ ids.push(event.id)
93
88
  }
89
+ return ids
94
90
  }
95
91
 
96
- function advanceWatermarks(state: DreamingState, snapshots: StreamSnapshot[]): DreamingState {
92
+ function advanceDreamedIds(state: DreamingState, snapshots: StreamSnapshot[]): DreamingState {
97
93
  const ts = formatLocalDateTime()
98
94
  let next = state
99
95
  for (const snap of snapshots) {
100
- next = setDreamedLines(next, snap.date, snap.totalLines, ts)
96
+ next = addDreamedIds(next, snap.date, snap.undreamedIds, ts)
101
97
  }
102
98
  return next
103
99
  }
104
100
 
101
+ export type CompactionStats = {
102
+ filesCompacted: number
103
+ watermarksDropped: number
104
+ fragmentsDropped: number
105
+ }
106
+
107
+ export type CompactionOptions = {
108
+ // When false, fragment GC is suppressed (watermark GC still runs). The
109
+ // handler passes false whenever MEMORY.md was NOT rewritten during this
110
+ // dreaming pass, because in that case citedIdsByDate reflects the prior
111
+ // run's citations — not a fresh judgment by THIS run's subagent. Dropping
112
+ // fragments based on stale citations is fragment-eating-disease: a subagent
113
+ // that decided "nothing meets the bar this run" would otherwise have its
114
+ // unconsolidated fragments silently nuked, with no way to ever recover
115
+ // them. Watermark GC is unaffected because watermarks are never cited.
116
+ applyFragmentGc: boolean
117
+ }
118
+
119
+ // Compact the daily stream files touched on this dreaming pass.
120
+ //
121
+ // GC rule 1 (watermarks, always applied): keep only the latest watermark per
122
+ // source per file. Nothing cites watermarks, so the only live one for any
123
+ // source is the most recent — that's what readLatestWatermark resolves to
124
+ // anyway.
125
+ //
126
+ // GC rule 2 (fragments, gated by applyFragmentGc): drop fragment events
127
+ // whose id is in dreamedIds but is NOT in citedIds. dreamedIds means the
128
+ // dreaming subagent already saw this fragment; citedIds means MEMORY.md
129
+ // still references it. A fragment in dreamedIds-but-not-citedIds has either
130
+ // been folded into a topic's conclusion paragraph in the subagent's own
131
+ // words or was consciously discarded as not worth promoting; either way, it
132
+ // carries no future information and the bytes are pure overhead in the
133
+ // force-committed git history.
134
+ //
135
+ // Atomicity: each file rewrite is tmpfile + rename. Recovery from a crash
136
+ // mid-loop: the per-file rewrite is atomic, dreamedIds is already on disk
137
+ // (caller must invoke saveDreamingState before compactDailyStreams), so a
138
+ // later dreaming pass sees the same dreamedIds and the same citedIds and
139
+ // computes the same kept set for any files that weren't yet rewritten.
140
+ export async function compactDailyStreams(
141
+ agentDir: string,
142
+ state: DreamingState,
143
+ citedIdsByDate: ReadonlyMap<string, ReadonlySet<string>>,
144
+ touchedDates: readonly string[],
145
+ options: CompactionOptions,
146
+ ): Promise<CompactionStats> {
147
+ const stats: CompactionStats = { filesCompacted: 0, watermarksDropped: 0, fragmentsDropped: 0 }
148
+ const memoryDir = join(agentDir, 'memory')
149
+
150
+ for (const date of touchedDates) {
151
+ const path = join(memoryDir, `${date}.jsonl`)
152
+ if (!existsSync(path)) continue
153
+
154
+ const events = await readEvents(path)
155
+ if (events.length === 0) continue
156
+
157
+ const dreamedIds = getDreamedIds(state, date)
158
+ const citedIds = citedIdsByDate.get(date) ?? EMPTY_ID_SET
159
+
160
+ const latestWatermarkBySource = new Map<string, string>()
161
+ for (const event of events) {
162
+ if (event.type === 'watermark') latestWatermarkBySource.set(event.source, event.id)
163
+ }
164
+
165
+ let watermarksDropped = 0
166
+ let fragmentsDropped = 0
167
+ const kept: StreamEvent[] = []
168
+ for (const event of events) {
169
+ if (event.type === 'watermark') {
170
+ if (latestWatermarkBySource.get(event.source) === event.id) {
171
+ kept.push(event)
172
+ } else {
173
+ watermarksDropped++
174
+ }
175
+ continue
176
+ }
177
+ if (event.type === 'fragment') {
178
+ if (options.applyFragmentGc && dreamedIds.has(event.id) && !citedIds.has(event.id)) {
179
+ fragmentsDropped++
180
+ continue
181
+ }
182
+ kept.push(event)
183
+ continue
184
+ }
185
+ kept.push(event)
186
+ }
187
+
188
+ if (watermarksDropped === 0 && fragmentsDropped === 0) continue
189
+
190
+ await writeEventsAtomic(path, kept)
191
+ stats.filesCompacted++
192
+ stats.watermarksDropped += watermarksDropped
193
+ stats.fragmentsDropped += fragmentsDropped
194
+ }
195
+
196
+ return stats
197
+ }
198
+
199
+ const EMPTY_ID_SET: ReadonlySet<string> = new Set()
200
+
201
+ async function loadCitedIds(agentDir: string): Promise<ReadonlyMap<string, ReadonlySet<string>>> {
202
+ try {
203
+ const raw = await readFile(join(agentDir, 'MEMORY.md'), 'utf8')
204
+ return parseCitations(raw)
205
+ } catch {
206
+ return new Map()
207
+ }
208
+ }
209
+
210
+ async function safeContentHash(path: string): Promise<string | null> {
211
+ try {
212
+ const raw = await readFile(path)
213
+ return createHash('sha256').update(raw).digest('hex')
214
+ } catch {
215
+ return null
216
+ }
217
+ }
218
+
105
219
  const SNAPSHOT_PATHS = ['MEMORY.md', 'memory/'] as const
106
220
 
107
221
  // MEMORY.md scaffolding is no longer in `typeclaw init`; the dreaming subagent
@@ -374,18 +488,20 @@ You also distill **muscle memory**: when the streams show a repeated multi-step
374
488
 
375
489
  **2. Only read the undreamed tail.** The runtime gives you a list like \`memory/2026-04-27.jsonl (lines 43-60)\`. Use \`read\` with \`offset\` set to the first undreamed line. Do not read earlier lines — they have already been consolidated, re-citing them would create duplicate fragment references in MEMORY.md. Treat each JSONL line as one event; consolidate only \`type: "fragment"\` events and ignore \`watermark\` events except as evidence that progress was recorded.
376
490
 
377
- **3. Every entry in MEMORY.md cites its source fragments.** When you consolidate, group fragments by topic and produce a single conclusion paragraph per topic, then list the source fragments below it. Use this exact format:
491
+ **3. Every entry in MEMORY.md cites its source fragments by id.** When you consolidate, group fragments by topic and produce a single conclusion paragraph per topic, then list the source fragments below it. The id is the \`id\` field of the fragment event in the JSONL line you read — a UUIDv7 like \`019e2eca-6fc5-71ef-add9-67a0955a4b35\`. Use this exact format:
378
492
 
379
493
  \`\`\`
380
494
  ## <topic>
381
495
  <conclusion paragraph in your own words>
382
496
 
383
497
  fragments:
384
- - memory/yyyy-MM-dd:<fragment line range>
385
- - memory/yyyy-MM-dd:<fragment line range>
498
+ - memory/yyyy-MM-dd#<fragment-id>
499
+ - memory/yyyy-MM-dd#<fragment-id>
386
500
  \`\`\`
387
501
 
388
- A fragment with no useful content (a watermark-only marker, a near-duplicate, a session-specific quirk that fails the generalizability bar) is discarded. Never invent fragments. Never cite a fragment that did not appear in the undreamed tail you actually read.
502
+ The date in the prefix is the same as the filename you read the fragment from; the id after \`#\` is the full UUIDv7 from the event's \`id\` field. Do not abbreviate the id. Do not use line numbers citations are id-based, not line-based, so daily streams can be compacted between dreaming runs without breaking your references.
503
+
504
+ A fragment with no useful content (a watermark-only marker, a near-duplicate, a session-specific quirk that fails the generalizability bar) is discarded. Never invent fragments. Never cite a fragment id you did not see in the undreamed tail you actually read.
389
505
 
390
506
  **4. Inherit the memory-logger's standards.** The memory-logger already filtered fragments using strict certainty rules (explicit / deductive / inductive). Your job is consolidation, not loosening the bar. If two fragments contradict, prefer the more recent. If a fragment is ambiguous in isolation but clarified by a later fragment, merge them under one topic. Never promote a single fragment from one day into a stable claim unless its certainty was already \`explicit\` or \`deductive\`.
391
507
 
@@ -404,14 +520,14 @@ A fragment with no useful content (a watermark-only marker, a near-duplicate, a
404
520
  <conclusion paragraph>
405
521
 
406
522
  fragments:
407
- - memory/yyyy-MM-dd:<line>-<line>
523
+ - memory/yyyy-MM-dd#<fragment-id>
408
524
 
409
525
  ## <topic>
410
526
  <conclusion paragraph>
411
527
 
412
528
  fragments:
413
- - memory/yyyy-MM-dd:<line>-<line>
414
- - memory/yyyy-MM-dd:<line>-<line>
529
+ - memory/yyyy-MM-dd#<fragment-id>
530
+ - memory/yyyy-MM-dd#<fragment-id>
415
531
  \`\`\`
416
532
 
417
533
  The first line is always \`# Memory\`. Topics are level-2 headings. No other top-level structure.
@@ -479,8 +595,8 @@ Use this exact shape — pick one of the two \`proposal:\` lines:
479
595
  proposal: cli packages/<name>
480
596
 
481
597
  fragments:
482
- - memory/yyyy-MM-dd:<line>-<line>
483
- - memory/yyyy-MM-dd:<line>-<line>
598
+ - memory/yyyy-MM-dd#<fragment-id>
599
+ - memory/yyyy-MM-dd#<fragment-id>
484
600
  \`\`\`
485
601
 
486
602
  \`\`\`
@@ -490,8 +606,8 @@ fragments:
490
606
  proposal: plugin packages/<name>
491
607
 
492
608
  fragments:
493
- - memory/yyyy-MM-dd:<line>-<line>
494
- - memory/yyyy-MM-dd:<line>-<line>
609
+ - memory/yyyy-MM-dd#<fragment-id>
610
+ - memory/yyyy-MM-dd#<fragment-id>
495
611
  \`\`\`
496
612
 
497
613
  The \`proposal:\` line is the contract. \`cli packages/<name>\` means "scaffold a bun package with a \`bin\` entry under that path". \`plugin packages/<name>\` means "scaffold a typeclaw plugin under that path and wire it into \`typeclaw.json\`'s \`plugins\` array". The package name is single-segment kebab-case (same rule as skill names) and must not collide with anything already in \`packages/\` — the main agent will check before scaffolding, but pick a descriptive name (\`standup-log\`, not \`my-cli\`) so the suggestion is actionable on its own.
@@ -527,17 +643,15 @@ function buildInitialPrompt(payload: DreamingPayload, snapshots: StreamSnapshot[
527
643
  `Today's local date: ${today}`,
528
644
  `Dreaming state: ${join(payload.agentDir, DREAMING_STATE_FILE)}`,
529
645
  '',
530
- 'Undreamed tails to consolidate (read each with `offset` set to the first undreamed line earlier lines are already in MEMORY.md):',
646
+ 'Undreamed fragments to consolidate. Each entry lists the daily JSONL file and the ids of fragments in that file you have not yet consolidated into MEMORY.md. Read the file, locate each id, and decide what (if anything) belongs in MEMORY.md. Cite by id (memory/yyyy-MM-dd#<id>), not by line number.',
531
647
  ]
532
648
  for (const snap of snapshots) {
533
- const firstLine = snap.dreamedLines + 1
534
- lines.push(
535
- `- memory/${snap.filename}: read offset=${firstLine}, total file lines=${snap.totalLines} (undreamed: ${firstLine}-${snap.totalLines})`,
536
- )
649
+ lines.push('', `- memory/${snap.filename}:`)
650
+ for (const id of snap.undreamedIds) lines.push(` - ${id}`)
537
651
  }
538
652
  lines.push(
539
653
  '',
540
- 'Dream now. Read MEMORY.md and each undreamed tail listed above. Consolidate the new fragments into long-term memory and write the full new MEMORY.md if anything changed. If nothing meets the bar, stop without writing — the runtime will advance the watermark either way.',
654
+ 'Dream now. Read MEMORY.md and the listed fragments. Consolidate them into long-term memory and write the full new MEMORY.md if anything changed. If nothing meets the bar, stop without writing — the runtime advances the dreamed-id set either way so you will not see these fragments again on the next run.',
541
655
  )
542
656
  return lines.join('\n')
543
657
  }
@@ -568,12 +682,15 @@ export function createDreamingSubagent(options: CreateDreamingSubagentOptions =
568
682
  return
569
683
  }
570
684
 
571
- const undreamedLines = snapshots.undreamed.reduce((sum, s) => sum + (s.totalLines - s.dreamedLines), 0)
685
+ const undreamedFragments = snapshots.undreamed.reduce((sum, s) => sum + s.undreamedIds.length, 0)
572
686
  const start = Date.now()
573
687
  logger.info(
574
- `[dreaming] start days=${snapshots.undreamed.length} undreamed_lines=${undreamedLines} agent_dir=${ctx.payload.agentDir}`,
688
+ `[dreaming] start days=${snapshots.undreamed.length} undreamed_fragments=${undreamedFragments} agent_dir=${ctx.payload.agentDir}`,
575
689
  )
576
690
 
691
+ const memoryFilePath = join(ctx.payload.agentDir, 'MEMORY.md')
692
+ const memoryHashBefore = await safeContentHash(memoryFilePath)
693
+
577
694
  try {
578
695
  await runSession({ userPrompt: buildInitialPrompt(ctx.payload, snapshots.undreamed) })
579
696
  } catch (err) {
@@ -582,9 +699,23 @@ export function createDreamingSubagent(options: CreateDreamingSubagentOptions =
582
699
  throw err
583
700
  }
584
701
 
585
- const advanced = advanceWatermarks(state, snapshots.undreamed)
702
+ const memoryHashAfter = await safeContentHash(memoryFilePath)
703
+ const memoryRewrittenThisRun = memoryHashBefore !== memoryHashAfter
704
+
705
+ const advanced = advanceDreamedIds(state, snapshots.undreamed)
586
706
  await saveDreamingState(ctx.payload.agentDir, advanced)
587
- logger.info(`[dreaming] watermarks advanced days=${snapshots.undreamed.length}`)
707
+ logger.info(`[dreaming] dreamed-ids advanced days=${snapshots.undreamed.length}`)
708
+
709
+ const citedIdsByDate = await loadCitedIds(ctx.payload.agentDir)
710
+ const touchedDates = snapshots.undreamed.map((s) => s.date)
711
+ const compaction = await compactDailyStreams(ctx.payload.agentDir, advanced, citedIdsByDate, touchedDates, {
712
+ applyFragmentGc: memoryRewrittenThisRun,
713
+ })
714
+ if (compaction.filesCompacted > 0) {
715
+ logger.info(
716
+ `[dreaming] compaction files=${compaction.filesCompacted} watermarks_dropped=${compaction.watermarksDropped} fragments_dropped=${compaction.fragmentsDropped} fragment_gc=${memoryRewrittenThisRun ? 'on' : 'off'}`,
717
+ )
718
+ }
588
719
 
589
720
  try {
590
721
  await commit(ctx.payload.agentDir)
@@ -3,7 +3,7 @@ import { join } from 'node:path'
3
3
 
4
4
  import type { SessionOrigin } from '@/agent/session-origin'
5
5
 
6
- import { getDreamedLines, loadDreamingState } from './dreaming-state'
6
+ import { getDreamedIds, loadDreamingState } from './dreaming-state'
7
7
  import type { StreamEvent } from './stream-events'
8
8
  import { readEvents } from './stream-io'
9
9
 
@@ -80,10 +80,10 @@ async function readStreamEntries(agentDir: string, currentSessionId: string | un
80
80
  const entries = await Promise.all(
81
81
  dated.map(async (name) => {
82
82
  const date = STREAM_DATE_FROM_FILENAME.exec(name)?.[1] ?? ''
83
- const dreamedLines = getDreamedLines(state, date)
83
+ const dreamedIds = getDreamedIds(state, date)
84
84
  const entry = await readStreamEntry(memoryDir, name)
85
85
  const filtered = dropSelfSessionFragments({ ...entry, name: `memory/${name}` }, currentSessionId)
86
- const tail = sliceUndreamedTail(filtered, dreamedLines)
86
+ const tail = sliceUndreamedTail(filtered, dreamedIds)
87
87
  return renderStreamEntry(tail)
88
88
  }),
89
89
  )
@@ -96,12 +96,18 @@ async function readStreamEntry(memoryDir: string, name: string): Promise<StreamE
96
96
  return { name, path: filePath, events }
97
97
  }
98
98
 
99
- // Slice off the events already consolidated into MEMORY.md so the agent never
100
- // sees a fragment twice (once in MEMORY.md and once in the daily stream).
101
- function sliceUndreamedTail(entry: StreamEntry, dreamedLines: number): StreamEntry {
102
- if (dreamedLines <= 0) return entry
103
- if (dreamedLines >= entry.events.length) return { ...entry, fullyDreamed: true }
104
- const tail = entry.events.slice(dreamedLines)
99
+ // Slice off the events whose ids already appear in the dreamed-id set so the
100
+ // agent never sees a fragment twice (once in MEMORY.md and once in the daily
101
+ // stream). Events without an id (legacy_prose) are always kept — they
102
+ // pre-date the dreamed-id contract and cannot be addressed by id.
103
+ function sliceUndreamedTail(entry: StreamEntry, dreamedIds: ReadonlySet<string>): StreamEntry {
104
+ if (dreamedIds.size === 0) return entry
105
+ const tail = entry.events.filter((event) => {
106
+ if (event.type === 'legacy_prose') return true
107
+ return !dreamedIds.has(event.id)
108
+ })
109
+ if (tail.length === 0) return { ...entry, fullyDreamed: true }
110
+ if (tail.length === entry.events.length) return entry
105
111
  return { ...entry, name: `${entry.name} (undreamed tail)`, events: tail }
106
112
  }
107
113
 
@@ -1,10 +1,9 @@
1
- import { randomUUID } from 'node:crypto'
2
1
  import { existsSync } from 'node:fs'
3
2
  import { readdir, readFile, unlink } from 'node:fs/promises'
4
3
  import { join } from 'node:path'
5
4
 
6
- import { loadDreamingState, saveDreamingState, setDreamedLines } from './dreaming-state'
7
- import { type StreamEvent, streamEventSchema } from './stream-events'
5
+ import { clearDreamedIds, loadDreamingState, saveDreamingState } from './dreaming-state'
6
+ import { newEventId, type StreamEvent, streamEventSchema, timestampFromId } from './stream-events'
8
7
  import { writeEventsAtomic as defaultWriteEventsAtomic } from './stream-io'
9
8
 
10
9
  export type MigrationResult = {
@@ -136,20 +135,22 @@ function parseLegacyMarkdown(content: string): StreamEvent[] {
136
135
 
137
136
  addLegacyProse(events, content.slice(cursor, next.match.index))
138
137
  if (next.kind === 'fragment') {
138
+ const id = newEventId()
139
139
  events.push({
140
140
  type: 'fragment',
141
- id: randomUUID(),
142
- ts: new Date().toISOString(),
141
+ id,
142
+ ts: timestampFromId(id),
143
143
  source: next.match[1]!,
144
144
  entry: next.match[2]!,
145
145
  topic: next.match[3]!,
146
146
  body: next.match[4]!,
147
147
  })
148
148
  } else {
149
+ const id = newEventId()
149
150
  events.push({
150
151
  type: 'watermark',
151
- id: randomUUID(),
152
- ts: new Date().toISOString(),
152
+ id,
153
+ ts: timestampFromId(id),
153
154
  source: next.match[1]!,
154
155
  entry: next.match[2]!,
155
156
  })
@@ -211,7 +212,7 @@ async function resetDreamingWatermarks(agentDir: string, dates: readonly string[
211
212
  let state = await loadDreamingState(agentDir)
212
213
  const ts = new Date().toISOString()
213
214
  for (const date of dates) {
214
- state = setDreamedLines(state, date, 0, ts)
215
+ state = clearDreamedIds(state, date, ts)
215
216
  }
216
217
  await saveDreamingState(agentDir, state)
217
218
  }
@@ -1,5 +1,35 @@
1
1
  import { z } from 'zod'
2
2
 
3
+ // Event ids are UUIDv7. The 48-bit Unix-ms timestamp prefix gives two
4
+ // load-bearing properties:
5
+ // 1. Lexicographic order = chronological order. Sort ids as strings and
6
+ // they come out in creation order — across files, within a file, and
7
+ // after any future compaction that may reorder events on disk.
8
+ // 2. The timestamp in the `ts` field is structurally derivable from the
9
+ // id, so `ts` is now a denormalized convenience for grep-ability rather
10
+ // than independent state. Append paths derive `ts` from the id at write
11
+ // time so the two never drift.
12
+ //
13
+ // Bun.randomUUIDv7() is the single id source for this subsystem. Node's
14
+ // `crypto.randomUUID()` (v4) is not used here.
15
+ export function newEventId(): string {
16
+ return Bun.randomUUIDv7()
17
+ }
18
+
19
+ // Recover the wall-clock instant a UUIDv7 was minted at. The first 48 bits
20
+ // of the 128-bit id are big-endian milliseconds since the Unix epoch. We
21
+ // only need the first 12 hex chars (= 48 bits) — the 4-bit version nibble
22
+ // at position 12 and the 12-bit random tail that follow are not part of the
23
+ // timestamp. Returns ISO 8601 in UTC. Throws on shapes that lack a parseable
24
+ // timestamp prefix; callers should only pass ids produced by `newEventId`.
25
+ export function timestampFromId(id: string): string {
26
+ const hex = id.replace(/-/g, '').slice(0, 12)
27
+ if (hex.length !== 12) throw new Error(`timestampFromId: not a UUIDv7-shaped id: ${id}`)
28
+ const ms = Number.parseInt(hex, 16)
29
+ if (!Number.isFinite(ms)) throw new Error(`timestampFromId: unparseable timestamp prefix in: ${id}`)
30
+ return new Date(ms).toISOString()
31
+ }
32
+
3
33
  export const fragmentEventSchema = z
4
34
  .object({
5
35
  type: z.literal('fragment'),
@@ -317,7 +317,7 @@ export function createKakaotalkAdapter(options: KakaotalkAdapterOptions): Kakaot
317
317
  // Stickers arrive on a separate listener event in agent-messenger
318
318
  // 2.15.0 and have no `message` field. We wrap them into the same
319
319
  // MSG-shaped payload classifyInbound expects so the engagement /
320
- // allow-list / self-author rules apply identically across plain
320
+ // self-author / unknown-chat rules apply identically across plain
321
321
  // messages and stickers — there is no second classifier to keep in
322
322
  // sync.
323
323
  await processInbound(emoticonEventToMessageEvent(event))
@@ -332,10 +332,11 @@ export function createKakaotalkAdapter(options: KakaotalkAdapterOptions): Kakaot
332
332
  // The push event itself proves the chat exists, even when
333
333
  // getChats({all:true}) does not surface it (e.g. memo chats,
334
334
  // certain open chats, recently-joined groups that haven't
335
- // propagated). Register a provisional @kakao-group entry so the
336
- // strictest allow rules still apply, but the message is no longer
337
- // silently dropped as unknown_chat. The next real refresh
338
- // upgrades the entry if the chat is actually a DM or open chat.
335
+ // propagated). Register a provisional @kakao-group entry (the
336
+ // strictest workspace bucket narrowest engagement assumptions)
337
+ // so the message is no longer silently dropped as unknown_chat.
338
+ // The next real refresh upgrades the entry if the chat is
339
+ // actually a DM or open chat.
339
340
  channelResolver.ingestProvisional(event.chat_id)
340
341
  logger.warn(
341
342
  `[kakaotalk] provisional chat=${event.chat_id} log_id=${event.log_id} bucket=@kakao-group reason=not_in_getchats`,
@@ -353,7 +354,7 @@ export function createKakaotalkAdapter(options: KakaotalkAdapterOptions): Kakaot
353
354
 
354
355
  // Ack the message BEFORE classify/route so the sender's unread "1"
355
356
  // (노란숫자) clears even when we drop the message (self-author,
356
- // not-in-allow, empty text, etc.). The receiver of a kakao adapter is
357
+ // unknown chat, empty text, etc.). The receiver of a kakao adapter is
357
358
  // expected to behave like a "read it as soon as it arrives" client —
358
359
  // the agent has observed the bytes, so the user should see the read
359
360
  // acknowledgement regardless of what we decide to do with the message
@@ -4,6 +4,7 @@ import type { AssistantMessage } from '@mariozechner/pi-ai'
4
4
  import { SessionManager } from '@mariozechner/pi-coding-agent'
5
5
 
6
6
  import { createSession, type AgentSession } from '@/agent'
7
+ import { subscribeProviderErrors } from '@/agent/provider-error'
7
8
  import type { ChannelParticipant, SessionOrigin } from '@/agent/session-origin'
8
9
  import { createCommandRegistry } from '@/commands'
9
10
  import { CORE_PERMISSIONS, type PermissionService } from '@/permissions'
@@ -255,6 +256,7 @@ type LiveSession = {
255
256
  loopGuardActive: boolean
256
257
  membershipFetch: Promise<MembershipCount | null> | null
257
258
  destroyed: boolean
259
+ unsubProviderErrors: (() => void) | null
258
260
  }
259
261
 
260
262
  type ChannelCommandContext = {
@@ -297,6 +299,7 @@ export type ChannelRouter = {
297
299
  fireTypingHeartbeat: (key: ChannelKey, phase?: 'tick' | 'stop') => Promise<void>
298
300
  fireTypingInterval: (key: ChannelKey) => Promise<void>
299
301
  isTypingActive: (key: ChannelKey) => boolean
302
+ stopTyping: (key: ChannelKey) => Promise<void>
300
303
  runIdleGc: () => Promise<void>
301
304
  }
302
305
  }
@@ -722,7 +725,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
722
725
  loopGuardActive: false,
723
726
  membershipFetch,
724
727
  destroyed: false,
728
+ unsubProviderErrors: null,
725
729
  }
730
+ live.unsubProviderErrors = subscribeProviderErrors(created.session, (err) => {
731
+ logger.error(`[channels] ${live.keyId}: LLM call failed: ${err.message}`)
732
+ })
726
733
  liveSessions.set(keyId, live)
727
734
 
728
735
  if (isColdStart) {
@@ -1027,7 +1034,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1027
1034
  live.consecutiveAborts = 0
1028
1035
  logger.info(`[channels] ${live.keyId} prompted elapsed_ms=${now() - promptStart}`)
1029
1036
  } catch (err) {
1030
- logger.warn(`[channels] ${live.keyId}: prompt threw: ${describe(err)}`)
1037
+ logger.error(`[channels] ${live.keyId}: prompt threw: ${describe(err)}`)
1031
1038
  live.consecutiveSends.clear()
1032
1039
  } finally {
1033
1040
  await fireSessionTurnEnd(live)
@@ -1448,7 +1455,19 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1448
1455
  const live = liveSessions.get(keyId)
1449
1456
  if (live) {
1450
1457
  live.successfulChannelSends++
1451
- await stopTypingHeartbeat(live)
1458
+ // Don't stop the heartbeat here: the agent may still be mid-turn and
1459
+ // about to send another reply. drain()'s finally block owns turn-end
1460
+ // stop. But Slack's adapter outbound callback explicitly clears
1461
+ // platform-side typing after every successful postMessage (to defeat
1462
+ // the heartbeat-vs-postMessage race fixed in PR #52), so a fresh
1463
+ // 'tick' must land in the FIFO right after that clear — otherwise
1464
+ // the indicator stays cleared until the next 8s interval, leaving a
1465
+ // visible idle gap between mid-turn sends on Slack. The await on
1466
+ // cb(msg) above already drained the outbound callback's clearAfterSend
1467
+ // through the per-(chat,thread) FIFO, so this tick is guaranteed to
1468
+ // land after it. Discord and Telegram treat the extra tick as a
1469
+ // no-op refresh of their already-armed (auto-expiring) indicators.
1470
+ if (live.typingTimer) void fireTyping(live, 'tick')
1452
1471
  const adapterConfig = options.configForAdapter(msg.adapter)
1453
1472
  if (adapterConfig) {
1454
1473
  const targetIds = Array.from(
@@ -1512,6 +1531,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1512
1531
  live.destroyed = true
1513
1532
  if (live.debounceTimer) clearTimeout(live.debounceTimer)
1514
1533
  live.debounceTimer = null
1534
+ live.unsubProviderErrors?.()
1535
+ live.unsubProviderErrors = null
1515
1536
  await stopTypingHeartbeat(live)
1516
1537
  try {
1517
1538
  await live.session.abort()
@@ -1616,6 +1637,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1616
1637
  const live = liveSessions.get(channelKeyId(key))
1617
1638
  return live?.typingTimer !== null && live?.typingTimer !== undefined
1618
1639
  },
1640
+ stopTyping: async (key: ChannelKey) => {
1641
+ const live = liveSessions.get(channelKeyId(key))
1642
+ if (!live) return
1643
+ await stopTypingHeartbeat(live)
1644
+ },
1619
1645
  runIdleGc,
1620
1646
  },
1621
1647
  }