@ekkos/cli 1.3.7 → 1.3.8
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/dist/commands/dashboard.js +67 -11
- package/dist/commands/gemini.js +34 -8
- package/dist/commands/init.js +92 -21
- package/dist/commands/living-docs.d.ts +8 -0
- package/dist/commands/living-docs.js +66 -0
- package/dist/commands/run.js +55 -6
- package/dist/commands/scan.d.ts +68 -0
- package/dist/commands/scan.js +318 -22
- package/dist/commands/setup.js +2 -6
- package/dist/deploy/index.d.ts +1 -0
- package/dist/deploy/index.js +1 -0
- package/dist/deploy/instructions.d.ts +10 -3
- package/dist/deploy/instructions.js +34 -7
- package/dist/index.js +91 -53
- package/dist/lib/usage-parser.js +18 -2
- package/dist/local/index.d.ts +4 -0
- package/dist/local/index.js +14 -1
- package/dist/local/language-config.d.ts +55 -0
- package/dist/local/language-config.js +729 -0
- package/dist/local/living-docs-manager.d.ts +59 -0
- package/dist/local/living-docs-manager.js +1084 -0
- package/dist/local/stack-detection.d.ts +21 -0
- package/dist/local/stack-detection.js +406 -0
- package/package.json +10 -7
- package/templates/CLAUDE.md +89 -99
- package/LICENSE +0 -21
|
@@ -0,0 +1,1084 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LocalLivingDocsManager = void 0;
|
|
4
|
+
const fs_1 = require("fs");
|
|
5
|
+
const child_process_1 = require("child_process");
|
|
6
|
+
const crypto_1 = require("crypto");
|
|
7
|
+
const path_1 = require("path");
|
|
8
|
+
const os_1 = require("os");
|
|
9
|
+
const scan_js_1 = require("../commands/scan.js");
|
|
10
|
+
const stack_detection_js_1 = require("./stack-detection.js");
|
|
11
|
+
const language_config_js_1 = require("./language-config.js");
|
|
12
|
+
// ── Language-adaptive defaults (overridden per-system when stack is detected) ──
|
|
13
|
+
const DEFAULT_EXCLUDED_DIRS = new Set([
|
|
14
|
+
...language_config_js_1.BASE_EXCLUDED_DIRS,
|
|
15
|
+
'node_modules', '.next', 'dist', 'out', 'build', '.turbo', '.cache',
|
|
16
|
+
'.github', '.vscode', '.idea', '.claude', '.windsurf',
|
|
17
|
+
'coverage', '__pycache__', '.pytest_cache', '.mypy_cache',
|
|
18
|
+
'vendor', '.vercel', '.svelte-kit', 'target', 'tmp', 'temp',
|
|
19
|
+
'logs', 'downloads', '.venv', 'venv', '.tox', '.eggs',
|
|
20
|
+
'.gradle', '.mvn', '.bundle',
|
|
21
|
+
]);
|
|
22
|
+
const EXCLUDED_FILES = new Set([
|
|
23
|
+
'ekkOS_CONTEXT.md',
|
|
24
|
+
'.DS_Store',
|
|
25
|
+
]);
|
|
26
|
+
const DEFAULT_SOURCE_EXTENSIONS = new Set([
|
|
27
|
+
'.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
|
|
28
|
+
'.py', '.rs', '.go', '.java', '.kt', '.swift',
|
|
29
|
+
'.rb', '.ex', '.exs', '.php', '.cs', '.dart',
|
|
30
|
+
'.c', '.cpp', '.h', '.hpp',
|
|
31
|
+
'.sql', '.json', '.yml', '.yaml', '.toml', '.md',
|
|
32
|
+
]);
|
|
33
|
+
const DEFAULT_KEY_FILE_NAMES = [
|
|
34
|
+
'package.json', 'README.md', 'readme.md',
|
|
35
|
+
'index.ts', 'index.tsx', 'index.js', 'main.ts', 'server.ts', 'app.ts', 'mod.ts',
|
|
36
|
+
'pyproject.toml', 'requirements.txt', 'main.py', 'app.py', 'manage.py',
|
|
37
|
+
'Cargo.toml', 'main.rs', 'lib.rs',
|
|
38
|
+
'go.mod', 'main.go',
|
|
39
|
+
'Gemfile', 'config.ru',
|
|
40
|
+
'pom.xml', 'build.gradle',
|
|
41
|
+
'pubspec.yaml', 'mix.exs', 'composer.json',
|
|
42
|
+
];
|
|
43
|
+
const ENV_VAR_REGEXES = [
|
|
44
|
+
/\bprocess\.env\.([A-Z0-9_]+)/g,
|
|
45
|
+
/\bimport\.meta\.env\.([A-Z0-9_]+)/g,
|
|
46
|
+
/\benv\.([A-Z0-9_]+)/g,
|
|
47
|
+
/\bgetEnvVar\(\s*['"`]([A-Z0-9_]+)['"`]\s*\)/g,
|
|
48
|
+
];
|
|
49
|
+
const LOCAL_CONTEXT_TEMPLATE_VERSION = 'local-mechanical-v4';
|
|
50
|
+
/**
|
|
51
|
+
* Cooldown period for full system discovery + compile on startup.
|
|
52
|
+
* If the last successful scan completed within this window, skip the expensive
|
|
53
|
+
* discoverSystems/buildSnapshot/compileStaleSystems work and just start the watcher.
|
|
54
|
+
* This avoids redundant multi-second scans when the user runs `ekkos run` many times a day.
|
|
55
|
+
* Default: 10 minutes. Override via EKKOS_LIVING_DOCS_COOLDOWN_MS env var.
|
|
56
|
+
*/
|
|
57
|
+
const SCAN_COOLDOWN_MS = parseInt(process.env.EKKOS_LIVING_DOCS_COOLDOWN_MS || String(10 * 60 * 1000), 10);
|
|
58
|
+
function normalizePath(input) {
|
|
59
|
+
return input.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
|
|
60
|
+
}
|
|
61
|
+
function matchesDirectoryPath(filePath, directoryPath) {
|
|
62
|
+
const normalizedFile = normalizePath(filePath);
|
|
63
|
+
const normalizedDirectory = normalizePath(directoryPath);
|
|
64
|
+
if (!normalizedDirectory || normalizedDirectory === '.')
|
|
65
|
+
return true;
|
|
66
|
+
return normalizedFile === normalizedDirectory || normalizedFile.startsWith(`${normalizedDirectory}/`);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Convert ISO timestamp to human-readable: "Mar 16, 2:52 PM" or "Mar 14" (older).
|
|
70
|
+
*/
|
|
71
|
+
function formatReadableDate(isoTimestamp) {
|
|
72
|
+
try {
|
|
73
|
+
const date = new Date(isoTimestamp);
|
|
74
|
+
if (isNaN(date.getTime()))
|
|
75
|
+
return isoTimestamp;
|
|
76
|
+
const now = new Date();
|
|
77
|
+
const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
|
|
78
|
+
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
79
|
+
const month = months[date.getMonth()];
|
|
80
|
+
const day = date.getDate();
|
|
81
|
+
if (diffDays === 0) {
|
|
82
|
+
// Today: "Today, 2:52 PM"
|
|
83
|
+
const hours = date.getHours();
|
|
84
|
+
const minutes = date.getMinutes().toString().padStart(2, '0');
|
|
85
|
+
const ampm = hours >= 12 ? 'PM' : 'AM';
|
|
86
|
+
const h12 = hours % 12 || 12;
|
|
87
|
+
return `Today, ${h12}:${minutes} ${ampm}`;
|
|
88
|
+
}
|
|
89
|
+
else if (diffDays === 1) {
|
|
90
|
+
return 'Yesterday';
|
|
91
|
+
}
|
|
92
|
+
else if (diffDays < 7) {
|
|
93
|
+
return `${diffDays} days ago`;
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
return `${month} ${day}`;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return isoTimestamp;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Extract the purpose/description from README or package.json.
|
|
105
|
+
* This tells humans and agents what this system actually DOES.
|
|
106
|
+
*/
|
|
107
|
+
function extractPurpose(files) {
|
|
108
|
+
// Try README first — first paragraph after the title
|
|
109
|
+
const readme = files.find(f => (0, path_1.basename)(f.relativePath).toLowerCase() === 'readme.md');
|
|
110
|
+
if (readme?.content) {
|
|
111
|
+
const lines = readme.content.split('\n');
|
|
112
|
+
let pastTitle = false;
|
|
113
|
+
for (const line of lines) {
|
|
114
|
+
if (line.startsWith('#')) {
|
|
115
|
+
pastTitle = true;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (pastTitle && line.trim().length > 20) {
|
|
119
|
+
return line.trim().slice(0, 300);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// Try package.json description
|
|
124
|
+
const pkgJson = files.find(f => (0, path_1.basename)(f.relativePath) === 'package.json');
|
|
125
|
+
if (pkgJson?.content) {
|
|
126
|
+
try {
|
|
127
|
+
const pkg = JSON.parse(pkgJson.content);
|
|
128
|
+
if (pkg.description && pkg.description.length > 10) {
|
|
129
|
+
return pkg.description.slice(0, 300);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
catch { /* ignore */ }
|
|
133
|
+
}
|
|
134
|
+
// Try first JSDoc comment in entry file
|
|
135
|
+
const entry = files.find(f => ['index.ts', 'index.tsx', 'main.ts', 'server.ts', 'app.ts'].includes((0, path_1.basename)(f.relativePath)));
|
|
136
|
+
if (entry?.content) {
|
|
137
|
+
const jsdocMatch = entry.content.match(/\/\*\*\s*\n\s*\*\s*(.+?)(?:\n|\*\/)/);
|
|
138
|
+
if (jsdocMatch && jsdocMatch[1].length > 15) {
|
|
139
|
+
return jsdocMatch[1].trim().slice(0, 300);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Extract key modules — top-level directories and important files.
|
|
146
|
+
* Summarizes what each sub-directory contains based on file names.
|
|
147
|
+
*/
|
|
148
|
+
function extractKeyModules(files, systemDir) {
|
|
149
|
+
// Group files by first-level subdirectory
|
|
150
|
+
const dirFiles = new Map();
|
|
151
|
+
for (const f of files) {
|
|
152
|
+
const parts = f.relativePath.split('/');
|
|
153
|
+
if (parts.length >= 2) {
|
|
154
|
+
const dir = parts[0];
|
|
155
|
+
if (!dirFiles.has(dir))
|
|
156
|
+
dirFiles.set(dir, []);
|
|
157
|
+
dirFiles.get(dir).push((0, path_1.basename)(f.relativePath));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const modules = [];
|
|
161
|
+
for (const [dir, fileNames] of dirFiles) {
|
|
162
|
+
if (fileNames.length === 0)
|
|
163
|
+
continue;
|
|
164
|
+
const count = fileNames.length;
|
|
165
|
+
const exts = [...new Set(fileNames.map(f => (0, path_1.extname)(f)).filter(Boolean))].slice(0, 3).join(', ');
|
|
166
|
+
// Try to infer what this directory does from its name
|
|
167
|
+
const desc = inferDirPurpose(dir, count, exts, fileNames);
|
|
168
|
+
modules.push({ path: dir + '/', description: desc });
|
|
169
|
+
}
|
|
170
|
+
// Also include standalone important files at root level
|
|
171
|
+
for (const f of files) {
|
|
172
|
+
if (!f.relativePath.includes('/') && ['index.ts', 'server.ts', 'main.ts', 'app.ts', 'cli.ts'].includes((0, path_1.basename)(f.relativePath))) {
|
|
173
|
+
const firstComment = f.content.match(/\/\*\*?\s*\n?\s*\*?\s*(.+?)(?:\n|\*\/)/);
|
|
174
|
+
const desc = firstComment ? firstComment[1].trim().slice(0, 80) : 'Entry point';
|
|
175
|
+
modules.push({ path: (0, path_1.basename)(f.relativePath), description: desc });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return modules.slice(0, 12);
|
|
179
|
+
}
|
|
180
|
+
function inferDirPurpose(dir, fileCount, exts, fileNames) {
|
|
181
|
+
const name = dir.toLowerCase();
|
|
182
|
+
const hasTests = fileNames.some(f => /test|spec/i.test(f));
|
|
183
|
+
const knownDirs = {
|
|
184
|
+
'commands': 'CLI commands',
|
|
185
|
+
'lib': 'shared utilities and helpers',
|
|
186
|
+
'utils': 'utility functions',
|
|
187
|
+
'types': 'TypeScript type definitions',
|
|
188
|
+
'hooks': 'React hooks',
|
|
189
|
+
'components': 'UI components',
|
|
190
|
+
'api': 'API routes and handlers',
|
|
191
|
+
'middleware': 'request middleware',
|
|
192
|
+
'routes': 'route handlers',
|
|
193
|
+
'models': 'data models',
|
|
194
|
+
'services': 'business logic services',
|
|
195
|
+
'config': 'configuration',
|
|
196
|
+
'constants': 'constant values',
|
|
197
|
+
'scripts': 'utility scripts',
|
|
198
|
+
'tests': 'test suites',
|
|
199
|
+
'migrations': 'database migrations',
|
|
200
|
+
'cron': 'scheduled jobs',
|
|
201
|
+
'workers': 'background workers',
|
|
202
|
+
'cache': 'caching layer',
|
|
203
|
+
'auth': 'authentication',
|
|
204
|
+
'database': 'database access',
|
|
205
|
+
'embeddings': 'embedding generation',
|
|
206
|
+
'layers': 'memory layer implementations',
|
|
207
|
+
'capture': 'data capture and recording',
|
|
208
|
+
'local': 'local-only features',
|
|
209
|
+
'deploy': 'deployment tooling',
|
|
210
|
+
};
|
|
211
|
+
if (knownDirs[name])
|
|
212
|
+
return `${knownDirs[name]} (${fileCount} files)`;
|
|
213
|
+
return `${fileCount} ${exts || ''} files${hasTests ? ' (includes tests)' : ''}`.trim();
|
|
214
|
+
}
|
|
215
|
+
function sha256(text) {
|
|
216
|
+
return (0, crypto_1.createHash)('sha256').update(text).digest('hex');
|
|
217
|
+
}
|
|
218
|
+
function isWatchableFile(relativePath) {
|
|
219
|
+
const normalized = normalizePath(relativePath);
|
|
220
|
+
if (!normalized)
|
|
221
|
+
return false;
|
|
222
|
+
const parts = normalized.split('/');
|
|
223
|
+
for (const part of parts.slice(0, -1)) {
|
|
224
|
+
if (DEFAULT_EXCLUDED_DIRS.has(part))
|
|
225
|
+
return false;
|
|
226
|
+
if (part.startsWith('.'))
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
const name = parts[parts.length - 1];
|
|
230
|
+
if (EXCLUDED_FILES.has(name))
|
|
231
|
+
return false;
|
|
232
|
+
if (name.startsWith('.'))
|
|
233
|
+
return false;
|
|
234
|
+
if (name.endsWith('.log') || name.endsWith('.tmp') || name.endsWith('.swp'))
|
|
235
|
+
return false;
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
function formatDate(date) {
|
|
239
|
+
return date.toISOString().slice(0, 10);
|
|
240
|
+
}
|
|
241
|
+
function escapeInline(value) {
|
|
242
|
+
return value.replace(/\r?\n/g, ' ').trim();
|
|
243
|
+
}
|
|
244
|
+
function describeRuntime(extCounts) {
|
|
245
|
+
const ordered = [...extCounts.entries()].sort((a, b) => b[1] - a[1]);
|
|
246
|
+
if (ordered.length === 0)
|
|
247
|
+
return 'Unknown';
|
|
248
|
+
const top = ordered[0][0];
|
|
249
|
+
if (['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].includes(top))
|
|
250
|
+
return 'TypeScript / JavaScript';
|
|
251
|
+
if (top === '.py')
|
|
252
|
+
return 'Python';
|
|
253
|
+
if (top === '.rs')
|
|
254
|
+
return 'Rust';
|
|
255
|
+
if (top === '.go')
|
|
256
|
+
return 'Go';
|
|
257
|
+
if (top === '.sql')
|
|
258
|
+
return 'SQL / database scripts';
|
|
259
|
+
return ordered
|
|
260
|
+
.slice(0, 3)
|
|
261
|
+
.map(([ext]) => ext.replace(/^\./, '').toUpperCase())
|
|
262
|
+
.join(', ');
|
|
263
|
+
}
|
|
264
|
+
function extractDependencies(packageJsonPath) {
|
|
265
|
+
if (!(0, fs_1.existsSync)(packageJsonPath))
|
|
266
|
+
return [];
|
|
267
|
+
try {
|
|
268
|
+
const pkg = JSON.parse((0, fs_1.readFileSync)(packageJsonPath, 'utf-8'));
|
|
269
|
+
return [
|
|
270
|
+
...Object.keys(pkg.dependencies || {}),
|
|
271
|
+
...Object.keys(pkg.devDependencies || {}),
|
|
272
|
+
].slice(0, 8);
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
return [];
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
function extractEnvVars(contents) {
|
|
279
|
+
const vars = new Set();
|
|
280
|
+
for (const content of contents) {
|
|
281
|
+
for (const regex of ENV_VAR_REGEXES) {
|
|
282
|
+
regex.lastIndex = 0;
|
|
283
|
+
let match;
|
|
284
|
+
while ((match = regex.exec(content)) !== null) {
|
|
285
|
+
if (match[1])
|
|
286
|
+
vars.add(match[1]);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return [...vars].sort().slice(0, 20);
|
|
291
|
+
}
|
|
292
|
+
function readRecentGitChanges(repoRoot, directoryPath, timeZone) {
|
|
293
|
+
try {
|
|
294
|
+
const scope = directoryPath === '.' ? '.' : directoryPath;
|
|
295
|
+
// Use %s (subject) + --stat scoped to this directory for richer context.
|
|
296
|
+
// Format: epoch\tsubject\tfiles-changed summary
|
|
297
|
+
const output = (0, child_process_1.execSync)(`git -C ${JSON.stringify(repoRoot)} log --max-count=10 --pretty=format:"%ct%x09%s%x09%b" --stat -- ${JSON.stringify(scope)} ':!**/ekkOS_CONTEXT.md'`, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1024 * 64 }).trim();
|
|
298
|
+
if (!output)
|
|
299
|
+
return [];
|
|
300
|
+
// Parse: format is "epoch\tsubject\tbody" followed by --stat lines per commit.
|
|
301
|
+
// Commits are separated by blank lines between stat block and next header.
|
|
302
|
+
const entries = [];
|
|
303
|
+
const blocks = output.split(/\n(?="\d+\t)/);
|
|
304
|
+
for (const block of blocks) {
|
|
305
|
+
const lines = block.split('\n');
|
|
306
|
+
const header = (lines[0] || '').replace(/^"|"$/g, '');
|
|
307
|
+
const parts = header.split('\t');
|
|
308
|
+
const epochSeconds = parts[0];
|
|
309
|
+
const subject = parts[1] || '';
|
|
310
|
+
// Body is the "why" — commit message body after the subject line
|
|
311
|
+
const body = (parts[2] || '').trim();
|
|
312
|
+
const epochMs = Number(epochSeconds || '0') * 1000;
|
|
313
|
+
if (!epochMs)
|
|
314
|
+
continue;
|
|
315
|
+
// Extract changed file names from stat lines (e.g. " src/commands/run.ts | 42 +++---")
|
|
316
|
+
const changedFiles = [];
|
|
317
|
+
for (let i = 1; i < lines.length; i++) {
|
|
318
|
+
const statMatch = lines[i].match(/^\s*(.+?)\s*\|\s*\d+/);
|
|
319
|
+
if (statMatch) {
|
|
320
|
+
const filePath = statMatch[1].trim();
|
|
321
|
+
if (scope === '.' || filePath.startsWith(scope + '/') || filePath.startsWith(scope.replace(/^\.\//, '') + '/')) {
|
|
322
|
+
const shortName = filePath.split('/').pop() || filePath;
|
|
323
|
+
changedFiles.push(shortName);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const timestamp = formatZonedTimestamp(new Date(epochMs), timeZone);
|
|
328
|
+
// Build rich message: subject + files + body (why)
|
|
329
|
+
let message = subject || 'Updated local code';
|
|
330
|
+
if (changedFiles.length > 0) {
|
|
331
|
+
message += ` — files: ${changedFiles.slice(0, 5).join(', ')}${changedFiles.length > 5 ? ` +${changedFiles.length - 5} more` : ''}`;
|
|
332
|
+
}
|
|
333
|
+
// Include first line of body as the "why" if it exists and isn't just repeating the subject
|
|
334
|
+
if (body && !body.toLowerCase().startsWith(subject.toLowerCase().slice(0, 20))) {
|
|
335
|
+
const bodyFirstLine = body.split('\n')[0].slice(0, 120);
|
|
336
|
+
if (bodyFirstLine.length > 10) {
|
|
337
|
+
message += ` | ${bodyFirstLine}`;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
entries.push({
|
|
341
|
+
date: timestamp || formatDate(new Date()),
|
|
342
|
+
timestamp,
|
|
343
|
+
message,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
return entries;
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
return [];
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
function parseStoredFingerprint(markdown) {
|
|
353
|
+
const match = markdown.match(/^invalidation_fingerprint:\s*(.+)$/m);
|
|
354
|
+
return match ? match[1].trim() : null;
|
|
355
|
+
}
|
|
356
|
+
function parseStoredTemplateVersion(markdown) {
|
|
357
|
+
const match = markdown.match(/^compiler_template_version:\s*(.+)$/m);
|
|
358
|
+
return match ? match[1].trim() : null;
|
|
359
|
+
}
|
|
360
|
+
function isValidTimeZone(timeZone) {
|
|
361
|
+
try {
|
|
362
|
+
new Intl.DateTimeFormat('en-US', { timeZone }).format(new Date());
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
function resolveUserTimeZone(preferred) {
|
|
370
|
+
const candidates = [
|
|
371
|
+
preferred,
|
|
372
|
+
process.env.EKKOS_USER_TIMEZONE,
|
|
373
|
+
process.env.TZ,
|
|
374
|
+
Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
375
|
+
'UTC',
|
|
376
|
+
];
|
|
377
|
+
for (const candidate of candidates) {
|
|
378
|
+
if (!candidate)
|
|
379
|
+
continue;
|
|
380
|
+
const trimmed = candidate.trim();
|
|
381
|
+
if (!trimmed)
|
|
382
|
+
continue;
|
|
383
|
+
if (isValidTimeZone(trimmed))
|
|
384
|
+
return trimmed;
|
|
385
|
+
}
|
|
386
|
+
return 'UTC';
|
|
387
|
+
}
|
|
388
|
+
function formatZonedTimestamp(value, timeZone) {
|
|
389
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
390
|
+
if (Number.isNaN(date.getTime()))
|
|
391
|
+
return new Date().toISOString();
|
|
392
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
393
|
+
timeZone,
|
|
394
|
+
year: 'numeric',
|
|
395
|
+
month: '2-digit',
|
|
396
|
+
day: '2-digit',
|
|
397
|
+
hour: '2-digit',
|
|
398
|
+
minute: '2-digit',
|
|
399
|
+
second: '2-digit',
|
|
400
|
+
hourCycle: 'h23',
|
|
401
|
+
}).formatToParts(date);
|
|
402
|
+
const map = Object.fromEntries(parts
|
|
403
|
+
.filter(part => part.type !== 'literal')
|
|
404
|
+
.map(part => [part.type, part.value]));
|
|
405
|
+
const asUtc = Date.UTC(Number(map.year), Number(map.month) - 1, Number(map.day), Number(map.hour), Number(map.minute), Number(map.second), date.getUTCMilliseconds());
|
|
406
|
+
const offsetMinutes = Math.round((asUtc - date.getTime()) / 60000);
|
|
407
|
+
const zoned = new Date(date.getTime() + offsetMinutes * 60000);
|
|
408
|
+
const sign = offsetMinutes >= 0 ? '+' : '-';
|
|
409
|
+
const absOffset = Math.abs(offsetMinutes);
|
|
410
|
+
const pad = (num, width = 2) => num.toString().padStart(width, '0');
|
|
411
|
+
return [
|
|
412
|
+
`${pad(zoned.getUTCFullYear(), 4)}-${pad(zoned.getUTCMonth() + 1)}-${pad(zoned.getUTCDate())}`,
|
|
413
|
+
`T${pad(zoned.getUTCHours())}:${pad(zoned.getUTCMinutes())}:${pad(zoned.getUTCSeconds())}.${pad(zoned.getUTCMilliseconds(), 3)}`,
|
|
414
|
+
`${sign}${pad(Math.floor(absOffset / 60))}:${pad(absOffset % 60)}`,
|
|
415
|
+
].join('');
|
|
416
|
+
}
|
|
417
|
+
class LocalLivingDocsManager {
|
|
418
|
+
constructor(options) {
|
|
419
|
+
this.scopePath = '.';
|
|
420
|
+
this.systems = [];
|
|
421
|
+
this.watcher = null;
|
|
422
|
+
this.pollTimer = null;
|
|
423
|
+
this.flushTimer = null;
|
|
424
|
+
this.initialCompilePromise = null;
|
|
425
|
+
this.snapshot = new Map();
|
|
426
|
+
this.pendingFiles = new Set();
|
|
427
|
+
this.compileChain = Promise.resolve();
|
|
428
|
+
this.stopped = false;
|
|
429
|
+
this.targetPath = (0, path_1.resolve)(options.targetPath);
|
|
430
|
+
this.apiUrl = options.apiUrl;
|
|
431
|
+
this.apiKey = options.apiKey;
|
|
432
|
+
this.timeZone = resolveUserTimeZone(options.timeZone);
|
|
433
|
+
this.pollIntervalMs = options.pollIntervalMs ?? 5000;
|
|
434
|
+
this.flushDebounceMs = options.flushDebounceMs ?? 2500;
|
|
435
|
+
this.onLog = options.onLog;
|
|
436
|
+
this.repoRoot = this.targetPath;
|
|
437
|
+
this.watchRoot = this.targetPath;
|
|
438
|
+
}
|
|
439
|
+
start() {
|
|
440
|
+
// Check if a recent scan already ran (avoids redundant multi-second scans on repeated `ekkos run`)
|
|
441
|
+
// OS-agnostic cache location — works for Python, Rust, Go, etc. (not Node-specific)
|
|
442
|
+
const ekkosCache = (0, path_1.join)((0, os_1.homedir)(), '.ekkos', 'cache');
|
|
443
|
+
const repoHash = (0, crypto_1.createHash)('md5').update(this.targetPath).digest('hex').slice(0, 12);
|
|
444
|
+
const markerPath = (0, path_1.join)(ekkosCache, `living-docs-last-scan-${repoHash}`);
|
|
445
|
+
let skipFullScan = false;
|
|
446
|
+
try {
|
|
447
|
+
if ((0, fs_1.existsSync)(markerPath)) {
|
|
448
|
+
const lastScanMs = Number((0, fs_1.readFileSync)(markerPath, 'utf-8').trim());
|
|
449
|
+
if (Date.now() - lastScanMs < SCAN_COOLDOWN_MS) {
|
|
450
|
+
skipFullScan = true;
|
|
451
|
+
this.log(`Skipping full scan — last scan was ${Math.round((Date.now() - lastScanMs) / 1000)}s ago (cooldown: ${SCAN_COOLDOWN_MS / 1000}s)`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
catch { /* marker missing or unreadable — run full scan */ }
|
|
456
|
+
if (skipFullScan) {
|
|
457
|
+
// Still need systems list for the watcher, but skip seeding + compile
|
|
458
|
+
this.refreshSystems(false);
|
|
459
|
+
this.snapshot = this.buildSnapshot();
|
|
460
|
+
this.startWatcher();
|
|
461
|
+
// No full compile, but still sync server-compiled docs (fast API call)
|
|
462
|
+
this.enqueue(() => this.syncServerCompiledDocs());
|
|
463
|
+
}
|
|
464
|
+
else {
|
|
465
|
+
this.refreshSystems(true);
|
|
466
|
+
this.snapshot = this.buildSnapshot();
|
|
467
|
+
this.startWatcher();
|
|
468
|
+
this.initialCompilePromise = this.enqueue(async () => {
|
|
469
|
+
// 1. Pull server-compiled (Gemini) docs first — they're richer
|
|
470
|
+
await this.syncServerCompiledDocs();
|
|
471
|
+
// 2. Then fill gaps with local mechanical compile
|
|
472
|
+
await this.compileStaleSystems('initial local compile');
|
|
473
|
+
// Write marker on successful scan
|
|
474
|
+
try {
|
|
475
|
+
if (!(0, fs_1.existsSync)(ekkosCache))
|
|
476
|
+
(0, fs_1.mkdirSync)(ekkosCache, { recursive: true });
|
|
477
|
+
(0, fs_1.writeFileSync)(markerPath, String(Date.now()));
|
|
478
|
+
}
|
|
479
|
+
catch { /* non-fatal */ }
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
stop() {
|
|
484
|
+
this.stopped = true;
|
|
485
|
+
if (this.watcher) {
|
|
486
|
+
this.watcher.close();
|
|
487
|
+
this.watcher = null;
|
|
488
|
+
}
|
|
489
|
+
if (this.pollTimer) {
|
|
490
|
+
clearInterval(this.pollTimer);
|
|
491
|
+
this.pollTimer = null;
|
|
492
|
+
}
|
|
493
|
+
if (this.flushTimer) {
|
|
494
|
+
clearTimeout(this.flushTimer);
|
|
495
|
+
this.flushTimer = null;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
log(message) {
|
|
499
|
+
this.onLog?.(`[LivingDocs:Local] ${message}`);
|
|
500
|
+
}
|
|
501
|
+
enqueue(task) {
|
|
502
|
+
this.compileChain = this.compileChain
|
|
503
|
+
.then(task)
|
|
504
|
+
.catch(err => this.log(`Task failed: ${err.message}`));
|
|
505
|
+
return this.compileChain;
|
|
506
|
+
}
|
|
507
|
+
refreshSystems(seedRemote) {
|
|
508
|
+
const discovered = (0, scan_js_1.discoverSystems)(this.targetPath, { scopeToTarget: true });
|
|
509
|
+
this.repoRoot = discovered.repoRoot;
|
|
510
|
+
this.watchRoot = discovered.targetRoot;
|
|
511
|
+
this.scopePath = discovered.scopePath;
|
|
512
|
+
this.systems = discovered.systems;
|
|
513
|
+
this.log(`Discovered ${this.systems.length} systems for ${this.watchRoot}` +
|
|
514
|
+
(this.scopePath === '.' ? ` within ${this.repoRoot}` : ` (scope: ${this.scopePath})`));
|
|
515
|
+
if (seedRemote && this.apiUrl && this.apiKey) {
|
|
516
|
+
void (0, scan_js_1.seedSystems)({
|
|
517
|
+
systems: this.systems,
|
|
518
|
+
apiUrl: this.apiUrl,
|
|
519
|
+
apiKey: this.apiKey,
|
|
520
|
+
compile: false,
|
|
521
|
+
})
|
|
522
|
+
.then(result => {
|
|
523
|
+
this.log(`Seeded ${result.total} systems to platform registry`);
|
|
524
|
+
})
|
|
525
|
+
.catch(err => {
|
|
526
|
+
this.log(`Registry seed skipped: ${err.message}`);
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
startWatcher() {
|
|
531
|
+
try {
|
|
532
|
+
this.watcher = (0, fs_1.watch)(this.watchRoot, { recursive: true }, (_eventType, fileName) => {
|
|
533
|
+
if (this.stopped || !fileName)
|
|
534
|
+
return;
|
|
535
|
+
const changedPath = (0, path_1.resolve)(this.watchRoot, String(fileName));
|
|
536
|
+
const normalized = normalizePath((0, path_1.relative)(this.repoRoot, changedPath));
|
|
537
|
+
if (!normalized || normalized.startsWith('..'))
|
|
538
|
+
return;
|
|
539
|
+
if (!isWatchableFile(normalized))
|
|
540
|
+
return;
|
|
541
|
+
this.pendingFiles.add(normalized);
|
|
542
|
+
this.scheduleFlush();
|
|
543
|
+
});
|
|
544
|
+
this.log('Using native recursive file watcher');
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
catch (err) {
|
|
548
|
+
this.log(`Native watcher unavailable, falling back to polling: ${err.message}`);
|
|
549
|
+
}
|
|
550
|
+
this.pollTimer = setInterval(() => {
|
|
551
|
+
if (this.stopped)
|
|
552
|
+
return;
|
|
553
|
+
const nextSnapshot = this.buildSnapshot();
|
|
554
|
+
const changed = new Set();
|
|
555
|
+
for (const [file, sig] of nextSnapshot.entries()) {
|
|
556
|
+
if (this.snapshot.get(file) !== sig)
|
|
557
|
+
changed.add(file);
|
|
558
|
+
}
|
|
559
|
+
for (const file of this.snapshot.keys()) {
|
|
560
|
+
if (!nextSnapshot.has(file))
|
|
561
|
+
changed.add(file);
|
|
562
|
+
}
|
|
563
|
+
this.snapshot = nextSnapshot;
|
|
564
|
+
if (changed.size === 0)
|
|
565
|
+
return;
|
|
566
|
+
for (const file of changed)
|
|
567
|
+
this.pendingFiles.add(file);
|
|
568
|
+
this.scheduleFlush();
|
|
569
|
+
}, this.pollIntervalMs);
|
|
570
|
+
this.log(`Polling for local changes every ${this.pollIntervalMs}ms`);
|
|
571
|
+
}
|
|
572
|
+
buildSnapshot() {
|
|
573
|
+
const snapshot = new Map();
|
|
574
|
+
const walk = (dirPath) => {
|
|
575
|
+
let entries;
|
|
576
|
+
try {
|
|
577
|
+
entries = (0, fs_1.readdirSync)(dirPath);
|
|
578
|
+
}
|
|
579
|
+
catch {
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
for (const entry of entries) {
|
|
583
|
+
const fullPath = (0, path_1.join)(dirPath, entry);
|
|
584
|
+
let stats;
|
|
585
|
+
try {
|
|
586
|
+
stats = (0, fs_1.statSync)(fullPath);
|
|
587
|
+
}
|
|
588
|
+
catch {
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
const relativePath = normalizePath((0, path_1.relative)(this.repoRoot, fullPath));
|
|
592
|
+
if (stats.isDirectory()) {
|
|
593
|
+
if (DEFAULT_EXCLUDED_DIRS.has(entry) || entry.startsWith('.'))
|
|
594
|
+
continue;
|
|
595
|
+
walk(fullPath);
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
if (!isWatchableFile(relativePath))
|
|
599
|
+
continue;
|
|
600
|
+
snapshot.set(relativePath, `${stats.mtimeMs}:${stats.size}`);
|
|
601
|
+
}
|
|
602
|
+
};
|
|
603
|
+
walk(this.watchRoot);
|
|
604
|
+
return snapshot;
|
|
605
|
+
}
|
|
606
|
+
scheduleFlush() {
|
|
607
|
+
if (this.flushTimer)
|
|
608
|
+
clearTimeout(this.flushTimer);
|
|
609
|
+
this.flushTimer = setTimeout(() => {
|
|
610
|
+
this.flushTimer = null;
|
|
611
|
+
void this.enqueue(async () => {
|
|
612
|
+
await this.processPendingFiles();
|
|
613
|
+
});
|
|
614
|
+
}, this.flushDebounceMs);
|
|
615
|
+
}
|
|
616
|
+
async processPendingFiles() {
|
|
617
|
+
if (this.pendingFiles.size === 0)
|
|
618
|
+
return;
|
|
619
|
+
const changedFiles = [...this.pendingFiles].sort();
|
|
620
|
+
this.pendingFiles.clear();
|
|
621
|
+
let affectedSystems = this.findAffectedSystems(changedFiles);
|
|
622
|
+
if (affectedSystems.length === 0) {
|
|
623
|
+
this.refreshSystems(true);
|
|
624
|
+
affectedSystems = this.findAffectedSystems(changedFiles);
|
|
625
|
+
}
|
|
626
|
+
if (affectedSystems.length === 0) {
|
|
627
|
+
this.log(`No living-doc systems matched ${changedFiles.length} changed paths`);
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
this.log(`Compiling ${affectedSystems.length} local system(s) from ${changedFiles.length} file change(s)`);
|
|
631
|
+
for (const system of affectedSystems) {
|
|
632
|
+
await this.compileSystem(system, `${changedFiles.length} local file change${changedFiles.length === 1 ? '' : 's'}`, changedFiles);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
findAffectedSystems(files) {
|
|
636
|
+
const matched = new Map();
|
|
637
|
+
for (const file of files) {
|
|
638
|
+
for (const system of this.systems) {
|
|
639
|
+
if (matchesDirectoryPath(file, system.directory_path)) {
|
|
640
|
+
matched.set(system.system_id, system);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
return [...matched.values()].sort((a, b) => a.directory_path.localeCompare(b.directory_path));
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Pull server-compiled (Gemini Pro) docs from the platform registry.
|
|
648
|
+
* These are richer than local mechanical docs — narrative architecture,
|
|
649
|
+
* data flow, gotchas, connected systems, conventions.
|
|
650
|
+
*
|
|
651
|
+
* Only overwrites local docs that are locally-compiled (not server-compiled).
|
|
652
|
+
* Runs on every startup (fast — single API call, writes only changed files).
|
|
653
|
+
*/
|
|
654
|
+
async syncServerCompiledDocs() {
|
|
655
|
+
if (!this.apiUrl || !this.apiKey)
|
|
656
|
+
return;
|
|
657
|
+
try {
|
|
658
|
+
const controller = new AbortController();
|
|
659
|
+
const timeout = setTimeout(() => controller.abort(), 8000);
|
|
660
|
+
const systemIds = this.systems.map(s => s.system_id);
|
|
661
|
+
const res = await fetch(`${this.apiUrl}/api/v1/living-docs/compiled`, {
|
|
662
|
+
method: 'POST',
|
|
663
|
+
headers: {
|
|
664
|
+
'Authorization': `Bearer ${this.apiKey}`,
|
|
665
|
+
'Content-Type': 'application/json',
|
|
666
|
+
},
|
|
667
|
+
body: JSON.stringify({ system_ids: systemIds }),
|
|
668
|
+
signal: controller.signal,
|
|
669
|
+
});
|
|
670
|
+
clearTimeout(timeout);
|
|
671
|
+
if (!res.ok) {
|
|
672
|
+
this.log(`Server sync returned ${res.status} — skipping`);
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
const data = await res.json();
|
|
676
|
+
if (!data.docs || data.docs.length === 0) {
|
|
677
|
+
this.log('No server-compiled docs available');
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
let synced = 0;
|
|
681
|
+
for (const doc of data.docs) {
|
|
682
|
+
if (!doc.compiled_body || doc.compiled_body.length < 100)
|
|
683
|
+
continue;
|
|
684
|
+
const systemRoot = doc.directory_path === '.' ? this.repoRoot : (0, path_1.join)(this.repoRoot, doc.directory_path);
|
|
685
|
+
const contextPath = (0, path_1.join)(systemRoot, 'ekkOS_CONTEXT.md');
|
|
686
|
+
// Only overwrite if local doc is locally-compiled (or doesn't exist)
|
|
687
|
+
if ((0, fs_1.existsSync)(contextPath)) {
|
|
688
|
+
try {
|
|
689
|
+
const existing = (0, fs_1.readFileSync)(contextPath, 'utf-8');
|
|
690
|
+
const isServerCompiled = existing.includes('compiled_by: ekkOS Server Compiler')
|
|
691
|
+
|| existing.includes('compiler_model: gemini')
|
|
692
|
+
|| (existing.includes('compiled_by:') && !existing.includes('compiled_by: ekkOS Local Compiler'));
|
|
693
|
+
// If already server-compiled, check if the new one is newer
|
|
694
|
+
if (isServerCompiled) {
|
|
695
|
+
const existingDate = existing.match(/last_compiled_at:\s*(.+)/)?.[1]?.trim();
|
|
696
|
+
if (existingDate && new Date(existingDate) >= new Date(doc.compiled_at)) {
|
|
697
|
+
continue; // Existing is same or newer
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
catch { /* overwrite on error */ }
|
|
702
|
+
}
|
|
703
|
+
try {
|
|
704
|
+
(0, fs_1.writeFileSync)(contextPath, doc.compiled_body, 'utf-8');
|
|
705
|
+
synced++;
|
|
706
|
+
}
|
|
707
|
+
catch (err) {
|
|
708
|
+
this.log(`Failed to write server doc for ${doc.system_id}: ${err.message}`);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
if (synced > 0) {
|
|
712
|
+
this.log(`Synced ${synced} server-compiled (Gemini) docs to disk`);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
catch (err) {
|
|
716
|
+
// Non-fatal — server sync failure shouldn't block local compilation
|
|
717
|
+
const msg = err.message;
|
|
718
|
+
if (!msg.includes('aborted')) {
|
|
719
|
+
this.log(`Server sync failed: ${msg}`);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
async compileStaleSystems(reason) {
|
|
724
|
+
for (const system of this.systems) {
|
|
725
|
+
await this.compileSystem(system, reason, []);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
collectSystemFiles(system) {
|
|
729
|
+
const systemRoot = system.directory_path === '.' ? this.repoRoot : (0, path_1.join)(this.repoRoot, system.directory_path);
|
|
730
|
+
const files = [];
|
|
731
|
+
const walk = (dirPath) => {
|
|
732
|
+
let entries;
|
|
733
|
+
try {
|
|
734
|
+
entries = (0, fs_1.readdirSync)(dirPath);
|
|
735
|
+
}
|
|
736
|
+
catch {
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
for (const entry of entries) {
|
|
740
|
+
const fullPath = (0, path_1.join)(dirPath, entry);
|
|
741
|
+
let stats;
|
|
742
|
+
try {
|
|
743
|
+
stats = (0, fs_1.statSync)(fullPath);
|
|
744
|
+
}
|
|
745
|
+
catch {
|
|
746
|
+
continue;
|
|
747
|
+
}
|
|
748
|
+
if (stats.isDirectory()) {
|
|
749
|
+
if (DEFAULT_EXCLUDED_DIRS.has(entry) || entry.startsWith('.'))
|
|
750
|
+
continue;
|
|
751
|
+
walk(fullPath);
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
754
|
+
const relativePath = normalizePath((0, path_1.relative)(systemRoot, fullPath));
|
|
755
|
+
if (!isWatchableFile(relativePath))
|
|
756
|
+
continue;
|
|
757
|
+
const ext = (0, path_1.extname)(entry).toLowerCase();
|
|
758
|
+
if (ext && !DEFAULT_SOURCE_EXTENSIONS.has(ext) && !DEFAULT_KEY_FILE_NAMES.includes(entry))
|
|
759
|
+
continue;
|
|
760
|
+
let content = '';
|
|
761
|
+
try {
|
|
762
|
+
content = (0, fs_1.readFileSync)(fullPath, 'utf-8');
|
|
763
|
+
}
|
|
764
|
+
catch {
|
|
765
|
+
continue;
|
|
766
|
+
}
|
|
767
|
+
files.push({
|
|
768
|
+
relativePath,
|
|
769
|
+
fullPath,
|
|
770
|
+
size: stats.size,
|
|
771
|
+
ext,
|
|
772
|
+
content,
|
|
773
|
+
contentHash: sha256(content),
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
};
|
|
777
|
+
walk(systemRoot);
|
|
778
|
+
return files.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
|
779
|
+
}
|
|
780
|
+
async compileSystem(system, reason, changedFiles) {
|
|
781
|
+
const files = this.collectSystemFiles(system);
|
|
782
|
+
if (files.length === 0)
|
|
783
|
+
return;
|
|
784
|
+
const systemRoot = system.directory_path === '.' ? this.repoRoot : (0, path_1.join)(this.repoRoot, system.directory_path);
|
|
785
|
+
const fingerprint = sha256(files.map(file => `${file.relativePath}:${file.contentHash}`).join('\n'));
|
|
786
|
+
const contextPath = (0, path_1.join)(systemRoot, 'ekkOS_CONTEXT.md');
|
|
787
|
+
if ((0, fs_1.existsSync)(contextPath)) {
|
|
788
|
+
try {
|
|
789
|
+
const existing = (0, fs_1.readFileSync)(contextPath, 'utf-8');
|
|
790
|
+
// NEVER overwrite a server-compiled (Gemini) doc with the local mechanical template.
|
|
791
|
+
// Server docs are richer — they include narrative architecture, data flow, gotchas, etc.
|
|
792
|
+
// Instead: metadata-only patch (update frontmatter, preserve body).
|
|
793
|
+
const isServerCompiled = existing.includes('compiled_by: ekkOS Server Compiler')
|
|
794
|
+
|| existing.includes('compiler_model: gemini')
|
|
795
|
+
|| (existing.includes('compiled_by:') && !existing.includes('compiled_by: ekkOS Local Compiler'));
|
|
796
|
+
if (isServerCompiled) {
|
|
797
|
+
// Phase 3: Metadata-only patching — update frontmatter fields without touching body
|
|
798
|
+
this.patchServerDocMetadata(contextPath, existing, system, reason);
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
// Same fingerprint + same template version = nothing changed → skip
|
|
802
|
+
if (parseStoredFingerprint(existing) === fingerprint &&
|
|
803
|
+
existing.includes('\ncompiled_timezone: ') &&
|
|
804
|
+
parseStoredTemplateVersion(existing) === LOCAL_CONTEXT_TEMPLATE_VERSION) {
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
catch {
|
|
809
|
+
// Fall through and rewrite.
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
const extCounts = new Map();
|
|
813
|
+
for (const file of files) {
|
|
814
|
+
const ext = file.ext || '(none)';
|
|
815
|
+
extCounts.set(ext, (extCounts.get(ext) || 0) + 1);
|
|
816
|
+
}
|
|
817
|
+
const envVars = extractEnvVars(files.slice(0, 20).map(file => file.content));
|
|
818
|
+
// Detect stack for language-aware compilation
|
|
819
|
+
const fileList = files.map(f => f.relativePath);
|
|
820
|
+
const stack = await (0, stack_detection_js_1.detectStack)(fileList, async (path) => {
|
|
821
|
+
const f = files.find(x => x.relativePath === path);
|
|
822
|
+
return f?.content ?? null;
|
|
823
|
+
});
|
|
824
|
+
// Language-aware key files and entry points
|
|
825
|
+
const stackKeyFiles = new Set((0, language_config_js_1.getKeyFilesForStack)(stack).map(f => f.toLowerCase()));
|
|
826
|
+
const dependencies = extractDependencies((0, path_1.join)(systemRoot, 'package.json'));
|
|
827
|
+
const recentChanges = readRecentGitChanges(this.repoRoot, system.directory_path, this.timeZone);
|
|
828
|
+
const entryFileNames = ['index.ts', 'index.tsx', 'index.js', 'main.ts', 'server.ts', 'app.ts',
|
|
829
|
+
'main.py', 'app.py', 'manage.py', '__main__.py', 'main.rs', 'lib.rs', 'main.go', 'config.ru',
|
|
830
|
+
'Program.cs', 'lib/main.dart'];
|
|
831
|
+
const entryPoints = files.filter(file => entryFileNames.includes((0, path_1.basename)(file.relativePath)));
|
|
832
|
+
const readmeCount = files.filter(file => (0, path_1.basename)(file.relativePath).toLowerCase() === 'readme.md').length;
|
|
833
|
+
const testFileCount = files.filter(file => /(^|\/).*(test|spec)\.[^/]+$/i.test(file.relativePath)).length;
|
|
834
|
+
const packageManifestCount = files.filter(file => (0, path_1.basename)(file.relativePath) === 'package.json').length;
|
|
835
|
+
const directoryCount = new Set(files
|
|
836
|
+
.map(file => normalizePath(file.relativePath).split('/').slice(0, -1).join('/'))
|
|
837
|
+
.filter(Boolean)).size;
|
|
838
|
+
const now = formatZonedTimestamp(new Date(), this.timeZone);
|
|
839
|
+
const observedTimes = changedFiles
|
|
840
|
+
.map(file => {
|
|
841
|
+
try {
|
|
842
|
+
return (0, fs_1.statSync)((0, path_1.join)(this.repoRoot, file)).mtime.getTime();
|
|
843
|
+
}
|
|
844
|
+
catch {
|
|
845
|
+
return Date.now();
|
|
846
|
+
}
|
|
847
|
+
})
|
|
848
|
+
.filter(time => !Number.isNaN(time))
|
|
849
|
+
.sort((a, b) => a - b);
|
|
850
|
+
const evidenceStart = observedTimes.length > 0
|
|
851
|
+
? formatZonedTimestamp(new Date(observedTimes[0]), this.timeZone)
|
|
852
|
+
: (recentChanges[recentChanges.length - 1]?.timestamp || now);
|
|
853
|
+
const evidenceEnd = observedTimes.length > 0
|
|
854
|
+
? formatZonedTimestamp(new Date(observedTimes[observedTimes.length - 1]), this.timeZone)
|
|
855
|
+
: (recentChanges[0]?.timestamp || now);
|
|
856
|
+
const topExtensions = [...extCounts.entries()]
|
|
857
|
+
.sort((a, b) => b[1] - a[1])
|
|
858
|
+
.slice(0, 4)
|
|
859
|
+
.map(([ext, count]) => `${ext.replace(/^\./, '') || 'none'} (${count})`)
|
|
860
|
+
.join(', ');
|
|
861
|
+
// ── Dead code / activity detection ──────────────────────────────────────
|
|
862
|
+
const lastCommitEpochMs = (() => {
|
|
863
|
+
try {
|
|
864
|
+
const scope = system.directory_path === '.' ? '.' : system.directory_path;
|
|
865
|
+
// Exclude ekkOS_CONTEXT.md from the query so auto-generated writes don't mask staleness
|
|
866
|
+
const raw = (0, child_process_1.execSync)(`git -C ${JSON.stringify(this.repoRoot)} log --max-count=1 --pretty=format:%ct -- ${JSON.stringify(scope)} ':!**/ekkOS_CONTEXT.md'`, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
867
|
+
return raw ? Number(raw) * 1000 : null;
|
|
868
|
+
}
|
|
869
|
+
catch {
|
|
870
|
+
return null;
|
|
871
|
+
}
|
|
872
|
+
})();
|
|
873
|
+
const daysSinceLastChange = lastCommitEpochMs
|
|
874
|
+
? Math.floor((Date.now() - lastCommitEpochMs) / (1000 * 60 * 60 * 24))
|
|
875
|
+
: null;
|
|
876
|
+
const DORMANT_THRESHOLD_DAYS = 90;
|
|
877
|
+
const STALE_THRESHOLD_DAYS = 30;
|
|
878
|
+
let activityStatus = 'active';
|
|
879
|
+
if (daysSinceLastChange !== null) {
|
|
880
|
+
if (daysSinceLastChange >= DORMANT_THRESHOLD_DAYS)
|
|
881
|
+
activityStatus = 'dormant';
|
|
882
|
+
else if (daysSinceLastChange >= STALE_THRESHOLD_DAYS)
|
|
883
|
+
activityStatus = 'stale';
|
|
884
|
+
}
|
|
885
|
+
const bodySections = [];
|
|
886
|
+
const title = system.directory_path === '.' ? (0, path_1.basename)(this.repoRoot) : system.directory_path;
|
|
887
|
+
bodySections.push(`# ${title}`);
|
|
888
|
+
bodySections.push('');
|
|
889
|
+
// ── Health warnings ──
|
|
890
|
+
if (activityStatus === 'dormant') {
|
|
891
|
+
bodySections.push(`> **⚠️ POTENTIAL DEAD CODE** — No commits in ${daysSinceLastChange} days. Review whether this system is still needed.`);
|
|
892
|
+
bodySections.push('');
|
|
893
|
+
}
|
|
894
|
+
else if (activityStatus === 'stale') {
|
|
895
|
+
bodySections.push(`> **⏸ Inactive** — Last commit was ${daysSinceLastChange} days ago. May be stable or may need attention.`);
|
|
896
|
+
bodySections.push('');
|
|
897
|
+
}
|
|
898
|
+
// ── Purpose (from README first line or package.json description) ──
|
|
899
|
+
const purposeText = extractPurpose(files);
|
|
900
|
+
if (purposeText) {
|
|
901
|
+
bodySections.push(purposeText);
|
|
902
|
+
bodySections.push('');
|
|
903
|
+
}
|
|
904
|
+
// ── Architecture ──
|
|
905
|
+
bodySections.push('## Architecture');
|
|
906
|
+
const runtimeDesc = stack.language !== 'unknown'
|
|
907
|
+
? `${stack.language}${stack.framework ? ` / ${stack.framework}` : ''}${stack.packageManager ? ` (${stack.packageManager})` : ''}`
|
|
908
|
+
: describeRuntime(extCounts);
|
|
909
|
+
bodySections.push(`- **Runtime**: ${runtimeDesc}`);
|
|
910
|
+
if (entryPoints.length > 0) {
|
|
911
|
+
bodySections.push(`- **Entry points**: ${entryPoints.map(f => `\`${(0, path_1.basename)(f.relativePath)}\``).join(', ')}`);
|
|
912
|
+
}
|
|
913
|
+
if (dependencies.length > 0) {
|
|
914
|
+
bodySections.push(`- **Key dependencies**: ${dependencies.map(dep => `\`${dep}\``).join(', ')}`);
|
|
915
|
+
}
|
|
916
|
+
bodySections.push(`- **Size**: ${files.length} files across ${directoryCount} directories (${topExtensions})`);
|
|
917
|
+
if (testFileCount > 0)
|
|
918
|
+
bodySections.push(`- **Tests**: ${testFileCount} test file(s)`);
|
|
919
|
+
bodySections.push('');
|
|
920
|
+
// ── Key Modules (top-level source files and directories) ──
|
|
921
|
+
const keyModules = extractKeyModules(files, system.directory_path);
|
|
922
|
+
if (keyModules.length > 0) {
|
|
923
|
+
bodySections.push('## Key Modules');
|
|
924
|
+
for (const mod of keyModules) {
|
|
925
|
+
bodySections.push(`- \`${mod.path}\` — ${mod.description}`);
|
|
926
|
+
}
|
|
927
|
+
bodySections.push('');
|
|
928
|
+
}
|
|
929
|
+
// ── Environment Variables ──
|
|
930
|
+
if (envVars.length > 0) {
|
|
931
|
+
bodySections.push('## Environment Variables');
|
|
932
|
+
for (const envVar of envVars) {
|
|
933
|
+
bodySections.push(`- \`${envVar}\``);
|
|
934
|
+
}
|
|
935
|
+
bodySections.push('');
|
|
936
|
+
}
|
|
937
|
+
// ── Change Log (rich history with files + why) ──
|
|
938
|
+
bodySections.push('## Change Log');
|
|
939
|
+
if (recentChanges.length === 0) {
|
|
940
|
+
bodySections.push('No recent git commits found for this directory.');
|
|
941
|
+
}
|
|
942
|
+
else {
|
|
943
|
+
for (const change of recentChanges) {
|
|
944
|
+
const readableDate = formatReadableDate(change.timestamp);
|
|
945
|
+
bodySections.push(`- **${readableDate}**: ${escapeInline(change.message)}`);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
bodySections.push('');
|
|
949
|
+
// ── Active Work (what triggered this recompile) ──
|
|
950
|
+
if (changedFiles.length > 0) {
|
|
951
|
+
bodySections.push('## Active Work');
|
|
952
|
+
bodySections.push(`These ${changedFiles.length} file(s) changed since last compile:`);
|
|
953
|
+
bodySections.push('');
|
|
954
|
+
for (const file of changedFiles.slice(0, 15)) {
|
|
955
|
+
const shortFile = file.split('/').slice(-2).join('/');
|
|
956
|
+
bodySections.push(`- \`${shortFile}\``);
|
|
957
|
+
}
|
|
958
|
+
if (changedFiles.length > 15) {
|
|
959
|
+
bodySections.push(`- ... and ${changedFiles.length - 15} more`);
|
|
960
|
+
}
|
|
961
|
+
bodySections.push('');
|
|
962
|
+
}
|
|
963
|
+
// ── Metadata footer ──
|
|
964
|
+
bodySections.push('## System Info');
|
|
965
|
+
bodySections.push(`- **Path**: \`${system.directory_path}\` · **ID**: \`${system.system_id}\` · **Domain**: \`${system.domain}\``);
|
|
966
|
+
bodySections.push(`- **Status**: ${activityStatus}${daysSinceLastChange !== null ? ` (${daysSinceLastChange}d since last commit)` : ''} · **Compiled**: ${formatReadableDate(now)}`);
|
|
967
|
+
if (reason !== 'initial local compile') {
|
|
968
|
+
bodySections.push(`- **Trigger**: ${reason}`);
|
|
969
|
+
}
|
|
970
|
+
const assembledBody = bodySections.join('\n');
|
|
971
|
+
const contentHash = sha256(assembledBody);
|
|
972
|
+
const frontmatter = [
|
|
973
|
+
'---',
|
|
974
|
+
`system_id: ${system.system_id}`,
|
|
975
|
+
`domain: ${system.domain}`,
|
|
976
|
+
'status: active',
|
|
977
|
+
`last_compiled_at: ${now}`,
|
|
978
|
+
`compiled_timezone: ${this.timeZone}`,
|
|
979
|
+
`activity_status: ${activityStatus}`,
|
|
980
|
+
`days_since_last_change: ${daysSinceLastChange ?? 'unknown'}`,
|
|
981
|
+
`content_hash: ${contentHash}`,
|
|
982
|
+
`invalidation_fingerprint: ${fingerprint}`,
|
|
983
|
+
`recompile_reason: ${JSON.stringify(reason)}`,
|
|
984
|
+
'source_event_ids: []',
|
|
985
|
+
`source_event_count: ${changedFiles.length}`,
|
|
986
|
+
`evidence_window_start: ${evidenceStart}`,
|
|
987
|
+
`evidence_window_end: ${evidenceEnd}`,
|
|
988
|
+
'confidence: medium',
|
|
989
|
+
'redactions: 0',
|
|
990
|
+
'compiled_by: ekkOS Local Compiler',
|
|
991
|
+
'compiler_model: local-mechanical-v1',
|
|
992
|
+
`compiler_template_version: ${LOCAL_CONTEXT_TEMPLATE_VERSION}`,
|
|
993
|
+
'---',
|
|
994
|
+
].join('\n');
|
|
995
|
+
const markdown = [
|
|
996
|
+
frontmatter,
|
|
997
|
+
'',
|
|
998
|
+
`<!-- EKKOS_AUTOGENERATED_START ${contentHash} -->`,
|
|
999
|
+
'',
|
|
1000
|
+
assembledBody,
|
|
1001
|
+
'',
|
|
1002
|
+
'<!-- EKKOS_AUTOGENERATED_END -->',
|
|
1003
|
+
'',
|
|
1004
|
+
'---',
|
|
1005
|
+
'*Generated directly from the local workspace to keep CLI context aligned with on-disk code.*',
|
|
1006
|
+
'',
|
|
1007
|
+
].join('\n');
|
|
1008
|
+
(0, fs_1.mkdirSync)(systemRoot, { recursive: true });
|
|
1009
|
+
// Atomic write: temp file + rename to prevent corruption on crash
|
|
1010
|
+
const tmpPath = (0, path_1.join)((0, path_1.dirname)(contextPath), `.ekkOS_CONTEXT.md.${process.pid}.tmp`);
|
|
1011
|
+
(0, fs_1.writeFileSync)(tmpPath, markdown, 'utf-8');
|
|
1012
|
+
(0, fs_1.renameSync)(tmpPath, contextPath);
|
|
1013
|
+
this.log(`Wrote ${normalizePath((0, path_1.relative)(this.repoRoot, contextPath))}${stack.language !== 'unknown' ? ` [${stack.language}${stack.framework ? `/${stack.framework}` : ''}]` : ''}`);
|
|
1014
|
+
}
|
|
1015
|
+
/**
|
|
1016
|
+
* Metadata-only patch for server-compiled docs.
|
|
1017
|
+
* Updates ONLY the approved local-mutable frontmatter fields without touching the body.
|
|
1018
|
+
* Fields: activity_status, days_since_last_change, last_compiled_at, compiled_timezone, recompile_reason
|
|
1019
|
+
*/
|
|
1020
|
+
patchServerDocMetadata(contextPath, existing, system, reason) {
|
|
1021
|
+
try {
|
|
1022
|
+
const lastCommitEpochMs = (() => {
|
|
1023
|
+
try {
|
|
1024
|
+
const scope = system.directory_path === '.' ? '.' : system.directory_path;
|
|
1025
|
+
const raw = (0, child_process_1.execSync)(`git -C ${JSON.stringify(this.repoRoot)} log --max-count=1 --pretty=format:%ct -- ${JSON.stringify(scope)} ':!**/ekkOS_CONTEXT.md'`, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
1026
|
+
return raw ? Number(raw) * 1000 : null;
|
|
1027
|
+
}
|
|
1028
|
+
catch {
|
|
1029
|
+
return null;
|
|
1030
|
+
}
|
|
1031
|
+
})();
|
|
1032
|
+
const daysSinceLastChange = lastCommitEpochMs
|
|
1033
|
+
? Math.floor((Date.now() - lastCommitEpochMs) / (1000 * 60 * 60 * 24))
|
|
1034
|
+
: null;
|
|
1035
|
+
let activityStatus = 'active';
|
|
1036
|
+
if (daysSinceLastChange !== null) {
|
|
1037
|
+
if (daysSinceLastChange >= 90)
|
|
1038
|
+
activityStatus = 'dormant';
|
|
1039
|
+
else if (daysSinceLastChange >= 30)
|
|
1040
|
+
activityStatus = 'stale';
|
|
1041
|
+
}
|
|
1042
|
+
const now = formatZonedTimestamp(new Date(), this.timeZone);
|
|
1043
|
+
// Patch only approved fields in frontmatter, preserve body byte-for-byte
|
|
1044
|
+
const firstDash = existing.indexOf('---');
|
|
1045
|
+
const frontmatterEnd = existing.indexOf('---', firstDash + 3);
|
|
1046
|
+
if (frontmatterEnd === -1)
|
|
1047
|
+
return;
|
|
1048
|
+
const frontmatter = existing.slice(0, frontmatterEnd + 3);
|
|
1049
|
+
const body = existing.slice(frontmatterEnd + 3);
|
|
1050
|
+
// Detect and preserve line ending style (LF vs CRLF)
|
|
1051
|
+
const lineEnding = existing.includes('\r\n') ? '\r\n' : '\n';
|
|
1052
|
+
const patches = [
|
|
1053
|
+
[/^activity_status: .+$/m, `activity_status: ${activityStatus}`],
|
|
1054
|
+
[/^days_since_last_change: .+$/m, `days_since_last_change: ${daysSinceLastChange ?? 'unknown'}`],
|
|
1055
|
+
[/^last_compiled_at: .+$/m, `last_compiled_at: ${now}`],
|
|
1056
|
+
[/^compiled_timezone: .+$/m, `compiled_timezone: ${this.timeZone}`],
|
|
1057
|
+
[/^recompile_reason: .+$/m, `recompile_reason: ${JSON.stringify(reason)}`],
|
|
1058
|
+
];
|
|
1059
|
+
let patched = frontmatter;
|
|
1060
|
+
let patchCount = 0;
|
|
1061
|
+
for (const [regex, replacement] of patches) {
|
|
1062
|
+
if (regex.test(patched)) {
|
|
1063
|
+
patched = patched.replace(regex, replacement);
|
|
1064
|
+
patchCount++;
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
if (patchCount === 0) {
|
|
1068
|
+
this.log(`Skipping ${system.directory_path} — server doc (no patchable fields)`);
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
const result = patched + body;
|
|
1072
|
+
const finalResult = lineEnding === '\r\n' ? result.replace(/\r\n/g, '\n').replace(/\n/g, '\r\n') : result;
|
|
1073
|
+
// Atomic write
|
|
1074
|
+
const tmpPath = (0, path_1.join)((0, path_1.dirname)(contextPath), `.ekkOS_CONTEXT.md.${process.pid}.tmp`);
|
|
1075
|
+
(0, fs_1.writeFileSync)(tmpPath, finalResult, 'utf-8');
|
|
1076
|
+
(0, fs_1.renameSync)(tmpPath, contextPath);
|
|
1077
|
+
this.log(`Patched metadata for ${system.directory_path} (${activityStatus}, ${daysSinceLastChange}d) — server body preserved`);
|
|
1078
|
+
}
|
|
1079
|
+
catch (err) {
|
|
1080
|
+
this.log(`Metadata patch failed for ${system.directory_path}: ${err.message}`);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
exports.LocalLivingDocsManager = LocalLivingDocsManager;
|