@@ -3,6 +3,7 @@ package agent
33import (
44 "errors"
55 "fmt"
6+ "io/fs"
67 "os"
78 "path/filepath"
89 "runtime"
@@ -105,6 +106,7 @@ Your workspace is at: %s
1051064. **Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content.
106107
1071085. **Team delegation** - For any task that is non-trivial, multi-step, or involves distinct concerns (e.g. "convert React to Vue", "build a feature", "analyze and report"), you MUST use the 'team' tool to delegate and parallelize. Do NOT attempt to handle complex tasks inline by calling tools one by one yourself. Decompose first, delegate second, then report the outcome.
109+
108110%s` ,
109111 version , workspacePath , workspacePath , workspacePath , workspacePath , workspacePath , toolDiscovery )
110112}
@@ -150,8 +152,11 @@ The following skills extend your capabilities. To use a skill, read its SKILL.md
150152%s` , skillsSummary ))
151153 }
152154
153- // Memory context is no longer injected here. It has moved to buildDynamicContextAndMemory
154- // so that vector memory search can use the specific user query per-request.
155+ // Memory context
156+ memoryContext := cb .memory .GetMemoryContext ("" )
157+ if memoryContext != "" {
158+ parts = append (parts , "# Memory\n \n " + memoryContext )
159+ }
155160
156161 // Join with "---" separator
157162 return strings .Join (parts , "\n \n ---\n \n " )
@@ -224,7 +229,7 @@ func (cb *ContextBuilder) sourcePaths() []string {
224229 filepath .Join (cb .workspace , "SOUL.md" ),
225230 filepath .Join (cb .workspace , "USER.md" ),
226231 filepath .Join (cb .workspace , "IDENTITY.md" ),
227- // MEMORY.md is no longer cached in the static system prompt
232+ filepath . Join ( cb . workspace , "memory" , " MEMORY.md" ),
228233 }
229234}
230235
@@ -254,12 +259,10 @@ type cacheBaseline struct {
254259// the latest mtime across all tracked files + skills directory contents.
255260// Called under write lock when the cache is built.
256261func (cb * ContextBuilder ) buildCacheBaseline () cacheBaseline {
257- memoryDir := filepath . Join ( cb .workspace , "memory" )
262+ skillRoots := cb .skillRoots ( )
258263
259- // All paths whose existence we track: source files + skill roots + memory dir.
260- allPaths := cb .sourcePaths ()
261- allPaths = append (allPaths , cb .skillRoots ()... )
262- allPaths = append (allPaths , memoryDir )
264+ // All paths whose existence we track: source files + all skill roots.
265+ allPaths := append (cb .sourcePaths (), skillRoots ... )
263266
264267 existed := make (map [string ]bool , len (allPaths ))
265268 skillFiles := make (map [string ]time.Time )
@@ -273,11 +276,10 @@ func (cb *ContextBuilder) buildCacheBaseline() cacheBaseline {
273276 }
274277 }
275278
276- // Walk skills files to capture their mtimes too.
277- // Use os.Stat (not d.Info) to match the stat method used in
278- // fileChangedSince / skillFilesChangedSince for consistency.
279- for _ , root := range cb .skillRoots () {
280- _ = filepath .WalkDir (root , func (path string , d os.DirEntry , walkErr error ) error {
279+ // Walk all skill roots recursively to snapshot skill files and mtimes.
280+ // Use os.Stat (not d.Info) for consistency with sourceFilesChanged checks.
281+ for _ , root := range skillRoots {
282+ _ = filepath .WalkDir (root , func (path string , d fs.DirEntry , walkErr error ) error {
281283 if walkErr == nil && ! d .IsDir () {
282284 if info , err := os .Stat (path ); err == nil {
283285 skillFiles [path ] = info .ModTime ()
@@ -290,16 +292,6 @@ func (cb *ContextBuilder) buildCacheBaseline() cacheBaseline {
290292 })
291293 }
292294
293- // Also walk memory files
294- _ = filepath .WalkDir (memoryDir , func (path string , d os.DirEntry , walkErr error ) error {
295- if walkErr == nil && ! d .IsDir () {
296- if info , err := os .Stat (path ); err == nil && info .ModTime ().After (maxMtime ) {
297- maxMtime = info .ModTime ()
298- }
299- }
300- return nil
301- })
302-
303295 // If no tracked files exist yet (empty workspace), maxMtime is zero.
304296 // Use a very old non-zero time so that:
305297 // 1. cachedAt.IsZero() won't trigger perpetual rebuilds.
@@ -340,26 +332,10 @@ func (cb *ContextBuilder) sourceFilesChangedLocked() bool {
340332 return true
341333 }
342334 }
343-
344- // 2. Structural changes (add/remove entries inside the dir) are reflected
345- // in the directory's own mtime, which fileChangedSince already checks.
346- //
347- // 3. Content-only edits to files inside skills/ do NOT update the parent
348- // directory mtime on most filesystems, so we recursively walk to check
349- // individual file mtimes at any nesting depth.
350335 if skillFilesChangedSince (cb .skillRoots (), cb .skillFilesAtCache ) {
351336 return true
352337 }
353338
354- // --- Memory directory (handled identically to skills) ---
355- memoryDir := filepath .Join (cb .workspace , "memory" )
356- if cb .fileChangedSince (memoryDir ) {
357- return true
358- }
359- if filesModifiedSince (memoryDir , cb .cachedAt ) {
360- return true
361- }
362-
363339 return false
364340}
365341
@@ -397,32 +373,6 @@ func (cb *ContextBuilder) fileChangedSince(path string) bool {
397373// if the callback returned nil when its err parameter is non-nil.
398374var errWalkStop = errors .New ("walk stop" )
399375
400- // filesModifiedSince recursively checks if any file directly or indirectly
401- // inside dirPath has been modified since the cached time.
402- func filesModifiedSince (dirPath string , since time.Time ) bool {
403- changed := false
404- err := filepath .WalkDir (dirPath , func (path string , d os.DirEntry , walkErr error ) error {
405- if changed || walkErr != nil || d .IsDir () {
406- return nil
407- }
408- if info , err := os .Stat (path ); err == nil && info .ModTime ().After (since ) {
409- changed = true
410- return errWalkStop // stop walking
411- }
412- return nil
413- })
414-
415- if err != nil && ! errors .Is (err , errWalkStop ) && ! os .IsNotExist (err ) {
416- logger .DebugCF ("agent" , "Failed to walk directory for mtime check" ,
417- map [string ]any {
418- "dir" : dirPath ,
419- "error" : err .Error (),
420- })
421- }
422-
423- return changed
424- }
425-
426376// skillFilesChangedSince compares the current recursive skill file tree
427377// against the cache-time snapshot. Any create/delete/mtime drift invalidates
428378// the cache.
@@ -452,7 +402,7 @@ func skillFilesChangedSince(skillRoots []string, filesAtCache map[string]time.Ti
452402 continue
453403 }
454404
455- err := filepath .WalkDir (root , func (path string , d os .DirEntry , walkErr error ) error {
405+ err := filepath .WalkDir (root , func (path string , d fs .DirEntry , walkErr error ) error {
456406 if walkErr != nil {
457407 // Treat unexpected walk errors as changed to avoid stale cache.
458408 if ! os .IsNotExist (walkErr ) {
@@ -502,10 +452,15 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string {
502452 return sb .String ()
503453}
504454
505- // buildDynamicContextAndMemory returns a short dynamic context string with per-request info,
506- // including semantic memory retrieved based on the current user message.
507- // This changes every request so it is NOT part of the cached prompt.
508- func (cb * ContextBuilder ) buildDynamicContextAndMemory (channel , chatID , currentMessage string ) string {
455+ // buildDynamicContext returns a short dynamic context string with per-request info.
456+ // This changes every request (time, session) so it is NOT part of the cached prompt.
457+ // LLM-side KV cache reuse is achieved by each provider adapter's native mechanism:
458+ // - Anthropic: per-block cache_control (ephemeral) on the static SystemParts block
459+ // - OpenAI / Codex: prompt_cache_key for prefix-based caching
460+ //
461+ // See: https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching
462+ // See: https://platform.openai.com/docs/guides/prompt-caching
463+ func (cb * ContextBuilder ) buildDynamicContext (channel , chatID string ) string {
509464 now := time .Now ().Format ("2006-01-02 15:04 (Monday)" )
510465 rt := fmt .Sprintf ("%s %s, Go %s" , runtime .GOOS , runtime .GOARCH , runtime .Version ())
511466
@@ -516,12 +471,6 @@ func (cb *ContextBuilder) buildDynamicContextAndMemory(channel, chatID, currentM
516471 fmt .Fprintf (& sb , "\n \n ## Current Session\n Channel: %s\n Chat ID: %s" , channel , chatID )
517472 }
518473
519- // Dynamic memory context (retrieving relevant context based on user message)
520- memoryContext := cb .memory .GetMemoryContext (currentMessage )
521- if memoryContext != "" {
522- fmt .Fprintf (& sb , "\n \n # Memory\n \n %s" , memoryContext )
523- }
524-
525474 return sb .String ()
526475}
527476
@@ -545,8 +494,8 @@ func (cb *ContextBuilder) BuildMessages(
545494 // - OpenAI-compat passes messages through as-is.
546495 staticPrompt := cb .BuildSystemPromptWithCache ()
547496
548- // Build short dynamic context (time, runtime, session, dynamic semantic memory)
549- dynamicCtx := cb .buildDynamicContextAndMemory (channel , chatID , currentMessage )
497+ // Build short dynamic context (time, runtime, session) — changes per request
498+ dynamicCtx := cb .buildDynamicContext (channel , chatID )
550499
551500 // Compose a single system message: static (cached) + dynamic + optional summary.
552501 // Keeping all system content in one message ensures every provider adapter can
0 commit comments