nano-brain 2026.1.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/AGENTS_SNIPPET.md +36 -0
- package/CHANGELOG.md +68 -0
- package/README.md +281 -0
- package/SKILL.md +153 -0
- package/bin/cli.js +18 -0
- package/index.html +929 -0
- package/nano-brain +4 -0
- package/opencode-mcp.json +9 -0
- package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/.openspec.yaml +2 -0
- package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/design.md +68 -0
- package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/proposal.md +27 -0
- package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/specs/mcp-integration-testing/spec.md +50 -0
- package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/specs/mcp-server/spec.md +40 -0
- package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/specs/search-pipeline/spec.md +29 -0
- package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/tasks.md +37 -0
- package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/.openspec.yaml +2 -0
- package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/design.md +111 -0
- package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/proposal.md +30 -0
- package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/specs/mcp-server/spec.md +33 -0
- package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/specs/storage-limits/spec.md +90 -0
- package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/specs/workspace-scoping/spec.md +66 -0
- package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/tasks.md +199 -0
- package/openspec/changes/codebase-indexing/.openspec.yaml +2 -0
- package/openspec/changes/codebase-indexing/design.md +169 -0
- package/openspec/changes/codebase-indexing/proposal.md +30 -0
- package/openspec/changes/codebase-indexing/specs/codebase-collection/spec.md +187 -0
- package/openspec/changes/codebase-indexing/specs/mcp-server/spec.md +36 -0
- package/openspec/changes/codebase-indexing/tasks.md +56 -0
- package/openspec/specs/mcp-integration-testing/spec.md +50 -0
- package/openspec/specs/mcp-server/spec.md +75 -0
- package/openspec/specs/search-pipeline/spec.md +29 -0
- package/openspec/specs/storage-limits/spec.md +94 -0
- package/openspec/specs/workspace-scoping/spec.md +70 -0
- package/package.json +34 -0
- package/site/build.js +66 -0
- package/site/partials/_api.html +83 -0
- package/site/partials/_compare.html +100 -0
- package/site/partials/_config.html +23 -0
- package/site/partials/_features.html +43 -0
- package/site/partials/_footer.html +6 -0
- package/site/partials/_hero.html +9 -0
- package/site/partials/_how-it-works.html +26 -0
- package/site/partials/_models.html +18 -0
- package/site/partials/_quick-start.html +15 -0
- package/site/partials/_stats.html +1 -0
- package/site/partials/_tech-stack.html +13 -0
- package/site/script.js +12 -0
- package/site/shell.html +44 -0
- package/site/styles.css +548 -0
- package/src/chunker.ts +427 -0
- package/src/codebase.ts +331 -0
- package/src/collections.ts +192 -0
- package/src/embeddings.ts +293 -0
- package/src/expansion.ts +79 -0
- package/src/harvester.ts +306 -0
- package/src/index.ts +503 -0
- package/src/reranker.ts +103 -0
- package/src/search.ts +294 -0
- package/src/server.ts +664 -0
- package/src/storage.ts +221 -0
- package/src/store.ts +623 -0
- package/src/types.ts +202 -0
- package/src/watcher.ts +384 -0
- package/test/chunker.test.ts +479 -0
- package/test/cli.test.ts +309 -0
- package/test/codebase-chunker.test.ts +446 -0
- package/test/codebase.test.ts +678 -0
- package/test/collections.test.ts +571 -0
- package/test/harvester.test.ts +636 -0
- package/test/integration.test.ts +150 -0
- package/test/llm.test.ts +322 -0
- package/test/search.test.ts +572 -0
- package/test/server.test.ts +541 -0
- package/test/storage.test.ts +302 -0
- package/test/store.test.ts +465 -0
- package/test/watcher.test.ts +656 -0
- package/test/workspace.test.ts +239 -0
- package/tsconfig.json +19 -0
- package/vitest.config.ts +16 -0
package/src/types.ts
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
export interface SearchResult {
|
|
2
|
+
id: string;
|
|
3
|
+
path: string;
|
|
4
|
+
collection: string;
|
|
5
|
+
title: string;
|
|
6
|
+
snippet: string;
|
|
7
|
+
score: number;
|
|
8
|
+
startLine: number;
|
|
9
|
+
endLine: number;
|
|
10
|
+
docid: string;
|
|
11
|
+
agent?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface Document {
|
|
15
|
+
id: number;
|
|
16
|
+
collection: string;
|
|
17
|
+
path: string;
|
|
18
|
+
title: string;
|
|
19
|
+
hash: string;
|
|
20
|
+
agent?: string;
|
|
21
|
+
createdAt: string;
|
|
22
|
+
modifiedAt: string;
|
|
23
|
+
active: boolean;
|
|
24
|
+
projectHash?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface MemoryChunk {
|
|
28
|
+
hash: string;
|
|
29
|
+
seq: number;
|
|
30
|
+
pos: number;
|
|
31
|
+
text: string;
|
|
32
|
+
startLine: number;
|
|
33
|
+
endLine: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface BreakPoint {
|
|
37
|
+
pos: number;
|
|
38
|
+
score: number;
|
|
39
|
+
type: string;
|
|
40
|
+
lineNo: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface CodeFenceRegion {
|
|
44
|
+
start: number;
|
|
45
|
+
end: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface Collection {
|
|
49
|
+
name: string;
|
|
50
|
+
path: string;
|
|
51
|
+
pattern: string;
|
|
52
|
+
context?: Record<string, string>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface CollectionConfig {
|
|
56
|
+
globalContext?: string
|
|
57
|
+
collections: Record<string, {
|
|
58
|
+
path: string
|
|
59
|
+
pattern?: string
|
|
60
|
+
context?: Record<string, string>
|
|
61
|
+
update?: string
|
|
62
|
+
}>
|
|
63
|
+
storage?: {
|
|
64
|
+
maxSize?: string
|
|
65
|
+
retention?: string
|
|
66
|
+
minFreeDisk?: string
|
|
67
|
+
}
|
|
68
|
+
codebase?: CodebaseConfig
|
|
69
|
+
embedding?: EmbeddingConfig
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface CodebaseConfig {
|
|
73
|
+
enabled: boolean
|
|
74
|
+
root?: string
|
|
75
|
+
exclude?: string[]
|
|
76
|
+
extensions?: string[]
|
|
77
|
+
maxFileSize?: string
|
|
78
|
+
maxSize?: string
|
|
79
|
+
batchSize?: number
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface EmbeddingConfig {
|
|
83
|
+
provider?: 'ollama' | 'local'
|
|
84
|
+
url?: string
|
|
85
|
+
model?: string
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface CodebaseIndexResult {
|
|
89
|
+
filesScanned: number
|
|
90
|
+
filesIndexed: number
|
|
91
|
+
filesSkippedUnchanged: number
|
|
92
|
+
filesSkippedTooLarge: number
|
|
93
|
+
filesSkippedBudget: number
|
|
94
|
+
chunksCreated: number
|
|
95
|
+
storageUsedBytes: number
|
|
96
|
+
maxSizeBytes: number
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface StorageConfig {
|
|
100
|
+
maxSize: number;
|
|
101
|
+
retention: number;
|
|
102
|
+
minFreeDisk: number;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface EmbeddingResult {
|
|
106
|
+
embedding: number[];
|
|
107
|
+
model: string;
|
|
108
|
+
dimensions: number;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface RerankResult {
|
|
112
|
+
results: Array<{
|
|
113
|
+
file: string;
|
|
114
|
+
score: number;
|
|
115
|
+
index: number;
|
|
116
|
+
}>;
|
|
117
|
+
model: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface RerankDocument {
|
|
121
|
+
text: string;
|
|
122
|
+
file: string;
|
|
123
|
+
index: number;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface HarvestedSession {
|
|
127
|
+
sessionId: string;
|
|
128
|
+
slug: string;
|
|
129
|
+
title: string;
|
|
130
|
+
agent: string;
|
|
131
|
+
date: string;
|
|
132
|
+
project: string;
|
|
133
|
+
projectHash: string;
|
|
134
|
+
messages: Array<{
|
|
135
|
+
role: 'user' | 'assistant';
|
|
136
|
+
agent?: string;
|
|
137
|
+
text: string;
|
|
138
|
+
}>;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface IndexHealth {
|
|
142
|
+
documentCount: number
|
|
143
|
+
chunkCount: number
|
|
144
|
+
pendingEmbeddings: number
|
|
145
|
+
collections: Array<{
|
|
146
|
+
name: string
|
|
147
|
+
documentCount: number
|
|
148
|
+
path: string
|
|
149
|
+
}>
|
|
150
|
+
databaseSize: number
|
|
151
|
+
modelStatus: {
|
|
152
|
+
embedding: string
|
|
153
|
+
reranker: string
|
|
154
|
+
expander: string
|
|
155
|
+
}
|
|
156
|
+
workspaceStats?: Array<{ projectHash: string; count: number }>
|
|
157
|
+
codebase?: {
|
|
158
|
+
enabled: boolean
|
|
159
|
+
documents: number
|
|
160
|
+
chunks: number
|
|
161
|
+
extensions: string[]
|
|
162
|
+
excludeCount: number
|
|
163
|
+
storageUsed: number
|
|
164
|
+
maxSize: number
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface Store {
|
|
169
|
+
close(): void;
|
|
170
|
+
|
|
171
|
+
insertDocument(doc: Omit<Document, 'id'>): number;
|
|
172
|
+
findDocument(pathOrDocid: string): Document | null;
|
|
173
|
+
getDocumentBody(hash: string, fromLine?: number, maxLines?: number): string | null;
|
|
174
|
+
deactivateDocument(collection: string, path: string): void;
|
|
175
|
+
bulkDeactivateExcept(collection: string, activePaths: string[]): number;
|
|
176
|
+
|
|
177
|
+
insertContent(hash: string, body: string): void;
|
|
178
|
+
|
|
179
|
+
insertEmbedding(hash: string, seq: number, pos: number, embedding: number[], model: string): void;
|
|
180
|
+
ensureVecTable(dimensions: number): void;
|
|
181
|
+
|
|
182
|
+
searchFTS(query: string, limit?: number, collection?: string, projectHash?: string): SearchResult[];
|
|
183
|
+
searchVec(query: string, embedding: number[], limit?: number, collection?: string, projectHash?: string): SearchResult[];
|
|
184
|
+
|
|
185
|
+
getCachedResult(hash: string): string | null;
|
|
186
|
+
setCachedResult(hash: string, result: string): void;
|
|
187
|
+
|
|
188
|
+
getIndexHealth(): IndexHealth;
|
|
189
|
+
getHashesNeedingEmbedding(projectHash?: string): Array<{ hash: string; body: string; path: string }>;
|
|
190
|
+
getNextHashNeedingEmbedding(projectHash?: string): { hash: string; body: string; path: string } | null;
|
|
191
|
+
getWorkspaceStats(): Array<{ projectHash: string; count: number }>;
|
|
192
|
+
|
|
193
|
+
deleteDocumentsByPath(filePath: string): number;
|
|
194
|
+
cleanOrphanedEmbeddings(): number;
|
|
195
|
+
getCollectionStorageSize(collection: string): number;
|
|
196
|
+
|
|
197
|
+
modelStatus: {
|
|
198
|
+
embedding: string;
|
|
199
|
+
reranker: string;
|
|
200
|
+
expander: string;
|
|
201
|
+
};
|
|
202
|
+
}
|
package/src/watcher.ts
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import { watch, type FSWatcher } from 'chokidar';
|
|
2
|
+
import type { Store, Collection, StorageConfig, CodebaseConfig } from './types.js'
|
|
3
|
+
import { scanCollectionFiles } from './collections.js';
|
|
4
|
+
import { indexDocument, computeHash } from './store.js';
|
|
5
|
+
import { harvestSessions } from './harvester.js';
|
|
6
|
+
import { checkDiskSpace, evictExpiredSessions, evictBySize } from './storage.js';
|
|
7
|
+
import { indexCodebase, mergeExcludePatterns, resolveExtensions, embedPendingCodebase } from './codebase.js'
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import * as path from 'path';
|
|
10
|
+
import * as os from 'os';
|
|
11
|
+
|
|
12
|
+
export interface WatcherOptions {
|
|
13
|
+
store: Store
|
|
14
|
+
collections: Collection[]
|
|
15
|
+
embedder?: { embed(text: string): Promise<{ embedding: number[] }> } | null
|
|
16
|
+
onUpdate?: (path: string) => void
|
|
17
|
+
debounceMs?: number
|
|
18
|
+
pollIntervalMs?: number
|
|
19
|
+
sessionPollMs?: number
|
|
20
|
+
sessionStorageDir?: string
|
|
21
|
+
outputDir?: string
|
|
22
|
+
storageConfig?: StorageConfig
|
|
23
|
+
dbPath?: string
|
|
24
|
+
codebaseConfig?: CodebaseConfig
|
|
25
|
+
workspaceRoot?: string
|
|
26
|
+
projectHash?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface Watcher {
|
|
30
|
+
stop(): void;
|
|
31
|
+
isDirty(): boolean;
|
|
32
|
+
triggerReindex(): Promise<void>;
|
|
33
|
+
getStats(): WatcherStats;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface WatcherStats {
|
|
37
|
+
filesWatched: number;
|
|
38
|
+
lastReindexAt: number | null;
|
|
39
|
+
pendingChanges: number;
|
|
40
|
+
isReindexing: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function startWatcher(options: WatcherOptions): Watcher {
|
|
44
|
+
const {
|
|
45
|
+
store,
|
|
46
|
+
collections,
|
|
47
|
+
embedder,
|
|
48
|
+
onUpdate,
|
|
49
|
+
debounceMs = 2000,
|
|
50
|
+
pollIntervalMs = 300000,
|
|
51
|
+
sessionPollMs = 120000,
|
|
52
|
+
sessionStorageDir = path.join(os.homedir(), '.local/share/opencode/storage'),
|
|
53
|
+
outputDir = path.join(os.homedir(), '.nano-brain/sessions'),
|
|
54
|
+
storageConfig,
|
|
55
|
+
dbPath,
|
|
56
|
+
codebaseConfig,
|
|
57
|
+
workspaceRoot = process.cwd(),
|
|
58
|
+
projectHash = 'global',
|
|
59
|
+
} = options
|
|
60
|
+
|
|
61
|
+
const codebaseExtensions = codebaseConfig?.enabled
|
|
62
|
+
? new Set(resolveExtensions(codebaseConfig, workspaceRoot))
|
|
63
|
+
: new Set<string>()
|
|
64
|
+
|
|
65
|
+
let dirty = false;
|
|
66
|
+
const pendingPaths = new Set<string>();
|
|
67
|
+
let lastReindexAt: number | null = null;
|
|
68
|
+
let isReindexing = false;
|
|
69
|
+
let stopped = false;
|
|
70
|
+
let debounceTimer: NodeJS.Timeout | null = null;
|
|
71
|
+
let pollInterval: NodeJS.Timeout | null = null;
|
|
72
|
+
let sessionPollInterval: NodeJS.Timeout | null = null;
|
|
73
|
+
let watcher: FSWatcher | null = null;
|
|
74
|
+
let harvestCycleCount = 0;
|
|
75
|
+
const watchedPaths = new Set<string>();
|
|
76
|
+
let embeddingInterval: NodeJS.Timeout | null = null;
|
|
77
|
+
let isEmbedding = false;
|
|
78
|
+
|
|
79
|
+
const handleFileChange = (filePath: string) => {
|
|
80
|
+
if (stopped) return
|
|
81
|
+
|
|
82
|
+
dirty = true
|
|
83
|
+
pendingPaths.add(filePath)
|
|
84
|
+
if (debounceTimer) {
|
|
85
|
+
clearTimeout(debounceTimer)
|
|
86
|
+
}
|
|
87
|
+
debounceTimer = setTimeout(() => {
|
|
88
|
+
if (onUpdate) {
|
|
89
|
+
for (const p of pendingPaths) {
|
|
90
|
+
onUpdate(p)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}, debounceMs)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const isCodebaseFile = (filePath: string): boolean => {
|
|
97
|
+
if (!codebaseConfig?.enabled) return false
|
|
98
|
+
const ext = path.extname(filePath).toLowerCase()
|
|
99
|
+
return codebaseExtensions.has(ext)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const triggerReindex = async (): Promise<void> => {
|
|
103
|
+
if (isReindexing || stopped) return
|
|
104
|
+
|
|
105
|
+
isReindexing = true
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
for (const collection of collections) {
|
|
109
|
+
const files = await scanCollectionFiles(collection)
|
|
110
|
+
const activePaths: string[] = []
|
|
111
|
+
for (const filePath of files) {
|
|
112
|
+
if (!fs.existsSync(filePath)) continue
|
|
113
|
+
|
|
114
|
+
const content = fs.readFileSync(filePath, 'utf-8')
|
|
115
|
+
const hash = computeHash(content)
|
|
116
|
+
|
|
117
|
+
const existingDoc = store.findDocument(filePath)
|
|
118
|
+
if (!existingDoc || existingDoc.hash !== hash) {
|
|
119
|
+
const title = extractTitle(content)
|
|
120
|
+
indexDocument(store, collection.name, filePath, content, title, projectHash)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
activePaths.push(filePath)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
store.bulkDeactivateExcept(collection.name, activePaths)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (codebaseConfig?.enabled) {
|
|
130
|
+
await indexCodebase(store, workspaceRoot, codebaseConfig, projectHash, embedder)
|
|
131
|
+
}
|
|
132
|
+
if (embedder) {
|
|
133
|
+
await embedPendingCodebase(store, embedder, 10, projectHash)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
dirty = false
|
|
137
|
+
pendingPaths.clear()
|
|
138
|
+
lastReindexAt = Date.now()
|
|
139
|
+
} finally {
|
|
140
|
+
isReindexing = false
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const startupIntegrityCheck = async () => {
|
|
145
|
+
const health = store.getIndexHealth();
|
|
146
|
+
let mismatches = 0;
|
|
147
|
+
|
|
148
|
+
for (const collectionInfo of health.collections) {
|
|
149
|
+
const collection = collections.find(c => c.name === collectionInfo.name);
|
|
150
|
+
if (!collection) continue;
|
|
151
|
+
|
|
152
|
+
const files = await scanCollectionFiles(collection);
|
|
153
|
+
|
|
154
|
+
for (const filePath of files) {
|
|
155
|
+
if (!fs.existsSync(filePath)) continue;
|
|
156
|
+
|
|
157
|
+
const existingDoc = store.findDocument(filePath);
|
|
158
|
+
if (!existingDoc) continue;
|
|
159
|
+
|
|
160
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
161
|
+
const hash = computeHash(content);
|
|
162
|
+
|
|
163
|
+
if (existingDoc.hash !== hash) {
|
|
164
|
+
mismatches++;
|
|
165
|
+
dirty = true;
|
|
166
|
+
pendingPaths.add(filePath);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (mismatches > 0) {
|
|
172
|
+
console.log(`Integrity check: ${mismatches} file(s) need re-indexing`);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const setupWatcher = () => {
|
|
177
|
+
const pathsToWatch: string[] = []
|
|
178
|
+
const ignoredPatterns: (string | RegExp)[] = [/(^|[\/])\../]
|
|
179
|
+
for (const collection of collections) {
|
|
180
|
+
const expandedPath = collection.path.replace(/^~/, os.homedir())
|
|
181
|
+
if (fs.existsSync(expandedPath)) {
|
|
182
|
+
pathsToWatch.push(expandedPath)
|
|
183
|
+
watchedPaths.add(expandedPath)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (codebaseConfig?.enabled && fs.existsSync(workspaceRoot)) {
|
|
187
|
+
pathsToWatch.push(workspaceRoot)
|
|
188
|
+
watchedPaths.add(workspaceRoot)
|
|
189
|
+
const excludePatterns = mergeExcludePatterns(codebaseConfig, workspaceRoot)
|
|
190
|
+
for (const pattern of excludePatterns) {
|
|
191
|
+
// Convert glob patterns to regex for chokidar directory-level matching
|
|
192
|
+
// e.g. 'node_modules' -> /[\/]node_modules([\/]|$)/
|
|
193
|
+
// e.g. '*.min.js' -> /\.min\.js$/
|
|
194
|
+
if (pattern.startsWith('*')) {
|
|
195
|
+
const escaped = pattern.slice(1).replace(/\./g, '\\.').replace(/\*/g, '.*')
|
|
196
|
+
ignoredPatterns.push(new RegExp(`${escaped}$`))
|
|
197
|
+
} else {
|
|
198
|
+
const escaped = pattern.replace(/\./g, '\\.').replace(/\*/g, '.*')
|
|
199
|
+
ignoredPatterns.push(new RegExp(`[\\/]${escaped}([\\/]|$)`))
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (pathsToWatch.length === 0) return
|
|
204
|
+
watcher = watch(pathsToWatch, {
|
|
205
|
+
ignored: ignoredPatterns,
|
|
206
|
+
persistent: true,
|
|
207
|
+
ignoreInitial: true,
|
|
208
|
+
awaitWriteFinish: {
|
|
209
|
+
stabilityThreshold: 100,
|
|
210
|
+
pollInterval: 100,
|
|
211
|
+
},
|
|
212
|
+
})
|
|
213
|
+
watcher.on('error', (err: unknown) => {
|
|
214
|
+
console.error(`[watcher] Error: ${err instanceof Error ? err.message : String(err)}`)
|
|
215
|
+
})
|
|
216
|
+
watcher.on('add', (filePath) => {
|
|
217
|
+
if (filePath.endsWith('.md') || isCodebaseFile(filePath)) {
|
|
218
|
+
handleFileChange(filePath)
|
|
219
|
+
}
|
|
220
|
+
})
|
|
221
|
+
watcher.on('change', (filePath) => {
|
|
222
|
+
if (filePath.endsWith('.md') || isCodebaseFile(filePath)) {
|
|
223
|
+
handleFileChange(filePath)
|
|
224
|
+
}
|
|
225
|
+
})
|
|
226
|
+
watcher.on('unlink', (filePath) => {
|
|
227
|
+
if (filePath.endsWith('.md') || isCodebaseFile(filePath)) {
|
|
228
|
+
handleFileChange(filePath)
|
|
229
|
+
}
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const setupPolling = () => {
|
|
234
|
+
pollInterval = setInterval(async () => {
|
|
235
|
+
if (dirty && !isReindexing) {
|
|
236
|
+
await triggerReindex();
|
|
237
|
+
}
|
|
238
|
+
}, pollIntervalMs);
|
|
239
|
+
|
|
240
|
+
sessionPollInterval = setInterval(async () => {
|
|
241
|
+
if (stopped) return;
|
|
242
|
+
if (storageConfig) {
|
|
243
|
+
const diskCheck = checkDiskSpace(outputDir, storageConfig.minFreeDisk);
|
|
244
|
+
if (!diskCheck.ok) {
|
|
245
|
+
console.warn(`[storage] Disk space critically low (<${Math.round(storageConfig.minFreeDisk / 1024 / 1024)}MB free), skipping writes`);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
const sessions = await harvestSessions({
|
|
252
|
+
sessionDir: sessionStorageDir,
|
|
253
|
+
outputDir,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
if (sessions.length > 0) {
|
|
257
|
+
await triggerReindex();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (storageConfig && dbPath) {
|
|
261
|
+
const expiredCount = evictExpiredSessions(outputDir, storageConfig.retention, store);
|
|
262
|
+
if (expiredCount > 0) {
|
|
263
|
+
console.log(`[storage] Evicted ${expiredCount} expired session(s)`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const sizeEvictedCount = evictBySize(outputDir, dbPath, storageConfig.maxSize, store);
|
|
267
|
+
if (sizeEvictedCount > 0) {
|
|
268
|
+
console.log(`[storage] Evicted ${sizeEvictedCount} session(s) due to size limit`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
harvestCycleCount++;
|
|
273
|
+
if (harvestCycleCount % 10 === 0) {
|
|
274
|
+
const orphansDeleted = store.cleanOrphanedEmbeddings();
|
|
275
|
+
if (orphansDeleted > 0) {
|
|
276
|
+
console.log(`[storage] Cleaned ${orphansDeleted} orphaned embedding(s)`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
} catch (err) {
|
|
280
|
+
console.warn('Session harvest failed:', err);
|
|
281
|
+
}
|
|
282
|
+
}, sessionPollMs);
|
|
283
|
+
|
|
284
|
+
if (embedder) {
|
|
285
|
+
embeddingInterval = setInterval(async () => {
|
|
286
|
+
if (stopped || isEmbedding) return;
|
|
287
|
+
isEmbedding = true;
|
|
288
|
+
try {
|
|
289
|
+
const count = await embedPendingCodebase(store, embedder, 10, projectHash);
|
|
290
|
+
if (count > 0) {
|
|
291
|
+
console.log(`[embed] Embedded ${count} document(s)`);
|
|
292
|
+
}
|
|
293
|
+
} catch (err) {
|
|
294
|
+
console.warn('[embed] Embedding cycle failed:', err);
|
|
295
|
+
} finally {
|
|
296
|
+
isEmbedding = false;
|
|
297
|
+
}
|
|
298
|
+
}, 60000);
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
setupWatcher();
|
|
303
|
+
setupPolling();
|
|
304
|
+
startupIntegrityCheck().catch(err => {
|
|
305
|
+
console.warn('Startup integrity check failed:', err);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
if (embedder) {
|
|
309
|
+
setTimeout(async () => {
|
|
310
|
+
isEmbedding = true;
|
|
311
|
+
try {
|
|
312
|
+
const count = await embedPendingCodebase(store, embedder, 10, projectHash);
|
|
313
|
+
if (count > 0) {
|
|
314
|
+
console.log(`[embed] Initial embedding: ${count} document(s)`);
|
|
315
|
+
}
|
|
316
|
+
} catch (err) {
|
|
317
|
+
console.warn('[embed] Initial embedding failed:', err);
|
|
318
|
+
} finally {
|
|
319
|
+
isEmbedding = false;
|
|
320
|
+
}
|
|
321
|
+
}, 5000);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return {
|
|
325
|
+
stop() {
|
|
326
|
+
stopped = true;
|
|
327
|
+
|
|
328
|
+
if (debounceTimer) {
|
|
329
|
+
clearTimeout(debounceTimer);
|
|
330
|
+
debounceTimer = null;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (pollInterval) {
|
|
334
|
+
clearInterval(pollInterval);
|
|
335
|
+
pollInterval = null;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (sessionPollInterval) {
|
|
339
|
+
clearInterval(sessionPollInterval);
|
|
340
|
+
sessionPollInterval = null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (embeddingInterval) {
|
|
344
|
+
clearInterval(embeddingInterval);
|
|
345
|
+
embeddingInterval = null;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (watcher) {
|
|
349
|
+
watcher.close();
|
|
350
|
+
watcher = null;
|
|
351
|
+
}
|
|
352
|
+
},
|
|
353
|
+
|
|
354
|
+
isDirty() {
|
|
355
|
+
return dirty;
|
|
356
|
+
},
|
|
357
|
+
|
|
358
|
+
async triggerReindex() {
|
|
359
|
+
await triggerReindex();
|
|
360
|
+
},
|
|
361
|
+
|
|
362
|
+
getStats(): WatcherStats {
|
|
363
|
+
return {
|
|
364
|
+
filesWatched: watchedPaths.size,
|
|
365
|
+
lastReindexAt,
|
|
366
|
+
pendingChanges: pendingPaths.size,
|
|
367
|
+
isReindexing,
|
|
368
|
+
};
|
|
369
|
+
},
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function extractTitle(content: string): string {
|
|
374
|
+
const lines = content.split('\n');
|
|
375
|
+
|
|
376
|
+
for (const line of lines) {
|
|
377
|
+
const trimmed = line.trim();
|
|
378
|
+
if (trimmed.startsWith('# ')) {
|
|
379
|
+
return trimmed.substring(2).trim();
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return 'Untitled';
|
|
384
|
+
}
|