@kaelio/ktx 0.6.0 → 0.8.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.
Files changed (82) hide show
  1. package/assets/python/{kaelio_ktx-0.6.0-py3-none-any.whl → kaelio_ktx-0.8.0-py3-none-any.whl} +0 -0
  2. package/assets/python/manifest.json +4 -4
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/cli-program.js +7 -0
  5. package/dist/command-schemas.d.ts +1 -1
  6. package/dist/command-tree.js +5 -1
  7. package/dist/commands/completion-commands.d.ts +3 -0
  8. package/dist/commands/completion-commands.js +38 -0
  9. package/dist/commands/ingest-commands.js +0 -4
  10. package/dist/commands/knowledge-commands.js +15 -2
  11. package/dist/commands/setup-commands.js +2 -2
  12. package/dist/commands/sl-commands.js +19 -7
  13. package/dist/completion/complete-engine.d.ts +19 -0
  14. package/dist/completion/complete-engine.js +128 -0
  15. package/dist/completion/completion-scripts.d.ts +1 -0
  16. package/dist/completion/completion-scripts.js +36 -0
  17. package/dist/completion/dynamic-candidates.d.ts +6 -0
  18. package/dist/completion/dynamic-candidates.js +98 -0
  19. package/dist/connection-drivers.d.ts +3 -0
  20. package/dist/connection-drivers.js +17 -0
  21. package/dist/context/ingest/ingest-bundle.runner.d.ts +8 -0
  22. package/dist/context/ingest/ingest-bundle.runner.js +72 -15
  23. package/dist/context/ingest/ingest-profile.d.ts +102 -0
  24. package/dist/context/ingest/ingest-profile.js +306 -0
  25. package/dist/context/ingest/isolated-diff/work-unit-executor.js +25 -2
  26. package/dist/context/ingest/local-bundle-runtime.js +1 -0
  27. package/dist/context/ingest/local-ingest.d.ts +1 -1
  28. package/dist/context/ingest/local-ingest.js +6 -4
  29. package/dist/context/ingest/memory-flow/events.js +2 -1
  30. package/dist/context/ingest/ports.d.ts +2 -0
  31. package/dist/context/ingest/reports.d.ts +3 -0
  32. package/dist/context/ingest/reports.js +10 -0
  33. package/dist/context/ingest/stages/stage-3-work-units.d.ts +3 -1
  34. package/dist/context/ingest/stages/stage-3-work-units.js +2 -0
  35. package/dist/context/ingest/stages/stage-4-reconciliation.d.ts +2 -1
  36. package/dist/context/ingest/stages/stage-4-reconciliation.js +1 -1
  37. package/dist/context/ingest/tools/tool-call-logger.d.ts +6 -0
  38. package/dist/context/ingest/tools/tool-call-logger.js +36 -1
  39. package/dist/context/llm/ai-sdk-runtime.js +32 -3
  40. package/dist/context/llm/claude-code-runtime.js +51 -3
  41. package/dist/context/llm/runtime-port.d.ts +25 -0
  42. package/dist/context/mcp/context-tools.d.ts +2 -1
  43. package/dist/context/mcp/context-tools.js +82 -15
  44. package/dist/context/mcp/server.js +4 -0
  45. package/dist/context/mcp/types.d.ts +15 -1
  46. package/dist/context/project/config.d.ts +1 -0
  47. package/dist/context/project/config.js +4 -0
  48. package/dist/context/project/driver-schemas.js +1 -1
  49. package/dist/context/search/discover.js +4 -3
  50. package/dist/context/sl/local-sl.d.ts +15 -0
  51. package/dist/context/sl/local-sl.js +30 -0
  52. package/dist/context/wiki/local-knowledge.d.ts +10 -0
  53. package/dist/context/wiki/local-knowledge.js +22 -0
  54. package/dist/context-build-view.d.ts +0 -3
  55. package/dist/context-build-view.js +1 -7
  56. package/dist/ingest.js +7 -10
  57. package/dist/knowledge.d.ts +5 -0
  58. package/dist/knowledge.js +10 -1
  59. package/dist/public-ingest-copy.js +1 -1
  60. package/dist/public-ingest.d.ts +0 -7
  61. package/dist/public-ingest.js +20 -34
  62. package/dist/setup-context.js +6 -38
  63. package/dist/setup-databases.js +13 -82
  64. package/dist/setup-project.d.ts +0 -8
  65. package/dist/setup-project.js +3 -27
  66. package/dist/setup-sources.js +33 -5
  67. package/dist/setup.js +3 -16
  68. package/dist/skills/analytics/SKILL.md +6 -1
  69. package/dist/sl.d.ts +6 -1
  70. package/dist/sl.js +32 -8
  71. package/dist/telemetry/emitter.js +1 -1
  72. package/dist/telemetry/events.d.ts +4 -3
  73. package/dist/telemetry/events.js +7 -3
  74. package/dist/telemetry/identity.d.ts +1 -1
  75. package/dist/telemetry/identity.js +13 -10
  76. package/dist/telemetry/index.d.ts +1 -1
  77. package/dist/telemetry/index.js +5 -1
  78. package/package.json +22 -22
  79. package/dist/ingest-depth.d.ts +0 -8
  80. package/dist/ingest-depth.js +0 -56
  81. package/dist/setup-database-context-depth.d.ts +0 -23
  82. package/dist/setup-database-context-depth.js +0 -84
@@ -13,6 +13,7 @@ import { selectRelevantCanonicalPins } from './canonical-pins.js';
13
13
  import { finalGateRepairPaths, repairFinalGateFailure } from './final-gate-repair.js';
14
14
  import { compareFinalizationDeclarations, deriveFinalizationTouchedSources, deriveFinalizationWikiPageKeys, } from './finalization-scope.js';
15
15
  import { FileIngestTraceWriter, ingestTracePathForJob, traceTimed } from './ingest-trace.js';
16
+ import { formatIngestProfile, formatIngestProfileJson, readIngestProfile, resolveIngestProfileMode } from './ingest-profile.js';
16
17
  import { integrateWorkUnitPatch } from './isolated-diff/patch-integrator.js';
17
18
  import { resolveTextualConflict } from './isolated-diff/textual-conflict-resolver.js';
18
19
  import { runIsolatedWorkUnit } from './isolated-diff/work-unit-executor.js';
@@ -34,7 +35,7 @@ import { createEvictionListTool } from './tools/eviction-list.tool.js';
34
35
  import { createReadRawSpanTool } from './tools/read-raw-span.tool.js';
35
36
  import { createStageDiffTool } from './tools/stage-diff.tool.js';
36
37
  import { createStageListTool } from './tools/stage-list.tool.js';
37
- import { wrapToolsWithLogger } from './tools/tool-call-logger.js';
38
+ import { flushToolCallLogs, wrapToolsWithLogger } from './tools/tool-call-logger.js';
38
39
  import { createMutableToolTranscriptSummary, recordToolTranscriptEntry, } from './tools/tool-transcript-summary.js';
39
40
  import { repairWikiSlRefs } from './wiki-sl-ref-repair.js';
40
41
  async function copyTransientIngestEvidence(sourceWorkdir, targetWorkdir) {
@@ -137,6 +138,36 @@ export class IngestBundleRunner {
137
138
  ctx?.memoryFlow?.finish('error', [sanitizeMemoryFlowError(error)]);
138
139
  throw error;
139
140
  }
141
+ finally {
142
+ await this.maybeEmitIngestProfile(job.jobId);
143
+ }
144
+ }
145
+ /**
146
+ * When profiling is enabled — via the `KTX_PROFILE_INGEST` env var or the
147
+ * `ingest.profile` config setting — read the job's trace + tool transcripts
148
+ * and print a rolled-up timing breakdown to stderr. `json` emits the raw
149
+ * structured profile for coding agents; `table` emits a human summary.
150
+ * Best-effort: profiling never affects the run outcome.
151
+ */
152
+ async maybeEmitIngestProfile(jobId) {
153
+ const mode = resolveIngestProfileMode(this.deps.settings.profileIngest);
154
+ if (mode === 'off') {
155
+ return;
156
+ }
157
+ try {
158
+ // Tool transcripts are appended fire-and-forget; flush them so per-work-unit
159
+ // toolMs (and the derived model-vs-tool split) is complete before we read.
160
+ await flushToolCallLogs();
161
+ const storage = this.deps.storage;
162
+ const profile = await readIngestProfile(jobId, {
163
+ tracePath: storage.resolveTracePath?.(jobId) ?? ingestTracePathForJob(this.deps.storage.homeDir, jobId),
164
+ transcriptDir: this.deps.storage.resolveTranscriptDir(jobId),
165
+ });
166
+ process.stderr.write(`\n${mode === 'json' ? formatIngestProfileJson(profile) : formatIngestProfile(profile)}`);
167
+ }
168
+ catch (error) {
169
+ this.logger.warn(`[ingest-bundle] ingest profile unavailable for job=${jobId}: ${error instanceof Error ? error.message : String(error)}`);
170
+ }
140
171
  }
141
172
  stageRawFilesStage1 = stageRawFilesStage1;
142
173
  async syncKnowledgeSlRefsFromActions(connectionId, actions) {
@@ -789,7 +820,7 @@ export class IngestBundleRunner {
789
820
  }));
790
821
  const fetchReport = adapter.readFetchReport ? await adapter.readFetchReport(stagedDir) : null;
791
822
  const scopeDescriptor = adapter.describeScope ? await adapter.describeScope(stagedDir) : null;
792
- const sessionWorktree = await this.deps.lockingService.withLock('config:repo', () => this.deps.sessionWorktreeService.create(job.jobId, baseSha));
823
+ const sessionWorktree = await traceTimed(trace, 'worktree', 'session_worktree_created', { jobId: job.jobId }, () => this.deps.lockingService.withLock('config:repo', () => this.deps.sessionWorktreeService.create(job.jobId, baseSha)));
793
824
  let cleanupOutcome = 'crash';
794
825
  try {
795
826
  activePhase = 'stage_raw_files';
@@ -916,26 +947,26 @@ export class IngestBundleRunner {
916
947
  sourceContextReport = chunk.contextReport;
917
948
  parseArtifacts = chunk.parseArtifacts;
918
949
  reconcileNotes = chunk.reconcileNotes ?? [];
950
+ const pageTriage = this.deps.pageTriage;
951
+ const triageRunId = runRow.id;
919
952
  triageResult =
920
- contextReport && adapter.triageSupported && this.deps.pageTriage
921
- ? await this.deps.pageTriage.triageRun({
953
+ contextReport && adapter.triageSupported && pageTriage
954
+ ? await traceTimed(runTrace, 'triage', 'page_triage', { sourceKey: job.sourceKey }, () => pageTriage.triageRun({
922
955
  stagedDir,
923
- runId: runRow.id,
956
+ runId: triageRunId,
924
957
  connectionId: job.connectionId,
925
958
  sourceKey: job.sourceKey,
926
959
  syncId,
927
960
  jobId: job.jobId,
928
961
  diffSet,
929
962
  adapter,
930
- })
963
+ }))
931
964
  : null;
932
965
  workUnits = this.filterWorkUnitsForTriage(workUnits, triageResult);
933
- if (adapter.clusterWorkUnits && workUnits.length > 0) {
934
- workUnits = await adapter.clusterWorkUnits({
935
- workUnits,
936
- stagedDir,
937
- embedding: this.deps.embedding,
938
- });
966
+ const clusterWorkUnits = adapter.clusterWorkUnits;
967
+ if (clusterWorkUnits && workUnits.length > 0) {
968
+ const preClusterCount = workUnits.length;
969
+ workUnits = await traceTimed(runTrace, 'clustering', 'cluster_work_units', { workUnitCount: preClusterCount }, () => clusterWorkUnits({ workUnits, stagedDir, embedding: this.deps.embedding }));
939
970
  }
940
971
  await stage2?.updateProgress(1.0, `Planned ${workUnits.length} update${workUnits.length === 1 ? '' : 's'}`);
941
972
  }
@@ -968,7 +999,7 @@ export class IngestBundleRunner {
968
999
  connectionIds: slConnectionIds,
969
1000
  });
970
1001
  // Build shared per-job context.
971
- const [wikiIndex, slIndex] = await Promise.all([this.buildWikiIndex(), this.buildSlIndex(slConnectionIds)]);
1002
+ const [wikiIndex, slIndex] = await traceTimed(runTrace, 'index_build', 'build_indexes', { connectionCount: slConnectionIds.length }, () => Promise.all([this.buildWikiIndex(), this.buildSlIndex(slConnectionIds)]));
972
1003
  const baseFraming = await this.deps.promptService.loadPrompt('memory_agent_bundle_ingest_work_unit');
973
1004
  const wuSkillNames = Array.from(new Set([...adapter.skillNames, 'ingest_triage', 'sl_capture', 'wiki_capture']));
974
1005
  const wuSkills = await this.deps.skillsRegistry.listSkills(wuSkillNames, 'memory_agent');
@@ -1420,6 +1451,8 @@ export class IngestBundleRunner {
1420
1451
  let curatorReport = null;
1421
1452
  let curatorWarnings = [];
1422
1453
  let reconcileOutcome;
1454
+ const reconcileStartedAt = Date.now();
1455
+ const reconcileMode = contextReport && this.deps.curatorPagination ? 'curator' : 'single';
1423
1456
  if (contextReport && this.deps.curatorPagination) {
1424
1457
  const curatorOutcome = await this.deps.curatorPagination.reconcile({
1425
1458
  runId: runRow.id,
@@ -1506,6 +1539,26 @@ export class IngestBundleRunner {
1506
1539
  : undefined,
1507
1540
  });
1508
1541
  }
1542
+ await runTrace.event('debug', 'reconciliation', 'reconciliation_executed', {
1543
+ mode: reconcileMode,
1544
+ skipped: reconcileOutcome.skipped,
1545
+ ...(reconcileOutcome.stopReason ? { stopReason: reconcileOutcome.stopReason } : {}),
1546
+ ...(reconcileOutcome.metrics
1547
+ ? {
1548
+ agentLoopMs: reconcileOutcome.metrics.totalMs,
1549
+ stepCount: reconcileOutcome.metrics.stepCount,
1550
+ ...(reconcileOutcome.metrics.usage.inputTokens !== undefined
1551
+ ? { inputTokens: reconcileOutcome.metrics.usage.inputTokens }
1552
+ : {}),
1553
+ ...(reconcileOutcome.metrics.usage.outputTokens !== undefined
1554
+ ? { outputTokens: reconcileOutcome.metrics.usage.outputTokens }
1555
+ : {}),
1556
+ ...(reconcileOutcome.metrics.usage.totalTokens !== undefined
1557
+ ? { totalTokens: reconcileOutcome.metrics.usage.totalTokens }
1558
+ : {}),
1559
+ }
1560
+ : {}),
1561
+ }, undefined, Date.now() - reconcileStartedAt);
1509
1562
  latestReconciliationSkipped = reconcileOutcome.skipped;
1510
1563
  const danglingReconcileWikiRefs = await findDanglingWikiRefsForActions({
1511
1564
  wikiService: rcScopedWiki,
@@ -1544,6 +1597,7 @@ export class IngestBundleRunner {
1544
1597
  activePhase = 'finalization';
1545
1598
  if (adapter.finalize) {
1546
1599
  const stageFinalization = ctx?.startPhase(0.04);
1600
+ const finalizationStartedAt = Date.now();
1547
1601
  emitStageProgress('finalization', 87, 'Running deterministic finalization');
1548
1602
  await stageFinalization?.updateProgress(0.0, 'Running deterministic finalization');
1549
1603
  await runTrace.event('debug', 'finalization', 'finalization_started', { sourceKey: job.sourceKey });
@@ -1706,7 +1760,7 @@ export class IngestBundleRunner {
1706
1760
  touchedSources: finalizationTouchedSources,
1707
1761
  changedWikiPageKeys: finalizationChangedWikiPageKeys,
1708
1762
  warnings: result.warnings,
1709
- });
1763
+ }, undefined, Date.now() - finalizationStartedAt);
1710
1764
  }
1711
1765
  else {
1712
1766
  await runTrace.event('debug', 'finalization', 'finalization_skipped', { sourceKey: job.sourceKey });
@@ -1933,6 +1987,7 @@ export class IngestBundleRunner {
1933
1987
  const stage6 = ctx?.startPhase(0.04);
1934
1988
  emitStageProgress('save', 91, 'Saving changes');
1935
1989
  await stage6?.updateProgress(0.0, 'Saving changes');
1990
+ const squashStartedAt = Date.now();
1936
1991
  try {
1937
1992
  await sessionWorktree.git.assertWorktreeClean();
1938
1993
  }
@@ -1955,7 +2010,7 @@ export class IngestBundleRunner {
1955
2010
  await runTrace.event('debug', 'squash', 'squash_finished', {
1956
2011
  commitSha,
1957
2012
  touchedPaths: mergeResult.touchedPaths,
1958
- });
2013
+ }, undefined, Date.now() - squashStartedAt);
1959
2014
  const memoryFlowSavedActions = stageIndex.workUnits
1960
2015
  .flatMap((wu) => wu.actions)
1961
2016
  .concat(reconcileActions)
@@ -1971,6 +2026,7 @@ export class IngestBundleRunner {
1971
2026
  // transaction. If this throws, the run fails and no partial index state
1972
2027
  // survives (thanks to the transactional upsert in applyDiffTransactional).
1973
2028
  if (commitSha) {
2029
+ const indexSyncStartedAt = Date.now();
1974
2030
  // Multi-file squash → omit path so the handler diffs the whole commit
1975
2031
  // (a comma-joined pathspec would match nothing and the job would no-op).
1976
2032
  const pathFilter = mergeResult.touchedPaths.length === 1 ? mergeResult.touchedPaths[0] : '';
@@ -1992,6 +2048,7 @@ export class IngestBundleRunner {
1992
2048
  this.logger.warn(`[ingest-bundle] post-squash SL reindex failed for connection=${connectionId}: ${err instanceof Error ? err.message : String(err)}`);
1993
2049
  }
1994
2050
  }
2051
+ await runTrace.event('debug', 'index_sync', 'post_squash_index_sync_finished', { connectionCount: touchedConnections.length }, undefined, Date.now() - indexSyncStartedAt);
1995
2052
  }
1996
2053
  const stage5 = ctx?.startPhase(0.04);
1997
2054
  emitStageProgress('provenance', 95, 'Recording history');
@@ -0,0 +1,102 @@
1
+ import { z } from 'zod';
2
+ export interface IngestProfilePaths {
3
+ tracePath: string;
4
+ transcriptDir: string;
5
+ }
6
+ /**
7
+ * Post-processor over the ingest trace (`<home>/ingest-traces/<jobId>/trace.jsonl`)
8
+ * and per-work-unit tool transcripts. Turns the durations recorded during a run
9
+ * into a rolled-up "where did the time go" view. Gated for display by
10
+ * `KTX_PROFILE_INGEST`; the durations themselves are always written to the trace.
11
+ */
12
+ declare const traceEventSchema: z.ZodObject<{
13
+ at: z.ZodOptional<z.ZodString>;
14
+ phase: z.ZodString;
15
+ event: z.ZodString;
16
+ durationMs: z.ZodOptional<z.ZodNumber>;
17
+ data: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
18
+ }, z.core.$loose>;
19
+ /** @internal */
20
+ export type ProfiledTraceEvent = z.infer<typeof traceEventSchema>;
21
+ export interface IngestProfile {
22
+ jobId: string;
23
+ totalWallMs?: number;
24
+ phases: Array<{
25
+ phase: string;
26
+ totalMs: number;
27
+ /** Number of timed (durationMs-bearing) events that contributed to this phase. */
28
+ count: number;
29
+ }>;
30
+ workUnits: Array<{
31
+ unitKey: string;
32
+ status?: string;
33
+ /** Wall-clock for the whole work-unit run (agent loop + validation + git). */
34
+ totalMs?: number;
35
+ /** Pure `generateText` agent-loop time reported by the runtime. */
36
+ agentLoopMs?: number;
37
+ /** Summed tool-execution time from the work-unit transcript. */
38
+ toolMs?: number;
39
+ /** Derived model "thinking" time = agentLoopMs - toolMs (clamped at 0). */
40
+ modelMs?: number;
41
+ /** Worktree create time. */
42
+ createMs?: number;
43
+ /** Worktree teardown time. */
44
+ cleanupMs?: number;
45
+ stepCount?: number;
46
+ totalTokens?: number;
47
+ }>;
48
+ workUnitCount: number;
49
+ failedWorkUnitCount: number;
50
+ /**
51
+ * Plain-language diagnosis plus the raw numbers behind it, so a reader (human
52
+ * or coding agent) gets the conclusion without re-deriving it from the tables.
53
+ */
54
+ summary: {
55
+ /** One-sentence conclusion, e.g. which phase dominated and whether work was model- or tool-bound. */
56
+ headline: string;
57
+ dominantPhase?: {
58
+ phase: string;
59
+ totalMs: number;
60
+ pctOfWall?: number;
61
+ };
62
+ /** Aggregate across all work units, in milliseconds. */
63
+ workUnits?: {
64
+ count: number;
65
+ failed: number;
66
+ agentLoopMs: number;
67
+ modelMs: number;
68
+ toolMs: number;
69
+ /** Percent of agent-loop time spent in model generation vs tool execution. */
70
+ modelPct?: number;
71
+ };
72
+ };
73
+ }
74
+ /** @internal */
75
+ export declare function parseTraceEvents(traceText: string): ProfiledTraceEvent[];
76
+ /** @internal */
77
+ export declare function aggregateIngestProfile(input: {
78
+ jobId: string;
79
+ events: ProfiledTraceEvent[];
80
+ toolMsByUnit: Record<string, number>;
81
+ }): IngestProfile;
82
+ /** Read the trace and tool transcripts for a job and aggregate them into a profile. */
83
+ export declare function readIngestProfile(jobId: string, paths: IngestProfilePaths): Promise<IngestProfile>;
84
+ /** Render a human-readable profile table for stderr / the admin command. */
85
+ export declare function formatIngestProfile(profile: IngestProfile, options?: {
86
+ topWorkUnits?: number;
87
+ }): string;
88
+ /**
89
+ * Machine-readable rendering for coding agents: the full structured profile
90
+ * (raw milliseconds and token counts, stable keys) as a single JSON object
91
+ * under a stable marker line so it is easy to locate and parse in stderr.
92
+ */
93
+ export declare function formatIngestProfileJson(profile: IngestProfile): string;
94
+ export type IngestProfileMode = 'off' | 'table' | 'json';
95
+ /**
96
+ * Resolve how (and whether) to emit the ingest profile, from the
97
+ * `ingest.profile` config value and the `KTX_PROFILE_INGEST` env var. Either
98
+ * source may request `json` (raw, agent-friendly) or a human `table`; `json`
99
+ * wins if either asks for it.
100
+ */
101
+ export declare function resolveIngestProfileMode(configValue: boolean | 'json' | undefined, env?: NodeJS.ProcessEnv): IngestProfileMode;
102
+ export {};
@@ -0,0 +1,306 @@
1
+ import { readdir, readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { z } from 'zod';
4
+ /**
5
+ * Post-processor over the ingest trace (`<home>/ingest-traces/<jobId>/trace.jsonl`)
6
+ * and per-work-unit tool transcripts. Turns the durations recorded during a run
7
+ * into a rolled-up "where did the time go" view. Gated for display by
8
+ * `KTX_PROFILE_INGEST`; the durations themselves are always written to the trace.
9
+ */
10
+ const traceEventSchema = z
11
+ .object({
12
+ at: z.string().optional(),
13
+ phase: z.string(),
14
+ event: z.string(),
15
+ durationMs: z.number().optional(),
16
+ data: z.record(z.string(), z.unknown()).optional(),
17
+ })
18
+ .loose();
19
+ function asNumber(value) {
20
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
21
+ }
22
+ function asString(value) {
23
+ return typeof value === 'string' ? value : undefined;
24
+ }
25
+ /** @internal */
26
+ export function parseTraceEvents(traceText) {
27
+ const events = [];
28
+ for (const line of traceText.split('\n')) {
29
+ const trimmed = line.trim();
30
+ if (!trimmed) {
31
+ continue;
32
+ }
33
+ let json;
34
+ try {
35
+ json = JSON.parse(trimmed);
36
+ }
37
+ catch {
38
+ continue;
39
+ }
40
+ const parsed = traceEventSchema.safeParse(json);
41
+ if (parsed.success) {
42
+ events.push(parsed.data);
43
+ }
44
+ }
45
+ return events;
46
+ }
47
+ /** @internal */
48
+ export function aggregateIngestProfile(input) {
49
+ const { jobId, events, toolMsByUnit } = input;
50
+ const phaseTotals = new Map();
51
+ const workUnits = new Map();
52
+ const wu = (unitKey) => {
53
+ let existing = workUnits.get(unitKey);
54
+ if (!existing) {
55
+ existing = { unitKey };
56
+ workUnits.set(unitKey, existing);
57
+ }
58
+ return existing;
59
+ };
60
+ let minAt = Number.POSITIVE_INFINITY;
61
+ let maxAt = Number.NEGATIVE_INFINITY;
62
+ for (const event of events) {
63
+ const at = event.at ? Date.parse(event.at) : Number.NaN;
64
+ if (!Number.isNaN(at)) {
65
+ minAt = Math.min(minAt, at);
66
+ maxAt = Math.max(maxAt, at);
67
+ }
68
+ if (event.durationMs !== undefined) {
69
+ const bucket = phaseTotals.get(event.phase) ?? { totalMs: 0, count: 0 };
70
+ bucket.totalMs += event.durationMs;
71
+ bucket.count += 1;
72
+ phaseTotals.set(event.phase, bucket);
73
+ }
74
+ const data = event.data ?? {};
75
+ const unitKey = asString(data.unitKey);
76
+ if (unitKey) {
77
+ const entry = wu(unitKey);
78
+ if (event.event === 'work_unit_executed') {
79
+ entry.totalMs = event.durationMs;
80
+ entry.agentLoopMs = asNumber(data.agentLoopMs);
81
+ entry.stepCount = asNumber(data.stepCount);
82
+ entry.totalTokens = asNumber(data.totalTokens);
83
+ entry.status = asString(data.status) ?? entry.status;
84
+ }
85
+ else if (event.event === 'work_unit_child_created') {
86
+ entry.createMs = event.durationMs;
87
+ }
88
+ else if (event.event === 'work_unit_child_cleanup') {
89
+ entry.cleanupMs = event.durationMs;
90
+ }
91
+ else if (event.event === 'work_unit_failed_before_patch') {
92
+ entry.status = entry.status ?? 'failed';
93
+ }
94
+ }
95
+ }
96
+ for (const [unitKey, entry] of workUnits) {
97
+ const toolMs = toolMsByUnit[unitKey];
98
+ if (toolMs !== undefined) {
99
+ entry.toolMs = toolMs;
100
+ if (entry.agentLoopMs !== undefined) {
101
+ entry.modelMs = Math.max(0, entry.agentLoopMs - toolMs);
102
+ }
103
+ }
104
+ else if (entry.agentLoopMs !== undefined) {
105
+ entry.modelMs = entry.agentLoopMs;
106
+ }
107
+ }
108
+ const phases = [...phaseTotals.entries()]
109
+ .map(([phase, { totalMs, count }]) => ({ phase, totalMs, count }))
110
+ .sort((a, b) => b.totalMs - a.totalMs);
111
+ const workUnitList = [...workUnits.values()].sort((a, b) => (b.totalMs ?? 0) - (a.totalMs ?? 0));
112
+ const totalWallMs = Number.isFinite(minAt) && Number.isFinite(maxAt) && maxAt >= minAt ? maxAt - minAt : undefined;
113
+ const failedWorkUnitCount = workUnitList.filter((entry) => entry.status === 'failed').length;
114
+ return {
115
+ jobId,
116
+ ...(totalWallMs !== undefined ? { totalWallMs } : {}),
117
+ phases,
118
+ workUnits: workUnitList,
119
+ workUnitCount: workUnitList.length,
120
+ failedWorkUnitCount,
121
+ summary: buildSummary(phases, workUnitList, failedWorkUnitCount, totalWallMs),
122
+ };
123
+ }
124
+ function buildSummary(phases, workUnits, failed, totalWallMs) {
125
+ const dominant = phases[0];
126
+ const dominantPhase = dominant
127
+ ? {
128
+ phase: dominant.phase,
129
+ totalMs: dominant.totalMs,
130
+ ...(totalWallMs && totalWallMs > 0
131
+ ? { pctOfWall: Math.round((dominant.totalMs / totalWallMs) * 100) }
132
+ : {}),
133
+ }
134
+ : undefined;
135
+ const agentLoopMs = workUnits.reduce((sum, wu) => sum + (wu.agentLoopMs ?? 0), 0);
136
+ const toolMs = workUnits.reduce((sum, wu) => sum + (wu.toolMs ?? 0), 0);
137
+ const modelMs = workUnits.reduce((sum, wu) => sum + (wu.modelMs ?? 0), 0);
138
+ const workUnitAggregate = workUnits.length > 0
139
+ ? {
140
+ count: workUnits.length,
141
+ failed,
142
+ agentLoopMs,
143
+ modelMs,
144
+ toolMs,
145
+ ...(agentLoopMs > 0 ? { modelPct: Math.round((modelMs / agentLoopMs) * 100) } : {}),
146
+ }
147
+ : undefined;
148
+ const parts = [];
149
+ if (dominantPhase) {
150
+ const pct = dominantPhase.pctOfWall !== undefined ? `, ${dominantPhase.pctOfWall}% of wall time` : '';
151
+ parts.push(`Slowest phase: ${dominantPhase.phase} (${formatMs(dominantPhase.totalMs)}${pct})`);
152
+ }
153
+ if (workUnitAggregate) {
154
+ const split = workUnitAggregate.modelPct !== undefined
155
+ ? `, ~${workUnitAggregate.modelPct}% model generation vs ~${100 - workUnitAggregate.modelPct}% tools`
156
+ : '';
157
+ parts.push(`${workUnitAggregate.count} work unit${workUnitAggregate.count === 1 ? '' : 's'}${failed > 0 ? ` (${failed} failed)` : ''}${split}`);
158
+ }
159
+ const headline = parts.length > 0 ? parts.join('. ') + '.' : 'No timed phases recorded.';
160
+ return {
161
+ headline,
162
+ ...(dominantPhase ? { dominantPhase } : {}),
163
+ ...(workUnitAggregate ? { workUnits: workUnitAggregate } : {}),
164
+ };
165
+ }
166
+ /** Read the trace and tool transcripts for a job and aggregate them into a profile. */
167
+ export async function readIngestProfile(jobId, paths) {
168
+ const traceText = await readFile(paths.tracePath, 'utf-8');
169
+ const events = parseTraceEvents(traceText);
170
+ const toolMsByUnit = await readToolMsByUnit(paths.transcriptDir);
171
+ return aggregateIngestProfile({ jobId, events, toolMsByUnit });
172
+ }
173
+ async function listTranscriptFiles(dir) {
174
+ // Work-unit keys can contain slashes (e.g. "cards/marketing"), so the runner
175
+ // writes nested transcript files (".../cards/marketing.jsonl"). Walk
176
+ // recursively and bucket by the `wuKey` field inside each entry rather than
177
+ // by file name.
178
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => null);
179
+ if (!entries) {
180
+ return [];
181
+ }
182
+ const files = [];
183
+ for (const entry of entries) {
184
+ const full = join(dir, entry.name);
185
+ if (entry.isDirectory()) {
186
+ files.push(...(await listTranscriptFiles(full)));
187
+ }
188
+ else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
189
+ files.push(full);
190
+ }
191
+ }
192
+ return files;
193
+ }
194
+ async function readToolMsByUnit(transcriptDir) {
195
+ const toolMs = {};
196
+ for (const file of await listTranscriptFiles(transcriptDir)) {
197
+ let text;
198
+ try {
199
+ text = await readFile(file, 'utf-8');
200
+ }
201
+ catch {
202
+ continue;
203
+ }
204
+ for (const line of text.split('\n')) {
205
+ const trimmed = line.trim();
206
+ if (!trimmed) {
207
+ continue;
208
+ }
209
+ try {
210
+ const entry = JSON.parse(trimmed);
211
+ const wuKey = asString(entry.wuKey);
212
+ const ms = asNumber(entry.durationMs);
213
+ if (wuKey && ms !== undefined) {
214
+ toolMs[wuKey] = (toolMs[wuKey] ?? 0) + ms;
215
+ }
216
+ }
217
+ catch {
218
+ // skip malformed line
219
+ }
220
+ }
221
+ }
222
+ return toolMs;
223
+ }
224
+ function formatMs(ms) {
225
+ if (ms === undefined) {
226
+ return '—';
227
+ }
228
+ if (ms < 1000) {
229
+ return `${Math.round(ms)}ms`;
230
+ }
231
+ const seconds = ms / 1000;
232
+ if (seconds < 60) {
233
+ return `${seconds.toFixed(1)}s`;
234
+ }
235
+ const minutes = Math.floor(seconds / 60);
236
+ const rem = Math.round(seconds - minutes * 60);
237
+ return `${minutes}m ${String(rem).padStart(2, '0')}s`;
238
+ }
239
+ function formatTokens(tokens) {
240
+ if (tokens === undefined) {
241
+ return '—';
242
+ }
243
+ if (tokens < 1000) {
244
+ return String(tokens);
245
+ }
246
+ return `${(tokens / 1000).toFixed(1)}k`;
247
+ }
248
+ function pad(value, width) {
249
+ return value.length >= width ? value : value + ' '.repeat(width - value.length);
250
+ }
251
+ function padStart(value, width) {
252
+ return value.length >= width ? value : ' '.repeat(width - value.length) + value;
253
+ }
254
+ /** Render a human-readable profile table for stderr / the admin command. */
255
+ export function formatIngestProfile(profile, options = {}) {
256
+ const topWorkUnits = options.topWorkUnits ?? 10;
257
+ const lines = [];
258
+ lines.push(`ktx ingest profile — job ${profile.jobId}`);
259
+ if (profile.totalWallMs !== undefined) {
260
+ lines.push(` total wall time: ${formatMs(profile.totalWallMs)}`);
261
+ }
262
+ lines.push(` ${profile.summary.headline}`);
263
+ const wall = profile.totalWallMs;
264
+ lines.push('');
265
+ lines.push(' Phase breakdown (by total duration):');
266
+ if (profile.phases.length === 0) {
267
+ lines.push(' (no timed phases recorded)');
268
+ }
269
+ for (const phase of profile.phases) {
270
+ const pct = wall && wall > 0 ? `(${((phase.totalMs / wall) * 100).toFixed(1)}%)` : '';
271
+ lines.push(` ${pad(phase.phase, 22)}${padStart(formatMs(phase.totalMs), 9)} ${padStart(pct, 8)} ${padStart(String(phase.count), 4)} event${phase.count === 1 ? '' : 's'}`);
272
+ }
273
+ if (profile.workUnits.length > 0) {
274
+ lines.push('');
275
+ lines.push(` Work units (top ${Math.min(topWorkUnits, profile.workUnits.length)} slowest):`);
276
+ lines.push(` ${pad('unitKey', 30)}${padStart('total', 9)}${padStart('model', 9)}${padStart('tool', 9)}${padStart('steps', 8)}${padStart('tokens', 9)} status`);
277
+ for (const entry of profile.workUnits.slice(0, topWorkUnits)) {
278
+ const steps = entry.stepCount !== undefined ? String(entry.stepCount) : '—';
279
+ lines.push(` ${pad(entry.unitKey.slice(0, 30), 30)}${padStart(formatMs(entry.totalMs), 9)}${padStart(formatMs(entry.modelMs), 9)}${padStart(formatMs(entry.toolMs), 9)}${padStart(steps, 8)}${padStart(formatTokens(entry.totalTokens), 9)} ${entry.status ?? '—'}`);
280
+ }
281
+ lines.push(` (${profile.workUnitCount} work unit${profile.workUnitCount === 1 ? '' : 's'} total; ${profile.failedWorkUnitCount} failed)`);
282
+ }
283
+ return `${lines.join('\n')}\n`;
284
+ }
285
+ /**
286
+ * Machine-readable rendering for coding agents: the full structured profile
287
+ * (raw milliseconds and token counts, stable keys) as a single JSON object
288
+ * under a stable marker line so it is easy to locate and parse in stderr.
289
+ */
290
+ export function formatIngestProfileJson(profile) {
291
+ return `ktx ingest profile (json)\n${JSON.stringify(profile, null, 2)}\n`;
292
+ }
293
+ /**
294
+ * Resolve how (and whether) to emit the ingest profile, from the
295
+ * `ingest.profile` config value and the `KTX_PROFILE_INGEST` env var. Either
296
+ * source may request `json` (raw, agent-friendly) or a human `table`; `json`
297
+ * wins if either asks for it.
298
+ */
299
+ export function resolveIngestProfileMode(configValue, env = process.env) {
300
+ const envValue = env.KTX_PROFILE_INGEST;
301
+ if (configValue === 'json' || envValue === 'json') {
302
+ return 'json';
303
+ }
304
+ const wantsTable = configValue === true || envValue === '1' || envValue === 'true' || envValue === 'table';
305
+ return wantsTable ? 'table' : 'off';
306
+ }
@@ -8,15 +8,37 @@ function patchFileName(unitIndex, unitKey) {
8
8
  export async function runIsolatedWorkUnit(input) {
9
9
  const sessionKey = `${input.trace.context.jobId}-${input.workUnit.unitKey}`;
10
10
  let cleanupOutcome = 'crash';
11
+ const createStartedAt = Date.now();
11
12
  const child = await input.sessionWorktreeService.create(sessionKey, input.ingestionBaseSha);
12
13
  await input.trace.event('debug', 'work_unit', 'work_unit_child_created', {
13
14
  unitKey: input.workUnit.unitKey,
14
15
  unitIndex: input.unitIndex,
15
16
  worktreePath: child.workdir,
16
17
  baseSha: input.ingestionBaseSha,
17
- });
18
+ }, undefined, Date.now() - createStartedAt);
18
19
  try {
20
+ const runStartedAt = Date.now();
19
21
  const outcome = await input.run(child);
22
+ await input.trace.event('debug', 'work_unit', 'work_unit_executed', {
23
+ unitKey: input.workUnit.unitKey,
24
+ unitIndex: input.unitIndex,
25
+ status: outcome.status,
26
+ ...(outcome.metrics
27
+ ? {
28
+ agentLoopMs: outcome.metrics.totalMs,
29
+ stepCount: outcome.metrics.stepCount,
30
+ ...(outcome.metrics.usage.inputTokens !== undefined
31
+ ? { inputTokens: outcome.metrics.usage.inputTokens }
32
+ : {}),
33
+ ...(outcome.metrics.usage.outputTokens !== undefined
34
+ ? { outputTokens: outcome.metrics.usage.outputTokens }
35
+ : {}),
36
+ ...(outcome.metrics.usage.totalTokens !== undefined
37
+ ? { totalTokens: outcome.metrics.usage.totalTokens }
38
+ : {}),
39
+ }
40
+ : {}),
41
+ }, undefined, Date.now() - runStartedAt);
20
42
  if (outcome.status !== 'success') {
21
43
  cleanupOutcome = 'success';
22
44
  await input.trace.event('error', 'work_unit', 'work_unit_failed_before_patch', {
@@ -51,11 +73,12 @@ export async function runIsolatedWorkUnit(input) {
51
73
  throw error;
52
74
  }
53
75
  finally {
76
+ const cleanupStartedAt = Date.now();
54
77
  await input.sessionWorktreeService.cleanup(child, cleanupOutcome);
55
78
  await input.trace.event('trace', 'work_unit', 'work_unit_child_cleanup', {
56
79
  unitKey: input.workUnit.unitKey,
57
80
  outcome: cleanupOutcome,
58
81
  worktreePath: child.workdir,
59
- });
82
+ }, undefined, Date.now() - cleanupStartedAt);
60
83
  }
61
84
  }
@@ -574,6 +574,7 @@ export function createLocalBundleIngestRuntime(options) {
574
574
  workUnitMaxConcurrency: options.project.config.ingest.workUnits.maxConcurrency,
575
575
  workUnitStepBudget: options.project.config.ingest.workUnits.stepBudget,
576
576
  workUnitFailureMode: options.project.config.ingest.workUnits.failureMode,
577
+ profileIngest: options.project.config.ingest.profile,
577
578
  ingestTraceLevel: ingestTraceLevelFromEnv(),
578
579
  },
579
580
  skillsRegistry: new SkillsRegistryService({ skillsDir, logger }),
@@ -67,7 +67,7 @@ export interface LocalMetabaseFanoutProgress {
67
67
  metabaseDatabaseId: number;
68
68
  targetConnectionId: string;
69
69
  jobId: string;
70
- status: 'done' | 'failed';
70
+ status: 'done' | 'partial' | 'failed';
71
71
  }): void;
72
72
  }
73
73
  export interface RunLocalMetabaseIngestOptions extends Omit<RunLocalIngestOptions, 'adapter' | 'connectionId' | 'sourceDir' | 'jobId'> {