@moda-ai/cli 1.13.5 → 1.13.6

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.
@@ -0,0 +1,21 @@
1
+ import type { HarnessArtifactType, HarnessEvidence, HarnessGraph } from './harness.js';
2
+ export interface ArtifactBodyAnnotation {
3
+ values: Record<string, string[]>;
4
+ evidence: HarnessEvidence;
5
+ sourceAbsPath: string;
6
+ rootDir: string;
7
+ }
8
+ export interface ArtifactBodyFileItem {
9
+ path: string;
10
+ rootDir: string;
11
+ text?: string;
12
+ }
13
+ export declare function extractToolBodyFromText(text: string, toolName: string): string | undefined;
14
+ export declare function readPromptOrToolBody(type: HarnessArtifactType, sourcePath: string, annotation: ArtifactBodyAnnotation): string | undefined;
15
+ export declare function extractToolBody(file: ArtifactBodyFileItem, tool: {
16
+ name: string;
17
+ reason: string;
18
+ }): string | undefined;
19
+ export declare function hydratePromptAndToolBodies(graph: HarnessGraph, rootDir: string): void;
20
+ export declare function enrichScanCaptureMetadata(graph: HarnessGraph): void;
21
+ //# sourceMappingURL=harness-artifact-bodies.d.ts.map
@@ -0,0 +1,201 @@
1
+ import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs';
2
+ import { basename, dirname, isAbsolute, relative, resolve } from 'node:path';
3
+ import { artifactHasBody, firstEvidencePath, requiresArtifactBody, } from './harness-report.js';
4
+ const MAX_SCANNED_FILE_BYTES = 512 * 1024;
5
+ const SNAPSHOT_BUDGET_SKIP_REASONS = new Set([
6
+ 'file too large for harness scan',
7
+ 'scan text budget exceeded',
8
+ ]);
9
+ function first(values) {
10
+ return values?.[0];
11
+ }
12
+ function normalizePath(path) {
13
+ return path.replace(/\\/g, '/');
14
+ }
15
+ function isTextFile(path) {
16
+ return /\.(ts|tsx|js|jsx|mjs|cjs|py|md|json|ya?ml|toml|txt|sh|rb|go|rs|swift|kt|java|cs|php|sql|graphql|gql|proto|css|scss|less|html|vue|svelte)$/i.test(path);
17
+ }
18
+ function isSecretishPath(path) {
19
+ const normalized = normalizePath(path).toLowerCase();
20
+ return normalized.includes('.env') ||
21
+ normalized.endsWith('.pem') ||
22
+ normalized.endsWith('.key') ||
23
+ normalized.includes('/secrets/') ||
24
+ normalized.includes('/credentials/');
25
+ }
26
+ function isOutsideRoot(rel) {
27
+ return rel === '' || rel === '..' || rel.startsWith('../') || isAbsolute(rel);
28
+ }
29
+ function readBoundedRepoText(absPath, rootDir) {
30
+ if (!existsSync(absPath))
31
+ return undefined;
32
+ const rel = normalizePath(relative(rootDir, absPath));
33
+ // Containment: never read files that resolve outside the scan root. Guards against
34
+ // `..` traversal or absolute paths (e.g. via a crafted `path=` annotation / sourcePath /
35
+ // evidence path) being hydrated into synced artifact bodies.
36
+ if (isOutsideRoot(rel))
37
+ return undefined;
38
+ if (isSecretishPath(rel))
39
+ return undefined;
40
+ let stat;
41
+ try {
42
+ stat = lstatSync(absPath);
43
+ }
44
+ catch {
45
+ return undefined;
46
+ }
47
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_SCANNED_FILE_BYTES) {
48
+ return undefined;
49
+ }
50
+ if (!isTextFile(rel))
51
+ return undefined;
52
+ // Realpath containment: the lexical `rel` check is purely textual and `lstatSync` only
53
+ // inspects the final component, so an intermediate directory symlink (e.g.
54
+ // `tools/outside -> /tmp/public`) could still let `readFileSync` follow the link out of
55
+ // the repo. Canonicalize both sides and re-check before reading.
56
+ try {
57
+ if (isOutsideRoot(normalizePath(relative(realpathSync(rootDir), realpathSync(absPath))))) {
58
+ return undefined;
59
+ }
60
+ }
61
+ catch {
62
+ return undefined;
63
+ }
64
+ try {
65
+ return readFileSync(absPath, 'utf8');
66
+ }
67
+ catch {
68
+ return undefined;
69
+ }
70
+ }
71
+ function readRepoRelativeText(rootDir, relPath) {
72
+ return readBoundedRepoText(resolve(rootDir, relPath), rootDir);
73
+ }
74
+ function isDedicatedToolFilePath(path) {
75
+ return /(^|\/)tools?\//i.test(path) && /\.(ts|tsx|js|mjs|py)$/i.test(path);
76
+ }
77
+ function firstEvidenceLineNumber(evidence) {
78
+ if (!Array.isArray(evidence))
79
+ return undefined;
80
+ for (const entry of evidence) {
81
+ if (typeof entry.line === 'number' && Number.isFinite(entry.line) && entry.line > 0) {
82
+ return Math.floor(entry.line);
83
+ }
84
+ }
85
+ return undefined;
86
+ }
87
+ function extractSnippetAroundLine(text, line, radius) {
88
+ const lines = text.split(/\r?\n/);
89
+ const start = Math.max(0, line - 1 - radius);
90
+ // `line` is 1-indexed and slice's end is exclusive, so include through
91
+ // (line-1)+radius: end = line + radius. Using line-1+radius dropped the tail line.
92
+ const end = Math.min(lines.length, line + radius);
93
+ return lines.slice(start, end).join('\n');
94
+ }
95
+ export function extractToolBodyFromText(text, toolName) {
96
+ const lines = text.split(/\r?\n/);
97
+ const normalizedName = toolName.replace(/\s+/g, '').toLowerCase();
98
+ for (let index = 0; index < lines.length; index += 1) {
99
+ const line = lines[index];
100
+ const compact = line.replace(/\s+/g, '').toLowerCase();
101
+ const mentionsTool = compact.includes(normalizedName) || compact.includes(`def${normalizedName}`);
102
+ if (!mentionsTool)
103
+ continue;
104
+ if (line.includes('function_tool') ||
105
+ line.includes('tool(') ||
106
+ line.includes('createTool') ||
107
+ line.includes('functionTool') ||
108
+ line.includes('dynamicTool') ||
109
+ line.includes('new Tool') ||
110
+ line.includes('def ')) {
111
+ return extractSnippetAroundLine(text, index + 1, 30);
112
+ }
113
+ }
114
+ return undefined;
115
+ }
116
+ function readAnnotatedSourceText(sourcePath, annotation) {
117
+ const explicitSourcePath = normalizePath(sourcePath) !== normalizePath(annotation.evidence.path);
118
+ const candidates = explicitSourcePath ? [
119
+ resolve(annotation.rootDir, sourcePath),
120
+ resolve(dirname(annotation.sourceAbsPath), sourcePath),
121
+ ] : [
122
+ resolve(annotation.rootDir, sourcePath),
123
+ resolve(dirname(annotation.sourceAbsPath), sourcePath),
124
+ annotation.sourceAbsPath,
125
+ ];
126
+ const seen = new Set();
127
+ for (const candidate of candidates) {
128
+ if (seen.has(candidate))
129
+ continue;
130
+ seen.add(candidate);
131
+ const text = readBoundedRepoText(candidate, annotation.rootDir);
132
+ if (text !== undefined)
133
+ return text;
134
+ }
135
+ return undefined;
136
+ }
137
+ export function readPromptOrToolBody(type, sourcePath, annotation) {
138
+ if (!requiresArtifactBody(type))
139
+ return undefined;
140
+ const text = readAnnotatedSourceText(sourcePath, annotation);
141
+ if (text === undefined)
142
+ return undefined;
143
+ if (type === 'prompt' || isDedicatedToolFilePath(sourcePath))
144
+ return text;
145
+ return extractToolBodyFromText(text, first(annotation.values.name) ?? basename(sourcePath)) ?? text;
146
+ }
147
+ export function extractToolBody(file, tool) {
148
+ const text = file.text ?? readRepoRelativeText(file.rootDir, file.path);
149
+ if (!text)
150
+ return undefined;
151
+ if (isDedicatedToolFilePath(file.path))
152
+ return text;
153
+ return extractToolBodyFromText(text, tool.name);
154
+ }
155
+ function hydrateArtifactBody(artifact, rootDir) {
156
+ if (artifact.sourcePath) {
157
+ const wholeFile = readRepoRelativeText(rootDir, artifact.sourcePath);
158
+ if (wholeFile !== undefined) {
159
+ if (artifact.type === 'prompt' || isDedicatedToolFilePath(artifact.sourcePath)) {
160
+ return wholeFile;
161
+ }
162
+ if (artifact.type === 'tool') {
163
+ return extractToolBodyFromText(wholeFile, artifact.name) ?? wholeFile;
164
+ }
165
+ }
166
+ }
167
+ const evidenceLine = firstEvidenceLineNumber(artifact.evidence);
168
+ const evidencePath = firstEvidencePath(artifact);
169
+ if (evidencePath && evidenceLine !== undefined) {
170
+ const text = readRepoRelativeText(rootDir, evidencePath);
171
+ if (text !== undefined) {
172
+ return extractSnippetAroundLine(text, evidenceLine, artifact.type === 'tool' ? 30 : 80);
173
+ }
174
+ }
175
+ return undefined;
176
+ }
177
+ export function hydratePromptAndToolBodies(graph, rootDir) {
178
+ for (const artifact of graph.artifacts) {
179
+ if (!requiresArtifactBody(artifact.type) || artifactHasBody(artifact)) {
180
+ continue;
181
+ }
182
+ const hydrated = hydrateArtifactBody(artifact, rootDir);
183
+ if (hydrated)
184
+ artifact.body = hydrated;
185
+ }
186
+ }
187
+ function countIncompletePromptToolBodies(graph) {
188
+ return graph.artifacts.filter((artifact) => requiresArtifactBody(artifact.type) &&
189
+ !artifactHasBody(artifact)).length;
190
+ }
191
+ export function enrichScanCaptureMetadata(graph) {
192
+ const budgetSkips = graph.scan.skipped
193
+ .map((skip) => skip.reason)
194
+ .filter((reason) => SNAPSHOT_BUDGET_SKIP_REASONS.has(reason));
195
+ const truncationReasons = [...new Set(budgetSkips)];
196
+ graph.scan.truncated = truncationReasons.length > 0;
197
+ graph.scan.truncationReasons = truncationReasons.length > 0 ? truncationReasons : undefined;
198
+ const incomplete = countIncompletePromptToolBodies(graph);
199
+ graph.scan.incompleteArtifactCount = incomplete > 0 ? incomplete : undefined;
200
+ }
201
+ //# sourceMappingURL=harness-artifact-bodies.js.map
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { resolveApiKey, resolveIngestUrl } from './config.js';
11
11
  import { CliInputError } from './errors.js';
12
- import { normalizeHarnessReport, validateHarnessReport, writeHarnessReportArtifacts, } from './harness-report.js';
12
+ import { normalizeHarnessReport, stampRemoteSnapshotTruncation, validateHarnessReport, writeHarnessReportArtifacts, } from './harness-report.js';
13
13
  import { buildSourceSnapshot, emitWrittenArtifacts, numericEnv, numericEnvAllowZero, repairExternalAnalystReport, sleep, summarizeHarness, summarizeInvalidAnalystReport, truncateText, writeHarnessAnalyzeResult, writeHarnessArtifacts, writeHumanProgress, } from './harness.js';
14
14
  const DEFAULT_REMOTE_ANALYZE_TIMEOUT_MS = 900_000;
15
15
  const DEFAULT_REMOTE_POLL_INITIAL_MS = 3_000;
@@ -75,6 +75,15 @@ export async function runRemoteHarnessAnalyst(options) {
75
75
  elapsed_ms: Date.now() - context.startedAt,
76
76
  });
77
77
  writeHumanProgress(context, `Moda Analyst (remote): uploading ${snapshot.fileCount} file(s) for server-side analysis...`);
78
+ if (snapshot.truncated) {
79
+ context.output.writeEvent({
80
+ event: 'warning',
81
+ phase: 'remote_upload',
82
+ message: 'Source snapshot is truncated; server-side analysis may be incomplete.',
83
+ elapsed_ms: Date.now() - context.startedAt,
84
+ });
85
+ writeHumanProgress(context, 'Warning: source snapshot is truncated — analysis may be incomplete.');
86
+ }
78
87
  const accepted = await postRemoteAnalyze(ingestUrl, apiKey, {
79
88
  snapshot,
80
89
  options: buildRemoteAnalyzeOptions(context.flags),
@@ -98,7 +107,10 @@ export async function runRemoteHarnessAnalyst(options) {
98
107
  'Increase MODA_HARNESS_REMOTE_TIMEOUT_MS if large repos need longer.',
99
108
  ].join('\n'));
100
109
  }
101
- const report = repairExternalAnalystReport(normalizeHarnessReport(final.report), rootDir);
110
+ let report = repairExternalAnalystReport(normalizeHarnessReport(final.report), rootDir);
111
+ if (snapshot.truncated) {
112
+ report = stampRemoteSnapshotTruncation(report);
113
+ }
102
114
  const validation = validateHarnessReport(report, rootDir);
103
115
  context.output.writeEvent({
104
116
  event: 'progress',
@@ -123,6 +123,13 @@ export declare function loadHarnessReportForSync(rootDir: string, reportPath: st
123
123
  };
124
124
  export declare function readHarnessReport(reportPath: string): HarnessReport;
125
125
  export declare function normalizeHarnessReport(value: unknown): HarnessReport;
126
+ /** prompt/tool artifacts must carry full usable content for downstream optimization. */
127
+ export declare function requiresArtifactBody(type: unknown): boolean;
128
+ export declare function artifactHasBody(item: Record<string, unknown>): boolean;
129
+ /** Canonical evidence path for graph items: prefer explicit sourcePath, then evidence. */
130
+ export declare function firstEvidencePath(item: Record<string, unknown>): string | undefined;
131
+ /** Stamp remote snapshot truncation onto a validated report graph scan metadata. */
132
+ export declare function stampRemoteSnapshotTruncation(report: HarnessReport): HarnessReport;
126
133
  export declare function resolveHarnessReportPath(rootDir: string, value?: string): string;
127
134
  export declare function writeHarnessReportArtifacts(options: {
128
135
  rootDir: string;
@@ -320,6 +320,7 @@ export function validateHarnessReport(report, rootDir) {
320
320
  check(`graph_${field}_array`, Array.isArray(report.graph[field]), `Graph field ${field} must be an array.`);
321
321
  }
322
322
  validateGraphItemShapes(report.graph, errors);
323
+ validateCaptureQuality(report.graph, warn);
323
324
  }
324
325
  if (report?.summary && typeof report.summary === 'object') {
325
326
  for (const field of HARNESS_REPORT_SUMMARY_FIELDS) {
@@ -811,8 +812,18 @@ function validateGraphCollection(graph, errors, field, shape) {
811
812
  if (field === 'artifacts' && requiresArtifactEvidenceLine(item.type) && !artifactHasRetrievableLocation(item)) {
812
813
  errors.push(`Graph ${label} is a ${String(item.type)} artifact and must include a numeric evidence line (or a dedicated whole-file source) so its exact text can be retrieved.`);
813
814
  }
815
+ if (field === 'artifacts' && requiresArtifactBody(item.type) && !artifactHasBody(item)) {
816
+ errors.push(`Graph ${label} is a ${String(item.type)} artifact and must include a non-empty string body with its usable content.`);
817
+ }
814
818
  }
815
819
  }
820
+ /** prompt/tool artifacts must carry full usable content for downstream optimization. */
821
+ export function requiresArtifactBody(type) {
822
+ return type === 'prompt' || type === 'tool';
823
+ }
824
+ export function artifactHasBody(item) {
825
+ return typeof item.body === 'string' && item.body.trim().length > 0;
826
+ }
816
827
  /** prompt/tool artifacts must pin an exact source location for downstream retrieval. */
817
828
  function requiresArtifactEvidenceLine(type) {
818
829
  return type === 'prompt' || type === 'tool';
@@ -828,6 +839,20 @@ function artifactHasRetrievableLocation(item) {
828
839
  return true;
829
840
  return isWholeFileArtifact(item);
830
841
  }
842
+ function validateCaptureQuality(graph, warn) {
843
+ const scan = graph.scan;
844
+ if (!isRecord(scan))
845
+ return;
846
+ if (scan.truncated === true) {
847
+ const reasons = Array.isArray(scan.truncationReasons) && scan.truncationReasons.length > 0
848
+ ? scan.truncationReasons.map((reason) => String(reason)).join(', ')
849
+ : 'scan budget or upload cap reached';
850
+ warn('scan_truncated', `Harness scan is partial (truncated): ${reasons}. Re-run with --path=<agent-dir> or review skipped files in the report.`);
851
+ }
852
+ if (typeof scan.incompleteArtifactCount === 'number' && scan.incompleteArtifactCount > 0) {
853
+ warn('artifacts_missing_body', `${scan.incompleteArtifactCount} prompt/tool artifact(s) are still missing captured body content after hydration.`);
854
+ }
855
+ }
831
856
  function isWholeFileArtifact(item) {
832
857
  if (typeof item.sourcePath !== 'string' || item.sourcePath.length === 0)
833
858
  return false;
@@ -884,7 +909,10 @@ function normalizeGraphItemFieldAliases(collection, item) {
884
909
  }
885
910
  }
886
911
  }
887
- function firstEvidencePath(item) {
912
+ /** Canonical evidence path for graph items: prefer explicit sourcePath, then evidence. */
913
+ export function firstEvidencePath(item) {
914
+ if (typeof item.sourcePath === 'string' && item.sourcePath.length > 0)
915
+ return item.sourcePath;
888
916
  if (!Array.isArray(item.evidence))
889
917
  return undefined;
890
918
  for (const evidence of item.evidence) {
@@ -894,6 +922,27 @@ function firstEvidencePath(item) {
894
922
  }
895
923
  return undefined;
896
924
  }
925
+ /** Stamp remote snapshot truncation onto a validated report graph scan metadata. */
926
+ export function stampRemoteSnapshotTruncation(report) {
927
+ const scanRecord = isRecord(report.graph.scan) ? report.graph.scan : {};
928
+ const truncationReasons = Array.isArray(scanRecord.truncationReasons)
929
+ ? scanRecord.truncationReasons.map(String)
930
+ : [];
931
+ return {
932
+ ...report,
933
+ graph: {
934
+ ...report.graph,
935
+ scan: {
936
+ ...report.graph.scan,
937
+ truncated: true,
938
+ truncationReasons: [
939
+ ...truncationReasons,
940
+ 'remote source snapshot truncated before upload',
941
+ ],
942
+ },
943
+ },
944
+ };
945
+ }
897
946
  function requiresGraphItemConfidence(collection) {
898
947
  return collection !== 'relationships' && collection !== 'unknowns';
899
948
  }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Maximum serialized harness graph size accepted by the backend sync endpoint.
3
+ * Must match `MAX_HARNESS_SYNC_GRAPH_BYTES` in
4
+ * `apps/moda-backend/src/harnesses/harness-sync-limits.ts`.
5
+ */
6
+ export declare const MAX_HARNESS_SYNC_GRAPH_BYTES = 2000000;
7
+ export declare function assertHarnessGraphSyncSize(graph: unknown): void;
8
+ //# sourceMappingURL=harness-sync-limits.d.ts.map
@@ -0,0 +1,21 @@
1
+ import { CliInputError } from './errors.js';
2
+ /**
3
+ * Maximum serialized harness graph size accepted by the backend sync endpoint.
4
+ * Must match `MAX_HARNESS_SYNC_GRAPH_BYTES` in
5
+ * `apps/moda-backend/src/harnesses/harness-sync-limits.ts`.
6
+ */
7
+ export const MAX_HARNESS_SYNC_GRAPH_BYTES = 2_000_000;
8
+ export function assertHarnessGraphSyncSize(graph) {
9
+ if (!graph || typeof graph !== 'object' || Array.isArray(graph)) {
10
+ throw new CliInputError('Harness graph must be an object before sync.');
11
+ }
12
+ const graphBytes = Buffer.byteLength(JSON.stringify(graph), 'utf8');
13
+ if (graphBytes > MAX_HARNESS_SYNC_GRAPH_BYTES) {
14
+ throw new CliInputError(`Harness graph is too large to sync (${graphBytes.toLocaleString()} bytes; limit ${MAX_HARNESS_SYNC_GRAPH_BYTES.toLocaleString()}).`, [
15
+ 'Run `moda harness analyze --path=<agent-dir>` to narrow the harness root.',
16
+ 'Reduce captured prompt/tool bodies if the graph grew too large.',
17
+ 'Split multiple agent roots into separate harness syncs per tenant.',
18
+ ].join('\n'));
19
+ }
20
+ }
21
+ //# sourceMappingURL=harness-sync-limits.js.map
package/dist/harness.d.ts CHANGED
@@ -32,6 +32,12 @@ export interface HarnessScanMetadata {
32
32
  path: string;
33
33
  reason: string;
34
34
  }>;
35
+ /** True when the scan dropped files due to size/budget caps or snapshot limits. */
36
+ truncated?: boolean;
37
+ /** Human-readable reasons the scan is partial (budget skips, upload cap, etc.). */
38
+ truncationReasons?: string[];
39
+ /** Prompt/tool artifacts still missing a captured body after hydration. */
40
+ incompleteArtifactCount?: number;
35
41
  }
36
42
  export interface HarnessScanReadPlan {
37
43
  scope: 'broad' | 'narrow';
package/dist/harness.js CHANGED
@@ -10,7 +10,9 @@ import { resolveApiKey, resolveTenantId } from './config.js';
10
10
  import { CliInputError, CliInputRequiredError } from './errors.js';
11
11
  import { isRemoteHarnessAnalyze, runRemoteHarnessAnalyst } from './harness-remote.js';
12
12
  import { getActiveProfile } from './profiles.js';
13
+ import { enrichScanCaptureMetadata, extractToolBody, hydratePromptAndToolBodies, readPromptOrToolBody, } from './harness-artifact-bodies.js';
13
14
  import { approveHarnessReport, createHarnessReport, graphForReport, hashHarnessReport, hashStableJson as hashHarnessReportStableJson, HARNESS_REPORT_PATH, loadHarnessReportForSync, normalizeHarnessReport, readHarnessReport, resolveHarnessReportPath, validateHarnessReport, writeHarnessReportArtifacts, } from './harness-report.js';
15
+ import { assertHarnessGraphSyncSize } from './harness-sync-limits.js';
14
16
  import { renderHarnessAnalystChoice, renderHarnessAnalystLaunch, renderHarnessAnalystPreflight, renderHarnessAnalyzeHumanResult, renderHarnessProgress, } from './tui.js';
15
17
  const MODA_DIR = '.moda';
16
18
  const HARNESS_PATH = join(MODA_DIR, 'harness.json');
@@ -1607,6 +1609,8 @@ export function scanHarness(options = {}) {
1607
1609
  applyHeuristics(graph, files);
1608
1610
  linkFrameworksAndProviders(graph);
1609
1611
  finalizeGraph(graph);
1612
+ hydratePromptAndToolBodies(graph, rootDir);
1613
+ enrichScanCaptureMetadata(graph);
1610
1614
  return graph;
1611
1615
  }
1612
1616
  export function summarizeHarness(graph) {
@@ -1864,6 +1868,7 @@ function plannedDryRunRemote(plan) {
1864
1868
  };
1865
1869
  }
1866
1870
  async function syncHarnessWithRemote(context, body) {
1871
+ assertHarnessGraphSyncSize(body.graph);
1867
1872
  const profileOptions = { profile: context.profile, env: context.env };
1868
1873
  const hasApiKey = Boolean(resolveApiKey(profileOptions));
1869
1874
  if (context.dryRun && !hasApiKey)
@@ -2436,6 +2441,7 @@ function artifactFromAnnotation(annotation) {
2436
2441
  const type = artifactType(first(values.kind) ?? first(values.type));
2437
2442
  const sourcePath = first(values.path) ?? annotation.evidence.path;
2438
2443
  const id = first(values.id) ?? stableId(type, first(values.name) ?? sourcePath);
2444
+ const body = readPromptOrToolBody(type, sourcePath, annotation);
2439
2445
  return {
2440
2446
  id,
2441
2447
  type,
@@ -2443,6 +2449,7 @@ function artifactFromAnnotation(annotation) {
2443
2449
  scope: scopeValue(first(values.scope)),
2444
2450
  sourcePath,
2445
2451
  contentHash: contentHashForAnnotatedSource(sourcePath, annotation),
2452
+ body,
2446
2453
  ownedByAgentId: first(values.owner),
2447
2454
  usedByAgentIds: values.agent ?? [],
2448
2455
  confidence: numberValue(first(values.confidence), 0.85),
@@ -2811,12 +2818,14 @@ function maybeAddToolArtifact(graph, file) {
2811
2818
  if (tools.length === 0)
2812
2819
  return;
2813
2820
  for (const tool of tools) {
2821
+ const body = extractToolBody(file, tool);
2814
2822
  upsertArtifact(graph, {
2815
2823
  id: stableId('tool', `${file.path}:${tool.name}`),
2816
2824
  type: 'tool',
2817
2825
  name: titleFromId(tool.name),
2818
2826
  scope: 'agent',
2819
2827
  sourcePath: file.path,
2828
+ body,
2820
2829
  usedByAgentIds: [],
2821
2830
  confidence: 0.6,
2822
2831
  evidence: [{ path: file.path, reason: `Tool definition detected (${tool.reason}).` }],
@@ -3216,6 +3225,7 @@ function finalizeGraph(graph) {
3216
3225
  type: artifactTypeFromId(artifactId),
3217
3226
  name: titleFromId(artifactId),
3218
3227
  scope: 'agent',
3228
+ sourcePath: agent.evidence[0]?.path,
3219
3229
  usedByAgentIds: [agent.id],
3220
3230
  confidence: 0.45,
3221
3231
  evidence: agent.evidence,
@@ -3375,6 +3385,8 @@ function upsertArtifact(graph, next) {
3375
3385
  existing.ownedByAgentId = existing.ownedByAgentId ?? next.ownedByAgentId;
3376
3386
  existing.contentHash = existing.contentHash ?? next.contentHash;
3377
3387
  existing.sourcePath = existing.sourcePath ?? next.sourcePath;
3388
+ if (!existing.body && next.body)
3389
+ existing.body = next.body;
3378
3390
  if (existing.scope !== 'shared')
3379
3391
  existing.scope = next.scope;
3380
3392
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moda-ai/cli",
3
- "version": "1.13.5",
3
+ "version": "1.13.6",
4
4
  "description": "CLI for Moda - AI agent analytics and observability",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema_version": "moda.skill_index.v1",
3
- "bundled_at": "2026-07-06T22:15:52.641Z",
4
- "cli_version": "1.13.5",
3
+ "bundled_at": "2026-07-07T22:20:09.380Z",
4
+ "cli_version": "1.13.6",
5
5
  "skills": [
6
6
  {
7
7
  "id": "integration-node-anthropic",