typeclaw 0.36.7 → 0.37.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 (112) hide show
  1. package/README.md +2 -2
  2. package/package.json +3 -2
  3. package/src/agent/index.ts +31 -11
  4. package/src/agent/live-sessions.ts +12 -0
  5. package/src/agent/model-fallback.ts +17 -15
  6. package/src/agent/model-overrides.ts +2 -2
  7. package/src/agent/session-meta.ts +10 -0
  8. package/src/agent/subagents.ts +11 -2
  9. package/src/agent/system-prompt.ts +9 -3
  10. package/src/agent/todo/continuation-policy.ts +6 -3
  11. package/src/agent/todo/continuation-wiring.ts +4 -2
  12. package/src/agent/todo/continuation.ts +3 -3
  13. package/src/agent/tools/todo/index.ts +27 -4
  14. package/src/bundled-plugins/agent-browser/index.ts +33 -108
  15. package/src/bundled-plugins/agent-browser/shim.ts +3 -94
  16. package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +8 -33
  17. package/src/bundled-plugins/doc-render/skills/typeclaw-render-pdf/SKILL.md +2 -2
  18. package/src/bundled-plugins/guard/policies/memory-retrieval-cache-write.ts +7 -1
  19. package/src/bundled-plugins/memory/README.md +80 -23
  20. package/src/bundled-plugins/memory/append-tool.ts +74 -53
  21. package/src/bundled-plugins/memory/citation-superset.ts +4 -0
  22. package/src/bundled-plugins/memory/citations.ts +54 -0
  23. package/src/bundled-plugins/memory/dreaming-metrics.ts +30 -0
  24. package/src/bundled-plugins/memory/dreaming.ts +444 -21
  25. package/src/bundled-plugins/memory/index.ts +544 -400
  26. package/src/bundled-plugins/memory/load-memory.ts +87 -10
  27. package/src/bundled-plugins/memory/load-shards.ts +48 -22
  28. package/src/bundled-plugins/memory/memory-logger.ts +95 -106
  29. package/src/bundled-plugins/memory/memory-retrieval.ts +3 -3
  30. package/src/bundled-plugins/memory/parent-link.ts +33 -0
  31. package/src/bundled-plugins/memory/paths.ts +12 -0
  32. package/src/bundled-plugins/memory/references/frontmatter.ts +197 -0
  33. package/src/bundled-plugins/memory/references/load-references.ts +212 -0
  34. package/src/bundled-plugins/memory/references/store-reference-tool.ts +59 -0
  35. package/src/bundled-plugins/memory/search-tool.ts +282 -45
  36. package/src/bundled-plugins/memory/stream-events.ts +1 -0
  37. package/src/bundled-plugins/memory/stream-io.ts +28 -3
  38. package/src/bundled-plugins/memory/turn-dedup.ts +40 -0
  39. package/src/bundled-plugins/memory/vector/cache-write.ts +19 -0
  40. package/src/bundled-plugins/memory/vector/config.ts +28 -0
  41. package/src/bundled-plugins/memory/vector/doctor.ts +124 -0
  42. package/src/bundled-plugins/memory/vector/embedder.ts +246 -0
  43. package/src/bundled-plugins/memory/vector/hybrid.ts +439 -0
  44. package/src/bundled-plugins/memory/vector/index-on-write.ts +34 -0
  45. package/src/bundled-plugins/memory/vector/inspect.ts +111 -0
  46. package/src/bundled-plugins/memory/vector/passages.ts +125 -0
  47. package/src/bundled-plugins/memory/vector/reference-index-on-write.ts +50 -0
  48. package/src/bundled-plugins/memory/vector/relevance-gate.ts +93 -0
  49. package/src/bundled-plugins/memory/vector/startup.ts +71 -0
  50. package/src/bundled-plugins/memory/vector/store.ts +203 -0
  51. package/src/bundled-plugins/memory/vector/truncation.ts +124 -0
  52. package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +2 -0
  53. package/src/channels/router.ts +239 -40
  54. package/src/cli/incomplete-init.ts +57 -0
  55. package/src/cli/init.ts +143 -12
  56. package/src/cli/inspect.ts +11 -5
  57. package/src/cli/model.ts +112 -34
  58. package/src/cli/restart.ts +24 -0
  59. package/src/cli/start.ts +24 -0
  60. package/src/cli/tunnel.ts +53 -8
  61. package/src/config/config.ts +110 -19
  62. package/src/config/index.ts +5 -1
  63. package/src/config/models-mutation.ts +29 -11
  64. package/src/config/providers-mutation.ts +2 -2
  65. package/src/config/providers.ts +146 -12
  66. package/src/container/shared.ts +9 -0
  67. package/src/container/start.ts +87 -4
  68. package/src/cron/consumer.ts +13 -7
  69. package/src/hostd/models.ts +64 -0
  70. package/src/hostd/paths.ts +6 -0
  71. package/src/hostd/portbroker-manager.ts +2 -2
  72. package/src/init/checkpoint.ts +201 -0
  73. package/src/init/dockerfile.ts +164 -51
  74. package/src/init/gitignore.ts +7 -7
  75. package/src/init/index.ts +41 -9
  76. package/src/init/line-auth.ts +50 -21
  77. package/src/init/models-dev.ts +96 -21
  78. package/src/init/oauth-login.ts +3 -3
  79. package/src/init/progress.ts +29 -0
  80. package/src/init/validate-api-key.ts +4 -0
  81. package/src/inspect/index.ts +13 -6
  82. package/src/inspect/item-list.ts +11 -2
  83. package/src/inspect/live-list.ts +65 -0
  84. package/src/inspect/open-item.ts +22 -1
  85. package/src/inspect/session-list.ts +29 -0
  86. package/src/models/embedding-model.ts +114 -0
  87. package/src/models/transformers-version.ts +55 -0
  88. package/src/plugin/types.ts +3 -0
  89. package/src/portbroker/container-server.ts +23 -0
  90. package/src/portbroker/forward-request-bus.ts +35 -0
  91. package/src/portbroker/forward-result-bus.ts +2 -3
  92. package/src/portbroker/hostd-client.ts +182 -36
  93. package/src/portbroker/index.ts +6 -1
  94. package/src/portbroker/protocol.ts +9 -2
  95. package/src/run/channel-session-factory.ts +11 -1
  96. package/src/run/index.ts +41 -7
  97. package/src/server/command-runner.ts +24 -1
  98. package/src/server/index.ts +42 -8
  99. package/src/shared/index.ts +2 -0
  100. package/src/shared/protocol.ts +31 -0
  101. package/src/skills/typeclaw-channels/SKILL.md +4 -4
  102. package/src/skills/typeclaw-config/SKILL.md +2 -2
  103. package/src/skills/typeclaw-memory/SKILL.md +3 -1
  104. package/src/skills/typeclaw-permissions/SKILL.md +3 -3
  105. package/src/skills/typeclaw-skills/SKILL.md +1 -1
  106. package/src/skills/typeclaw-tunnels/SKILL.md +22 -1
  107. package/src/tunnels/providers/cloudflare-quick.ts +65 -7
  108. package/src/tunnels/upstream-probe.ts +25 -0
  109. package/typeclaw.schema.json +156 -67
  110. package/src/bundled-plugins/agent-browser/dashboard-discovery.ts +0 -170
  111. package/src/bundled-plugins/agent-browser/dashboard-proxy.ts +0 -421
  112. package/src/portbroker/bind-with-forward.ts +0 -102
@@ -2,7 +2,7 @@ import { z } from 'zod'
2
2
 
3
3
  import { lsTool, readTool, type Subagent, writeTool } from '@/plugin'
4
4
 
5
- import { memorySearchTool } from './search-tool'
5
+ import { createMemorySearchTool } from './search-tool'
6
6
 
7
7
  export const memoryRetrievalPayloadSchema = z.object({
8
8
  parentSessionId: z.string().min(1),
@@ -29,7 +29,7 @@ export type CreateMemoryRetrievalSubagentOptions = {
29
29
  timeoutMs?: number
30
30
  }
31
31
 
32
- export const MEMORY_RETRIEVAL_SYSTEM_PROMPT = `You are the memory-retrieval subagent. Read the user's most recent prompt and decide what's relevant from BOTH topic shards in \`memory/topics/\` (consolidated long-term memory) AND undreamed daily-stream events under \`memory/streams/\` (recent fragments not yet folded into shards). Use \`memory_search\` to query both surfaces; use \`read\`/\`ls\` to pull full shard bodies when needed. Synthesize a focused ≤8 KB summary of the relevant memory. Save by \`write\`ing it to the exact path provided in your payload as \`cacheFilePath\`. Be ruthlessly concise. Do NOT write anywhere else. Do NOT delete files.
32
+ export const MEMORY_RETRIEVAL_SYSTEM_PROMPT = `You are the memory-retrieval subagent. Read the user's most recent prompt and decide what's relevant across topic shards in \`memory/topics/\` (consolidated long-term memory), references in \`memory/references/\` (verbatim artifacts), AND undreamed daily-stream events under \`memory/streams/\` (recent fragments not yet folded into shards). Use \`memory_search\` to query all three surfaces; use \`read\`/\`ls\` to pull full shard bodies when needed. Synthesize a focused ≤8 KB summary of the relevant memory. Save by \`write\`ing it to the exact path provided in your payload as \`cacheFilePath\`. Be ruthlessly concise. Do NOT write anywhere else. Do NOT delete files.
33
33
 
34
34
  Search discipline: issue ALL your \`memory_search\` queries in a SINGLE response as parallel tool calls (up to 3 at once), then wait for every result before deciding what to do next. Different angles in parallel, NEVER one search per turn — sequential searches waste a full LLM round-trip per query (~3s each) on file I/O that takes milliseconds. Pick queries that match the user's literal phrasing — not framing vocabulary, not metadata (session ids, dates), not words from your own system prompt. If the parallel batch turns up nothing relevant, write the empty-context note and stop.`
35
35
 
@@ -62,7 +62,7 @@ export function createMemoryRetrievalSubagent(
62
62
  // operator hasn't configured it, so this is safe by construction.
63
63
  profile: 'fast',
64
64
  tools: [readTool, writeTool, lsTool],
65
- customTools: [memorySearchTool],
65
+ customTools: [createMemorySearchTool()],
66
66
  payloadSchema: memoryRetrievalPayloadSchema,
67
67
  inFlightKey: (payload) => payload.parentSessionId,
68
68
  ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
@@ -0,0 +1,33 @@
1
+ import { splitCitationsBySection } from './citations'
2
+ import type { TopicShard } from './load-shards'
3
+
4
+ export type ParentLinks = {
5
+ supersededFragmentIds: Set<string>
6
+ parentSlugsByFragmentId: Map<string, Set<string>>
7
+ }
8
+
9
+ export function buildParentLinks(shards: TopicShard[]): ParentLinks {
10
+ const parentSlugsByFragmentId = new Map<string, Set<string>>()
11
+ const supersededFragmentIds = new Set<string>()
12
+
13
+ for (const shard of shards) {
14
+ const { active, superseded } = splitCitationsBySection(shard.body)
15
+ // A fragment can be cited by multiple topics (it backs more than one belief),
16
+ // so collect every citing slug — first-wins would drop the fragment's other
17
+ // parents and a match would collapse to only one of them.
18
+ for (const fragmentId of active) addSlug(parentSlugsByFragmentId, fragmentId, shard.slug)
19
+ for (const fragmentId of superseded) supersededFragmentIds.add(fragmentId)
20
+ }
21
+
22
+ // Active in one shard outranks superseded in another: the fragment still backs
23
+ // a live belief, so it stays a valid retrieval hook.
24
+ for (const fragmentId of parentSlugsByFragmentId.keys()) supersededFragmentIds.delete(fragmentId)
25
+
26
+ return { parentSlugsByFragmentId, supersededFragmentIds }
27
+ }
28
+
29
+ function addSlug(map: Map<string, Set<string>>, fragmentId: string, slug: string): void {
30
+ const slugs = map.get(fragmentId)
31
+ if (slugs === undefined) map.set(fragmentId, new Set([slug]))
32
+ else slugs.add(slug)
33
+ }
@@ -6,6 +6,7 @@ export const MEMORY_DIR = 'memory'
6
6
  export const TOPICS_SUBDIR = 'topics'
7
7
  export const STREAMS_SUBDIR = 'streams'
8
8
  export const SKILLS_SUBDIR = 'skills'
9
+ export const REFERENCES_SUBDIR = 'references'
9
10
  export const PRE_SHARD_BACKUP_FILENAME = 'MEMORY.md.pre-shard.bak'
10
11
  export const MIGRATING_TMPDIR = 'memory/.migrating'
11
12
 
@@ -15,6 +16,10 @@ export function topicsDir(agentDir: string): string {
15
16
  return join(agentDir, MEMORY_DIR, TOPICS_SUBDIR)
16
17
  }
17
18
 
19
+ export function referencesDir(agentDir: string): string {
20
+ return join(agentDir, MEMORY_DIR, REFERENCES_SUBDIR)
21
+ }
22
+
18
23
  export function streamsDir(agentDir: string): string {
19
24
  return join(agentDir, MEMORY_DIR, STREAMS_SUBDIR)
20
25
  }
@@ -26,6 +31,13 @@ export function topicShardPath(agentDir: string, slug: string): string {
26
31
  return join(agentDir, MEMORY_DIR, TOPICS_SUBDIR, `${slug}.md`)
27
32
  }
28
33
 
34
+ export function referenceFilePath(agentDir: string, slug: string): string {
35
+ if (slug.includes('..') || slug.includes('/') || slug.includes('\\') || slug.startsWith('.')) {
36
+ throw new Error(`invalid reference slug: ${JSON.stringify(slug)}`)
37
+ }
38
+ return join(agentDir, MEMORY_DIR, REFERENCES_SUBDIR, `${slug}.md`)
39
+ }
40
+
29
41
  export function streamFilePath(agentDir: string, date: string): string {
30
42
  if (!STREAM_DATE_RE.test(date)) {
31
43
  throw new Error(`invalid stream date: ${JSON.stringify(date)}`)
@@ -0,0 +1,197 @@
1
+ export type ReferenceOrigin = 'episode' | 'curated' | 'external'
2
+
3
+ export type ReferenceFrontmatter = {
4
+ title: string
5
+ origin: ReferenceOrigin
6
+ created: string
7
+ lastAccessed: string
8
+ accessCount: number
9
+ pinned: boolean
10
+ demoted: boolean
11
+ tags: string[]
12
+ }
13
+
14
+ const ORIGINS = new Set<ReferenceOrigin>(['episode', 'curated', 'external'])
15
+ const ISO_WITH_TIMEZONE_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/
16
+
17
+ export function parseReference(text: string): { frontmatter: ReferenceFrontmatter; body: string } {
18
+ const normalized = text.replaceAll('\r\n', '\n')
19
+
20
+ if (!normalized.startsWith('---\n')) {
21
+ throw new Error('frontmatter delimiter missing')
22
+ }
23
+
24
+ const closeIndex = normalized.indexOf('\n---', 4)
25
+ if (closeIndex === -1) {
26
+ throw new Error('frontmatter delimiter missing')
27
+ }
28
+
29
+ const fmText = normalized.slice(4, closeIndex)
30
+ const body = normalized.slice(closeIndex + 5)
31
+
32
+ const frontmatter = parseFrontmatterBlock(fmText)
33
+ return { frontmatter, body }
34
+ }
35
+
36
+ export function renderReference(frontmatter: ReferenceFrontmatter, body: string): string {
37
+ const lines = ['---']
38
+ lines.push(`title: ${frontmatter.title}`)
39
+ lines.push(`origin: ${frontmatter.origin}`)
40
+ lines.push(`created: ${frontmatter.created}`)
41
+ lines.push(`lastAccessed: ${frontmatter.lastAccessed}`)
42
+ lines.push(`accessCount: ${frontmatter.accessCount}`)
43
+ lines.push(`pinned: ${frontmatter.pinned}`)
44
+ lines.push(`demoted: ${frontmatter.demoted}`)
45
+ if (frontmatter.tags.length === 0) {
46
+ lines.push('tags: []')
47
+ } else {
48
+ lines.push(`tags: [${frontmatter.tags.join(', ')}]`)
49
+ }
50
+ lines.push('---')
51
+ lines.push(body)
52
+ return lines.join('\n')
53
+ }
54
+
55
+ function parseFrontmatterBlock(text: string): ReferenceFrontmatter {
56
+ const lines = text.split('\n')
57
+ const values: Record<string, unknown> = {}
58
+
59
+ let i = 0
60
+ while (i < lines.length) {
61
+ const line = lines[i]!
62
+ if (line.trim() === '') {
63
+ i++
64
+ continue
65
+ }
66
+
67
+ const colonIndex = line.indexOf(':')
68
+ if (colonIndex === -1) {
69
+ i++
70
+ continue
71
+ }
72
+
73
+ const key = line.slice(0, colonIndex).trim()
74
+ const rest = line.slice(colonIndex + 1).trim()
75
+
76
+ if (key === 'tags') {
77
+ if (rest === '') {
78
+ const listItems: string[] = []
79
+ i++
80
+ while (i < lines.length) {
81
+ const listLine = lines[i]!
82
+ if (!listLine.startsWith(' - ')) break
83
+ listItems.push(listLine.slice(4).trim())
84
+ i++
85
+ }
86
+ values.tags = listItems
87
+ continue
88
+ }
89
+ if (rest.startsWith('[') && rest.endsWith(']')) {
90
+ values.tags = rest
91
+ .slice(1, -1)
92
+ .split(',')
93
+ .map((s) => s.trim())
94
+ .filter(Boolean)
95
+ i++
96
+ continue
97
+ }
98
+ throw new Error(`frontmatter field 'tags': expected array, got '${rest}'`)
99
+ }
100
+
101
+ if (key in FRONTMATTER_PARSERS) {
102
+ try {
103
+ values[key] = FRONTMATTER_PARSERS[key as keyof typeof FRONTMATTER_PARSERS](rest)
104
+ } catch (err) {
105
+ const message = err instanceof Error ? err.message : String(err)
106
+ throw new Error(`frontmatter field '${key}': ${message}`)
107
+ }
108
+ } else {
109
+ throw new Error(`frontmatter field '${key}': unknown`)
110
+ }
111
+
112
+ i++
113
+ }
114
+
115
+ return buildReferenceFrontmatter(values)
116
+ }
117
+
118
+ const FRONTMATTER_PARSERS = {
119
+ title: (v: string) => v,
120
+ origin: parseOrigin,
121
+ created: parseIsoWithTimezone,
122
+ lastAccessed: parseIsoWithTimezone,
123
+ accessCount: parseNonNegativeInt,
124
+ pinned: parseBoolean,
125
+ demoted: parseBoolean,
126
+ }
127
+
128
+ function buildReferenceFrontmatter(values: Record<string, unknown>): ReferenceFrontmatter {
129
+ const title = values.title
130
+ if (typeof title !== 'string' || title.length === 0) {
131
+ throw new Error("frontmatter field 'title': required")
132
+ }
133
+
134
+ const origin = values.origin
135
+ if (!isReferenceOrigin(origin)) {
136
+ throw new Error(`frontmatter field 'origin': expected episode | curated | external, got '${values.origin}'`)
137
+ }
138
+
139
+ const created = values.created
140
+ if (typeof created !== 'string') {
141
+ throw new Error(`frontmatter field 'created': expected ISO 8601 datetime with timezone, got '${values.created}'`)
142
+ }
143
+
144
+ const pinned = values.pinned
145
+ if (typeof pinned !== 'boolean') {
146
+ throw new Error(`frontmatter field 'pinned': expected boolean, got '${values.pinned}'`)
147
+ }
148
+
149
+ const tags = values.tags
150
+ if (!Array.isArray(tags) || !tags.every((tag) => typeof tag === 'string')) {
151
+ throw new Error(`frontmatter field 'tags': expected array, got '${values.tags}'`)
152
+ }
153
+
154
+ return {
155
+ title,
156
+ origin,
157
+ created,
158
+ lastAccessed: typeof values.lastAccessed === 'string' ? values.lastAccessed : created,
159
+ accessCount: typeof values.accessCount === 'number' ? values.accessCount : 0,
160
+ pinned,
161
+ demoted: typeof values.demoted === 'boolean' ? values.demoted : false,
162
+ tags,
163
+ }
164
+ }
165
+
166
+ function parseOrigin(value: string): ReferenceOrigin {
167
+ if (!isReferenceOrigin(value)) {
168
+ throw new Error(`expected episode | curated | external, got '${value}'`)
169
+ }
170
+ return value
171
+ }
172
+
173
+ function parseIsoWithTimezone(value: string): string {
174
+ if (!ISO_WITH_TIMEZONE_REGEX.test(value)) {
175
+ throw new Error(`expected ISO 8601 datetime with timezone, got '${value}'`)
176
+ }
177
+ return value
178
+ }
179
+
180
+ function parseNonNegativeInt(value: string): number {
181
+ const trimmed = value.trim()
182
+ const num = Number(trimmed)
183
+ if (!Number.isInteger(num) || String(num) !== trimmed || num < 0) {
184
+ throw new Error(`expected non-negative integer, got '${value}'`)
185
+ }
186
+ return num
187
+ }
188
+
189
+ function parseBoolean(value: string): boolean {
190
+ if (value === 'true') return true
191
+ if (value === 'false') return false
192
+ throw new Error(`expected boolean, got '${value}'`)
193
+ }
194
+
195
+ function isReferenceOrigin(value: unknown): value is ReferenceOrigin {
196
+ return typeof value === 'string' && ORIGINS.has(value as ReferenceOrigin)
197
+ }
@@ -0,0 +1,212 @@
1
+ import { readdir, readFile, stat, writeFile } from 'node:fs/promises'
2
+
3
+ import { referenceFilePath, referencesDir } from '../paths'
4
+ import { parseReference, renderReference, type ReferenceFrontmatter } from './frontmatter'
5
+
6
+ export type Reference = {
7
+ path: string
8
+ slug: string
9
+ frontmatter: ReferenceFrontmatter
10
+ body: string
11
+ }
12
+
13
+ type Logger = { warn(message: string): void }
14
+
15
+ type ReferenceCacheEntry = {
16
+ mtimeMs: number
17
+ ctimeMs: number
18
+ size: number
19
+ reference: Reference | null
20
+ }
21
+
22
+ type AgentReferenceCache = {
23
+ entries: Map<string, ReferenceCacheEntry>
24
+ lastSlugs: string[] | null
25
+ lastReferences: Reference[] | null
26
+ }
27
+
28
+ const referenceCache = new Map<string, AgentReferenceCache>()
29
+
30
+ export async function loadAllReferences(agentDir: string, options: { logger?: Logger } = {}): Promise<Reference[]> {
31
+ const slugs = await listReferenceSlugs(agentDir)
32
+ const cache = getOrCreateCache(agentDir)
33
+ const outcomes = await Promise.all(slugs.map((slug) => resolveReference(agentDir, slug, cache.entries, options)))
34
+
35
+ const references: Reference[] = []
36
+ const seen = new Set<string>()
37
+ let changed = !sameSlugs(slugs, cache.lastSlugs)
38
+
39
+ for (const outcome of outcomes) {
40
+ seen.add(outcome.slug)
41
+ if (outcome.kind === 'missing') {
42
+ changed = true
43
+ cache.entries.delete(outcome.slug)
44
+ continue
45
+ }
46
+ if (outcome.kind === 'read') {
47
+ changed = true
48
+ cache.entries.set(outcome.slug, outcome.entry)
49
+ }
50
+ if (outcome.reference !== null) references.push(outcome.reference)
51
+ }
52
+
53
+ for (const slug of cache.entries.keys()) {
54
+ if (!seen.has(slug)) {
55
+ changed = true
56
+ cache.entries.delete(slug)
57
+ }
58
+ }
59
+
60
+ if (!changed && cache.lastReferences !== null) return cache.lastReferences
61
+
62
+ cache.lastSlugs = slugs
63
+ cache.lastReferences = references
64
+ return references
65
+ }
66
+
67
+ export async function loadReference(
68
+ agentDir: string,
69
+ slug: string,
70
+ options: { logger?: Logger } = {},
71
+ ): Promise<Reference | null> {
72
+ return readAndParseReference(referenceFilePath(agentDir, slug), slug, options)
73
+ }
74
+
75
+ // Records an access against the given reference slugs: bumps accessCount and
76
+ // stamps lastAccessed. Used by both the memory_search hit path and the vector
77
+ // retrieval path so a reference surfaced by either counts toward the dreaming
78
+ // saturation model — otherwise a reference repeatedly injected by vector
79
+ // retrieval (but never explicitly searched) decays on time alone and gets
80
+ // evicted despite being actively useful. Best-effort: a missing or malformed
81
+ // slug is skipped, never thrown, so a caller can fire-and-forget off the turn
82
+ // critical path.
83
+ export async function bumpReferenceAccess(
84
+ agentDir: string,
85
+ slugs: Iterable<string>,
86
+ options: { logger?: Logger } = {},
87
+ ): Promise<void> {
88
+ const unique = [...new Set(slugs)]
89
+ await Promise.all(
90
+ unique.map(async (slug) => {
91
+ const reference = await readAndParseReference(referenceFilePath(agentDir, slug), slug, options)
92
+ if (reference === null) return
93
+ await writeFile(
94
+ reference.path,
95
+ renderReference(
96
+ {
97
+ ...reference.frontmatter,
98
+ lastAccessed: new Date().toISOString(),
99
+ accessCount: reference.frontmatter.accessCount + 1,
100
+ },
101
+ reference.body,
102
+ ),
103
+ 'utf8',
104
+ )
105
+ }),
106
+ )
107
+ }
108
+
109
+ export function __resetReferenceCacheForTests(): void {
110
+ referenceCache.clear()
111
+ }
112
+
113
+ type ReferenceOutcome =
114
+ | { kind: 'missing'; slug: string }
115
+ | { kind: 'cached'; slug: string; reference: Reference | null }
116
+ | { kind: 'read'; slug: string; reference: Reference | null; entry: ReferenceCacheEntry }
117
+
118
+ async function resolveReference(
119
+ agentDir: string,
120
+ slug: string,
121
+ cache: Map<string, ReferenceCacheEntry>,
122
+ options: { logger?: Logger },
123
+ ): Promise<ReferenceOutcome> {
124
+ const path = referenceFilePath(agentDir, slug)
125
+ const fileStat = await statReference(path)
126
+ if (fileStat === null) return { kind: 'missing', slug }
127
+
128
+ const cached = cache.get(slug)
129
+ if (
130
+ cached !== undefined &&
131
+ cached.mtimeMs === fileStat.mtimeMs &&
132
+ cached.ctimeMs === fileStat.ctimeMs &&
133
+ cached.size === fileStat.size
134
+ ) {
135
+ return { kind: 'cached', slug, reference: cached.reference }
136
+ }
137
+
138
+ const reference = await readAndParseReference(path, slug, options)
139
+ const entry: ReferenceCacheEntry = {
140
+ mtimeMs: fileStat.mtimeMs,
141
+ ctimeMs: fileStat.ctimeMs,
142
+ size: fileStat.size,
143
+ reference,
144
+ }
145
+ return { kind: 'read', slug, reference, entry }
146
+ }
147
+
148
+ export async function listReferenceSlugs(agentDir: string): Promise<string[]> {
149
+ let names: string[]
150
+ try {
151
+ names = await readdir(referencesDir(agentDir))
152
+ } catch (err) {
153
+ if (isEnoent(err)) return []
154
+ throw err
155
+ }
156
+
157
+ return names
158
+ .filter((name) => name.endsWith('.md'))
159
+ .map((name) => name.slice(0, -'.md'.length))
160
+ .sort()
161
+ }
162
+
163
+ async function readAndParseReference(
164
+ path: string,
165
+ slug: string,
166
+ options: { logger?: Logger },
167
+ ): Promise<Reference | null> {
168
+ let text: string
169
+ try {
170
+ text = await readFile(path, 'utf8')
171
+ } catch (err) {
172
+ if (isEnoent(err)) return null
173
+ throw err
174
+ }
175
+
176
+ try {
177
+ const { frontmatter, body } = parseReference(text)
178
+ return { path, slug, frontmatter, body }
179
+ } catch (err) {
180
+ const message = err instanceof Error ? err.message : String(err)
181
+ const logger = options.logger ?? console
182
+ logger.warn(`[memory] skipping malformed reference ${slug}: ${message}`)
183
+ return null
184
+ }
185
+ }
186
+
187
+ async function statReference(path: string): Promise<{ mtimeMs: number; ctimeMs: number; size: number } | null> {
188
+ try {
189
+ const s = await stat(path)
190
+ return { mtimeMs: s.mtimeMs, ctimeMs: s.ctimeMs, size: s.size }
191
+ } catch (err) {
192
+ if (isEnoent(err)) return null
193
+ throw err
194
+ }
195
+ }
196
+
197
+ function getOrCreateCache(agentDir: string): AgentReferenceCache {
198
+ let cache = referenceCache.get(agentDir)
199
+ if (cache === undefined) {
200
+ cache = { entries: new Map(), lastSlugs: null, lastReferences: null }
201
+ referenceCache.set(agentDir, cache)
202
+ }
203
+ return cache
204
+ }
205
+
206
+ function sameSlugs(slugs: string[], previous: string[] | null): boolean {
207
+ return previous !== null && slugs.length === previous.length && slugs.every((slug, index) => slug === previous[index])
208
+ }
209
+
210
+ function isEnoent(err: unknown): boolean {
211
+ return typeof err === 'object' && err !== null && 'code' in err && err.code === 'ENOENT'
212
+ }
@@ -0,0 +1,59 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises'
2
+
3
+ import { z } from 'zod'
4
+
5
+ import { defineTool } from '@/plugin'
6
+
7
+ import { referenceFilePath, referencesDir } from '../paths'
8
+ import { headingToSlug } from '../slug'
9
+ import { renderReference } from './frontmatter'
10
+ import { listReferenceSlugs } from './load-references'
11
+
12
+ export type ReferenceStoredHook = (context: { slug: string; body: string }) => Promise<void>
13
+
14
+ export const storeReferenceTool = createStoreReferenceTool()
15
+
16
+ export function createStoreReferenceTool(onReferenceStored?: ReferenceStoredHook) {
17
+ return defineTool({
18
+ description:
19
+ 'store_reference: Store a verbatim reference artifact under memory/references/ and return its slug. Use this for user-provided SQL, code blocks, runbooks, pasted specs, or other content explicitly meant to be remembered byte-for-byte. This tool does not write memory stream fragments.',
20
+ parameters: z.object({
21
+ title: z.string().min(1),
22
+ body: z.string(),
23
+ origin: z.enum(['episode', 'curated', 'external']),
24
+ tags: z.array(z.string()).optional(),
25
+ }),
26
+ async execute({ title, body, origin, tags }, ctx) {
27
+ const existingSlugs = new Set(await listReferenceSlugs(ctx.agentDir))
28
+ const slug = headingToSlug(title, existingSlugs)
29
+ const created = new Date().toISOString()
30
+ const path = referenceFilePath(ctx.agentDir, slug)
31
+
32
+ await mkdir(referencesDir(ctx.agentDir), { recursive: true })
33
+ await writeFile(
34
+ path,
35
+ renderReference(
36
+ {
37
+ title,
38
+ origin,
39
+ created,
40
+ lastAccessed: created,
41
+ accessCount: 0,
42
+ pinned: false,
43
+ demoted: false,
44
+ tags: tags ?? [],
45
+ },
46
+ body,
47
+ ),
48
+ 'utf8',
49
+ )
50
+
51
+ if (onReferenceStored !== undefined) await onReferenceStored({ slug, body })
52
+
53
+ return {
54
+ content: [{ type: 'text' as const, text: `Stored reference as ${slug}` }],
55
+ details: { path, slug },
56
+ }
57
+ },
58
+ })
59
+ }