@ekkos/cli 1.4.1 → 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.
@@ -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
- if (name.endsWith('.log') || name.endsWith('.tmp') || name.endsWith('.swp'))
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 formatDate(date) {
239
- return date.toISOString().slice(0, 10);
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) from ${changedFiles.length} file change(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
- await this.compileSystem(system, `${changedFiles.length} local file change${changedFiles.length === 1 ? '' : 's'}`, changedFiles);
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 local doc is locally-compiled (or doesn't exist)
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, check if the new one is newer
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
- // Phase 3: Metadata-only patching update frontmatter fields without touching body
798
- this.patchServerDocMetadata(contextPath, existing, system, reason);
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 = nothing changed → skip
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);
@@ -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
+ }
@@ -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
  */
@@ -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
  */
@@ -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>;