typeclaw 0.7.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.
Files changed (107) hide show
  1. package/README.md +15 -9
  2. package/package.json +5 -3
  3. package/scripts/dump-system-prompt.ts +12 -1
  4. package/scripts/require-parallel.ts +41 -0
  5. package/src/agent/auth.ts +3 -3
  6. package/src/agent/index.ts +116 -14
  7. package/src/agent/live-sessions.ts +34 -0
  8. package/src/agent/multimodal/read-redirect.ts +43 -0
  9. package/src/agent/plugin-tools.ts +97 -13
  10. package/src/agent/session-meta.ts +21 -2
  11. package/src/agent/session-origin.ts +6 -13
  12. package/src/agent/subagent-completion-reminder.ts +89 -0
  13. package/src/agent/subagents.ts +3 -2
  14. package/src/agent/system-prompt.ts +49 -15
  15. package/src/bundled-plugins/explorer/explorer.ts +2 -2
  16. package/src/bundled-plugins/guard/index.ts +14 -1
  17. package/src/bundled-plugins/guard/policies/managed-config.ts +43 -13
  18. package/src/bundled-plugins/guard/policies/memory-retrieval-cache-write.ts +37 -0
  19. package/src/bundled-plugins/guard/policies/memory-topics-delete.ts +67 -0
  20. package/src/bundled-plugins/guard/policies/memory-topics-write.ts +33 -0
  21. package/src/bundled-plugins/guard/policies/non-workspace-write.ts +8 -2
  22. package/src/bundled-plugins/guard/policy.ts +7 -0
  23. package/src/bundled-plugins/memory/README.md +76 -62
  24. package/src/bundled-plugins/memory/append-tool.ts +3 -2
  25. package/src/bundled-plugins/memory/citation-superset.ts +49 -11
  26. package/src/bundled-plugins/memory/citations.ts +19 -8
  27. package/src/bundled-plugins/memory/delete-tool.ts +57 -0
  28. package/src/bundled-plugins/memory/dreaming-state.ts +1 -1
  29. package/src/bundled-plugins/memory/dreaming.ts +364 -146
  30. package/src/bundled-plugins/memory/frontmatter.ts +165 -0
  31. package/src/bundled-plugins/memory/index.ts +236 -16
  32. package/src/bundled-plugins/memory/injection-plan.ts +15 -0
  33. package/src/bundled-plugins/memory/load-memory.ts +102 -103
  34. package/src/bundled-plugins/memory/load-shards.ts +156 -0
  35. package/src/bundled-plugins/memory/memory-logger.ts +16 -15
  36. package/src/bundled-plugins/memory/memory-retrieval.ts +105 -0
  37. package/src/bundled-plugins/memory/migration.ts +282 -1
  38. package/src/bundled-plugins/memory/paths.ts +42 -0
  39. package/src/bundled-plugins/memory/search-tool.ts +232 -0
  40. package/src/bundled-plugins/memory/secret-detector.ts +2 -2
  41. package/src/bundled-plugins/memory/shard-snapshot.ts +51 -0
  42. package/src/bundled-plugins/memory/slug.ts +59 -0
  43. package/src/bundled-plugins/memory/stream-io.ts +110 -1
  44. package/src/bundled-plugins/memory/strength.ts +3 -3
  45. package/src/bundled-plugins/memory/topics.ts +70 -16
  46. package/src/bundled-plugins/security/index.ts +24 -0
  47. package/src/bundled-plugins/security/permissions.ts +4 -0
  48. package/src/bundled-plugins/security/policies/cron-promotion.ts +349 -0
  49. package/src/bundled-plugins/security/policies/git-exfil.ts +2 -0
  50. package/src/bundled-plugins/security/policies/prompt-injection.ts +3 -0
  51. package/src/bundled-plugins/security/policies/role-promotion.ts +419 -0
  52. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +1 -0
  53. package/src/channels/adapters/discord-bot-slash-commands.ts +186 -0
  54. package/src/channels/adapters/discord-bot.ts +163 -1
  55. package/src/channels/adapters/kakaotalk-attachment.ts +7 -17
  56. package/src/channels/adapters/kakaotalk.ts +64 -37
  57. package/src/channels/adapters/slack-bot-classify.ts +2 -27
  58. package/src/channels/adapters/slack-bot-slash-commands.ts +82 -0
  59. package/src/channels/adapters/slack-bot.ts +139 -1
  60. package/src/channels/index.ts +5 -0
  61. package/src/channels/router.ts +328 -18
  62. package/src/channels/subagent-completion-bridge.ts +84 -0
  63. package/src/cli/builtins.ts +1 -0
  64. package/src/cli/index.ts +1 -0
  65. package/src/cli/init.ts +122 -14
  66. package/src/cli/inspect.ts +151 -0
  67. package/src/cli/role.ts +7 -2
  68. package/src/cli/tunnel.ts +13 -1
  69. package/src/cli/ui.ts +25 -1
  70. package/src/config/index.ts +1 -0
  71. package/src/config/models-mutation.ts +10 -2
  72. package/src/cron/consumer.ts +1 -1
  73. package/src/init/dockerfile.ts +353 -2
  74. package/src/init/hatching.ts +5 -6
  75. package/src/init/kakaotalk-auth.ts +6 -47
  76. package/src/init/validate-api-key.ts +121 -0
  77. package/src/inspect/index.ts +213 -0
  78. package/src/inspect/label.ts +50 -0
  79. package/src/inspect/live.ts +221 -0
  80. package/src/inspect/render.ts +163 -0
  81. package/src/inspect/replay.ts +265 -0
  82. package/src/inspect/session-list.ts +160 -0
  83. package/src/inspect/types.ts +110 -0
  84. package/src/plugin/hooks.ts +23 -1
  85. package/src/plugin/index.ts +2 -0
  86. package/src/plugin/manager.ts +1 -1
  87. package/src/plugin/registry.ts +1 -1
  88. package/src/plugin/types.ts +10 -0
  89. package/src/run/channel-session-factory.ts +7 -1
  90. package/src/run/index.ts +87 -21
  91. package/src/secrets/kakao-renewal.ts +3 -47
  92. package/src/server/index.ts +241 -60
  93. package/src/shared/index.ts +4 -1
  94. package/src/shared/local-time.ts +17 -0
  95. package/src/shared/protocol.ts +49 -0
  96. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +9 -9
  97. package/src/skills/typeclaw-claude-code/SKILL.md +83 -40
  98. package/src/skills/typeclaw-claude-code/references/stop-hook.md +2 -0
  99. package/src/skills/typeclaw-claude-code/references/tmux-driving.md +102 -16
  100. package/src/skills/typeclaw-config/SKILL.md +38 -33
  101. package/src/skills/typeclaw-cron/SKILL.md +1 -1
  102. package/src/skills/typeclaw-git/SKILL.md +2 -2
  103. package/src/skills/typeclaw-memory/SKILL.md +16 -163
  104. package/src/skills/typeclaw-permissions/SKILL.md +2 -2
  105. package/src/skills/typeclaw-plugins/SKILL.md +26 -15
  106. package/src/test-helpers/wait-for.ts +7 -1
  107. 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 promotes it
5
- // into MEMORY.md and the runtime force-commits both to git — at which point
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
+ }