typeclaw 0.36.8 → 0.37.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/package.json +3 -2
- package/src/agent/index.ts +31 -11
- package/src/agent/live-sessions.ts +12 -0
- package/src/agent/model-fallback.ts +17 -15
- package/src/agent/model-overrides.ts +2 -2
- package/src/agent/session-meta.ts +10 -0
- package/src/agent/subagents.ts +30 -3
- package/src/agent/system-prompt.ts +9 -3
- package/src/agent/todo/continuation-policy.ts +6 -3
- package/src/agent/todo/continuation-wiring.ts +4 -2
- package/src/agent/todo/continuation.ts +3 -3
- package/src/agent/tools/todo/index.ts +27 -4
- package/src/bundled-plugins/agent-browser/index.ts +33 -108
- package/src/bundled-plugins/agent-browser/shim.ts +3 -94
- package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +8 -33
- package/src/bundled-plugins/doc-render/skills/typeclaw-render-pdf/SKILL.md +2 -2
- package/src/bundled-plugins/guard/policies/memory-retrieval-cache-write.ts +7 -1
- package/src/bundled-plugins/memory/README.md +80 -23
- package/src/bundled-plugins/memory/append-tool.ts +74 -53
- package/src/bundled-plugins/memory/citation-superset.ts +4 -0
- package/src/bundled-plugins/memory/citations.ts +54 -0
- package/src/bundled-plugins/memory/dreaming-metrics.ts +30 -0
- package/src/bundled-plugins/memory/dreaming.ts +444 -21
- package/src/bundled-plugins/memory/index.ts +544 -400
- package/src/bundled-plugins/memory/load-memory.ts +87 -10
- package/src/bundled-plugins/memory/load-shards.ts +48 -22
- package/src/bundled-plugins/memory/memory-logger.ts +95 -106
- package/src/bundled-plugins/memory/memory-retrieval.ts +3 -3
- package/src/bundled-plugins/memory/parent-link.ts +33 -0
- package/src/bundled-plugins/memory/paths.ts +12 -0
- package/src/bundled-plugins/memory/references/frontmatter.ts +197 -0
- package/src/bundled-plugins/memory/references/load-references.ts +212 -0
- package/src/bundled-plugins/memory/references/store-reference-tool.ts +59 -0
- package/src/bundled-plugins/memory/search-tool.ts +282 -45
- package/src/bundled-plugins/memory/stream-events.ts +1 -0
- package/src/bundled-plugins/memory/stream-io.ts +28 -3
- package/src/bundled-plugins/memory/turn-dedup.ts +40 -0
- package/src/bundled-plugins/memory/vector/cache-write.ts +19 -0
- package/src/bundled-plugins/memory/vector/config.ts +28 -0
- package/src/bundled-plugins/memory/vector/doctor.ts +124 -0
- package/src/bundled-plugins/memory/vector/embedder.ts +246 -0
- package/src/bundled-plugins/memory/vector/hybrid.ts +439 -0
- package/src/bundled-plugins/memory/vector/index-on-write.ts +34 -0
- package/src/bundled-plugins/memory/vector/inspect.ts +111 -0
- package/src/bundled-plugins/memory/vector/passages.ts +125 -0
- package/src/bundled-plugins/memory/vector/reference-index-on-write.ts +50 -0
- package/src/bundled-plugins/memory/vector/relevance-gate.ts +93 -0
- package/src/bundled-plugins/memory/vector/startup.ts +71 -0
- package/src/bundled-plugins/memory/vector/store.ts +203 -0
- package/src/bundled-plugins/memory/vector/truncation.ts +124 -0
- package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +2 -0
- package/src/channels/router.ts +239 -40
- package/src/cli/incomplete-init.ts +57 -0
- package/src/cli/init.ts +166 -18
- package/src/cli/inspect.ts +11 -5
- package/src/cli/model.ts +115 -36
- package/src/cli/provider.ts +5 -3
- package/src/cli/restart.ts +24 -0
- package/src/cli/start.ts +24 -0
- package/src/cli/tunnel.ts +53 -8
- package/src/config/config.ts +110 -19
- package/src/config/index.ts +5 -1
- package/src/config/models-mutation.ts +29 -11
- package/src/config/providers-mutation.ts +2 -2
- package/src/config/providers.ts +146 -12
- package/src/container/shared.ts +9 -0
- package/src/container/start.ts +87 -4
- package/src/cron/consumer.ts +13 -7
- package/src/hostd/models.ts +64 -0
- package/src/hostd/paths.ts +6 -0
- package/src/hostd/portbroker-manager.ts +2 -2
- package/src/init/checkpoint.ts +201 -0
- package/src/init/dockerfile.ts +121 -34
- package/src/init/gitignore.ts +7 -7
- package/src/init/index.ts +41 -9
- package/src/init/models-dev.ts +96 -21
- package/src/init/oauth-login.ts +3 -3
- package/src/init/progress.ts +29 -0
- package/src/init/validate-api-key.ts +4 -0
- package/src/inspect/index.ts +13 -6
- package/src/inspect/item-list.ts +11 -2
- package/src/inspect/live-list.ts +65 -0
- package/src/inspect/open-item.ts +22 -1
- package/src/inspect/session-list.ts +29 -0
- package/src/models/embedding-model.ts +114 -0
- package/src/models/transformers-version.ts +55 -0
- package/src/plugin/types.ts +3 -0
- package/src/portbroker/container-server.ts +23 -0
- package/src/portbroker/forward-request-bus.ts +35 -0
- package/src/portbroker/forward-result-bus.ts +2 -3
- package/src/portbroker/hostd-client.ts +182 -36
- package/src/portbroker/index.ts +6 -1
- package/src/portbroker/protocol.ts +9 -2
- package/src/run/channel-session-factory.ts +11 -1
- package/src/run/index.ts +65 -8
- package/src/server/command-runner.ts +24 -1
- package/src/server/index.ts +42 -8
- package/src/shared/index.ts +2 -0
- package/src/shared/protocol.ts +31 -0
- package/src/skills/typeclaw-channels/SKILL.md +4 -4
- package/src/skills/typeclaw-config/SKILL.md +2 -2
- package/src/skills/typeclaw-memory/SKILL.md +3 -1
- package/src/skills/typeclaw-permissions/SKILL.md +3 -3
- package/src/skills/typeclaw-skills/SKILL.md +1 -1
- package/src/skills/typeclaw-tunnels/SKILL.md +22 -1
- package/src/tunnels/providers/cloudflare-quick.ts +65 -7
- package/src/tunnels/upstream-probe.ts +25 -0
- package/typeclaw.schema.json +156 -67
- package/src/bundled-plugins/agent-browser/dashboard-discovery.ts +0 -170
- package/src/bundled-plugins/agent-browser/dashboard-proxy.ts +0 -421
- 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 {
|
|
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
|
|
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: [
|
|
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
|
+
}
|