typeclaw 0.1.6 → 0.3.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.
- package/package.json +1 -1
- package/src/agent/index.ts +24 -3
- package/src/agent/system-prompt.ts +17 -0
- package/src/agent/tools/channel-send.ts +2 -3
- package/src/bundled-plugins/memory/README.md +8 -8
- package/src/bundled-plugins/memory/append-tool.ts +10 -7
- package/src/bundled-plugins/memory/citations.ts +45 -0
- package/src/bundled-plugins/memory/dreaming-state.ts +30 -18
- package/src/bundled-plugins/memory/dreaming.ts +179 -48
- package/src/bundled-plugins/memory/load-memory.ts +15 -9
- package/src/bundled-plugins/memory/migration.ts +9 -8
- package/src/bundled-plugins/memory/stream-events.ts +30 -0
- package/src/channels/adapters/kakaotalk.ts +7 -6
- package/src/cli/model.ts +51 -19
- package/src/cli/provider.ts +38 -24
- package/src/config/models-mutation.ts +34 -6
- package/src/init/index.ts +8 -0
- package/src/run/channel-session-factory.ts +2 -0
- package/src/run/index.ts +6 -0
- package/src/server/index.ts +3 -0
- package/src/skills/typeclaw-memory/SKILL.md +15 -15
|
@@ -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
|
-
|
|
16
|
+
getDreamedIds,
|
|
14
17
|
loadDreamingState,
|
|
15
18
|
saveDreamingState,
|
|
16
|
-
setDreamedLines,
|
|
17
19
|
} from './dreaming-state'
|
|
18
|
-
import {
|
|
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
|
-
|
|
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 {
|
|
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
|
|
70
|
+
const snapshots = await Promise.all(
|
|
70
71
|
dated.map(async ({ name, date }): Promise<StreamSnapshot> => {
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
if (
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
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 =
|
|
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
|
|
385
|
-
- memory/yyyy-MM-dd
|
|
498
|
+
- memory/yyyy-MM-dd#<fragment-id>
|
|
499
|
+
- memory/yyyy-MM-dd#<fragment-id>
|
|
386
500
|
\`\`\`
|
|
387
501
|
|
|
388
|
-
|
|
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
|
|
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
|
|
414
|
-
- memory/yyyy-MM-dd
|
|
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
|
|
483
|
-
- memory/yyyy-MM-dd
|
|
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
|
|
494
|
-
- memory/yyyy-MM-dd
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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}
|
|
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
|
|
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]
|
|
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 {
|
|
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
|
|
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,
|
|
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
|
|
100
|
-
// sees a fragment twice (once in MEMORY.md and once in the daily
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
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
|
|
142
|
-
ts:
|
|
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
|
|
152
|
-
ts:
|
|
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 =
|
|
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
|
-
//
|
|
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
|
|
336
|
-
// strictest
|
|
337
|
-
// silently dropped as unknown_chat.
|
|
338
|
-
// upgrades the entry if the chat is
|
|
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
|
-
//
|
|
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
|
package/src/cli/model.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
-
import { cancel, intro, isCancel, select } from '@clack/prompts'
|
|
1
|
+
import { cancel, intro, isCancel, log, select } from '@clack/prompts'
|
|
2
2
|
import { defineCommand } from 'citty'
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
addProfile,
|
|
6
|
+
listModelProfiles,
|
|
7
|
+
listRegisteredModelRefs,
|
|
8
|
+
removeProfile,
|
|
9
|
+
setProfile,
|
|
10
|
+
} from '@/config/models-mutation'
|
|
5
11
|
import {
|
|
6
12
|
KNOWN_PROVIDERS,
|
|
7
13
|
listKnownModelRefs,
|
|
@@ -11,8 +17,11 @@ import {
|
|
|
11
17
|
} from '@/config/providers'
|
|
12
18
|
import { findAgentDir, isInitialized } from '@/init'
|
|
13
19
|
|
|
20
|
+
import { runProviderAddFlow } from './provider'
|
|
14
21
|
import { c, done, errorLine } from './ui'
|
|
15
22
|
|
|
23
|
+
const ADD_PROVIDER_SENTINEL = '__add-provider__'
|
|
24
|
+
|
|
16
25
|
const setSub = defineCommand({
|
|
17
26
|
meta: {
|
|
18
27
|
name: 'set',
|
|
@@ -38,7 +47,7 @@ const setSub = defineCommand({
|
|
|
38
47
|
async run({ args }) {
|
|
39
48
|
const cwd = ensureAgentDir()
|
|
40
49
|
const profile = args.profile ?? (await pickProfileName())
|
|
41
|
-
const ref = args.ref ?? (await pickModelRef())
|
|
50
|
+
const ref = args.ref ?? (await pickModelRef(cwd))
|
|
42
51
|
|
|
43
52
|
intro(`Setting model profile: ${profile} → ${ref}`)
|
|
44
53
|
|
|
@@ -78,7 +87,7 @@ const addSub = defineCommand({
|
|
|
78
87
|
},
|
|
79
88
|
async run({ args }) {
|
|
80
89
|
const cwd = ensureAgentDir()
|
|
81
|
-
const ref = args.ref ?? (await pickModelRef())
|
|
90
|
+
const ref = args.ref ?? (await pickModelRef(cwd))
|
|
82
91
|
|
|
83
92
|
intro(`Adding model profile: ${args.profile} → ${ref}`)
|
|
84
93
|
|
|
@@ -198,22 +207,45 @@ async function pickProfileName(): Promise<string> {
|
|
|
198
207
|
return choice
|
|
199
208
|
}
|
|
200
209
|
|
|
201
|
-
async function pickModelRef(): Promise<string> {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
210
|
+
async function pickModelRef(cwd: string): Promise<string> {
|
|
211
|
+
while (true) {
|
|
212
|
+
const refs = listRegisteredModelRefs(cwd)
|
|
213
|
+
if (refs.length === 0) {
|
|
214
|
+
log.info("No provider credentials found. Let's add one first.")
|
|
215
|
+
const added = await runProviderAddFlow(cwd, {})
|
|
216
|
+
if (!added.ok) {
|
|
217
|
+
console.error(errorLine(added.reason))
|
|
218
|
+
process.exit(1)
|
|
219
|
+
}
|
|
220
|
+
continue
|
|
221
|
+
}
|
|
222
|
+
const choice = await select<KnownModelRef | typeof ADD_PROVIDER_SENTINEL>({
|
|
223
|
+
message: 'Pick a model',
|
|
224
|
+
options: [
|
|
225
|
+
...refs.map((ref) => ({
|
|
226
|
+
value: ref,
|
|
227
|
+
label: describeRef(ref),
|
|
228
|
+
hint: ref,
|
|
229
|
+
})),
|
|
230
|
+
{
|
|
231
|
+
value: ADD_PROVIDER_SENTINEL,
|
|
232
|
+
label: c.cyan('+ add provider'),
|
|
233
|
+
hint: 'configure a new provider',
|
|
234
|
+
},
|
|
235
|
+
],
|
|
236
|
+
initialValue: refs[0],
|
|
237
|
+
})
|
|
238
|
+
if (isCancel(choice)) {
|
|
239
|
+
cancel('Aborted.')
|
|
240
|
+
process.exit(0)
|
|
241
|
+
}
|
|
242
|
+
if (choice !== ADD_PROVIDER_SENTINEL) return choice
|
|
243
|
+
const added = await runProviderAddFlow(cwd, {})
|
|
244
|
+
if (!added.ok) {
|
|
245
|
+
console.error(errorLine(added.reason))
|
|
246
|
+
process.exit(1)
|
|
247
|
+
}
|
|
215
248
|
}
|
|
216
|
-
return choice
|
|
217
249
|
}
|
|
218
250
|
|
|
219
251
|
function describeRef(ref: KnownModelRef): string {
|