typeclaw 0.8.0 → 0.9.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/README.md +6 -6
- package/package.json +5 -3
- package/scripts/require-parallel.ts +41 -0
- package/src/agent/index.ts +55 -6
- package/src/agent/live-sessions.ts +34 -0
- package/src/agent/plugin-tools.ts +2 -0
- package/src/agent/session-meta.ts +21 -2
- package/src/agent/subagent-completion-reminder.ts +89 -0
- package/src/agent/subagents.ts +3 -2
- package/src/agent/system-prompt.ts +10 -8
- package/src/bundled-plugins/explorer/explorer.ts +2 -2
- package/src/bundled-plugins/guard/index.ts +14 -1
- package/src/bundled-plugins/guard/policies/managed-config.ts +43 -13
- package/src/bundled-plugins/guard/policies/memory-retrieval-cache-write.ts +37 -0
- package/src/bundled-plugins/guard/policies/memory-topics-delete.ts +67 -0
- package/src/bundled-plugins/guard/policies/memory-topics-write.ts +33 -0
- package/src/bundled-plugins/guard/policies/non-workspace-write.ts +8 -2
- package/src/bundled-plugins/guard/policy.ts +7 -0
- package/src/bundled-plugins/memory/README.md +76 -62
- package/src/bundled-plugins/memory/append-tool.ts +3 -2
- package/src/bundled-plugins/memory/citation-superset.ts +49 -11
- package/src/bundled-plugins/memory/citations.ts +19 -8
- package/src/bundled-plugins/memory/delete-tool.ts +57 -0
- package/src/bundled-plugins/memory/dreaming-state.ts +1 -1
- package/src/bundled-plugins/memory/dreaming.ts +364 -146
- package/src/bundled-plugins/memory/frontmatter.ts +165 -0
- package/src/bundled-plugins/memory/index.ts +236 -16
- package/src/bundled-plugins/memory/injection-plan.ts +15 -0
- package/src/bundled-plugins/memory/load-memory.ts +102 -103
- package/src/bundled-plugins/memory/load-shards.ts +156 -0
- package/src/bundled-plugins/memory/memory-logger.ts +16 -15
- package/src/bundled-plugins/memory/memory-retrieval.ts +105 -0
- package/src/bundled-plugins/memory/migration.ts +282 -1
- package/src/bundled-plugins/memory/paths.ts +42 -0
- package/src/bundled-plugins/memory/search-tool.ts +232 -0
- package/src/bundled-plugins/memory/secret-detector.ts +2 -2
- package/src/bundled-plugins/memory/shard-snapshot.ts +51 -0
- package/src/bundled-plugins/memory/slug.ts +59 -0
- package/src/bundled-plugins/memory/stream-io.ts +110 -1
- package/src/bundled-plugins/memory/strength.ts +3 -3
- package/src/bundled-plugins/memory/topics.ts +70 -16
- package/src/bundled-plugins/security/index.ts +24 -0
- package/src/bundled-plugins/security/permissions.ts +4 -0
- package/src/bundled-plugins/security/policies/cron-promotion.ts +349 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +2 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +3 -0
- package/src/bundled-plugins/security/policies/role-promotion.ts +419 -0
- package/src/bundled-plugins/security/policies/system-prompt-leak.ts +1 -0
- package/src/channels/adapters/kakaotalk-attachment.ts +7 -17
- package/src/channels/adapters/kakaotalk.ts +64 -37
- package/src/channels/adapters/slack-bot-classify.ts +2 -27
- package/src/channels/index.ts +5 -0
- package/src/channels/router.ts +201 -17
- package/src/channels/subagent-completion-bridge.ts +84 -0
- package/src/cli/builtins.ts +1 -0
- package/src/cli/index.ts +1 -0
- package/src/cli/init.ts +122 -14
- package/src/cli/inspect.ts +151 -0
- package/src/cron/consumer.ts +1 -1
- package/src/init/dockerfile.ts +268 -4
- package/src/init/hatching.ts +5 -6
- package/src/init/kakaotalk-auth.ts +6 -47
- package/src/init/validate-api-key.ts +121 -0
- package/src/inspect/index.ts +213 -0
- package/src/inspect/label.ts +50 -0
- package/src/inspect/live.ts +221 -0
- package/src/inspect/render.ts +163 -0
- package/src/inspect/replay.ts +265 -0
- package/src/inspect/session-list.ts +160 -0
- package/src/inspect/types.ts +110 -0
- package/src/plugin/hooks.ts +23 -1
- package/src/plugin/index.ts +2 -0
- package/src/plugin/manager.ts +1 -1
- package/src/plugin/registry.ts +1 -1
- package/src/plugin/types.ts +10 -0
- package/src/run/channel-session-factory.ts +7 -1
- package/src/run/index.ts +87 -21
- package/src/secrets/kakao-renewal.ts +3 -47
- package/src/server/index.ts +241 -60
- package/src/shared/index.ts +3 -0
- package/src/shared/protocol.ts +49 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +9 -9
- package/src/skills/typeclaw-claude-code/SKILL.md +57 -39
- package/src/skills/typeclaw-claude-code/references/stop-hook.md +2 -0
- package/src/skills/typeclaw-claude-code/references/tmux-driving.md +102 -16
- package/src/skills/typeclaw-config/SKILL.md +1 -1
- package/src/skills/typeclaw-cron/SKILL.md +1 -1
- package/src/skills/typeclaw-memory/SKILL.md +16 -163
- package/src/skills/typeclaw-permissions/SKILL.md +2 -2
- package/src/skills/typeclaw-plugins/SKILL.md +25 -14
- package/src/test-helpers/wait-for.ts +7 -1
- package/typeclaw.schema.json +7 -0
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
|
-
import { readdir, readFile, unlink } from 'node:fs/promises'
|
|
2
|
+
import { cp, mkdir, readdir, readFile, rename, rm, rmdir, unlink, writeFile } from 'node:fs/promises'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
|
|
5
|
+
import { checkCitationSupersetAcrossShards, summarizeMissingCitations } from './citation-superset'
|
|
6
|
+
import { normalizeCitation, parseCitations } from './citations'
|
|
5
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'
|
|
6
18
|
import { newEventId, type StreamEvent, streamEventSchema, timestampFromId } from './stream-events'
|
|
7
19
|
import { writeEventsAtomic as defaultWriteEventsAtomic } from './stream-io'
|
|
20
|
+
import { parseTopicsWithBodies } from './topics'
|
|
8
21
|
|
|
9
22
|
export type MigrationResult = {
|
|
10
23
|
migrated: string[]
|
|
@@ -31,12 +44,104 @@ export type RunMigrationOptions = {
|
|
|
31
44
|
writeEventsAtomic?: (path: string, events: readonly StreamEvent[]) => Promise<void>
|
|
32
45
|
}
|
|
33
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
|
+
|
|
34
63
|
const DAILY_MD_NAME = /^(\d{4}-\d{2}-\d{2})\.md$/
|
|
35
64
|
const DAILY_JSONL_NAME = /^(\d{4}-\d{2}-\d{2})\.jsonl$/
|
|
36
65
|
const LEGACY_FRAGMENT_RE =
|
|
37
66
|
/<!-- fragment source=(\S+) entry=(\S+) -->\n## (.+)\n([\s\S]*?)(?=<!-- fragment |<!-- watermark |$)/g
|
|
38
67
|
const LEGACY_WATERMARK_RE = /<!-- watermark source=(\S+) entry=(\S+) -->/g
|
|
39
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)
|
|
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
|
+
|
|
40
145
|
export async function runMigration(options: RunMigrationOptions): Promise<MigrationResult> {
|
|
41
146
|
const memoryDir = join(options.agentDir, 'memory')
|
|
42
147
|
const result: MigrationResult = {
|
|
@@ -123,6 +228,129 @@ function collectDailyDates(entries: readonly string[]): string[] {
|
|
|
123
228
|
return Array.from(dates).sort()
|
|
124
229
|
}
|
|
125
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(agentDir: string, logger: MigrationLogger): Promise<void> {
|
|
245
|
+
if (!existsSync(topicsDir(agentDir))) return
|
|
246
|
+
|
|
247
|
+
let cleaned = false
|
|
248
|
+
const memoryPath = rootMemoryPath(agentDir)
|
|
249
|
+
if (existsSync(memoryPath)) {
|
|
250
|
+
await unlink(memoryPath)
|
|
251
|
+
cleaned = true
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const memoryDir = join(agentDir, 'memory')
|
|
255
|
+
const dates = await collectFlatJsonlDates(memoryDir)
|
|
256
|
+
for (const date of dates) {
|
|
257
|
+
if (!existsSync(streamFilePath(agentDir, date))) continue
|
|
258
|
+
await unlink(join(memoryDir, `${date}.jsonl`))
|
|
259
|
+
cleaned = true
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (cleaned) logger.info('[memory:migration] cleaned orphaned pre-shard memory files')
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function collectFlatJsonlDates(memoryDir: string): Promise<string[]> {
|
|
266
|
+
let entries: string[]
|
|
267
|
+
try {
|
|
268
|
+
entries = await readdir(memoryDir)
|
|
269
|
+
} catch {
|
|
270
|
+
return []
|
|
271
|
+
}
|
|
272
|
+
return entries
|
|
273
|
+
.map((entry) => DAILY_JSONL_NAME.exec(entry)?.[1])
|
|
274
|
+
.filter((date): date is string => date !== undefined)
|
|
275
|
+
.sort()
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function frontmatterForTopic(heading: string, body: string): ShardFrontmatter {
|
|
279
|
+
const citations = parseCitations(body)
|
|
280
|
+
const dates = [...citations.keys()].sort()
|
|
281
|
+
let cites = 0
|
|
282
|
+
for (const ids of citations.values()) cites += ids.size
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
heading,
|
|
286
|
+
cites,
|
|
287
|
+
days: dates.length,
|
|
288
|
+
lastReinforced: dates.at(-1) ?? todayDate(),
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function readShardTexts(dir: string): Promise<Map<string, string>> {
|
|
293
|
+
const entries = (await readdir(dir)).filter((entry) => entry.endsWith('.md')).sort()
|
|
294
|
+
const out = new Map<string, string>()
|
|
295
|
+
for (const entry of entries) {
|
|
296
|
+
out.set(entry, await readFile(join(dir, entry), 'utf8'))
|
|
297
|
+
}
|
|
298
|
+
return out
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function finalizeShardingMigration(
|
|
302
|
+
agentDir: string,
|
|
303
|
+
streamDates: readonly string[],
|
|
304
|
+
logger: MigrationLogger,
|
|
305
|
+
): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
306
|
+
const tmpDir = migratingTmpDir(agentDir)
|
|
307
|
+
const renames: Array<[string, string]> = [
|
|
308
|
+
[join(tmpDir, 'topics'), topicsDir(agentDir)],
|
|
309
|
+
[join(tmpDir, 'streams'), streamsDir(agentDir)],
|
|
310
|
+
[join(tmpDir, 'MEMORY.md.pre-shard.bak'), preShardBackupPath(agentDir)],
|
|
311
|
+
]
|
|
312
|
+
|
|
313
|
+
for (const [from, to] of renames) {
|
|
314
|
+
try {
|
|
315
|
+
await rename(from, to)
|
|
316
|
+
} catch (err) {
|
|
317
|
+
const error = `failed to finalize sharding migration: ${describeError(err)}`
|
|
318
|
+
logger.error(`[memory:migration] ${error}`)
|
|
319
|
+
return { ok: false, error }
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
for (const date of streamDates) {
|
|
324
|
+
try {
|
|
325
|
+
await unlink(join(agentDir, 'memory', `${date}.jsonl`))
|
|
326
|
+
} catch (err) {
|
|
327
|
+
logger.warn(`[memory:migration] failed to remove flat stream ${date}.jsonl: ${describeError(err)}`)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
try {
|
|
332
|
+
await unlink(rootMemoryPath(agentDir))
|
|
333
|
+
} catch (err) {
|
|
334
|
+
logger.warn(`[memory:migration] failed to remove root MEMORY.md: ${describeError(err)}`)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
try {
|
|
338
|
+
await rmdir(tmpDir)
|
|
339
|
+
} catch (err) {
|
|
340
|
+
logger.warn(`[memory:migration] failed to remove sharding tmpdir: ${describeError(err)}`)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return { ok: true }
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function rootMemoryPath(agentDir: string): string {
|
|
347
|
+
return join(agentDir, 'MEMORY.md')
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function todayDate(): string {
|
|
351
|
+
return new Date().toISOString().slice(0, 10)
|
|
352
|
+
}
|
|
353
|
+
|
|
126
354
|
function parseLegacyMarkdown(content: string): StreamEvent[] {
|
|
127
355
|
const events: StreamEvent[] = []
|
|
128
356
|
let cursor = 0
|
|
@@ -259,6 +487,59 @@ async function commitMigration(
|
|
|
259
487
|
}
|
|
260
488
|
}
|
|
261
489
|
|
|
490
|
+
async function commitShardingMigration(
|
|
491
|
+
agentDir: string,
|
|
492
|
+
details: { slugs: readonly string[]; streamDates: readonly string[]; hadRootMemory: boolean },
|
|
493
|
+
logger: MigrationLogger,
|
|
494
|
+
git: MigrationGit | undefined,
|
|
495
|
+
): Promise<void> {
|
|
496
|
+
const spawn = git?.spawn ?? spawnGit
|
|
497
|
+
const inside = await spawn(['rev-parse', '--is-inside-work-tree'], { cwd: agentDir })
|
|
498
|
+
if (inside.exitCode !== 0) {
|
|
499
|
+
logger.info('[memory:migration] not in a git repo; skipping git commit')
|
|
500
|
+
return
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const newPaths = [
|
|
504
|
+
...details.slugs.map((slug) => `memory/topics/${slug}.md`),
|
|
505
|
+
...details.streamDates.map((date) => `memory/streams/${date}.jsonl`),
|
|
506
|
+
`memory/${PRE_SHARD_BACKUP_FILENAME}`,
|
|
507
|
+
]
|
|
508
|
+
const addNew = await spawn(['add', '--', ...newPaths], { cwd: agentDir })
|
|
509
|
+
if (addNew.exitCode !== 0) {
|
|
510
|
+
logger.warn(`[memory:migration] git add failed: ${addNew.stderr || addNew.stdout}`.trim())
|
|
511
|
+
return
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const candidateDeletions = [...details.streamDates.map((date) => `memory/${date}.jsonl`)]
|
|
515
|
+
if (details.hadRootMemory) candidateDeletions.push('MEMORY.md')
|
|
516
|
+
const trackedDeletions: string[] = []
|
|
517
|
+
for (const path of candidateDeletions) {
|
|
518
|
+
const tracked = await spawn(['ls-files', '--error-unmatch', '--', path], { cwd: agentDir })
|
|
519
|
+
if (tracked.exitCode === 0) trackedDeletions.push(path)
|
|
520
|
+
}
|
|
521
|
+
if (trackedDeletions.length > 0) {
|
|
522
|
+
const addDeletions = await spawn(['add', '-u', '--', ...trackedDeletions], { cwd: agentDir })
|
|
523
|
+
if (addDeletions.exitCode !== 0) {
|
|
524
|
+
logger.warn(`[memory:migration] git add failed: ${addDeletions.stderr || addDeletions.stdout}`.trim())
|
|
525
|
+
return
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const commitSharding = await spawn(
|
|
530
|
+
[
|
|
531
|
+
'commit',
|
|
532
|
+
'-m',
|
|
533
|
+
`memory: shard MEMORY.md into ${details.slugs.length} topic(s) and ${details.streamDates.length} daily stream(s)`,
|
|
534
|
+
'--no-edit',
|
|
535
|
+
],
|
|
536
|
+
{ cwd: agentDir },
|
|
537
|
+
)
|
|
538
|
+
if (commitSharding.exitCode !== 0) {
|
|
539
|
+
logger.warn(`[memory:migration] git commit failed: ${commitSharding.stderr || commitSharding.stdout}`.trim())
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
262
543
|
async function spawnGit(
|
|
263
544
|
args: string[],
|
|
264
545
|
options: { cwd: string },
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
|
|
3
|
+
export { DREAMING_STATE_FILE } from './dreaming-state'
|
|
4
|
+
|
|
5
|
+
export const MEMORY_DIR = 'memory'
|
|
6
|
+
export const TOPICS_SUBDIR = 'topics'
|
|
7
|
+
export const STREAMS_SUBDIR = 'streams'
|
|
8
|
+
export const SKILLS_SUBDIR = 'skills'
|
|
9
|
+
export const PRE_SHARD_BACKUP_FILENAME = 'MEMORY.md.pre-shard.bak'
|
|
10
|
+
export const MIGRATING_TMPDIR = 'memory/.migrating'
|
|
11
|
+
|
|
12
|
+
const STREAM_DATE_RE = /^\d{4}-\d{2}-\d{2}$/
|
|
13
|
+
|
|
14
|
+
export function topicsDir(agentDir: string): string {
|
|
15
|
+
return join(agentDir, MEMORY_DIR, TOPICS_SUBDIR)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function streamsDir(agentDir: string): string {
|
|
19
|
+
return join(agentDir, MEMORY_DIR, STREAMS_SUBDIR)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function topicShardPath(agentDir: string, slug: string): string {
|
|
23
|
+
if (slug.includes('..') || slug.includes('/') || slug.includes('\\') || slug.startsWith('.')) {
|
|
24
|
+
throw new Error(`invalid topic slug: ${JSON.stringify(slug)}`)
|
|
25
|
+
}
|
|
26
|
+
return join(agentDir, MEMORY_DIR, TOPICS_SUBDIR, `${slug}.md`)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function streamFilePath(agentDir: string, date: string): string {
|
|
30
|
+
if (!STREAM_DATE_RE.test(date)) {
|
|
31
|
+
throw new Error(`invalid stream date: ${JSON.stringify(date)}`)
|
|
32
|
+
}
|
|
33
|
+
return join(agentDir, MEMORY_DIR, STREAMS_SUBDIR, `${date}.jsonl`)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function preShardBackupPath(agentDir: string): string {
|
|
37
|
+
return join(agentDir, MEMORY_DIR, PRE_SHARD_BACKUP_FILENAME)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function migratingTmpDir(agentDir: string): string {
|
|
41
|
+
return join(agentDir, MEMORY_DIR, '.migrating')
|
|
42
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
import { defineTool } from '@/plugin'
|
|
4
|
+
|
|
5
|
+
import { loadAllShards, type TopicShard } from './load-shards'
|
|
6
|
+
import type { FragmentEvent, LegacyProseEvent, StreamEvent } from './stream-events'
|
|
7
|
+
import { readAllUndreamedStreamDays, type UndreamedStreamDay } from './stream-io'
|
|
8
|
+
|
|
9
|
+
const DEFAULT_MAX_RESULTS = 10
|
|
10
|
+
const EXCERPT_CONTEXT_LINES = 3
|
|
11
|
+
|
|
12
|
+
type TopicMatch = {
|
|
13
|
+
source: 'topic'
|
|
14
|
+
shardPath: string
|
|
15
|
+
slug: string
|
|
16
|
+
heading: string
|
|
17
|
+
excerpt: string
|
|
18
|
+
fullBody?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type StreamMatch = {
|
|
22
|
+
source: 'stream'
|
|
23
|
+
streamPath: string
|
|
24
|
+
date: string
|
|
25
|
+
eventId?: string
|
|
26
|
+
topic: string
|
|
27
|
+
excerpt: string
|
|
28
|
+
fullBody?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type MemorySearchMatch = TopicMatch | StreamMatch
|
|
32
|
+
|
|
33
|
+
type MemorySearchResult = { matches: MemorySearchMatch[]; truncatedAt?: number } | { error: string }
|
|
34
|
+
|
|
35
|
+
type Matcher = (haystack: string) => boolean
|
|
36
|
+
|
|
37
|
+
export const memorySearchTool = defineTool({
|
|
38
|
+
description:
|
|
39
|
+
'Search the agent\'s long-term memory. Covers both topic shards under memory/topics/ (consolidated facts) and undreamed daily-stream events under memory/streams/ (recent fragments not yet folded into shards). Case-insensitive substring by default; asRegex=true treats query as a JavaScript regex. Returns matches discriminated by `source: "topic" | "stream"`, each with line-context excerpts; full=true includes complete bodies. Topic matches come first (alphabetical by slug), then stream matches (newest day first).',
|
|
40
|
+
parameters: z.object({
|
|
41
|
+
query: z.string(),
|
|
42
|
+
asRegex: z.boolean().default(false),
|
|
43
|
+
full: z.boolean().default(false),
|
|
44
|
+
maxResults: z.number().int().min(0).default(DEFAULT_MAX_RESULTS),
|
|
45
|
+
}),
|
|
46
|
+
async execute({ query, asRegex, full, maxResults }, ctx) {
|
|
47
|
+
const matcherOrError = buildMatcher(query, asRegex)
|
|
48
|
+
if (typeof matcherOrError === 'string') {
|
|
49
|
+
return resultToToolResult({ error: matcherOrError })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const [shards, streamDays] = await Promise.all([
|
|
53
|
+
loadAllShards(ctx.agentDir, { logger: ctx.logger }),
|
|
54
|
+
readAllUndreamedStreamDays(ctx.agentDir),
|
|
55
|
+
])
|
|
56
|
+
if (shards.length === 0 && streamDays.length === 0) {
|
|
57
|
+
return resultToToolResult({ matches: [], truncatedAt: 0 })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const result = searchAll(shards, streamDays, matcherOrError, { full, maxResults })
|
|
61
|
+
return resultToToolResult(result)
|
|
62
|
+
},
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
function buildMatcher(query: string, asRegex: boolean): Matcher | string {
|
|
66
|
+
if (asRegex) {
|
|
67
|
+
try {
|
|
68
|
+
const regex = new RegExp(query, 'i')
|
|
69
|
+
return (haystack) => regex.test(haystack)
|
|
70
|
+
} catch (err) {
|
|
71
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
72
|
+
return `invalid regex: ${message}`
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const needle = query.toLowerCase()
|
|
77
|
+
return (haystack) => haystack.toLowerCase().includes(needle)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Result-ordering contract: topic shards first (alphabetical by slug, the
|
|
81
|
+
// order `loadAllShards` returns), then stream days (newest first). Truncation
|
|
82
|
+
// cuts from the tail of this concatenation — stream matches are sacrificed
|
|
83
|
+
// before topic matches when `maxResults` is exhausted. The agent reading
|
|
84
|
+
// results in this order sees long-term consolidated truth before recent
|
|
85
|
+
// ephemeral fragments, which mirrors the injection-side rendering order.
|
|
86
|
+
function searchAll(
|
|
87
|
+
shards: TopicShard[],
|
|
88
|
+
streamDays: UndreamedStreamDay[],
|
|
89
|
+
matcher: Matcher,
|
|
90
|
+
options: { full: boolean; maxResults: number },
|
|
91
|
+
): MemorySearchResult {
|
|
92
|
+
const matches: MemorySearchMatch[] = []
|
|
93
|
+
let truncatedAt: number | undefined
|
|
94
|
+
|
|
95
|
+
const push = (match: MemorySearchMatch): boolean => {
|
|
96
|
+
if (matches.length >= options.maxResults) {
|
|
97
|
+
truncatedAt = options.maxResults
|
|
98
|
+
return false
|
|
99
|
+
}
|
|
100
|
+
matches.push(match)
|
|
101
|
+
return true
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
for (const shard of shards) {
|
|
105
|
+
const match = matchShard(shard, matcher, options.full)
|
|
106
|
+
if (match === null) continue
|
|
107
|
+
if (!push(match)) return { matches, truncatedAt: truncatedAt! }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
for (let i = streamDays.length - 1; i >= 0; i--) {
|
|
111
|
+
const day = streamDays[i]!
|
|
112
|
+
for (const event of day.events) {
|
|
113
|
+
const match = matchStreamEvent(day, event, matcher, options.full)
|
|
114
|
+
if (match === null) continue
|
|
115
|
+
if (!push(match)) return { matches, truncatedAt: truncatedAt! }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return truncatedAt === undefined ? { matches } : { matches, truncatedAt }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function matchShard(shard: TopicShard, matcher: Matcher, full: boolean): TopicMatch | null {
|
|
123
|
+
const bodyLines = splitBodyLines(shard.body)
|
|
124
|
+
const firstBodyLineIndex = bodyLines.findIndex((line) => matcher(line))
|
|
125
|
+
|
|
126
|
+
const matched =
|
|
127
|
+
matcher(shard.slug) ||
|
|
128
|
+
matcher(shard.frontmatter.heading) ||
|
|
129
|
+
(shard.frontmatter.tags?.some((tag) => matcher(tag)) ?? false) ||
|
|
130
|
+
firstBodyLineIndex !== -1
|
|
131
|
+
if (!matched) return null
|
|
132
|
+
|
|
133
|
+
const match: TopicMatch = {
|
|
134
|
+
source: 'topic',
|
|
135
|
+
shardPath: shard.path,
|
|
136
|
+
slug: shard.slug,
|
|
137
|
+
heading: shard.frontmatter.heading,
|
|
138
|
+
excerpt:
|
|
139
|
+
firstBodyLineIndex === -1 ? fallbackExcerpt(shard, matcher) : excerptForLine(bodyLines, firstBodyLineIndex),
|
|
140
|
+
}
|
|
141
|
+
if (full) match.fullBody = shard.body
|
|
142
|
+
return match
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Stream-event matcher. `fragment` events expose `topic` + `body` for search;
|
|
146
|
+
// `legacy_prose` exposes `text` (no id, no topic). `watermark` events carry
|
|
147
|
+
// no human content and are skipped — they only mark dreaming progress.
|
|
148
|
+
//
|
|
149
|
+
// Fragment matches set `eventId` to the canonical citation format
|
|
150
|
+
// `streams/yyyy-MM-dd#<id>` so the agent can paste search hits straight into
|
|
151
|
+
// shard citations. Legacy prose has no fragment id and therefore omits
|
|
152
|
+
// `eventId` — `parseCitations` would reject any value we synthesised, so we
|
|
153
|
+
// leave the field absent to make the asymmetry visible to the agent.
|
|
154
|
+
function matchStreamEvent(
|
|
155
|
+
day: UndreamedStreamDay,
|
|
156
|
+
event: StreamEvent,
|
|
157
|
+
matcher: Matcher,
|
|
158
|
+
full: boolean,
|
|
159
|
+
): StreamMatch | null {
|
|
160
|
+
if (event.type === 'watermark') return null
|
|
161
|
+
if (event.type === 'fragment') return matchFragmentEvent(day, event, matcher, full)
|
|
162
|
+
return matchLegacyProseEvent(day, event, matcher, full)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function matchFragmentEvent(
|
|
166
|
+
day: UndreamedStreamDay,
|
|
167
|
+
event: FragmentEvent,
|
|
168
|
+
matcher: Matcher,
|
|
169
|
+
full: boolean,
|
|
170
|
+
): StreamMatch | null {
|
|
171
|
+
const bodyLines = splitBodyLines(event.body)
|
|
172
|
+
const firstBodyLineIndex = bodyLines.findIndex((line) => matcher(line))
|
|
173
|
+
const matched = matcher(event.topic) || firstBodyLineIndex !== -1
|
|
174
|
+
if (!matched) return null
|
|
175
|
+
|
|
176
|
+
const match: StreamMatch = {
|
|
177
|
+
source: 'stream',
|
|
178
|
+
streamPath: day.path,
|
|
179
|
+
date: day.date,
|
|
180
|
+
eventId: `streams/${day.date}#${event.id}`,
|
|
181
|
+
topic: event.topic,
|
|
182
|
+
excerpt: firstBodyLineIndex === -1 ? event.topic : excerptForLine(bodyLines, firstBodyLineIndex),
|
|
183
|
+
}
|
|
184
|
+
if (full) match.fullBody = event.body
|
|
185
|
+
return match
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function matchLegacyProseEvent(
|
|
189
|
+
day: UndreamedStreamDay,
|
|
190
|
+
event: LegacyProseEvent,
|
|
191
|
+
matcher: Matcher,
|
|
192
|
+
full: boolean,
|
|
193
|
+
): StreamMatch | null {
|
|
194
|
+
const lines = splitBodyLines(event.text)
|
|
195
|
+
const firstLineIndex = lines.findIndex((line) => matcher(line))
|
|
196
|
+
if (firstLineIndex === -1) return null
|
|
197
|
+
|
|
198
|
+
const match: StreamMatch = {
|
|
199
|
+
source: 'stream',
|
|
200
|
+
streamPath: day.path,
|
|
201
|
+
date: day.date,
|
|
202
|
+
topic: '[legacy prose from pre-shard migration]',
|
|
203
|
+
excerpt: excerptForLine(lines, firstLineIndex),
|
|
204
|
+
}
|
|
205
|
+
if (full) match.fullBody = event.text
|
|
206
|
+
return match
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function splitBodyLines(body: string): string[] {
|
|
210
|
+
const lines = body.split('\n')
|
|
211
|
+
return lines.length > 0 && lines[lines.length - 1] === '' ? lines.slice(0, -1) : lines
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function fallbackExcerpt(shard: TopicShard, matcher: Matcher): string {
|
|
215
|
+
if (matcher(shard.frontmatter.heading)) return shard.frontmatter.heading
|
|
216
|
+
if (matcher(shard.slug)) return shard.slug
|
|
217
|
+
const matchedTag = shard.frontmatter.tags?.find((tag) => matcher(tag))
|
|
218
|
+
return matchedTag ?? shard.frontmatter.heading
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function excerptForLine(lines: string[], matchIndex: number): string {
|
|
222
|
+
const start = Math.max(0, matchIndex - EXCERPT_CONTEXT_LINES)
|
|
223
|
+
const end = Math.min(lines.length, matchIndex + EXCERPT_CONTEXT_LINES + 1)
|
|
224
|
+
return lines.slice(start, end).join('\n')
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function resultToToolResult(result: MemorySearchResult) {
|
|
228
|
+
return {
|
|
229
|
+
content: [{ type: 'text' as const, text: JSON.stringify(result) }],
|
|
230
|
+
details: result,
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Defense-in-depth backstop against credential leakage into memory streams.
|
|
2
2
|
// The memory-logger system prompt forbids quoting secret values, but the LLM
|
|
3
3
|
// occasionally violates that rule by quoting `env | grep` output verbatim as
|
|
4
|
-
// "evidence". Once a secret reaches a daily stream file, dreaming
|
|
5
|
-
// into
|
|
4
|
+
// "evidence". Once a secret reaches a daily stream file, dreaming can promote it
|
|
5
|
+
// into memory/topics/ and the runtime force-commits both to git — at which point
|
|
6
6
|
// rotation is the only recourse. We deliberately avoid generic high-entropy
|
|
7
7
|
// heuristics: false positives here would silently lose legitimate fragments.
|
|
8
8
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile, unlink, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { dirname, extname, resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export async function captureShardSnapshot(topicsDir: string): Promise<Map<string, Buffer>> {
|
|
5
|
+
let entries: string[]
|
|
6
|
+
try {
|
|
7
|
+
entries = await readdir(topicsDir)
|
|
8
|
+
} catch (e) {
|
|
9
|
+
if ((e as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
10
|
+
return new Map()
|
|
11
|
+
}
|
|
12
|
+
throw e
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const snapshot = new Map<string, Buffer>()
|
|
16
|
+
for (const entry of entries) {
|
|
17
|
+
if (extname(entry) !== '.md') continue
|
|
18
|
+
const absPath = resolve(topicsDir, entry)
|
|
19
|
+
const bytes = await readFile(absPath)
|
|
20
|
+
snapshot.set(absPath, bytes)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const sorted = new Map([...snapshot.entries()].sort((a, b) => a[0].localeCompare(b[0])))
|
|
24
|
+
return sorted
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function restoreShardSnapshot(snapshot: Map<string, Buffer>, topicsDir: string): Promise<void> {
|
|
28
|
+
for (const [absPath, bytes] of snapshot) {
|
|
29
|
+
await mkdir(dirname(absPath), { recursive: true })
|
|
30
|
+
await writeFile(absPath, bytes)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let currentEntries: string[]
|
|
34
|
+
try {
|
|
35
|
+
currentEntries = await readdir(topicsDir)
|
|
36
|
+
} catch (e) {
|
|
37
|
+
if ((e as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
throw e
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const snapshotKeys = new Set(snapshot.keys())
|
|
44
|
+
for (const entry of currentEntries) {
|
|
45
|
+
if (extname(entry) !== '.md') continue
|
|
46
|
+
const absPath = resolve(topicsDir, entry)
|
|
47
|
+
if (!snapshotKeys.has(absPath)) {
|
|
48
|
+
await unlink(absPath)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|