Skip to content

Commit d68b190

Browse files
committed
Preserve sync progress and refresh synonyms with collision-free context keys
1 parent a5c2eeb commit d68b190

11 files changed

Lines changed: 456 additions & 284 deletions

‎README.md‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ Remote LLM endpoints must use HTTPS; HTTP is accepted only on loopback.
5252

5353
**Synonyms:** Each node (file or directory) gets 5–12 LLM-generated synonyms via batched API calls to an OpenAI-compatible endpoint. For example, `Skeletons.tsx → ["skeletons", "loading", "placeholder", "animation"]`. Names are smart-batched (up to 60 per request, 10 parallel by default). The prompt includes extracted symbols and, for JS/TS files, ESM imports to generate contextual terms. Use `--offline` to guarantee that no LLM requests are made.
5454

55-
**Bootstrap diff:** On subsequent runs, `watch` diffs the filesystem against the existing snapshot using file modification times and sizes. Timestamp-preserving edits with a different size are detected; edits preserving both size and timestamp require a full rebuild. Deleted files are removed, new files are added, modified files are re-extracted. No LLM calls for existing synonyms — run `ctxt sync` to fill gaps. Synonym keys use `parentDir/basename` (e.g., `webhook/route.ts`) instead of bare basenames to prevent duplicate filenames from overwriting each other in the synonym map.
55+
**Bootstrap diff:** On subsequent runs, `watch` diffs the filesystem against the existing snapshot using file modification times and sizes. Timestamp-preserving edits with a different size are detected; edits preserving both size and timestamp require a full rebuild. Deleted files are removed, new files are added, modified files are re-extracted. Watch bootstrap reuses cached synonyms; run `ctxt sync` to fill gaps and refresh changed symbol/import context. Synonym cache keys use the full project-relative path (e.g., `billing/routes/index.ts`) so identically named files in separate features remain distinct.
5656

5757
### Search mechanics
5858

@@ -105,13 +105,30 @@ On first run, creates a starter `.ctxt/ctx_config.toml` and pauses so you can ed
105105

106106
### `ctxt sync`
107107

108-
Targeted synonym generation — only generates synonyms for names that are missing or below `synonyms_min`. Works on the existing index without rebuilding.
108+
Targeted synonym generation — generates synonyms for indexed names that are missing, below `synonyms_min`, or have changed symbols/imports. It refreshes symbol extraction from disk before comparing context fingerprints. Works on the existing index without rebuilding the file tree.
109109

110110
```bash
111111
ctxt sync .
112+
ctxt sync . --path command_sync.go
113+
ctxt sync . --path docs --force
112114
```
113115

114-
Use after `init` to fill synonym gaps, or after adding new files to catch names that were missed.
116+
`--path` selects an indexed file or directory; `--force` regenerates selected
117+
entries even if their fingerprints are current. Use `--force` after changing
118+
models or generation instructions. Body-only edits that leave symbols/imports
119+
unchanged also require `--force`.
120+
121+
Successful LLM responses are saved even if another batch fails. The command
122+
still exits with an error; rerun without `--force` to request only missing,
123+
short, or stale entries. `--batch-size` explicitly controls batch size when set.
124+
125+
The cache automatically migrates to version 2 on the next write. Unique legacy
126+
entries are retained; ambiguous parent/file keys are dropped rather than shared
127+
between unrelated files. Legacy entries have no context fingerprint, so their
128+
first online `init` or `sync` refreshes them. Use `--offline` with `init` to migrate
129+
without LLM requests. Keep the CLI upgraded when sharing these cache files.
130+
131+
Use `init` or restart `watch` after adding files, then run `sync` to fill gaps.
115132

116133
### `ctxt watch`
117134

@@ -292,7 +309,7 @@ search-hints → load .ctxt/ctx_index.json (or query live memory) → score toke
292309
## File formats
293310

294311
- **`.ctxt/ctx_index.json`** — schema version, root path, timestamp, and indexed tree
295-
- **`.ctxt/ctx_cache.json`** — directory or parent/file key → synonyms for reuse
312+
- **`.ctxt/ctx_cache.json`** — versioned project-relative synonyms and symbol/import context fingerprints
296313
- **`.ctxt/ctx_config.toml`** — config-driven defaults
297314
- **`.ctxt/ctx_runtime.json`** — live watch discovery for memory search
298315
- **Bench/eval case files** — v2 format with categories (path-intent, symbol-lookup, concept-synonym, exact-file, narrow-scope, vague-intent); v1 bare array format is backward compatible

‎cache.go‎

Lines changed: 50 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,27 +6,65 @@ import (
66
"os"
77
)
88

9+
type synonymCacheState struct {
10+
Version int `json:"cache_version"`
11+
Entries SynonymResponse `json:"entries"`
12+
Fingerprints map[string]string `json:"fingerprints,omitempty"`
13+
}
14+
915
func LoadSynonymCache(path string) (SynonymResponse, error) {
16+
state, err := loadSynonymCacheState(path)
17+
return state.Entries, err
18+
}
19+
20+
func loadSynonymCacheState(path string) (synonymCacheState, error) {
21+
state := synonymCacheState{Entries: make(SynonymResponse), Fingerprints: make(map[string]string)}
1022
if path == "" {
11-
return make(SynonymResponse), nil
23+
return state, nil
24+
}
25+
data, err := os.ReadFile(path)
26+
if os.IsNotExist(err) {
27+
return state, nil
1228
}
13-
bytes, err := os.ReadFile(path)
1429
if err != nil {
15-
if os.IsNotExist(err) {
16-
return make(SynonymResponse), nil
30+
return state, fmt.Errorf("read synonym cache: %w", err)
31+
}
32+
var fields map[string]json.RawMessage
33+
if err := json.Unmarshal(data, &fields); err != nil {
34+
return state, fmt.Errorf("parse synonym cache: %w", err)
35+
}
36+
if version, ok := fields["cache_version"]; ok && len(version) > 0 && version[0] != '[' {
37+
if err := json.Unmarshal(data, &state); err != nil {
38+
return state, fmt.Errorf("parse synonym cache: %w", err)
1739
}
18-
return nil, fmt.Errorf("read synonym cache: %w", err)
40+
if state.Version != 2 {
41+
return state, fmt.Errorf("unsupported synonym cache version %d", state.Version)
42+
}
43+
} else if err := json.Unmarshal(data, &state.Entries); err != nil {
44+
return state, fmt.Errorf("parse synonym cache: %w", err)
1945
}
20-
21-
cache := make(SynonymResponse)
22-
if err := json.Unmarshal(bytes, &cache); err != nil {
23-
return nil, fmt.Errorf("parse synonym cache: %w", err)
46+
if state.Entries == nil {
47+
state.Entries = make(SynonymResponse)
2448
}
49+
if state.Fingerprints == nil {
50+
state.Fingerprints = make(map[string]string)
51+
}
52+
for name, syns := range state.Entries {
53+
state.Entries[name] = sanitizeSynonyms(syns, 16)
54+
}
55+
return state, nil
56+
}
2557

26-
for name, syns := range cache {
27-
cache[name] = sanitizeSynonyms(syns, 16)
58+
func saveSynonymCacheState(path string, state synonymCacheState) error {
59+
if path == "" {
60+
return nil
61+
}
62+
state.Version = 2
63+
data, err := json.MarshalIndent(state, "", " ")
64+
if err != nil {
65+
return fmt.Errorf("marshal synonym cache: %w", err)
2866
}
29-
return cache, nil
67+
return writeFileAtomic(path, append(data, '\n'), 0o600)
3068
}
3169

3270
func SaveSynonymCache(path string, cache SynonymResponse) error {

‎cache_migration.go‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package contexting
2+
3+
import "path/filepath"
4+
5+
// migrateSynonymCache preserves only legacy entries that identify one node.
6+
// Ambiguous suffixes must not carry one file's meaning into another file.
7+
func migrateSynonymCache(tree *Node, state synonymCacheState) synonymCacheState {
8+
counts := make(map[string]int)
9+
walkTree(tree, func(node *Node) {
10+
if node == tree {
11+
return
12+
}
13+
counts[legacySynonymKey(node)]++
14+
})
15+
entries := make(SynonymResponse)
16+
walkTree(tree, func(node *Node) {
17+
if node == tree {
18+
return
19+
}
20+
rel, err := filepath.Rel(tree.FullPath, node.FullPath)
21+
if err != nil {
22+
return
23+
}
24+
node.SynonymKey = filepath.ToSlash(rel)
25+
if state.Version >= 2 {
26+
return
27+
}
28+
legacy := legacySynonymKey(node)
29+
if counts[legacy] == 1 {
30+
if values, ok := state.Entries[legacy]; ok {
31+
entries[node.SynonymKey] = values
32+
return
33+
}
34+
}
35+
// Support callers already supplying relative keys in the public map API.
36+
if counts[node.SynonymKey] == 0 {
37+
if values, ok := state.Entries[node.SynonymKey]; ok {
38+
entries[node.SynonymKey] = values
39+
}
40+
}
41+
})
42+
if state.Version < 2 {
43+
state.Entries = entries
44+
}
45+
state.Version = 2
46+
if state.Fingerprints == nil {
47+
state.Fingerprints = make(map[string]string)
48+
}
49+
return state
50+
}
51+
52+
func legacySynonymKey(node *Node) string {
53+
if node.Type == "file" {
54+
return pathSuffix(node.FullPath)
55+
}
56+
return filepath.Base(node.FullPath)
57+
}

‎command_init.go‎

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ Examples:
107107
}
108108
llmEndpoint, llmModel, llmKey, llmTemp, llmMaxTokens, llmProvider := resolveLLMConfig(flags, cfg.LLM)
109109
LogInfof("LLM: provider=%s model=%s endpoint=%s api_key=%s", llmProvider, llmModel, endpointForLog(llmEndpoint), maskAPIKey(llmKey))
110-
cache, err := LoadSynonymCache(cachePath)
110+
cacheState, err := loadSynonymCacheState(cachePath)
111111
if err != nil {
112112
return err
113113
}
@@ -118,22 +118,24 @@ Examples:
118118
defer stop()
119119

120120
result, err := BuildIndex(BuildOptions{
121-
Ctx: ctx,
122-
RootPath: rootPath,
123-
IgnoredPaths: ignored,
124-
APIKey: llmKey,
125-
Model: llmModel,
126-
BatchSize: flags.BatchSize,
127-
SynonymsPerName: flags.SynonymsPerName,
128-
SynonymsMin: flags.SynonymsMin,
129-
SynonymsMax: flags.SynonymsMax,
130-
SynonymCache: cache,
131-
MaxBatchSize: cfg.Watch.MaxBatchSize,
132-
Endpoint: llmEndpoint,
133-
Temperature: llmTemp,
134-
MaxTokens: llmMaxTokens,
135-
ParallelRequests: cfg.LLM.ParallelRequests,
136-
Verbose: flags.Verbose,
121+
Ctx: ctx,
122+
RootPath: rootPath,
123+
IgnoredPaths: ignored,
124+
APIKey: llmKey,
125+
Model: llmModel,
126+
BatchSize: flags.BatchSize,
127+
SynonymsPerName: flags.SynonymsPerName,
128+
SynonymsMin: flags.SynonymsMin,
129+
SynonymsMax: flags.SynonymsMax,
130+
SynonymCache: cacheState.Entries,
131+
CacheVersion: cacheState.Version,
132+
SynonymFingerprints: cacheState.Fingerprints,
133+
MaxBatchSize: cfg.Watch.MaxBatchSize,
134+
Endpoint: llmEndpoint,
135+
Temperature: llmTemp,
136+
MaxTokens: llmMaxTokens,
137+
ParallelRequests: cfg.LLM.ParallelRequests,
138+
Verbose: flags.Verbose,
137139
})
138140
if err != nil {
139141
return err
@@ -143,7 +145,7 @@ Examples:
143145
}
144146

145147
emitSynonymWarning(result.SynonymError)
146-
if err := SaveSynonymCache(cachePath, result.SynonymCache); err != nil {
148+
if err := saveSynonymCacheState(cachePath, synonymCacheState{Version: 2, Entries: result.SynonymCache, Fingerprints: result.SynonymFingerprints}); err != nil {
147149
return err
148150
}
149151
if err := SaveContextIndex(outputPath, result.Index); err != nil {

0 commit comments

Comments
 (0)