@ekkos/cli 1.4.2 → 1.5.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/dist/commands/dashboard.js +309 -93
- package/dist/commands/init.js +59 -4
- package/dist/commands/living-docs.d.ts +1 -0
- package/dist/commands/living-docs.js +5 -2
- package/dist/commands/logout.d.ts +9 -0
- package/dist/commands/logout.js +104 -0
- package/dist/commands/run.js +56 -23
- package/dist/commands/workspaces.d.ts +4 -0
- package/dist/commands/workspaces.js +153 -0
- package/dist/index.js +82 -83
- package/dist/local/diff-engine.d.ts +19 -0
- package/dist/local/diff-engine.js +81 -0
- package/dist/local/entity-extractor.d.ts +18 -0
- package/dist/local/entity-extractor.js +67 -0
- package/dist/local/git-utils.d.ts +37 -0
- package/dist/local/git-utils.js +169 -0
- package/dist/local/living-docs-manager.d.ts +6 -0
- package/dist/local/living-docs-manager.js +180 -139
- package/dist/utils/notifier.d.ts +15 -0
- package/dist/utils/notifier.js +40 -0
- package/dist/utils/paths.d.ts +4 -0
- package/dist/utils/paths.js +7 -0
- package/dist/utils/state.d.ts +3 -0
- package/dist/utils/stdin-relay.d.ts +37 -0
- package/dist/utils/stdin-relay.js +155 -0
- package/package.json +4 -1
- package/templates/CLAUDE.md +3 -1
- package/dist/commands/setup.d.ts +0 -6
- package/dist/commands/setup.js +0 -389
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.LocalLivingDocsManager = void 0;
|
|
4
4
|
const fs_1 = require("fs");
|
|
5
|
-
const child_process_1 = require("child_process");
|
|
6
5
|
const crypto_1 = require("crypto");
|
|
7
6
|
const path_1 = require("path");
|
|
8
7
|
const os_1 = require("os");
|
|
9
8
|
const scan_js_1 = require("../commands/scan.js");
|
|
10
9
|
const stack_detection_js_1 = require("./stack-detection.js");
|
|
10
|
+
const git_utils_js_1 = require("./git-utils.js");
|
|
11
|
+
const diff_engine_js_1 = require("./diff-engine.js");
|
|
12
|
+
const entity_extractor_js_1 = require("./entity-extractor.js");
|
|
13
|
+
const notifier_js_1 = require("../utils/notifier.js");
|
|
11
14
|
const language_config_js_1 = require("./language-config.js");
|
|
12
15
|
// ── Language-adaptive defaults (overridden per-system when stack is detected) ──
|
|
13
16
|
const DEFAULT_EXCLUDED_DIRS = new Set([
|
|
@@ -231,12 +234,14 @@ function isWatchableFile(relativePath) {
|
|
|
231
234
|
return false;
|
|
232
235
|
if (name.startsWith('.'))
|
|
233
236
|
return false;
|
|
234
|
-
|
|
237
|
+
// Ignore .log, .tmp, .swp and the atomic write temp files (.ekkOS_CONTEXT.md.PID.TIME.tmp)
|
|
238
|
+
if (/\.(log|tmp|swp)$/i.test(name) || name.includes('.ekkOS_CONTEXT.md'))
|
|
235
239
|
return false;
|
|
236
240
|
return true;
|
|
237
241
|
}
|
|
238
|
-
function
|
|
239
|
-
|
|
242
|
+
function parseStoredFingerprint(markdown) {
|
|
243
|
+
const match = markdown.match(/^invalidation_fingerprint:\s*(.+)$/m);
|
|
244
|
+
return match ? match[1].trim() : null;
|
|
240
245
|
}
|
|
241
246
|
function escapeInline(value) {
|
|
242
247
|
return value.replace(/\r?\n/g, ' ').trim();
|
|
@@ -289,70 +294,6 @@ function extractEnvVars(contents) {
|
|
|
289
294
|
}
|
|
290
295
|
return [...vars].sort().slice(0, 20);
|
|
291
296
|
}
|
|
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
297
|
function parseStoredTemplateVersion(markdown) {
|
|
357
298
|
const match = markdown.match(/^compiler_template_version:\s*(.+)$/m);
|
|
358
299
|
return match ? match[1].trim() : null;
|
|
@@ -385,35 +326,6 @@ function resolveUserTimeZone(preferred) {
|
|
|
385
326
|
}
|
|
386
327
|
return 'UTC';
|
|
387
328
|
}
|
|
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
329
|
class LocalLivingDocsManager {
|
|
418
330
|
constructor(options) {
|
|
419
331
|
this.scopePath = '.';
|
|
@@ -432,6 +344,7 @@ class LocalLivingDocsManager {
|
|
|
432
344
|
this.timeZone = resolveUserTimeZone(options.timeZone);
|
|
433
345
|
this.pollIntervalMs = options.pollIntervalMs ?? 5000;
|
|
434
346
|
this.flushDebounceMs = options.flushDebounceMs ?? 2500;
|
|
347
|
+
this.richAnalysis = options.richAnalysis ?? true;
|
|
435
348
|
this.onLog = options.onLog;
|
|
436
349
|
this.repoRoot = this.targetPath;
|
|
437
350
|
this.watchRoot = this.targetPath;
|
|
@@ -618,23 +531,35 @@ class LocalLivingDocsManager {
|
|
|
618
531
|
return;
|
|
619
532
|
const changedFiles = [...this.pendingFiles].sort();
|
|
620
533
|
this.pendingFiles.clear();
|
|
534
|
+
this.log(`Processing ${changedFiles.length} changed files: ${changedFiles.join(', ')}`);
|
|
621
535
|
let affectedSystems = this.findAffectedSystems(changedFiles);
|
|
622
536
|
if (affectedSystems.length === 0) {
|
|
537
|
+
this.log('No systems affected by these files. Refreshing systems registry...');
|
|
623
538
|
this.refreshSystems(true);
|
|
624
539
|
affectedSystems = this.findAffectedSystems(changedFiles);
|
|
625
540
|
}
|
|
626
541
|
if (affectedSystems.length === 0) {
|
|
627
|
-
this.log(`No living-doc systems matched ${changedFiles.length} changed paths`);
|
|
542
|
+
this.log(`No living-doc systems matched ${changedFiles.length} changed paths: ${changedFiles.slice(0, 3).join(', ')}`);
|
|
628
543
|
return;
|
|
629
544
|
}
|
|
630
|
-
this.log(`Compiling ${affectedSystems.length} local system(s)
|
|
545
|
+
this.log(`Compiling ${affectedSystems.length} local system(s): ${affectedSystems.map(s => s.system_id).join(', ')}`);
|
|
631
546
|
for (const system of affectedSystems) {
|
|
632
|
-
|
|
547
|
+
// Filter changed files to only those within this system.
|
|
548
|
+
// Paths in changedFiles are already repo-relative.
|
|
549
|
+
const systemSpecificChanges = changedFiles.filter(f => matchesDirectoryPath(f, system.directory_path));
|
|
550
|
+
if (systemSpecificChanges.length > 0) {
|
|
551
|
+
this.log(`Triggering compile for ${system.system_id} with ${systemSpecificChanges.length} files`);
|
|
552
|
+
await this.compileSystem(system, `${systemSpecificChanges.length} local file change${systemSpecificChanges.length === 1 ? '' : 's'}`, systemSpecificChanges);
|
|
553
|
+
}
|
|
554
|
+
else {
|
|
555
|
+
this.log(`System ${system.system_id} matched but no files were within its directory (${system.directory_path})`);
|
|
556
|
+
}
|
|
633
557
|
}
|
|
634
558
|
}
|
|
635
559
|
findAffectedSystems(files) {
|
|
636
560
|
const matched = new Map();
|
|
637
561
|
for (const file of files) {
|
|
562
|
+
// file is already repo-relative
|
|
638
563
|
for (const system of this.systems) {
|
|
639
564
|
if (matchesDirectoryPath(file, system.directory_path)) {
|
|
640
565
|
matched.set(system.system_id, system);
|
|
@@ -683,20 +608,33 @@ class LocalLivingDocsManager {
|
|
|
683
608
|
continue;
|
|
684
609
|
const systemRoot = doc.directory_path === '.' ? this.repoRoot : (0, path_1.join)(this.repoRoot, doc.directory_path);
|
|
685
610
|
const contextPath = (0, path_1.join)(systemRoot, 'ekkOS_CONTEXT.md');
|
|
686
|
-
// Only overwrite if
|
|
611
|
+
// Only overwrite if the server doc is meaningfully different from what's on disk.
|
|
687
612
|
if ((0, fs_1.existsSync)(contextPath)) {
|
|
688
613
|
try {
|
|
689
614
|
const existing = (0, fs_1.readFileSync)(contextPath, 'utf-8');
|
|
690
615
|
const isServerCompiled = existing.includes('compiled_by: ekkOS Server Compiler')
|
|
691
616
|
|| existing.includes('compiler_model: gemini')
|
|
692
617
|
|| (existing.includes('compiled_by:') && !existing.includes('compiled_by: ekkOS Local Compiler'));
|
|
693
|
-
// If already server-compiled,
|
|
618
|
+
// If already server-compiled, only overwrite with a strictly newer version
|
|
694
619
|
if (isServerCompiled) {
|
|
695
620
|
const existingDate = existing.match(/last_compiled_at:\s*(.+)/)?.[1]?.trim();
|
|
696
621
|
if (existingDate && new Date(existingDate) >= new Date(doc.compiled_at)) {
|
|
697
622
|
continue; // Existing is same or newer
|
|
698
623
|
}
|
|
699
624
|
}
|
|
625
|
+
// Content hash check: don't write if the body hasn't actually changed.
|
|
626
|
+
// Strip frontmatter timestamps before comparing to avoid false positives.
|
|
627
|
+
const stripVolatile = (s) => s
|
|
628
|
+
.replace(/^last_compiled_at: .+$/m, '')
|
|
629
|
+
.replace(/^compiled_timezone: .+$/m, '')
|
|
630
|
+
.replace(/^recompile_reason: .+$/m, '')
|
|
631
|
+
.replace(/^days_since_last_change: .+$/m, '')
|
|
632
|
+
.replace(/^activity_status: .+$/m, '')
|
|
633
|
+
.replace(/^content_hash: .+$/m, '')
|
|
634
|
+
.trim();
|
|
635
|
+
if (stripVolatile(existing) === stripVolatile(doc.compiled_body)) {
|
|
636
|
+
continue; // Body is identical — skip to avoid noisy git diff
|
|
637
|
+
}
|
|
700
638
|
}
|
|
701
639
|
catch { /* overwrite on error */ }
|
|
702
640
|
}
|
|
@@ -788,20 +726,33 @@ class LocalLivingDocsManager {
|
|
|
788
726
|
try {
|
|
789
727
|
const existing = (0, fs_1.readFileSync)(contextPath, 'utf-8');
|
|
790
728
|
// 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
729
|
const isServerCompiled = existing.includes('compiled_by: ekkOS Server Compiler')
|
|
794
730
|
|| existing.includes('compiler_model: gemini')
|
|
795
731
|
|| (existing.includes('compiled_by:') && !existing.includes('compiled_by: ekkOS Local Compiler'));
|
|
796
732
|
if (isServerCompiled) {
|
|
797
|
-
//
|
|
798
|
-
|
|
733
|
+
// Only patch metadata + trigger analysis when actual source files changed.
|
|
734
|
+
// On startup with no changes, skip entirely to avoid noisy git diffs.
|
|
735
|
+
if (changedFiles.length > 0) {
|
|
736
|
+
this.patchServerDocMetadata(contextPath, existing, system, reason);
|
|
737
|
+
if (this.richAnalysis && this.apiUrl && this.apiKey) {
|
|
738
|
+
void this.injectSemanticAnalysis(system, changedFiles);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
799
741
|
return;
|
|
800
742
|
}
|
|
801
|
-
// Same fingerprint + same template version =
|
|
743
|
+
// Same fingerprint + same template version = source content unchanged → skip recompile.
|
|
744
|
+
// Only trigger semantic analysis when the caller explicitly passed changed files.
|
|
802
745
|
if (parseStoredFingerprint(existing) === fingerprint &&
|
|
803
746
|
existing.includes('\ncompiled_timezone: ') &&
|
|
804
747
|
parseStoredTemplateVersion(existing) === LOCAL_CONTEXT_TEMPLATE_VERSION) {
|
|
748
|
+
if (this.richAnalysis && this.apiUrl && this.apiKey && changedFiles.length > 0) {
|
|
749
|
+
void this.injectSemanticAnalysis(system, changedFiles);
|
|
750
|
+
}
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
// If no files actually changed (startup scan), don't rewrite the doc
|
|
754
|
+
// just because the template version or timezone field differs.
|
|
755
|
+
if (changedFiles.length === 0 && parseStoredFingerprint(existing) === fingerprint) {
|
|
805
756
|
return;
|
|
806
757
|
}
|
|
807
758
|
}
|
|
@@ -824,7 +775,7 @@ class LocalLivingDocsManager {
|
|
|
824
775
|
// Language-aware key files and entry points
|
|
825
776
|
const stackKeyFiles = new Set((0, language_config_js_1.getKeyFilesForStack)(stack).map(f => f.toLowerCase()));
|
|
826
777
|
const dependencies = extractDependencies((0, path_1.join)(systemRoot, 'package.json'));
|
|
827
|
-
const recentChanges = readRecentGitChanges(this.repoRoot, system.directory_path, this.timeZone);
|
|
778
|
+
const recentChanges = (0, git_utils_js_1.readRecentGitChanges)(this.repoRoot, system.directory_path, this.timeZone);
|
|
828
779
|
const entryFileNames = ['index.ts', 'index.tsx', 'index.js', 'main.ts', 'server.ts', 'app.ts',
|
|
829
780
|
'main.py', 'app.py', 'manage.py', '__main__.py', 'main.rs', 'lib.rs', 'main.go', 'config.ru',
|
|
830
781
|
'Program.cs', 'lib/main.dart'];
|
|
@@ -835,7 +786,7 @@ class LocalLivingDocsManager {
|
|
|
835
786
|
const directoryCount = new Set(files
|
|
836
787
|
.map(file => normalizePath(file.relativePath).split('/').slice(0, -1).join('/'))
|
|
837
788
|
.filter(Boolean)).size;
|
|
838
|
-
const now = formatZonedTimestamp(new Date(), this.timeZone);
|
|
789
|
+
const now = (0, git_utils_js_1.formatZonedTimestamp)(new Date(), this.timeZone);
|
|
839
790
|
const observedTimes = changedFiles
|
|
840
791
|
.map(file => {
|
|
841
792
|
try {
|
|
@@ -848,10 +799,10 @@ class LocalLivingDocsManager {
|
|
|
848
799
|
.filter(time => !Number.isNaN(time))
|
|
849
800
|
.sort((a, b) => a - b);
|
|
850
801
|
const evidenceStart = observedTimes.length > 0
|
|
851
|
-
? formatZonedTimestamp(new Date(observedTimes[0]), this.timeZone)
|
|
802
|
+
? (0, git_utils_js_1.formatZonedTimestamp)(new Date(observedTimes[0]), this.timeZone)
|
|
852
803
|
: (recentChanges[recentChanges.length - 1]?.timestamp || now);
|
|
853
804
|
const evidenceEnd = observedTimes.length > 0
|
|
854
|
-
? formatZonedTimestamp(new Date(observedTimes[observedTimes.length - 1]), this.timeZone)
|
|
805
|
+
? (0, git_utils_js_1.formatZonedTimestamp)(new Date(observedTimes[observedTimes.length - 1]), this.timeZone)
|
|
855
806
|
: (recentChanges[0]?.timestamp || now);
|
|
856
807
|
const topExtensions = [...extCounts.entries()]
|
|
857
808
|
.sort((a, b) => b[1] - a[1])
|
|
@@ -859,17 +810,7 @@ class LocalLivingDocsManager {
|
|
|
859
810
|
.map(([ext, count]) => `${ext.replace(/^\./, '') || 'none'} (${count})`)
|
|
860
811
|
.join(', ');
|
|
861
812
|
// ── 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
|
-
})();
|
|
813
|
+
const lastCommitEpochMs = (0, git_utils_js_1.getLastCommitTimestamp)(this.repoRoot, system.directory_path);
|
|
873
814
|
const daysSinceLastChange = lastCommitEpochMs
|
|
874
815
|
? Math.floor((Date.now() - lastCommitEpochMs) / (1000 * 60 * 60 * 24))
|
|
875
816
|
: null;
|
|
@@ -1011,6 +952,121 @@ class LocalLivingDocsManager {
|
|
|
1011
952
|
(0, fs_1.writeFileSync)(tmpPath, markdown, 'utf-8');
|
|
1012
953
|
(0, fs_1.renameSync)(tmpPath, contextPath);
|
|
1013
954
|
this.log(`Wrote ${normalizePath((0, path_1.relative)(this.repoRoot, contextPath))}${stack.language !== 'unknown' ? ` [${stack.language}${stack.framework ? `/${stack.framework}` : ''}]` : ''}`);
|
|
955
|
+
// Phase 3: Trigger Server-Side Semantic Analysis if --rich is enabled
|
|
956
|
+
if (this.richAnalysis && this.apiUrl && this.apiKey && changedFiles.length > 0) {
|
|
957
|
+
this.log(`Triggering rich semantic analysis for ${system.system_id} based on ${changedFiles.length} file change(s)...`);
|
|
958
|
+
void this.injectSemanticAnalysis(system, changedFiles);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
* Calls the server-side semantic analyzer and injects rich insights into the context file.
|
|
963
|
+
*/
|
|
964
|
+
async injectSemanticAnalysis(system, changedFiles) {
|
|
965
|
+
const systemRoot = system.directory_path === '.' ? this.repoRoot : (0, path_1.join)(this.repoRoot, system.directory_path);
|
|
966
|
+
const contextPath = (0, path_1.join)(systemRoot, 'ekkOS_CONTEXT.md');
|
|
967
|
+
try {
|
|
968
|
+
// 1. Extract diff and local entities
|
|
969
|
+
const { rawDiff } = await (0, diff_engine_js_1.getSemanticDiff)({
|
|
970
|
+
repoRoot: this.repoRoot,
|
|
971
|
+
directoryPath: system.directory_path
|
|
972
|
+
});
|
|
973
|
+
if (!rawDiff) {
|
|
974
|
+
this.log(`No semantic diff found for ${system.system_id}, skipping rich analysis.`);
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
const entities = (0, entity_extractor_js_1.extractEntitiesFromDiff)(rawDiff);
|
|
978
|
+
// 2. POST to ekkOS cloud for rich analysis (Directive Audit + Cascade + Explanation)
|
|
979
|
+
this.log(`Requesting rich semantic analysis for ${system.system_id} from ekkOS Cloud (${this.apiUrl})...`);
|
|
980
|
+
const timeoutController = new AbortController();
|
|
981
|
+
const id = setTimeout(() => timeoutController.abort(), 20000); // 20s timeout
|
|
982
|
+
const res = await fetch(`${this.apiUrl}/api/v1/cortex/analyze-diff`, {
|
|
983
|
+
method: 'POST',
|
|
984
|
+
headers: {
|
|
985
|
+
'Authorization': `Bearer ${this.apiKey}`,
|
|
986
|
+
'Content-Type': 'application/json',
|
|
987
|
+
},
|
|
988
|
+
body: JSON.stringify({
|
|
989
|
+
diff: rawDiff,
|
|
990
|
+
directory_path: system.directory_path,
|
|
991
|
+
system_id: system.system_id,
|
|
992
|
+
entities
|
|
993
|
+
}),
|
|
994
|
+
signal: timeoutController.signal,
|
|
995
|
+
});
|
|
996
|
+
clearTimeout(id);
|
|
997
|
+
if (!res.ok) {
|
|
998
|
+
const errorText = await res.text();
|
|
999
|
+
this.log(`Semantic analysis API failed (${res.status} ${res.statusText}): ${errorText.slice(0, 200)}`);
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
const data = await res.json();
|
|
1003
|
+
if (!data.success || !data.result.markdown) {
|
|
1004
|
+
this.log(`Semantic analysis returned no results for ${system.system_id}`);
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
// 2.5 Send desktop notification if requested
|
|
1008
|
+
if (data.result.shouldNotify && data.result.notificationSummary) {
|
|
1009
|
+
(0, notifier_js_1.sendDesktopNotification)({
|
|
1010
|
+
title: `ekkOS: ${system.system_id}`,
|
|
1011
|
+
message: data.result.notificationSummary,
|
|
1012
|
+
urgency: 'critical',
|
|
1013
|
+
systemId: system.system_id
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
// 3. Prepare the analysis block
|
|
1017
|
+
let analysisMarkdown = data.result.markdown.trim();
|
|
1018
|
+
// If we are injecting into a doc, and it's a server-compiled doc,
|
|
1019
|
+
// we should ensure the changed files are listed (since server body won't have them)
|
|
1020
|
+
if (changedFiles.length > 0 && !analysisMarkdown.includes('These') && !analysisMarkdown.includes('file(s) changed')) {
|
|
1021
|
+
const fileList = changedFiles.map(f => `- \`${f.split('/').pop()}\``).join('\n');
|
|
1022
|
+
const fileHeader = `#### 📝 Recent Local Changes\nThese ${changedFiles.length} file(s) changed since last compile:\n\n${fileList}\n`;
|
|
1023
|
+
analysisMarkdown = `${fileHeader}\n${analysisMarkdown}`;
|
|
1024
|
+
}
|
|
1025
|
+
// 4. Inject into the context file body
|
|
1026
|
+
const existing = (0, fs_1.readFileSync)(contextPath, 'utf-8');
|
|
1027
|
+
const activeWorkHeader = '## Active Work';
|
|
1028
|
+
const systemInfoHeader = '## System Info';
|
|
1029
|
+
const endMarker = '<!-- EKKOS_AUTOGENERATED_END -->';
|
|
1030
|
+
let insertIndex = existing.indexOf(activeWorkHeader);
|
|
1031
|
+
let limitIndex = -1;
|
|
1032
|
+
if (insertIndex !== -1) {
|
|
1033
|
+
// Section exists: append to it
|
|
1034
|
+
const nextSectionIndex = existing.indexOf('\n## ', insertIndex + activeWorkHeader.length);
|
|
1035
|
+
const endBlockIndex = existing.indexOf(endMarker, insertIndex);
|
|
1036
|
+
limitIndex = nextSectionIndex !== -1 ? Math.min(nextSectionIndex, endBlockIndex) : endBlockIndex;
|
|
1037
|
+
}
|
|
1038
|
+
else {
|
|
1039
|
+
// Section missing: try to insert before System Info or at the end of autogenerated block
|
|
1040
|
+
const sysInfoIndex = existing.indexOf(systemInfoHeader);
|
|
1041
|
+
if (sysInfoIndex !== -1) {
|
|
1042
|
+
insertIndex = sysInfoIndex;
|
|
1043
|
+
limitIndex = sysInfoIndex;
|
|
1044
|
+
}
|
|
1045
|
+
else {
|
|
1046
|
+
const endMarkerIndex = existing.indexOf(endMarker);
|
|
1047
|
+
if (endMarkerIndex !== -1) {
|
|
1048
|
+
insertIndex = endMarkerIndex;
|
|
1049
|
+
limitIndex = endMarkerIndex;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
if (insertIndex !== -1) {
|
|
1054
|
+
const before = existing.slice(0, limitIndex);
|
|
1055
|
+
const after = existing.slice(limitIndex);
|
|
1056
|
+
// Clean up formatting: ensure sections are separated by double newlines
|
|
1057
|
+
// If we inserted before System Info, we might need a header if it was missing
|
|
1058
|
+
const headerPrefix = existing.includes(activeWorkHeader) ? '' : `## Active Work\n\n`;
|
|
1059
|
+
const patched = `${before.trim()}\n\n${headerPrefix}${analysisMarkdown}\n\n${after.trim()}`;
|
|
1060
|
+
(0, fs_1.writeFileSync)(contextPath, patched, 'utf-8');
|
|
1061
|
+
this.log(`✅ Successfully injected rich semantic analysis into ${system.system_id}`);
|
|
1062
|
+
}
|
|
1063
|
+
else {
|
|
1064
|
+
this.log(`Could not find a suitable insertion point in ${contextPath} to inject analysis.`);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
catch (err) {
|
|
1068
|
+
this.log(`Rich analysis failed: ${err.message}`);
|
|
1069
|
+
}
|
|
1014
1070
|
}
|
|
1015
1071
|
/**
|
|
1016
1072
|
* Metadata-only patch for server-compiled docs.
|
|
@@ -1019,16 +1075,7 @@ class LocalLivingDocsManager {
|
|
|
1019
1075
|
*/
|
|
1020
1076
|
patchServerDocMetadata(contextPath, existing, system, reason) {
|
|
1021
1077
|
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
|
-
})();
|
|
1078
|
+
const lastCommitEpochMs = (0, git_utils_js_1.getLastCommitTimestamp)(this.repoRoot, system.directory_path);
|
|
1032
1079
|
const daysSinceLastChange = lastCommitEpochMs
|
|
1033
1080
|
? Math.floor((Date.now() - lastCommitEpochMs) / (1000 * 60 * 60 * 24))
|
|
1034
1081
|
: null;
|
|
@@ -1039,7 +1086,7 @@ class LocalLivingDocsManager {
|
|
|
1039
1086
|
else if (daysSinceLastChange >= 30)
|
|
1040
1087
|
activityStatus = 'stale';
|
|
1041
1088
|
}
|
|
1042
|
-
const now = formatZonedTimestamp(new Date(), this.timeZone);
|
|
1089
|
+
const now = (0, git_utils_js_1.formatZonedTimestamp)(new Date(), this.timeZone);
|
|
1043
1090
|
// Patch only approved fields in frontmatter, preserve body byte-for-byte
|
|
1044
1091
|
const firstDash = existing.indexOf('---');
|
|
1045
1092
|
const frontmatterEnd = existing.indexOf('---', firstDash + 3);
|
|
@@ -1070,12 +1117,6 @@ class LocalLivingDocsManager {
|
|
|
1070
1117
|
}
|
|
1071
1118
|
const result = patched + body;
|
|
1072
1119
|
const finalResult = lineEnding === '\r\n' ? result.replace(/\r\n/g, '\n').replace(/\n/g, '\r\n') : result;
|
|
1073
|
-
// Skip write if the only change is last_compiled_at timestamp — prevents git noise
|
|
1074
|
-
// Compare by stripping the timestamp lines from both old and new
|
|
1075
|
-
const stripTimestamps = (s) => s.replace(/^last_compiled_at: .+$/m, '').replace(/^compiled_timezone: .+$/m, '');
|
|
1076
|
-
if (stripTimestamps(existing) === stripTimestamps(finalResult)) {
|
|
1077
|
-
return; // Nothing meaningful changed — don't touch the file
|
|
1078
|
-
}
|
|
1079
1120
|
// Atomic write
|
|
1080
1121
|
const tmpPath = (0, path_1.join)((0, path_1.dirname)(contextPath), `.ekkOS_CONTEXT.md.${process.pid}.tmp`);
|
|
1081
1122
|
(0, fs_1.writeFileSync)(tmpPath, finalResult, 'utf-8');
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ekkOS Desktop Notifier
|
|
3
|
+
* Cross-platform desktop notifications for real-time architectural feedback.
|
|
4
|
+
*/
|
|
5
|
+
export type NotificationUrgency = 'low' | 'normal' | 'critical';
|
|
6
|
+
export interface NotificationOptions {
|
|
7
|
+
title: string;
|
|
8
|
+
message: string;
|
|
9
|
+
urgency?: NotificationUrgency;
|
|
10
|
+
systemId?: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Sends a native desktop notification to the user.
|
|
14
|
+
*/
|
|
15
|
+
export declare function sendDesktopNotification(options: NotificationOptions): void;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.sendDesktopNotification = sendDesktopNotification;
|
|
7
|
+
const node_notifier_1 = __importDefault(require("node-notifier"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
// const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
// Fallback to commonjs __dirname for current build config
|
|
11
|
+
const ASSETS_DIR = path_1.default.resolve(__dirname, '../../assets');
|
|
12
|
+
// Simple anti-spam state
|
|
13
|
+
const lastNotificationTime = new Map();
|
|
14
|
+
const NOTIFICATION_COOLDOWN_MS = 60000; // 1 minute per system
|
|
15
|
+
/**
|
|
16
|
+
* Sends a native desktop notification to the user.
|
|
17
|
+
*/
|
|
18
|
+
function sendDesktopNotification(options) {
|
|
19
|
+
const { title, message, urgency = 'normal', systemId = 'global' } = options;
|
|
20
|
+
// Anti-spam check
|
|
21
|
+
const now = Date.now();
|
|
22
|
+
const lastTime = lastNotificationTime.get(systemId) || 0;
|
|
23
|
+
if (now - lastTime < NOTIFICATION_COOLDOWN_MS && urgency !== 'critical') {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
node_notifier_1.default.notify({
|
|
28
|
+
title: title,
|
|
29
|
+
message: message,
|
|
30
|
+
icon: path_1.default.join(ASSETS_DIR, 'ekkOS-icon.png'), // Fallback if icon doesn't exist
|
|
31
|
+
sound: urgency === 'critical',
|
|
32
|
+
wait: false,
|
|
33
|
+
});
|
|
34
|
+
lastNotificationTime.set(systemId, now);
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
// Non-fatal fallback - just log it
|
|
38
|
+
console.warn(`[Notifier] Failed to send notification: ${err.message}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
package/dist/utils/paths.d.ts
CHANGED
|
@@ -82,6 +82,10 @@ export declare function getLegacyMetaPath(sessionId: string): string;
|
|
|
82
82
|
* ~/.ekkos/cache/sessions/{sessionId}.stream.jsonl
|
|
83
83
|
*/
|
|
84
84
|
export declare function getLegacyStreamLogPath(sessionId: string): string;
|
|
85
|
+
/**
|
|
86
|
+
* Get the path to the daemon control port file
|
|
87
|
+
*/
|
|
88
|
+
export declare function getDaemonPortPath(): string;
|
|
85
89
|
/**
|
|
86
90
|
* Ensure a directory exists (mkdirp)
|
|
87
91
|
*/
|
package/dist/utils/paths.js
CHANGED
|
@@ -58,6 +58,7 @@ exports.getInstanceFilePath = getInstanceFilePath;
|
|
|
58
58
|
exports.getLegacyTurnsPath = getLegacyTurnsPath;
|
|
59
59
|
exports.getLegacyMetaPath = getLegacyMetaPath;
|
|
60
60
|
exports.getLegacyStreamLogPath = getLegacyStreamLogPath;
|
|
61
|
+
exports.getDaemonPortPath = getDaemonPortPath;
|
|
61
62
|
exports.ensureDir = ensureDir;
|
|
62
63
|
exports.ensureInstanceDir = ensureInstanceDir;
|
|
63
64
|
exports.ensureBaseDirs = ensureBaseDirs;
|
|
@@ -189,6 +190,12 @@ function getLegacyMetaPath(sessionId) {
|
|
|
189
190
|
function getLegacyStreamLogPath(sessionId) {
|
|
190
191
|
return path.join(SESSIONS_DIR, `${sessionId}.stream.jsonl`);
|
|
191
192
|
}
|
|
193
|
+
/**
|
|
194
|
+
* Get the path to the daemon control port file
|
|
195
|
+
*/
|
|
196
|
+
function getDaemonPortPath() {
|
|
197
|
+
return path.join(EKKOS_DIR, 'synk', 'daemon.port');
|
|
198
|
+
}
|
|
192
199
|
/**
|
|
193
200
|
* Ensure a directory exists (mkdirp)
|
|
194
201
|
*/
|
package/dist/utils/state.d.ts
CHANGED
|
@@ -15,6 +15,9 @@ export interface ClaudeSessionMetadata {
|
|
|
15
15
|
geminiProjectId?: string;
|
|
16
16
|
dashboardEnabled?: boolean;
|
|
17
17
|
bypassEnabled?: boolean;
|
|
18
|
+
stdinPort?: number;
|
|
19
|
+
stdinToken?: string;
|
|
20
|
+
transcriptPath?: string;
|
|
18
21
|
}
|
|
19
22
|
export interface ActiveSession extends ClaudeSessionMetadata {
|
|
20
23
|
sessionId: string;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stdin-relay.ts — Universal Session Attach (Phase 1)
|
|
3
|
+
*
|
|
4
|
+
* Lightweight HTTP server bound to 127.0.0.1 that accepts remote input
|
|
5
|
+
* and injects it into the running PTY's stdin. The daemon (or any local
|
|
6
|
+
* process with the auth token) can POST text to be typed into Claude.
|
|
7
|
+
*
|
|
8
|
+
* Security:
|
|
9
|
+
* - Binds to 127.0.0.1 only (no network exposure)
|
|
10
|
+
* - Requires a one-time token generated at startup
|
|
11
|
+
* - Token is stored in active-sessions.json (filesystem-permission scoped)
|
|
12
|
+
*/
|
|
13
|
+
export interface StdinRelayOptions {
|
|
14
|
+
/** Function to write data into the PTY stdin */
|
|
15
|
+
write: (data: string) => void;
|
|
16
|
+
/** Optional callback when a remote client attaches */
|
|
17
|
+
onAttach?: (remoteInfo: string) => void;
|
|
18
|
+
/** Optional debug logger */
|
|
19
|
+
dlog?: (...args: unknown[]) => void;
|
|
20
|
+
}
|
|
21
|
+
export interface StdinRelayHandle {
|
|
22
|
+
/** The localhost port the relay is listening on */
|
|
23
|
+
port: number;
|
|
24
|
+
/** The auth token required to send input */
|
|
25
|
+
token: string;
|
|
26
|
+
/** Stop the relay server */
|
|
27
|
+
stop: () => Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Start a stdin relay HTTP server on a random available port.
|
|
31
|
+
*
|
|
32
|
+
* Endpoints:
|
|
33
|
+
* POST /stdin — inject text into the PTY (body: { text: string })
|
|
34
|
+
* GET /health — check if session is alive
|
|
35
|
+
* GET /status — session metadata (attached clients, uptime)
|
|
36
|
+
*/
|
|
37
|
+
export declare function startStdinRelay(options: StdinRelayOptions): Promise<StdinRelayHandle>;
|