@karmaniverous/jeeves-meta 0.10.1 → 0.11.1

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.
@@ -46,11 +46,11 @@ const metaConfigSchema = z.object({
46
46
  /** Maximum lines of context to include in subprocess prompts. */
47
47
  maxLines: z.number().int().min(50).default(500),
48
48
  /** Architect subprocess timeout in seconds. */
49
- architectTimeout: z.number().int().min(30).default(120),
49
+ architectTimeout: z.number().int().min(30).default(180),
50
50
  /** Builder subprocess timeout in seconds. */
51
- builderTimeout: z.number().int().min(60).default(600),
51
+ builderTimeout: z.number().int().min(60).default(360),
52
52
  /** Critic subprocess timeout in seconds. */
53
- criticTimeout: z.number().int().min(30).default(300),
53
+ criticTimeout: z.number().int().min(30).default(240),
54
54
  /** Thinking level for spawned synthesis sessions. */
55
55
  thinking: z.string().default('low'),
56
56
  /** Resolved architect system prompt text. Falls back to built-in default. */
@@ -834,6 +834,26 @@ async function listMetas(config, watcher) {
834
834
  };
835
835
  }
836
836
 
837
+ /**
838
+ * Escape special glob characters in a path so it can be used as a literal
839
+ * prefix in glob patterns.
840
+ *
841
+ * Glob metacharacters `* ? [ ] { } ( ) !` are escaped with a backslash so
842
+ * that paths containing parentheses (e.g. Slack channel IDs) or other
843
+ * special characters are matched literally by the watcher's walk endpoint.
844
+ *
845
+ * @module escapeGlob
846
+ */
847
+ /**
848
+ * Escape glob metacharacters in a string.
849
+ *
850
+ * @param s - Raw path string.
851
+ * @returns String with glob metacharacters backslash-escaped.
852
+ */
853
+ function escapeGlob(s) {
854
+ return s.replace(/[*?[\]{}()!]/g, '\\$&');
855
+ }
856
+
837
857
  /**
838
858
  * Filter file paths by modification time.
839
859
  *
@@ -934,7 +954,7 @@ function filterInScope(node, files) {
934
954
  */
935
955
  async function getScopeFiles(node, watcher, logger) {
936
956
  const walkStart = Date.now();
937
- const rawFiles = await watcher.walk([`${node.ownerPath}/**`]);
957
+ const rawFiles = await watcher.walk([`${escapeGlob(node.ownerPath)}/**`]);
938
958
  const allFiles = rawFiles.map(normalizePath);
939
959
  const scopeFiles = filterInScope(node, allFiles);
940
960
  logger?.debug({
@@ -1746,7 +1766,7 @@ async function buildMinimalNode(metaPath, watcher) {
1746
1766
  // We include only *direct* children (nearest descendants in the ownership tree)
1747
1767
  // to match the ownership semantics used elsewhere.
1748
1768
  const rawMetaJsonPaths = await watcher.walk([
1749
- `${ownerPath}/**/.meta/meta.json`,
1769
+ `${escapeGlob(ownerPath)}/**/.meta/meta.json`,
1750
1770
  ]);
1751
1771
  const candidateMetaPaths = [
1752
1772
  ...new Set(rawMetaJsonPaths.map((p) => normalizePath(dirname(p)))),
@@ -1882,7 +1902,7 @@ function discoverStalestPath(candidates, depthWeight) {
1882
1902
  async function isStale(scopePrefix, meta, watcher) {
1883
1903
  if (!meta._generatedAt)
1884
1904
  return true; // Never synthesized = stale
1885
- const files = await watcher.walk([`${scopePrefix}/**`]);
1905
+ const files = await watcher.walk([`${escapeGlob(scopePrefix)}/**`]);
1886
1906
  return hasModifiedAfter(files, new Date(meta._generatedAt).getTime());
1887
1907
  }
1888
1908
  /** Maximum staleness for never-synthesized metas (1 year in seconds). */
@@ -2151,6 +2171,25 @@ async function synthesizeNode(node, currentMeta, config, executor, watcher, onPr
2151
2171
  const steerChanged = hasSteerChanged(currentMeta._steer, latestArchive?._steer, Boolean(latestArchive));
2152
2172
  // Step 7: Compute context (includes scope files and delta files)
2153
2173
  const ctx = await buildContextPackage(node, currentMeta, watcher, logger);
2174
+ // Skip empty-scope entities that have no prior content.
2175
+ // Without scope files, child metas, or cross-refs there is nothing for
2176
+ // the architect/builder to work with and the cycle will either time out
2177
+ // or produce empty output.
2178
+ const hasScope = ctx.scopeFiles.length > 0 ||
2179
+ Object.keys(ctx.childMetas).length > 0 ||
2180
+ Object.keys(ctx.crossRefMetas).length > 0;
2181
+ if (!hasScope && !currentMeta._content) {
2182
+ // Bump _generatedAt so this entity doesn't keep winning the staleness
2183
+ // race every cycle. It will be re-evaluated when files appear.
2184
+ // Uses lock-staging for atomic write consistency.
2185
+ currentMeta._generatedAt = new Date().toISOString();
2186
+ const lockPath = join(node.metaPath, '.lock');
2187
+ const metaJsonPath = join(node.metaPath, 'meta.json');
2188
+ await writeFile(lockPath, JSON.stringify(currentMeta, null, 2));
2189
+ await copyFile(lockPath, metaJsonPath);
2190
+ logger?.debug({ path: node.ownerPath }, 'Skipping empty-scope entity');
2191
+ return { synthesized: false };
2192
+ }
2154
2193
  // Step 5 (deferred): Structure hash from context scope files
2155
2194
  const newStructureHash = computeStructureHash(ctx.scopeFiles);
2156
2195
  const structureChanged = newStructureHash !== currentMeta._structureHash;
@@ -2447,21 +2486,41 @@ function formatSeconds(durationMs) {
2447
2486
  function titleCasePhase(phase) {
2448
2487
  return phase.charAt(0).toUpperCase() + phase.slice(1);
2449
2488
  }
2450
- /** Build a link to the entity's meta.json output file. */
2451
- function buildEntityLink(path, serverBaseUrl) {
2452
- // Normalize backslashes, then convert drive letter to URL path segment
2489
+ /**
2490
+ * URL-encode each path segment individually so that spaces and special
2491
+ * characters are safe while preserving the `/` separators.
2492
+ */
2493
+ function encodePathSegments(p) {
2494
+ return p
2495
+ .split('/')
2496
+ .map((seg) => encodeURIComponent(seg))
2497
+ .join('/');
2498
+ }
2499
+ /** Build a link (or plain path) to the owner directory. */
2500
+ function buildDirectoryLink(path, serverBaseUrl) {
2501
+ const normalized = normalizePath(path).replace(/^([A-Za-z]):/, '/$1');
2502
+ const encoded = encodePathSegments(normalized);
2503
+ if (!serverBaseUrl)
2504
+ return normalized;
2505
+ const base = serverBaseUrl.replace(/\/+$/, '');
2506
+ return `${base}/path${encoded}`;
2507
+ }
2508
+ /** Build a link (or plain path) to the entity's meta.json output file. */
2509
+ function buildMetaJsonLink(path, serverBaseUrl) {
2453
2510
  const normalized = normalizePath(path).replace(/^([A-Za-z]):/, '/$1');
2454
2511
  const metaJsonPath = `${normalized}/.meta/meta.json`;
2512
+ const encoded = encodePathSegments(metaJsonPath);
2455
2513
  if (!serverBaseUrl)
2456
2514
  return metaJsonPath;
2457
2515
  const base = serverBaseUrl.replace(/\/+$/, '');
2458
- return `${base}/path${metaJsonPath}`;
2516
+ return `${base}/path${encoded}`;
2459
2517
  }
2460
2518
  function formatProgressEvent(event, serverBaseUrl) {
2461
- const pathDisplay = buildEntityLink(event.path, serverBaseUrl);
2462
2519
  switch (event.type) {
2463
- case 'synthesis_start':
2464
- return `🔬 Started meta synthesis: ${pathDisplay}`;
2520
+ case 'synthesis_start': {
2521
+ const dirLink = buildDirectoryLink(event.path, serverBaseUrl);
2522
+ return `🔬 Started meta synthesis: ${dirLink}`;
2523
+ }
2465
2524
  case 'phase_start': {
2466
2525
  if (!event.phase) {
2467
2526
  return ' ⚙️ Phase started';
@@ -2475,16 +2534,18 @@ function formatProgressEvent(event, serverBaseUrl) {
2475
2534
  return ` ✅ ${phase} complete (${formatNumber(tokens)} tokens / ${duration})`;
2476
2535
  }
2477
2536
  case 'synthesis_complete': {
2537
+ const metaLink = buildMetaJsonLink(event.path, serverBaseUrl);
2478
2538
  const tokens = event.tokens ?? 0;
2479
2539
  const duration = event.durationMs !== undefined
2480
2540
  ? formatSeconds(event.durationMs)
2481
2541
  : '0.0s';
2482
- return `✅ Completed: ${pathDisplay} (${formatNumber(tokens)} tokens / ${duration})`;
2542
+ return `✅ Completed: ${metaLink} (${formatNumber(tokens)} tokens / ${duration})`;
2483
2543
  }
2484
2544
  case 'error': {
2545
+ const dirLink = buildDirectoryLink(event.path, serverBaseUrl);
2485
2546
  const phase = event.phase ? `${titleCasePhase(event.phase)} ` : '';
2486
2547
  const error = event.error ?? 'Unknown error';
2487
- return `❌ Synthesis failed at ${phase}phase: ${pathDisplay}\n Error: ${error}`;
2548
+ return `❌ Synthesis failed at ${phase}phase: ${dirLink}\n Error: ${error}`;
2488
2549
  }
2489
2550
  default: {
2490
2551
  return 'Unknown progress event';
@@ -11078,6 +11139,11 @@ async function startService(config, configPath) {
11078
11139
  // orchestrate() always returns exactly one result
11079
11140
  const result = results[0];
11080
11141
  const durationMs = Date.now() - startMs;
11142
+ if (!result.synthesized) {
11143
+ // Entity was skipped (e.g. empty scope) — no progress to report.
11144
+ logger.debug({ path: ownerPath }, 'Synthesis skipped');
11145
+ return;
11146
+ }
11081
11147
  // Update stats
11082
11148
  stats.totalSyntheses++;
11083
11149
  stats.lastCycleDurationMs = durationMs;
@@ -11087,6 +11153,7 @@ async function startService(config, configPath) {
11087
11153
  await progress.report({
11088
11154
  type: 'error',
11089
11155
  path: ownerPath,
11156
+ phase: result.error.step,
11090
11157
  error: result.error.message,
11091
11158
  });
11092
11159
  }
package/dist/index.js CHANGED
@@ -254,11 +254,11 @@ const metaConfigSchema = z.object({
254
254
  /** Maximum lines of context to include in subprocess prompts. */
255
255
  maxLines: z.number().int().min(50).default(500),
256
256
  /** Architect subprocess timeout in seconds. */
257
- architectTimeout: z.number().int().min(30).default(120),
257
+ architectTimeout: z.number().int().min(30).default(180),
258
258
  /** Builder subprocess timeout in seconds. */
259
- builderTimeout: z.number().int().min(60).default(600),
259
+ builderTimeout: z.number().int().min(60).default(360),
260
260
  /** Critic subprocess timeout in seconds. */
261
- criticTimeout: z.number().int().min(30).default(300),
261
+ criticTimeout: z.number().int().min(30).default(240),
262
262
  /** Thinking level for spawned synthesis sessions. */
263
263
  thinking: z.string().default('low'),
264
264
  /** Resolved architect system prompt text. Falls back to built-in default. */
@@ -826,6 +826,26 @@ async function listMetas(config, watcher) {
826
826
  };
827
827
  }
828
828
 
829
+ /**
830
+ * Escape special glob characters in a path so it can be used as a literal
831
+ * prefix in glob patterns.
832
+ *
833
+ * Glob metacharacters `* ? [ ] { } ( ) !` are escaped with a backslash so
834
+ * that paths containing parentheses (e.g. Slack channel IDs) or other
835
+ * special characters are matched literally by the watcher's walk endpoint.
836
+ *
837
+ * @module escapeGlob
838
+ */
839
+ /**
840
+ * Escape glob metacharacters in a string.
841
+ *
842
+ * @param s - Raw path string.
843
+ * @returns String with glob metacharacters backslash-escaped.
844
+ */
845
+ function escapeGlob(s) {
846
+ return s.replace(/[*?[\]{}()!]/g, '\\$&');
847
+ }
848
+
829
849
  /**
830
850
  * Filter file paths by modification time.
831
851
  *
@@ -926,7 +946,7 @@ function filterInScope(node, files) {
926
946
  */
927
947
  async function getScopeFiles(node, watcher, logger) {
928
948
  const walkStart = Date.now();
929
- const rawFiles = await watcher.walk([`${node.ownerPath}/**`]);
949
+ const rawFiles = await watcher.walk([`${escapeGlob(node.ownerPath)}/**`]);
930
950
  const allFiles = rawFiles.map(normalizePath);
931
951
  const scopeFiles = filterInScope(node, allFiles);
932
952
  logger?.debug({
@@ -1738,7 +1758,7 @@ async function buildMinimalNode(metaPath, watcher) {
1738
1758
  // We include only *direct* children (nearest descendants in the ownership tree)
1739
1759
  // to match the ownership semantics used elsewhere.
1740
1760
  const rawMetaJsonPaths = await watcher.walk([
1741
- `${ownerPath}/**/.meta/meta.json`,
1761
+ `${escapeGlob(ownerPath)}/**/.meta/meta.json`,
1742
1762
  ]);
1743
1763
  const candidateMetaPaths = [
1744
1764
  ...new Set(rawMetaJsonPaths.map((p) => normalizePath(dirname(p)))),
@@ -1874,7 +1894,7 @@ function discoverStalestPath(candidates, depthWeight) {
1874
1894
  async function isStale(scopePrefix, meta, watcher) {
1875
1895
  if (!meta._generatedAt)
1876
1896
  return true; // Never synthesized = stale
1877
- const files = await watcher.walk([`${scopePrefix}/**`]);
1897
+ const files = await watcher.walk([`${escapeGlob(scopePrefix)}/**`]);
1878
1898
  return hasModifiedAfter(files, new Date(meta._generatedAt).getTime());
1879
1899
  }
1880
1900
  /** Maximum staleness for never-synthesized metas (1 year in seconds). */
@@ -2143,6 +2163,25 @@ async function synthesizeNode(node, currentMeta, config, executor, watcher, onPr
2143
2163
  const steerChanged = hasSteerChanged(currentMeta._steer, latestArchive?._steer, Boolean(latestArchive));
2144
2164
  // Step 7: Compute context (includes scope files and delta files)
2145
2165
  const ctx = await buildContextPackage(node, currentMeta, watcher, logger);
2166
+ // Skip empty-scope entities that have no prior content.
2167
+ // Without scope files, child metas, or cross-refs there is nothing for
2168
+ // the architect/builder to work with and the cycle will either time out
2169
+ // or produce empty output.
2170
+ const hasScope = ctx.scopeFiles.length > 0 ||
2171
+ Object.keys(ctx.childMetas).length > 0 ||
2172
+ Object.keys(ctx.crossRefMetas).length > 0;
2173
+ if (!hasScope && !currentMeta._content) {
2174
+ // Bump _generatedAt so this entity doesn't keep winning the staleness
2175
+ // race every cycle. It will be re-evaluated when files appear.
2176
+ // Uses lock-staging for atomic write consistency.
2177
+ currentMeta._generatedAt = new Date().toISOString();
2178
+ const lockPath = join(node.metaPath, '.lock');
2179
+ const metaJsonPath = join(node.metaPath, 'meta.json');
2180
+ await writeFile(lockPath, JSON.stringify(currentMeta, null, 2));
2181
+ await copyFile(lockPath, metaJsonPath);
2182
+ logger?.debug({ path: node.ownerPath }, 'Skipping empty-scope entity');
2183
+ return { synthesized: false };
2184
+ }
2146
2185
  // Step 5 (deferred): Structure hash from context scope files
2147
2186
  const newStructureHash = computeStructureHash(ctx.scopeFiles);
2148
2187
  const structureChanged = newStructureHash !== currentMeta._structureHash;
@@ -2439,21 +2478,41 @@ function formatSeconds(durationMs) {
2439
2478
  function titleCasePhase(phase) {
2440
2479
  return phase.charAt(0).toUpperCase() + phase.slice(1);
2441
2480
  }
2442
- /** Build a link to the entity's meta.json output file. */
2443
- function buildEntityLink(path, serverBaseUrl) {
2444
- // Normalize backslashes, then convert drive letter to URL path segment
2481
+ /**
2482
+ * URL-encode each path segment individually so that spaces and special
2483
+ * characters are safe while preserving the `/` separators.
2484
+ */
2485
+ function encodePathSegments(p) {
2486
+ return p
2487
+ .split('/')
2488
+ .map((seg) => encodeURIComponent(seg))
2489
+ .join('/');
2490
+ }
2491
+ /** Build a link (or plain path) to the owner directory. */
2492
+ function buildDirectoryLink(path, serverBaseUrl) {
2493
+ const normalized = normalizePath(path).replace(/^([A-Za-z]):/, '/$1');
2494
+ const encoded = encodePathSegments(normalized);
2495
+ if (!serverBaseUrl)
2496
+ return normalized;
2497
+ const base = serverBaseUrl.replace(/\/+$/, '');
2498
+ return `${base}/path${encoded}`;
2499
+ }
2500
+ /** Build a link (or plain path) to the entity's meta.json output file. */
2501
+ function buildMetaJsonLink(path, serverBaseUrl) {
2445
2502
  const normalized = normalizePath(path).replace(/^([A-Za-z]):/, '/$1');
2446
2503
  const metaJsonPath = `${normalized}/.meta/meta.json`;
2504
+ const encoded = encodePathSegments(metaJsonPath);
2447
2505
  if (!serverBaseUrl)
2448
2506
  return metaJsonPath;
2449
2507
  const base = serverBaseUrl.replace(/\/+$/, '');
2450
- return `${base}/path${metaJsonPath}`;
2508
+ return `${base}/path${encoded}`;
2451
2509
  }
2452
2510
  function formatProgressEvent(event, serverBaseUrl) {
2453
- const pathDisplay = buildEntityLink(event.path, serverBaseUrl);
2454
2511
  switch (event.type) {
2455
- case 'synthesis_start':
2456
- return `🔬 Started meta synthesis: ${pathDisplay}`;
2512
+ case 'synthesis_start': {
2513
+ const dirLink = buildDirectoryLink(event.path, serverBaseUrl);
2514
+ return `🔬 Started meta synthesis: ${dirLink}`;
2515
+ }
2457
2516
  case 'phase_start': {
2458
2517
  if (!event.phase) {
2459
2518
  return ' ⚙️ Phase started';
@@ -2467,16 +2526,18 @@ function formatProgressEvent(event, serverBaseUrl) {
2467
2526
  return ` ✅ ${phase} complete (${formatNumber(tokens)} tokens / ${duration})`;
2468
2527
  }
2469
2528
  case 'synthesis_complete': {
2529
+ const metaLink = buildMetaJsonLink(event.path, serverBaseUrl);
2470
2530
  const tokens = event.tokens ?? 0;
2471
2531
  const duration = event.durationMs !== undefined
2472
2532
  ? formatSeconds(event.durationMs)
2473
2533
  : '0.0s';
2474
- return `✅ Completed: ${pathDisplay} (${formatNumber(tokens)} tokens / ${duration})`;
2534
+ return `✅ Completed: ${metaLink} (${formatNumber(tokens)} tokens / ${duration})`;
2475
2535
  }
2476
2536
  case 'error': {
2537
+ const dirLink = buildDirectoryLink(event.path, serverBaseUrl);
2477
2538
  const phase = event.phase ? `${titleCasePhase(event.phase)} ` : '';
2478
2539
  const error = event.error ?? 'Unknown error';
2479
- return `❌ Synthesis failed at ${phase}phase: ${pathDisplay}\n Error: ${error}`;
2540
+ return `❌ Synthesis failed at ${phase}phase: ${dirLink}\n Error: ${error}`;
2480
2541
  }
2481
2542
  default: {
2482
2543
  return 'Unknown progress event';
@@ -11070,6 +11131,11 @@ async function startService(config, configPath) {
11070
11131
  // orchestrate() always returns exactly one result
11071
11132
  const result = results[0];
11072
11133
  const durationMs = Date.now() - startMs;
11134
+ if (!result.synthesized) {
11135
+ // Entity was skipped (e.g. empty scope) — no progress to report.
11136
+ logger.debug({ path: ownerPath }, 'Synthesis skipped');
11137
+ return;
11138
+ }
11073
11139
  // Update stats
11074
11140
  stats.totalSyntheses++;
11075
11141
  stats.lastCycleDurationMs = durationMs;
@@ -11079,6 +11145,7 @@ async function startService(config, configPath) {
11079
11145
  await progress.report({
11080
11146
  type: 'error',
11081
11147
  path: ownerPath,
11148
+ phase: result.error.step,
11082
11149
  error: result.error.message,
11083
11150
  });
11084
11151
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@karmaniverous/jeeves-meta",
3
- "version": "0.10.1",
3
+ "version": "0.11.1",
4
4
  "author": "Jason Williscroft",
5
5
  "description": "Fastify HTTP service for the Jeeves Meta synthesis engine",
6
6
  "license": "BSD-3-Clause",