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.
- package/README.md +2 -2
- 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 +11 -2
- 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 +143 -12
- package/src/cli/inspect.ts +11 -5
- package/src/cli/model.ts +112 -34
- 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 +164 -51
- package/src/init/gitignore.ts +7 -7
- package/src/init/index.ts +41 -9
- package/src/init/line-auth.ts +50 -21
- 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 +41 -7
- 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
|
@@ -11,11 +11,27 @@ import { formatLocalDate } from '@/shared'
|
|
|
11
11
|
|
|
12
12
|
import { createDreamingSubagent, type DreamingPayload } from './dreaming'
|
|
13
13
|
import { buildInjectionPlan, DEFAULT_INJECTION_BUDGET_BYTES, MIN_INJECTION_BUDGET_BYTES } from './injection-plan'
|
|
14
|
+
import {
|
|
15
|
+
forceIndexForChannel,
|
|
16
|
+
loadMemoryInjectionPlan,
|
|
17
|
+
renderDedupedMemorySection,
|
|
18
|
+
renderMemorySection,
|
|
19
|
+
renderRetrievedMemorySection,
|
|
20
|
+
} from './load-memory'
|
|
14
21
|
import { loadAllShards } from './load-shards'
|
|
15
22
|
import { createMemoryLoggerSubagent, type MemoryLoggerPayload } from './memory-logger'
|
|
16
23
|
import { createMemoryRetrievalSubagent, type MemoryRetrievalPayload } from './memory-retrieval'
|
|
17
24
|
import { preShardBackupPath, streamFilePath, streamsDir, topicsDir } from './paths'
|
|
18
|
-
import {
|
|
25
|
+
import { bumpReferenceAccess } from './references/load-references'
|
|
26
|
+
import { createMemorySearchTool } from './search-tool'
|
|
27
|
+
import { type InjectedShardState, partitionDirectShards } from './turn-dedup'
|
|
28
|
+
import { vectorConfigSchema } from './vector/config'
|
|
29
|
+
import { runVectorIndexDoctor } from './vector/doctor'
|
|
30
|
+
import { embed } from './vector/embedder'
|
|
31
|
+
import { hybridSearch, type EmbedFn } from './vector/hybrid'
|
|
32
|
+
import { makeAppendHook } from './vector/index-on-write'
|
|
33
|
+
import { makeReferenceStoredHook } from './vector/reference-index-on-write'
|
|
34
|
+
import { VectorStore } from './vector/store'
|
|
19
35
|
|
|
20
36
|
const DEFAULT_IDLE_MS = 60_000
|
|
21
37
|
const DEFAULT_BUFFER_BYTES = 500_000
|
|
@@ -120,6 +136,7 @@ const memoryConfigSchema = z
|
|
|
120
136
|
// in milliseconds instead of the production 30s.
|
|
121
137
|
retrievalSpawnTimeoutMs: z.number().int().min(1).default(RETRIEVAL_SPAWN_TIMEOUT_MS),
|
|
122
138
|
dreaming: dreamingConfigSchema.optional(),
|
|
139
|
+
vector: vectorConfigSchema,
|
|
123
140
|
})
|
|
124
141
|
.default({
|
|
125
142
|
idleMs: DEFAULT_IDLE_MS,
|
|
@@ -128,442 +145,569 @@ const memoryConfigSchema = z
|
|
|
128
145
|
minIdleDeltaLines: DEFAULT_MIN_IDLE_DELTA_LINES,
|
|
129
146
|
spawnTimeoutMs: SPAWN_TIMEOUT_MS,
|
|
130
147
|
retrievalSpawnTimeoutMs: RETRIEVAL_SPAWN_TIMEOUT_MS,
|
|
148
|
+
vector: { enabled: false },
|
|
131
149
|
})
|
|
132
150
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
151
|
+
const VECTOR_TURN_TOP_K = 10
|
|
152
|
+
|
|
153
|
+
// Per-instance collaborators for the vector index-mode retrieval path. Injected
|
|
154
|
+
// through the plugin factory (not a module global or PluginContext field) so a
|
|
155
|
+
// test can override exactly one — `queryEmbedFn` to drive the real hybridSearch
|
|
156
|
+
// without loading the ~279 MB model, or `hybridSearch` to fake retrieval while
|
|
157
|
+
// testing hook orchestration — without leaking state across other tests in the
|
|
158
|
+
// same worker. Production uses the real `embed` and `hybridSearch`.
|
|
159
|
+
type MemoryPluginDeps = {
|
|
160
|
+
hybridSearch: typeof hybridSearch
|
|
161
|
+
queryEmbedFn: EmbedFn
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const defaultDeps: MemoryPluginDeps = { hybridSearch, queryEmbedFn: embed }
|
|
165
|
+
|
|
166
|
+
// Builds the per-turn user-prompt memory block for a vector agent. Under budget
|
|
167
|
+
// (direct mode) injects shard bodies, but de-duplicates across turns: a shard
|
|
168
|
+
// whose body was already injected in full this session is rendered as a compact
|
|
169
|
+
// slug reference (see `partitionDirectShards`) so a long conversation stops
|
|
170
|
+
// re-sending identical bodies every turn while keeping every topic named and
|
|
171
|
+
// recoverable. Over budget falls back to top-K hybrid search.
|
|
172
|
+
//
|
|
173
|
+
// Channel origins never carry bodies (memory-bleed defense). A channel direct-mode
|
|
174
|
+
// turn is force-indexed to a headings/slugs-only section over EVERY shard, not run
|
|
175
|
+
// through hybridSearch: hybrid is relevance-filtered top-K, so an off-topic turn or
|
|
176
|
+
// stale vector index could silently drop headings that direct mode always had.
|
|
177
|
+
async function renderVectorTurnMemory(
|
|
178
|
+
event: { agentDir: string; userPrompt: string; origin?: SessionOrigin },
|
|
179
|
+
injectionBudgetBytes: number,
|
|
180
|
+
injectedState: InjectedShardState,
|
|
181
|
+
deps: MemoryPluginDeps,
|
|
182
|
+
logger?: { info: (msg: string) => void },
|
|
183
|
+
): Promise<string> {
|
|
184
|
+
const plan = await loadMemoryInjectionPlan(event.agentDir, { injectionBudgetBytes })
|
|
185
|
+
const isChannel = event.origin?.kind === 'channel'
|
|
186
|
+
if (plan.mode === 'direct' && isChannel) {
|
|
187
|
+
const indexed = forceIndexForChannel(plan, { origin: event.origin, injectionBudgetBytes })
|
|
188
|
+
logger?.info(`[vector-retrieval] mode=index topics=${plan.shards.length} channel=forced`)
|
|
189
|
+
return renderMemorySection(indexed, { origin: event.origin })
|
|
190
|
+
}
|
|
191
|
+
if (plan.mode === 'direct') {
|
|
192
|
+
const { full, unchanged } = partitionDirectShards(plan.shards, injectedState)
|
|
193
|
+
logger?.info(`[vector-retrieval] mode=direct topics=${plan.shards.length} full=${full.length}`)
|
|
194
|
+
return renderDedupedMemorySection(full, unchanged)
|
|
195
|
+
}
|
|
196
|
+
const store = VectorStore.open(join(event.agentDir, 'memory', '.vectors', 'index.db'))
|
|
197
|
+
try {
|
|
198
|
+
const startedAt = Date.now()
|
|
199
|
+
const results = await deps.hybridSearch(
|
|
200
|
+
event.userPrompt,
|
|
201
|
+
store,
|
|
202
|
+
event.agentDir,
|
|
203
|
+
VECTOR_TURN_TOP_K,
|
|
204
|
+
deps.queryEmbedFn,
|
|
205
|
+
)
|
|
206
|
+
const elapsedMs = Date.now() - startedAt
|
|
207
|
+
let topicHits = 0
|
|
208
|
+
let referenceHits = 0
|
|
209
|
+
for (const result of results) {
|
|
210
|
+
if (result.source === 'topic') topicHits += 1
|
|
211
|
+
else if (result.source === 'reference') referenceHits += 1
|
|
212
|
+
}
|
|
213
|
+
const streamHits = results.length - topicHits - referenceHits
|
|
214
|
+
// results.length === 0 on a non-empty query means the relevance gate suppressed
|
|
215
|
+
// every candidate (or nothing matched) — an empty memory block, indistinguishable
|
|
216
|
+
// from "no memory" without this explicit signal.
|
|
217
|
+
const suppressed = results.length === 0 ? ' suppressed=1' : ''
|
|
218
|
+
logger?.info(
|
|
219
|
+
`[vector-retrieval] mode=index topic_results=${topicHits} stream_results=${streamHits} reference_results=${referenceHits} elapsed_ms=${elapsedMs}${suppressed}`,
|
|
220
|
+
)
|
|
221
|
+
// Count a vector-surfaced reference as an access so it survives dreaming's
|
|
222
|
+
// time-decay the same way a memory_search hit does. Fire-and-forget: the
|
|
223
|
+
// bump only feeds the 30-min dreaming saturation pass, so it must not add a
|
|
224
|
+
// frontmatter write to the per-turn response critical path.
|
|
225
|
+
const referenceSlugs = results.flatMap((r) => (r.source === 'reference' ? [r.key] : []))
|
|
226
|
+
if (referenceSlugs.length > 0) {
|
|
227
|
+
void bumpReferenceAccess(event.agentDir, referenceSlugs).catch((err) => {
|
|
228
|
+
logger?.info(`[vector-retrieval] reference access bump failed: ${err instanceof Error ? err.message : err}`)
|
|
229
|
+
})
|
|
230
|
+
}
|
|
231
|
+
return renderRetrievedMemorySection(results, { origin: event.origin })
|
|
232
|
+
} finally {
|
|
233
|
+
store.close()
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function createMemoryPlugin(deps: MemoryPluginDeps = defaultDeps) {
|
|
238
|
+
return definePlugin({
|
|
239
|
+
configSchema: memoryConfigSchema,
|
|
240
|
+
plugin: async (ctx) => {
|
|
241
|
+
const idleMs = ctx.config.idleMs
|
|
242
|
+
const bufferBytes = ctx.config.bufferBytes
|
|
243
|
+
const minIdleDeltaLines = ctx.config.minIdleDeltaLines
|
|
244
|
+
const spawnTimeoutMs = ctx.config.spawnTimeoutMs
|
|
245
|
+
const retrievalSpawnTimeoutMs = ctx.config.retrievalSpawnTimeoutMs
|
|
246
|
+
const dreamingSchedule = ctx.config.dreaming?.schedule ?? DEFAULT_DREAMING_SCHEDULE
|
|
247
|
+
|
|
248
|
+
const idleTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
249
|
+
const lastIdleEvent = new Map<string, { parentTranscriptPath: string | undefined; origin?: SessionOrigin }>()
|
|
250
|
+
const bytesAtLastRun = new Map<string, number>()
|
|
251
|
+
const linesAtLastRun = new Map<string, number>()
|
|
252
|
+
// Per-session stream-file cursor: the JSONL line count of the daily
|
|
253
|
+
// stream file at the END of this session's most recent memory-logger
|
|
254
|
+
// spawn. Keyed by sessionId, valued by `{ date, lineCount }`. Honored
|
|
255
|
+
// only when `date` matches today's date — yesterday's cursor points
|
|
256
|
+
// into yesterday's file and the spawn's payload omits it.
|
|
257
|
+
const streamCursorAtLastRun = new Map<string, { date: string; lineCount: number }>()
|
|
258
|
+
// Per-session record of shard bodies already injected in full this session,
|
|
259
|
+
// so direct-mode vector turns can de-duplicate unchanged bodies across turns.
|
|
260
|
+
// Cleared on session.end alongside the other per-session bookkeeping below.
|
|
261
|
+
const injectedShards = new Map<string, InjectedShardState>()
|
|
262
|
+
|
|
263
|
+
// memory-logger is coalesced per agentDir (not per parentSessionId) so that
|
|
264
|
+
// two concurrent channel sessions for the same agent never write to the same
|
|
265
|
+
// daily stream file at the same time. The subagent consumer would silently drop
|
|
266
|
+
// a colliding fire, so we serialize spawn calls *here* (chaining each onto the
|
|
267
|
+
// previous one's settlement) instead of letting the consumer choose between
|
|
268
|
+
// dropping or queueing. The chain holds at most one in-flight promise plus one
|
|
269
|
+
// queued.
|
|
270
|
+
//
|
|
271
|
+
// The `lastIdleEvent` lookup happens SYNCHRONOUSLY at call time and the
|
|
272
|
+
// snapshot is captured in `payload` before any await. This is load-bearing
|
|
273
|
+
// for `session.end`'s fire-and-forget path (see hook below): the hook
|
|
274
|
+
// synchronously cleans up `lastIdleEvent.delete(sessionId)` immediately
|
|
275
|
+
// after calling fireMemoryLogger, so if the snapshot were read lazily
|
|
276
|
+
// inside the chained `.then`, it would race with cleanup and the spawn
|
|
277
|
+
// would silently no-op. Capturing the payload up front decouples the
|
|
278
|
+
// session-end snapshot from the cleanup that follows.
|
|
279
|
+
let spawnChain: Promise<void> = Promise.resolve()
|
|
280
|
+
|
|
281
|
+
const fireMemoryLogger = (sessionId: string, reason: 'idle' | 'buffer-trip' | 'session-end'): Promise<void> => {
|
|
282
|
+
const last = lastIdleEvent.get(sessionId)
|
|
283
|
+
if (!last || last.parentTranscriptPath === undefined) return Promise.resolve()
|
|
284
|
+
const parentTranscriptPath = last.parentTranscriptPath
|
|
285
|
+
const today = formatLocalDate()
|
|
286
|
+
const priorCursor = streamCursorAtLastRun.get(sessionId)
|
|
287
|
+
const streamLineCursor =
|
|
288
|
+
priorCursor !== undefined && priorCursor.date === today ? priorCursor.lineCount : undefined
|
|
289
|
+
const payload: MemoryLoggerPayload = {
|
|
290
|
+
parentSessionId: sessionId,
|
|
291
|
+
parentTranscriptPath,
|
|
292
|
+
agentDir: ctx.agentDir,
|
|
293
|
+
...(last.origin !== undefined ? { origin: last.origin } : {}),
|
|
294
|
+
...(streamLineCursor !== undefined ? { streamLineCursor } : {}),
|
|
295
|
+
}
|
|
296
|
+
// Execution authority is `system` (resolves to owner), NOT the
|
|
297
|
+
// triggering turn's role: memory-logging is TypeClaw infrastructure over
|
|
298
|
+
// operator-owned sessions//memory/, so a guest channel turn that triggers
|
|
299
|
+
// it must not demote the logger to guest and get its transcript read
|
|
300
|
+
// blocked by privateSurfaceRead. The triggering origin is preserved two
|
|
301
|
+
// ways: `triggeredBy` for audit provenance, and `payload.origin` for
|
|
302
|
+
// content provenance (memory extraction/retrieval channel-safety).
|
|
303
|
+
const spawnOptions: SpawnSubagentOptions = {
|
|
304
|
+
parentSessionId: sessionId,
|
|
305
|
+
spawnedByOrigin: {
|
|
306
|
+
kind: 'system',
|
|
307
|
+
component: 'memory-logger',
|
|
308
|
+
...(last.origin !== undefined ? { triggeredBy: last.origin } : {}),
|
|
309
|
+
},
|
|
310
|
+
}
|
|
311
|
+
const next = spawnChain
|
|
312
|
+
.catch(() => undefined)
|
|
313
|
+
.then(async () => {
|
|
314
|
+
const currentSize = await readSize(parentTranscriptPath)
|
|
315
|
+
const currentLines = await readLineCount(parentTranscriptPath)
|
|
316
|
+
bytesAtLastRun.set(sessionId, currentSize)
|
|
317
|
+
linesAtLastRun.set(sessionId, currentLines)
|
|
318
|
+
ctx.logger.info(`memory-logger spawn ${sessionId} reason=${reason} transcript_bytes=${currentSize}`)
|
|
319
|
+
try {
|
|
320
|
+
await raceSpawn(ctx.spawnSubagent('memory-logger', payload, spawnOptions), spawnTimeoutMs)
|
|
321
|
+
} catch (err) {
|
|
322
|
+
ctx.logger.error(`memory-logger spawn failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
323
|
+
}
|
|
324
|
+
// Capture the daily-stream line count POST-spawn so the next spawn
|
|
325
|
+
// (in the same session, on the same day) can resume past anything
|
|
326
|
+
// this spawn appended. Tied to today's date — `fireMemoryLogger`
|
|
327
|
+
// checks the date before honoring the cursor.
|
|
328
|
+
const todayAfterSpawn = formatLocalDate()
|
|
329
|
+
const streamPath = streamFilePath(ctx.agentDir, todayAfterSpawn)
|
|
330
|
+
const streamLineCount = await readLineCount(streamPath)
|
|
331
|
+
streamCursorAtLastRun.set(sessionId, { date: todayAfterSpawn, lineCount: streamLineCount })
|
|
332
|
+
})
|
|
333
|
+
spawnChain = next
|
|
334
|
+
return next
|
|
186
335
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const spawnOptions: SpawnSubagentOptions = {
|
|
195
|
-
parentSessionId: sessionId,
|
|
196
|
-
spawnedByOrigin: {
|
|
197
|
-
kind: 'system',
|
|
198
|
-
component: 'memory-logger',
|
|
199
|
-
...(last.origin !== undefined ? { triggeredBy: last.origin } : {}),
|
|
200
|
-
},
|
|
336
|
+
|
|
337
|
+
const cancelTimer = (sessionId: string): void => {
|
|
338
|
+
const t = idleTimers.get(sessionId)
|
|
339
|
+
if (t !== undefined) {
|
|
340
|
+
clearTimeout(t)
|
|
341
|
+
idleTimers.delete(sessionId)
|
|
342
|
+
}
|
|
201
343
|
}
|
|
202
|
-
const next = spawnChain
|
|
203
|
-
.catch(() => undefined)
|
|
204
|
-
.then(async () => {
|
|
205
|
-
const currentSize = await readSize(parentTranscriptPath)
|
|
206
|
-
const currentLines = await readLineCount(parentTranscriptPath)
|
|
207
|
-
bytesAtLastRun.set(sessionId, currentSize)
|
|
208
|
-
linesAtLastRun.set(sessionId, currentLines)
|
|
209
|
-
ctx.logger.info(`memory-logger spawn ${sessionId} reason=${reason} transcript_bytes=${currentSize}`)
|
|
210
|
-
try {
|
|
211
|
-
await raceSpawn(ctx.spawnSubagent('memory-logger', payload, spawnOptions), spawnTimeoutMs)
|
|
212
|
-
} catch (err) {
|
|
213
|
-
ctx.logger.error(`memory-logger spawn failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
214
|
-
}
|
|
215
|
-
// Capture the daily-stream line count POST-spawn so the next spawn
|
|
216
|
-
// (in the same session, on the same day) can resume past anything
|
|
217
|
-
// this spawn appended. Tied to today's date — `fireMemoryLogger`
|
|
218
|
-
// checks the date before honoring the cursor.
|
|
219
|
-
const todayAfterSpawn = formatLocalDate()
|
|
220
|
-
const streamPath = streamFilePath(ctx.agentDir, todayAfterSpawn)
|
|
221
|
-
const streamLineCount = await readLineCount(streamPath)
|
|
222
|
-
streamCursorAtLastRun.set(sessionId, { date: todayAfterSpawn, lineCount: streamLineCount })
|
|
223
|
-
})
|
|
224
|
-
spawnChain = next
|
|
225
|
-
return next
|
|
226
|
-
}
|
|
227
344
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
345
|
+
const shouldTripBufferCeiling = async (sessionId: string, transcriptPath: string): Promise<boolean> => {
|
|
346
|
+
if (bufferBytes === 0) return false
|
|
347
|
+
const currentSize = await readSize(transcriptPath)
|
|
348
|
+
const baseline = bytesAtLastRun.get(sessionId)
|
|
349
|
+
if (baseline === undefined) {
|
|
350
|
+
bytesAtLastRun.set(sessionId, currentSize)
|
|
351
|
+
return false
|
|
352
|
+
}
|
|
353
|
+
return currentSize - baseline >= bufferBytes
|
|
233
354
|
}
|
|
234
|
-
}
|
|
235
355
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
return false
|
|
356
|
+
const shouldSkipIdleSpawn = async (sessionId: string, transcriptPath: string): Promise<boolean> => {
|
|
357
|
+
if (minIdleDeltaLines === 0) return false
|
|
358
|
+
const currentLines = await readLineCount(transcriptPath)
|
|
359
|
+
if (currentLines === 0) return false
|
|
360
|
+
const baseline = linesAtLastRun.get(sessionId) ?? 0
|
|
361
|
+
return currentLines - baseline < minIdleDeltaLines
|
|
243
362
|
}
|
|
244
|
-
return currentSize - baseline >= bufferBytes
|
|
245
|
-
}
|
|
246
363
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
364
|
+
const runMemoryRetrieval = async (event: {
|
|
365
|
+
sessionId: string
|
|
366
|
+
agentDir: string
|
|
367
|
+
userPrompt: string
|
|
368
|
+
origin?: SessionOrigin
|
|
369
|
+
}): Promise<void> => {
|
|
370
|
+
const shards = await loadAllShards(event.agentDir)
|
|
371
|
+
const plan = buildInjectionPlan(shards, { budgetBytes: ctx.config.injectionBudgetBytes })
|
|
372
|
+
if (plan.mode === 'direct') return
|
|
254
373
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
374
|
+
const cacheFilePath = join(event.agentDir, 'memory', '.retrieval-cache', `${event.sessionId}.md`)
|
|
375
|
+
const payload: MemoryRetrievalPayload = {
|
|
376
|
+
parentSessionId: event.sessionId,
|
|
377
|
+
agentDir: event.agentDir,
|
|
378
|
+
recentPrompt: event.userPrompt,
|
|
379
|
+
cacheFilePath,
|
|
380
|
+
...(event.origin !== undefined ? { origin: event.origin } : {}),
|
|
381
|
+
}
|
|
382
|
+
// System authority, not the triggering turn's role — see the
|
|
383
|
+
// memory-logger spawn above. memory-retrieval writes
|
|
384
|
+
// memory/.retrieval-cache/, which a guest-demoted role cannot.
|
|
385
|
+
const retrievalSpawnOptions: SpawnSubagentOptions = {
|
|
386
|
+
parentSessionId: event.sessionId,
|
|
387
|
+
spawnedByOrigin: {
|
|
388
|
+
kind: 'system',
|
|
389
|
+
component: 'memory-retrieval',
|
|
390
|
+
...(event.origin !== undefined ? { triggeredBy: event.origin } : {}),
|
|
391
|
+
},
|
|
392
|
+
}
|
|
393
|
+
await ctx.spawnSubagent('memory-retrieval', payload, retrievalSpawnOptions)
|
|
272
394
|
}
|
|
273
|
-
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
},
|
|
395
|
+
|
|
396
|
+
// Subagents are constructed at boot here (rather than imported as constants)
|
|
397
|
+
// so their lifecycle logs route through the plugin logger and pick up the
|
|
398
|
+
// `[plugin:memory]` prefix. Without this, they would write directly to
|
|
399
|
+
// console and bypass the plugin namespace.
|
|
400
|
+
const subagentLogger = {
|
|
401
|
+
info: (m: string) => ctx.logger.info(m),
|
|
402
|
+
warn: (m: string) => ctx.logger.warn(m),
|
|
403
|
+
error: (m: string) => ctx.logger.error(m),
|
|
283
404
|
}
|
|
284
|
-
await ctx.spawnSubagent('memory-retrieval', payload, retrievalSpawnOptions)
|
|
285
|
-
}
|
|
286
405
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
const subagentLogger = {
|
|
292
|
-
info: (m: string) => ctx.logger.info(m),
|
|
293
|
-
warn: (m: string) => ctx.logger.warn(m),
|
|
294
|
-
error: (m: string) => ctx.logger.error(m),
|
|
295
|
-
}
|
|
406
|
+
// Open a long-lived VectorStore for append-time indexing when vector is enabled.
|
|
407
|
+
const appendVectorStore = ctx.config.vector.enabled
|
|
408
|
+
? VectorStore.open(join(ctx.agentDir, 'memory', '.vectors', 'index.db'))
|
|
409
|
+
: undefined
|
|
296
410
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
payload: { agentDir: ctx.agentDir } satisfies DreamingPayload,
|
|
411
|
+
return {
|
|
412
|
+
subagents: {
|
|
413
|
+
'memory-logger': createMemoryLoggerSubagent({
|
|
414
|
+
logger: subagentLogger,
|
|
415
|
+
...(appendVectorStore !== undefined
|
|
416
|
+
? {
|
|
417
|
+
onFragmentsAppended: makeAppendHook(appendVectorStore),
|
|
418
|
+
onReferenceStored: makeReferenceStoredHook(appendVectorStore),
|
|
419
|
+
}
|
|
420
|
+
: {}),
|
|
421
|
+
}),
|
|
422
|
+
'memory-retrieval': createMemoryRetrievalSubagent({
|
|
423
|
+
logger: subagentLogger,
|
|
424
|
+
timeoutMs: retrievalSpawnTimeoutMs,
|
|
425
|
+
}),
|
|
426
|
+
dreaming: createDreamingSubagent({
|
|
427
|
+
logger: subagentLogger,
|
|
428
|
+
}),
|
|
316
429
|
},
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
// Memory injection lives in core (`createResourceLoader` calls `loadMemory`
|
|
320
|
-
// directly, appended LAST in the system prompt). It does not run from a
|
|
321
|
-
// plugin hook because positioning matters for cache-prefix stability:
|
|
322
|
-
// the daily-stream file grows after every channel turn (memory-logger
|
|
323
|
-
// appends a fragment + watermark) and memory/topics/ changes on every dream.
|
|
324
|
-
// A volatile region in the middle of the system prompt invalidates the
|
|
325
|
-
// entire cacheable suffix below it on every session resurrection
|
|
326
|
-
// (channel sessions evicted by idle GC, container restarts). Pinning
|
|
327
|
-
// memory to the bottom of the system prompt keeps everything above it
|
|
328
|
-
// cacheable across resurrections, at the cost of re-billing only the
|
|
329
|
-
// memory section itself when it grows.
|
|
330
|
-
//
|
|
331
|
-
// Core fires `session.idle` immediately after every prompt completion;
|
|
332
|
-
// the plugin owns the debounce timer so memory-logger only spawns
|
|
333
|
-
// after the user has been quiet for `idleMs`. Re-arming a still-armed
|
|
334
|
-
// timer cancels it first, matching the previous core IdleDetector.
|
|
335
|
-
// The size-based ceiling fires synchronously when the transcript has
|
|
336
|
-
// grown by `bufferBytes` since the last run, so busy channel sessions
|
|
337
|
-
// (which rarely go idle) still produce memory updates.
|
|
338
|
-
'session.idle': async (event) => {
|
|
339
|
-
if (event.origin?.kind === 'subagent') return
|
|
340
|
-
lastIdleEvent.set(event.sessionId, {
|
|
341
|
-
parentTranscriptPath: event.parentTranscriptPath,
|
|
342
|
-
...(event.origin !== undefined ? { origin: event.origin } : {}),
|
|
343
|
-
})
|
|
344
|
-
cancelTimer(event.sessionId)
|
|
345
|
-
const sessionId = event.sessionId
|
|
346
|
-
const transcriptPath = event.parentTranscriptPath
|
|
347
|
-
const timer = setTimeout(() => {
|
|
348
|
-
idleTimers.delete(sessionId)
|
|
349
|
-
void (async () => {
|
|
350
|
-
if (transcriptPath !== undefined && (await shouldSkipIdleSpawn(sessionId, transcriptPath))) {
|
|
351
|
-
ctx.logger.info(
|
|
352
|
-
`memory-logger idle skip ${sessionId} (delta below minIdleDeltaLines=${minIdleDeltaLines})`,
|
|
353
|
-
)
|
|
354
|
-
return
|
|
355
|
-
}
|
|
356
|
-
void fireMemoryLogger(sessionId, 'idle')
|
|
357
|
-
})()
|
|
358
|
-
}, idleMs)
|
|
359
|
-
idleTimers.set(sessionId, timer)
|
|
360
|
-
if (
|
|
361
|
-
event.parentTranscriptPath !== undefined &&
|
|
362
|
-
(await shouldTripBufferCeiling(sessionId, event.parentTranscriptPath))
|
|
363
|
-
) {
|
|
364
|
-
ctx.logger.info(`buffer-ceiling trip ${sessionId} bufferBytes=${bufferBytes}`)
|
|
365
|
-
cancelTimer(sessionId)
|
|
366
|
-
await fireMemoryLogger(sessionId, 'buffer-trip')
|
|
367
|
-
}
|
|
430
|
+
tools: {
|
|
431
|
+
memory_search: createMemorySearchTool(),
|
|
368
432
|
},
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
// before each `session.prompt(text)` call with the actual text the
|
|
378
|
-
// session is about to receive.
|
|
379
|
-
//
|
|
380
|
-
// The hook body is fully detached. `runSessionTurnStart` has no
|
|
381
|
-
// per-handler timeout (unlike session.prompt/idle/end), and the
|
|
382
|
-
// caller awaits it before `session.prompt(text)` runs — so an
|
|
383
|
-
// inline `await loadAllShards(...)` would gate every channel turn
|
|
384
|
-
// on N shard reads. Detaching mirrors PR #337's "fire-and-forget
|
|
385
|
-
// the slow work" pattern, pushed one level earlier to cover both
|
|
386
|
-
// the shard read AND the LLM spawn.
|
|
387
|
-
//
|
|
388
|
-
// Per-turn instead of per-session-creation means N spawns over the
|
|
389
|
-
// session's lifetime, but the subagent's own `inFlightKey` on
|
|
390
|
-
// `parentSessionId` (memory-retrieval.ts) coalesces overlapping
|
|
391
|
-
// fires: if turn N's retrieval is still running when turn N+1 fires,
|
|
392
|
-
// the second spawn is dropped with a warning, and the cache from
|
|
393
|
-
// turn N is still consumed by turn N+1 via the documented
|
|
394
|
-
// lag-by-one-prompt contract (load-memory.ts:appendRetrievalCache).
|
|
395
|
-
//
|
|
396
|
-
// Known limitation: a detached spawn can theoretically settle after
|
|
397
|
-
// `session.end` has unlinked the cache file, leaving a single ~5 KB
|
|
398
|
-
// file in memory/.retrieval-cache/ that never gets read. The next
|
|
399
|
-
// session.end that matches this sessionId would clean it up, but
|
|
400
|
-
// sessionIds are UUIDv7 so reuse is effectively never. Fix would
|
|
401
|
-
// serialize session.end behind the in-flight spawn, which
|
|
402
|
-
// re-introduces the cold-start blocking shape PR #337 fixed. The
|
|
403
|
-
// leaked file is bounded (one per disconnected session) and lives
|
|
404
|
-
// in a dir already marked transient.
|
|
405
|
-
//
|
|
406
|
-
// ctx.spawnSubagent IS reject-able in production: it wraps
|
|
407
|
-
// dispatchSpawnSubagent (src/run/index.ts) which calls invokeSubagent
|
|
408
|
-
// directly with no try/catch. SubagentConsumer's catch only protects
|
|
409
|
-
// stream-initiated spawns (target.kind === 'new-session'), not the
|
|
410
|
-
// direct ctx.spawnSubagent path the hooks use. Same for
|
|
411
|
-
// loadAllShards' fs errors. The .catch() on the void-discarded
|
|
412
|
-
// promise below is load-bearing — without it, every shard-read or
|
|
413
|
-
// handler failure (LLM provider error, payload validation throw)
|
|
414
|
-
// would surface as an unhandled rejection because nothing awaits
|
|
415
|
-
// the promise.
|
|
416
|
-
'session.turn.start': (event) => {
|
|
417
|
-
if (event.origin?.kind === 'subagent') return
|
|
418
|
-
void runMemoryRetrieval(event).catch((err) => {
|
|
419
|
-
ctx.logger.error(`memory-retrieval spawn failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
420
|
-
})
|
|
433
|
+
cronJobs: {
|
|
434
|
+
dreaming: {
|
|
435
|
+
schedule: dreamingSchedule,
|
|
436
|
+
kind: 'prompt' as const,
|
|
437
|
+
prompt: '(internal: dreaming consolidation; user prompt is built by the dreaming subagent handler)',
|
|
438
|
+
subagent: 'dreaming',
|
|
439
|
+
payload: { agentDir: ctx.agentDir } satisfies DreamingPayload,
|
|
440
|
+
},
|
|
421
441
|
},
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
// never race with the chained spawn. The cache-cleanup and
|
|
456
|
-
// bookkeeping deletes are dispatched alongside (not blocking the
|
|
457
|
-
// hook return) to preserve the "session.end returns synchronously"
|
|
458
|
-
// contract that the channel router's tearDownLive path depends on
|
|
459
|
-
// (see the comment block above this hook).
|
|
460
|
-
void (async () => {
|
|
461
|
-
const last = lastIdleEvent.get(sessionId)
|
|
462
|
-
let skip = false
|
|
463
|
-
if (last?.parentTranscriptPath !== undefined) {
|
|
464
|
-
const baseline = bytesAtLastRun.get(sessionId)
|
|
465
|
-
if (baseline !== undefined && baseline > 0) {
|
|
466
|
-
const currentSize = await readSize(last.parentTranscriptPath)
|
|
467
|
-
if (currentSize === baseline) {
|
|
442
|
+
hooks: {
|
|
443
|
+
// Memory injection lives in core (`createResourceLoader` calls `loadMemory`
|
|
444
|
+
// directly, appended LAST in the system prompt). It does not run from a
|
|
445
|
+
// plugin hook because positioning matters for cache-prefix stability:
|
|
446
|
+
// the daily-stream file grows after every channel turn (memory-logger
|
|
447
|
+
// appends a fragment + watermark) and memory/topics/ changes on every dream.
|
|
448
|
+
// A volatile region in the middle of the system prompt invalidates the
|
|
449
|
+
// entire cacheable suffix below it on every session resurrection
|
|
450
|
+
// (channel sessions evicted by idle GC, container restarts). Pinning
|
|
451
|
+
// memory to the bottom of the system prompt keeps everything above it
|
|
452
|
+
// cacheable across resurrections, at the cost of re-billing only the
|
|
453
|
+
// memory section itself when it grows.
|
|
454
|
+
//
|
|
455
|
+
// Core fires `session.idle` immediately after every prompt completion;
|
|
456
|
+
// the plugin owns the debounce timer so memory-logger only spawns
|
|
457
|
+
// after the user has been quiet for `idleMs`. Re-arming a still-armed
|
|
458
|
+
// timer cancels it first, matching the previous core IdleDetector.
|
|
459
|
+
// The size-based ceiling fires synchronously when the transcript has
|
|
460
|
+
// grown by `bufferBytes` since the last run, so busy channel sessions
|
|
461
|
+
// (which rarely go idle) still produce memory updates.
|
|
462
|
+
'session.idle': async (event) => {
|
|
463
|
+
if (event.origin?.kind === 'subagent') return
|
|
464
|
+
lastIdleEvent.set(event.sessionId, {
|
|
465
|
+
parentTranscriptPath: event.parentTranscriptPath,
|
|
466
|
+
...(event.origin !== undefined ? { origin: event.origin } : {}),
|
|
467
|
+
})
|
|
468
|
+
cancelTimer(event.sessionId)
|
|
469
|
+
const sessionId = event.sessionId
|
|
470
|
+
const transcriptPath = event.parentTranscriptPath
|
|
471
|
+
const timer = setTimeout(() => {
|
|
472
|
+
idleTimers.delete(sessionId)
|
|
473
|
+
void (async () => {
|
|
474
|
+
if (transcriptPath !== undefined && (await shouldSkipIdleSpawn(sessionId, transcriptPath))) {
|
|
468
475
|
ctx.logger.info(
|
|
469
|
-
`memory-logger
|
|
476
|
+
`memory-logger idle skip ${sessionId} (delta below minIdleDeltaLines=${minIdleDeltaLines})`,
|
|
470
477
|
)
|
|
471
|
-
|
|
478
|
+
return
|
|
472
479
|
}
|
|
473
|
-
|
|
480
|
+
void fireMemoryLogger(sessionId, 'idle')
|
|
481
|
+
})()
|
|
482
|
+
}, idleMs)
|
|
483
|
+
idleTimers.set(sessionId, timer)
|
|
484
|
+
if (
|
|
485
|
+
event.parentTranscriptPath !== undefined &&
|
|
486
|
+
(await shouldTripBufferCeiling(sessionId, event.parentTranscriptPath))
|
|
487
|
+
) {
|
|
488
|
+
ctx.logger.info(`buffer-ceiling trip ${sessionId} bufferBytes=${bufferBytes}`)
|
|
489
|
+
cancelTimer(sessionId)
|
|
490
|
+
await fireMemoryLogger(sessionId, 'buffer-trip')
|
|
474
491
|
}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
} catch {
|
|
492
|
+
},
|
|
493
|
+
// memory-retrieval used to run from `session.prompt`, which fires
|
|
494
|
+
// during system-prompt assembly (createResourceLoader) and carries
|
|
495
|
+
// the ASSEMBLING SYSTEM PROMPT as `event.prompt` — not the user's
|
|
496
|
+
// message. The plugin was feeding that string into the subagent as
|
|
497
|
+
// `recentPrompt`, so the LLM keyword-mined TypeClaw's framing prose
|
|
498
|
+
// (`TypeClaw`, `subagent`, `AGENTS.md`, `systemPromptLeak`, etc.)
|
|
499
|
+
// and burned 15+ memory_search calls per session on terms the user
|
|
500
|
+
// never said. `session.turn.start` is the correct trigger: it fires
|
|
501
|
+
// before each `session.prompt(text)` call with the actual text the
|
|
502
|
+
// session is about to receive.
|
|
503
|
+
//
|
|
504
|
+
'session.turn.start': async (event) => {
|
|
505
|
+
if (ctx.config.vector.enabled) {
|
|
506
|
+
// Vector agents inject long-term memory PER-TURN into the user
|
|
507
|
+
// prompt (the system-prompt `# Memory` section is suppressed at
|
|
508
|
+
// session creation). This runs for every origin that supplies a
|
|
509
|
+
// retrievalContext bag — including subagents, which no longer get
|
|
510
|
+
// memory via the system prompt either.
|
|
511
|
+
if (event.retrievalContext === undefined) return
|
|
496
512
|
try {
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
status: 'error',
|
|
502
|
-
message: `${dir} is missing and could not be created`,
|
|
503
|
-
fix: { description: 'Create memory/topics/ in the agent folder or fix its permissions on the host.' },
|
|
513
|
+
let injectedState = injectedShards.get(event.sessionId)
|
|
514
|
+
if (injectedState === undefined) {
|
|
515
|
+
injectedState = new Map()
|
|
516
|
+
injectedShards.set(event.sessionId, injectedState)
|
|
504
517
|
}
|
|
518
|
+
event.retrievalContext.results = await renderVectorTurnMemory(
|
|
519
|
+
event,
|
|
520
|
+
ctx.config.injectionBudgetBytes,
|
|
521
|
+
injectedState,
|
|
522
|
+
deps,
|
|
523
|
+
ctx.logger,
|
|
524
|
+
)
|
|
525
|
+
} catch (err) {
|
|
526
|
+
ctx.logger.error(`vector-retrieval failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
505
527
|
}
|
|
528
|
+
return
|
|
506
529
|
}
|
|
530
|
+
// Non-vector agents keep memory in the system prompt. The index-mode
|
|
531
|
+
// retrieval subagent must NOT fire for subagent-origin turns (it would
|
|
532
|
+
// recurse: the subagent it spawns triggers another turn.start).
|
|
533
|
+
if (event.origin?.kind === 'subagent') return
|
|
534
|
+
void runMemoryRetrieval(event).catch((err) => {
|
|
535
|
+
ctx.logger.error(`memory-retrieval spawn failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
536
|
+
})
|
|
507
537
|
},
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
538
|
+
// The memory-logger spawn is intentionally detached (`void`) instead
|
|
539
|
+
// of awaited. The channel router calls `tearDownLive` synchronously
|
|
540
|
+
// inside `ensureLive`'s stale-rollover path (router.ts:718), and
|
|
541
|
+
// `tearDownLive` awaits `fireSessionEnd` which awaits this hook. An
|
|
542
|
+
// awaited memory-logger spawn here would block new-session creation
|
|
543
|
+
// for the full subagent runtime — observed as 22+ seconds of channel
|
|
544
|
+
// silence on a 22 KB transcript before the new session even starts
|
|
545
|
+
// its cold-start chain.
|
|
546
|
+
//
|
|
547
|
+
// Safety: `fireMemoryLogger` captures the payload synchronously from
|
|
548
|
+
// `lastIdleEvent` (see comment above), so the `delete` calls below
|
|
549
|
+
// cannot race with the chained spawn. `spawnChain` still serializes
|
|
550
|
+
// memory-logger fires per agentDir — the detached promise is queued
|
|
551
|
+
// onto the chain before this hook returns, so a subsequent fire from
|
|
552
|
+
// the new session (idle, buffer-trip, or session-end) waits for the
|
|
553
|
+
// session-end spawn to settle before running.
|
|
554
|
+
//
|
|
555
|
+
// The only durability tradeoff: if the agent process dies between
|
|
556
|
+
// this hook returning and `spawnChain` settling, the session-end
|
|
557
|
+
// memory-logger fire is lost (its transcript fragments don't make
|
|
558
|
+
// it into today's daily stream). This is already true for the idle
|
|
559
|
+
// and buffer-trip paths, which are timer-driven and fire-and-forget
|
|
560
|
+
// by design. Session JSONLs are force-committed elsewhere, so no
|
|
561
|
+
// user-visible transcript is lost — only the LLM-distilled stream
|
|
562
|
+
// fragments for the final batch.
|
|
563
|
+
'session.end': (event) => {
|
|
564
|
+
// Dedup state is populated for every vector turn (subagents included),
|
|
565
|
+
// so it must be cleared before the subagent-origin early-return below.
|
|
566
|
+
injectedShards.delete(event.sessionId)
|
|
567
|
+
if (event.origin?.kind === 'subagent') return
|
|
568
|
+
cancelTimer(event.sessionId)
|
|
569
|
+
const sessionId = event.sessionId
|
|
570
|
+
// The skip path detaches via `void (async () => …)()` because
|
|
571
|
+
// readSize requires an await. fireMemoryLogger itself captures its
|
|
572
|
+
// payload synchronously from `lastIdleEvent` (see fireMemoryLogger
|
|
573
|
+
// comment block), so the `lastIdleEvent.delete` that follows can
|
|
574
|
+
// never race with the chained spawn. The cache-cleanup and
|
|
575
|
+
// bookkeeping deletes are dispatched alongside (not blocking the
|
|
576
|
+
// hook return) to preserve the "session.end returns synchronously"
|
|
577
|
+
// contract that the channel router's tearDownLive path depends on
|
|
578
|
+
// (see the comment block above this hook).
|
|
579
|
+
void (async () => {
|
|
580
|
+
const last = lastIdleEvent.get(sessionId)
|
|
581
|
+
let skip = false
|
|
582
|
+
if (last?.parentTranscriptPath !== undefined) {
|
|
583
|
+
const baseline = bytesAtLastRun.get(sessionId)
|
|
584
|
+
if (baseline !== undefined && baseline > 0) {
|
|
585
|
+
const currentSize = await readSize(last.parentTranscriptPath)
|
|
586
|
+
if (currentSize === baseline) {
|
|
587
|
+
ctx.logger.info(
|
|
588
|
+
`memory-logger session-end skip ${sessionId} (no new bytes since last spawn at ${baseline})`,
|
|
589
|
+
)
|
|
590
|
+
skip = true
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
if (!skip) void fireMemoryLogger(sessionId, 'session-end')
|
|
595
|
+
lastIdleEvent.delete(sessionId)
|
|
596
|
+
bytesAtLastRun.delete(sessionId)
|
|
597
|
+
linesAtLastRun.delete(sessionId)
|
|
598
|
+
streamCursorAtLastRun.delete(sessionId)
|
|
599
|
+
})()
|
|
600
|
+
const cacheFilePath = join(ctx.agentDir, 'memory', '.retrieval-cache', `${sessionId}.md`)
|
|
601
|
+
unlink(cacheFilePath).catch((err) => {
|
|
602
|
+
if (!isEnoent(err)) ctx.logger.warn(`[memory] failed to clean retrieval cache: ${err}`)
|
|
603
|
+
})
|
|
528
604
|
},
|
|
529
605
|
},
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
606
|
+
doctorChecks: {
|
|
607
|
+
'dir-writable': {
|
|
608
|
+
description: 'memory/topics/ exists and is writable',
|
|
609
|
+
run: async (dctx) => {
|
|
610
|
+
const dir = topicsDir(dctx.agentDir)
|
|
611
|
+
try {
|
|
612
|
+
await access(dir, fsConstants.W_OK)
|
|
613
|
+
return { status: 'ok', message: `${dir} writable` }
|
|
614
|
+
} catch {
|
|
615
|
+
try {
|
|
616
|
+
await mkdir(dir, { recursive: true })
|
|
617
|
+
return { status: 'ok', message: `created ${dir}` }
|
|
618
|
+
} catch {
|
|
619
|
+
return {
|
|
620
|
+
status: 'error',
|
|
621
|
+
message: `${dir} is missing and could not be created`,
|
|
622
|
+
fix: {
|
|
623
|
+
description: 'Create memory/topics/ in the agent folder or fix its permissions on the host.',
|
|
624
|
+
},
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
},
|
|
629
|
+
},
|
|
630
|
+
'daily-stream-current': {
|
|
631
|
+
description: "today's daily stream file exists",
|
|
632
|
+
run: async (dctx) => {
|
|
633
|
+
const today = new Date().toISOString().slice(0, 10)
|
|
634
|
+
const rel = join('memory', 'streams', `${today}.jsonl`)
|
|
635
|
+
const abs = streamFilePath(dctx.agentDir, today)
|
|
636
|
+
if (existsSync(abs)) return { status: 'ok', message: `${rel} present` }
|
|
542
637
|
return {
|
|
543
638
|
status: 'warning',
|
|
544
|
-
message:
|
|
639
|
+
message: `${rel} missing`,
|
|
545
640
|
fix: {
|
|
546
|
-
description:
|
|
641
|
+
description: `Create empty ${rel} so memory-logger has a target.`,
|
|
547
642
|
apply: async () => {
|
|
548
|
-
await
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
changedPaths: [join('memory', 'MEMORY.md.pre-shard.bak')],
|
|
552
|
-
}
|
|
643
|
+
await mkdir(streamsDir(dctx.agentDir), { recursive: true })
|
|
644
|
+
await writeFile(abs, '', 'utf8')
|
|
645
|
+
return { summary: `created ${rel}`, changedPaths: [rel] }
|
|
553
646
|
},
|
|
554
647
|
},
|
|
555
648
|
}
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
649
|
+
},
|
|
650
|
+
},
|
|
651
|
+
'pre-shard-backup-age': {
|
|
652
|
+
description: 'Warn when pre-shard backup is older than 30 days',
|
|
653
|
+
run: async (dctx) => {
|
|
654
|
+
const backupPath = preShardBackupPath(dctx.agentDir)
|
|
655
|
+
let s
|
|
656
|
+
try {
|
|
657
|
+
s = await stat(backupPath)
|
|
658
|
+
} catch {
|
|
659
|
+
return { status: 'ok', message: 'no pre-shard backup present' }
|
|
660
|
+
}
|
|
661
|
+
const ageDays = (Date.now() - s.mtimeMs) / 86_400_000
|
|
662
|
+
if (ageDays > 30) {
|
|
663
|
+
return {
|
|
664
|
+
status: 'warning',
|
|
665
|
+
message: `pre-shard backup is ${Math.round(ageDays)} days old; safe to delete if migration is verified`,
|
|
666
|
+
fix: {
|
|
667
|
+
description: 'Delete the pre-shard backup file',
|
|
668
|
+
apply: async () => {
|
|
669
|
+
await unlink(backupPath)
|
|
670
|
+
return {
|
|
671
|
+
summary: 'deleted pre-shard backup',
|
|
672
|
+
changedPaths: [join('memory', 'MEMORY.md.pre-shard.bak')],
|
|
673
|
+
}
|
|
674
|
+
},
|
|
675
|
+
},
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
return {
|
|
679
|
+
status: 'ok',
|
|
680
|
+
message: `pre-shard backup is ${Math.round(ageDays)} days old (under 30-day threshold)`,
|
|
681
|
+
}
|
|
682
|
+
},
|
|
683
|
+
},
|
|
684
|
+
'vector-index': {
|
|
685
|
+
description: 'vector index is consistent with memory (only when memory.vector is enabled)',
|
|
686
|
+
run: async (dctx) => {
|
|
687
|
+
const config = dctx.config as typeof ctx.config
|
|
688
|
+
if (!vectorEnabled(config)) {
|
|
689
|
+
return { status: 'ok', message: 'vector memory not enabled; skipping index health check' }
|
|
690
|
+
}
|
|
691
|
+
return runVectorIndexDoctor(dctx.agentDir)
|
|
692
|
+
},
|
|
561
693
|
},
|
|
562
694
|
},
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
}
|
|
695
|
+
}
|
|
696
|
+
},
|
|
697
|
+
})
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
export default createMemoryPlugin()
|
|
701
|
+
|
|
702
|
+
export function createMemoryPluginForTests(overrides: Partial<MemoryPluginDeps> = {}) {
|
|
703
|
+
return createMemoryPlugin({ ...defaultDeps, ...overrides })
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function vectorEnabled(config: unknown): boolean {
|
|
707
|
+
if (typeof config !== 'object' || config === null) return false
|
|
708
|
+
const parsed = vectorConfigSchema.safeParse((config as Record<string, unknown>).vector)
|
|
709
|
+
return parsed.success && parsed.data.enabled
|
|
710
|
+
}
|
|
567
711
|
|
|
568
712
|
async function readSize(path: string): Promise<number> {
|
|
569
713
|
try {
|