@aiwg/cli 2026.10.9 → 2026.10.11

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 (116) hide show
  1. package/agentic/code/providers/model-capabilities.v1.json +275 -17
  2. package/bin/aiwg.mjs +8 -0
  3. package/dist/src/cli/handlers/mc-admission.js +39 -0
  4. package/dist/src/cli/handlers/mc.js +2 -31
  5. package/dist/src/cli/handlers/models-help.js +52 -0
  6. package/dist/src/cli/handlers/models.js +11 -81
  7. package/dist/src/cli/handlers/sessions.js +198 -15
  8. package/dist/src/cli/handlers/use-addon-profile.js +72 -0
  9. package/dist/src/cli/handlers/use.js +203 -234
  10. package/dist/src/cli/ui.js +1 -0
  11. package/dist/src/config/aiwg-config.js +38 -184
  12. package/dist/src/config/cli-help.js +54 -0
  13. package/dist/src/config/cli.js +5 -69
  14. package/dist/src/config/config-normalization.js +10 -0
  15. package/dist/src/config/worker-activation-gate.js +26 -0
  16. package/dist/src/config/worker-adoption-cli.js +102 -0
  17. package/dist/src/config/worker-adoption.js +368 -0
  18. package/dist/src/config/worker-config-digest.js +14 -0
  19. package/dist/src/config/worker-context.js +337 -0
  20. package/dist/src/config/worker-group-templates.js +150 -0
  21. package/dist/src/config/worker-guidance-warning.js +28 -0
  22. package/dist/src/config/worker-owner-lock.js +152 -0
  23. package/dist/src/config/worker-parallelism.js +154 -0
  24. package/dist/src/config/worker-write.js +75 -0
  25. package/dist/src/decision/telemetry/exporter.js +2 -1
  26. package/dist/src/decision/telemetry/index.js +1 -0
  27. package/dist/src/decision/telemetry/mapping.js +43 -0
  28. package/dist/src/decision/telemetry/otlp-http.js +5 -2
  29. package/dist/src/decision/telemetry/redaction.js +6 -1
  30. package/dist/src/decision/telemetry/types.js +3 -0
  31. package/dist/src/decision/telemetry/worker.js +69 -0
  32. package/dist/src/lint/rule-activation.js +15 -7
  33. package/dist/src/lint/runner.js +2 -2
  34. package/dist/src/mcp/server.mjs +2 -11
  35. package/dist/src/mcp/stdio-lifecycle.mjs +27 -0
  36. package/dist/src/mcp/tools/worker-failure-diagnostics.mjs +9 -0
  37. package/dist/src/mcp/tools/worker-invoke.mjs +114 -16
  38. package/dist/src/mission-protocol/codecs.js +8 -38
  39. package/dist/src/mission-protocol/worker-contract-diagnostics.js +62 -0
  40. package/dist/src/mission-protocol/worker-invocation-types.js +2 -0
  41. package/dist/src/models/conductor-data-policy.d.mts +8 -0
  42. package/dist/src/models/conductor-data-policy.mjs +190 -0
  43. package/dist/src/models/config-loader.js +19 -3
  44. package/dist/src/models/config-writer.js +27 -0
  45. package/dist/src/models/model-capabilities.v1.json +275 -17
  46. package/dist/src/models/provider-policy.js +14 -155
  47. package/dist/src/models/resolver.js +2 -293
  48. package/dist/src/models/worker-artifact-binding.mjs +12 -0
  49. package/dist/src/models/worker-direct-readiness-policy.js +155 -0
  50. package/dist/src/models/worker-direct-readiness.js +9 -218
  51. package/dist/src/models/worker-health-readiness.js +105 -26
  52. package/dist/src/models/worker-installation.mjs +12 -14
  53. package/dist/src/models/worker-member-references.js +64 -0
  54. package/dist/src/models/worker-plan.js +317 -0
  55. package/dist/src/models/worker-selection-policy.js +217 -0
  56. package/dist/src/models/wrapper-deployment.js +1 -1
  57. package/dist/src/policy/authorization.js +29 -1
  58. package/dist/src/process/aiwg-probe-command.d.mts +15 -0
  59. package/dist/src/process/aiwg-probe-command.mjs +37 -0
  60. package/dist/src/serve/shared-host-admission-checks.js +116 -0
  61. package/dist/src/serve/shared-host-scheduler.js +25 -107
  62. package/dist/src/serve/shared-host-starvation.js +73 -0
  63. package/dist/src/serve/worker-artifacts.d.mts +4 -0
  64. package/dist/src/serve/worker-artifacts.mjs +56 -0
  65. package/dist/src/serve/worker-command-request.js +68 -0
  66. package/dist/src/serve/worker-invocation-paths.js +144 -0
  67. package/dist/src/serve/worker-invocation-preparation.js +169 -0
  68. package/dist/src/serve/worker-invocation.js +24 -291
  69. package/dist/src/serve/worker-stdin-frame.d.mts +2 -2
  70. package/dist/src/serve/worker-stdin-frame.mjs +6 -6
  71. package/dist/src/sessions/adapters/grokbot.js +7 -0
  72. package/dist/src/sessions/batch-import.js +34 -6
  73. package/dist/src/sessions/contracts.js +19 -0
  74. package/dist/src/sessions/coverage-preflight.js +7 -1
  75. package/dist/src/sessions/fortemi-export-mapping.js +33 -3
  76. package/dist/src/sessions/host-catalog.js +157 -0
  77. package/dist/src/sessions/image-blobs.js +141 -0
  78. package/dist/src/sessions/importer.js +23 -3
  79. package/dist/src/sessions/index.js +2 -0
  80. package/dist/src/sessions/refresh.js +25 -2
  81. package/dist/src/sessions/repository.js +164 -22
  82. package/dist/src/sessions/workspace-discovery.js +5 -2
  83. package/dist/src/smiths/context-pipeline/parallelism-section.js +19 -7
  84. package/package.json +15 -1
  85. package/schemas/mission-v1.schema.json +20 -19
  86. package/schemas/models/model-config-compatibility.v1.schema.json +16 -0
  87. package/tools/agents/providers/claude-post-deploy.mjs +35 -0
  88. package/tools/agents/providers/claude.mjs +3 -30
  89. package/tools/agents/providers/codex-post-deploy.mjs +13 -0
  90. package/tools/agents/providers/codex.mjs +2 -8
  91. package/tools/agents/providers/conductor-data-guard.mjs +61 -0
  92. package/tools/agents/providers/rule-delivery.mjs +3 -16
  93. package/tools/agents/providers/rule-presentation.mjs +38 -0
  94. package/tools/plugin/package-plugins.mjs +1 -1
  95. package/tools/ralph-external/lib/claude-adapter.mjs +2 -2
  96. package/tools/ralph-external/lib/codex-adapter.mjs +3 -3
  97. package/tools/ralph-external/lib/managed-worker-attempt.mjs +86 -0
  98. package/tools/ralph-external/lib/muse-adapter.mjs +10 -0
  99. package/tools/ralph-external/lib/provider-adapter.mjs +11 -260
  100. package/tools/ralph-external/lib/session-attempt.mjs +85 -0
  101. package/tools/ralph-external/lib/strict-worker-json.mjs +190 -0
  102. package/tools/ralph-external/lib/worker-artifacts.mjs +209 -0
  103. package/tools/ralph-external/lib/worker-context.mjs +237 -0
  104. package/tools/ralph-external/lib/worker-final.mjs +143 -0
  105. package/tools/ralph-external/lib/worker-inputs.mjs +55 -0
  106. package/tools/ralph-external/lib/worker-invocation-ancestry.mjs +132 -0
  107. package/tools/ralph-external/lib/worker-invocation-processes.mjs +123 -0
  108. package/tools/ralph-external/lib/worker-isolation-status.mjs +48 -0
  109. package/tools/ralph-external/lib/worker-isolation.mjs +19 -73
  110. package/tools/ralph-external/lib/worker-launch-cli.mjs +49 -0
  111. package/tools/ralph-external/lib/worker-native-sandbox.mjs +83 -0
  112. package/tools/ralph-external/lib/worker-overflow.mjs +48 -13
  113. package/tools/ralph-external/lib/worker-retry-evidence.mjs +103 -0
  114. package/tools/ralph-external/lib/worker-termination.mjs +82 -0
  115. package/tools/ralph-external/session-launcher.mjs +124 -313
  116. package/vscode-extension/schemas/aiwg.config.v1.json +161 -2
@@ -6,6 +6,7 @@ import { coveragePreflight, sessionRuntimeIdentity } from './coverage-preflight.
6
6
  import { SESSION_CONTRACT_VERSION, SessionContractError, schemaIssueDetail, SessionSourceSchema, sha256, stableSessionId, stableEventId, } from './contracts.js';
7
7
  import { BATCH_IMPORT_VERSION, coverageFromBatchRun, } from './batch-contracts.js';
8
8
  import { IncrementalSessionImporter, SessionImportFailure } from './importer.js';
9
+ import { HOST_CATALOG_WORKSPACE_ID, isAccountScopedAdapter, publishHostRoutedBatch, } from './host-catalog.js';
9
10
  import { matchImportExclusion, resolveImportExclusions } from './import-exclusions.js';
10
11
  import { resolveImportLimits } from './import-limits.js';
11
12
  import { fingerprintSourceFile } from './readers.js';
@@ -45,6 +46,7 @@ export async function importDiscoveryManifest(options) {
45
46
  run.completedAt = null;
46
47
  options.repository.saveBatchImportRun(run);
47
48
  injectFault(options, run, 'run-saved');
49
+ const hostRoutedSourceIds = [];
48
50
  for (const manifestSource of options.manifest.sources) {
49
51
  const disposition = run.sources.find((candidate) => candidate.sourceId === manifestSource.sourceId);
50
52
  if (!disposition) {
@@ -76,23 +78,33 @@ export async function importDiscoveryManifest(options) {
76
78
  persistProgress(options.repository, run, now());
77
79
  try {
78
80
  await assertManifestSourceUnchanged(manifestSource);
81
+ const adapter = adapterFor(manifestSource.provider, limits);
82
+ // Account-scoped providers (Grok Bot) are imported once into the
83
+ // host catalog; the workspace catalog records a reference (#3232).
84
+ // Any adapter declaring account scope follows this route.
85
+ const hostRouted = options.hostRepository !== undefined
86
+ && options.hostCatalogPath !== undefined
87
+ && isAccountScopedAdapter(adapter);
88
+ const targetRepository = hostRouted ? options.hostRepository : options.repository;
89
+ const importWorkspaceId = hostRouted ? HOST_CATALOG_WORKSPACE_ID : options.manifest.workspaceId;
90
+ if (hostRouted)
91
+ hostRoutedSourceIds.push(manifestSource.sourceId);
79
92
  const selectedSource = {
80
93
  provider: manifestSource.provider,
81
94
  locator: manifestSource.locator,
82
95
  locatorClass: manifestSource.locatorClass,
83
96
  sourceId: manifestSource.sourceId,
84
97
  authorizedScope: {
85
- workspaceId: options.manifest.workspaceId,
98
+ workspaceId: importWorkspaceId,
86
99
  allowedRoots: [manifestSource.authorizedRoot],
87
100
  },
88
101
  };
89
- const adapter = adapterFor(manifestSource.provider, limits);
90
102
  const source = await sessionSource(selectedSource, adapter);
91
- const receipts = await new IncrementalSessionImporter(options.repository).import({
103
+ const receipts = await new IncrementalSessionImporter(targetRepository).import({
92
104
  source,
93
105
  selectedSource,
94
106
  adapter,
95
- workspaceId: options.manifest.workspaceId,
107
+ workspaceId: importWorkspaceId,
96
108
  policyVersion: '1.0.0',
97
109
  batchRunId: run.runId,
98
110
  publish: false,
@@ -101,7 +113,7 @@ export async function importDiscoveryManifest(options) {
101
113
  limits,
102
114
  });
103
115
  await assertManifestSourceUnchanged(manifestSource);
104
- const totals = options.repository.sourceImportTotals(manifestSource.sourceId, run.runId);
116
+ const totals = targetRepository.sourceImportTotals(manifestSource.sourceId, run.runId);
105
117
  disposition.sessionsAccepted = totals.sessions;
106
118
  disposition.eventsAccepted = totals.events;
107
119
  disposition.status = receipts.length > 0
@@ -134,6 +146,18 @@ export async function importDiscoveryManifest(options) {
134
146
  .map((source) => source.sourceId);
135
147
  injectFault(options, run, 'before-publication');
136
148
  options.repository.commitStagedBatch(run.runId, publishable);
149
+ if (options.hostRepository && options.hostCatalogPath && hostRoutedSourceIds.length > 0) {
150
+ const acceptedHost = run.sources
151
+ .filter((source) => hostRoutedSourceIds.includes(source.sourceId) && isAccepted(source.status))
152
+ .map((source) => source.sourceId);
153
+ publishHostRoutedBatch({
154
+ hostRepository: options.hostRepository,
155
+ workspaceRepository: options.repository,
156
+ batchRunId: run.runId,
157
+ sourceIds: acceptedHost,
158
+ hostCatalogPath: options.hostCatalogPath,
159
+ });
160
+ }
137
161
  injectFault(options, run, 'after-publication');
138
162
  run.status = pending ? 'interrupted' : rejected || acquisitionIncomplete ? 'partial' : 'complete';
139
163
  run.updatedAt = now().toISOString();
@@ -360,8 +384,12 @@ async function rebuildFromCatalog(options) {
360
384
  let staged;
361
385
  try {
362
386
  staged = new SessionRepository(join(stagingDirectory, 'staging.sqlite'));
387
+ // The staged rebuild transfers normalized rows (not host references) into
388
+ // the destination, so account-scoped sources rebuild locally here as
389
+ // before; the destination's own host routing applies on its next import.
363
390
  const receipt = await importDiscoveryManifest({ ...options, manifest, repository: staged,
364
- carryForward: undefined, fault: undefined });
391
+ carryForward: undefined, fault: undefined,
392
+ hostRepository: undefined, hostCatalogPath: undefined });
365
393
  const identities = new Set();
366
394
  const destinationCodex = new Map(catalogSessions(options.repository, workspaceId)
367
395
  .filter(session => session.provider === 'codex').map(session => [session.nativeSessionId, session.sourceId]));
@@ -103,6 +103,19 @@ export const SessionSourceSchema = z.object({
103
103
  authorizedAt: dateTime({ offset: true }),
104
104
  extensions: NativeExtensionsSchema.prefault({}),
105
105
  });
106
+ /**
107
+ * Content-addressed reference to a tool-output image whose bytes live in
108
+ * the catalog blob store instead of inline in the event (#3265). The
109
+ * persisted shape's schema is this zod contract (session events have no
110
+ * JSON-schema file; see `lint:schemas`).
111
+ */
112
+ export const SessionImageRefSchema = z.object({
113
+ digest: DigestSchema,
114
+ mediaType: z.string().min(1),
115
+ byteLength: integer().nonnegative(),
116
+ locatorClass: z.string().min(1).optional(),
117
+ offset: integer().nonnegative().optional(),
118
+ });
106
119
  export const SessionEventSchema = z.object({
107
120
  contractVersion: z.literal(SESSION_CONTRACT_VERSION),
108
121
  eventId: z.string().min(1),
@@ -130,6 +143,12 @@ export const SessionEventSchema = z.object({
130
143
  originRule: z.string().min(1).default('legacy:unknown'),
131
144
  originClassifierVersion: VersionSchema.default('1.0.0'),
132
145
  searchableText: z.string(),
146
+ /**
147
+ * Image references extracted from inline payloads at import. Optional so
148
+ * pre-#3265 catalog rows (inline bytes, field absent) still parse; readers
149
+ * must tolerate `undefined` as "no references".
150
+ */
151
+ imageRefs: z.array(SessionImageRefSchema).optional(),
133
152
  digest: DigestSchema,
134
153
  rawReference: z.object({
135
154
  locatorClass: z.string().min(1),
@@ -139,7 +139,13 @@ export function reconcileCoveragePreflight(preflight, sources) {
139
139
  return entry;
140
140
  const selected = sources.filter((source) => source.provider === entry.provider);
141
141
  const imported = selected.length === entry.sourceCount && selected.length > 0
142
- && selected.every((source) => source.status === 'excluded' || (accepted.has(source.status) && source.eventsAccepted > 0));
142
+ // duplicate/previously-committed sources were acquired by an earlier
143
+ // run, so zero new events still counts as imported. A host-routed
144
+ // second workspace import lands here: the host copy already exists
145
+ // and the workspace only records references (#3232). A freshly
146
+ // committed source with zero events stays incomplete.
147
+ && selected.every((source) => source.status === 'excluded' || (accepted.has(source.status)
148
+ && (source.eventsAccepted > 0 || source.status !== 'committed')));
143
149
  return {
144
150
  ...entry,
145
151
  disposition: !imported ? 'import-incomplete'
@@ -75,7 +75,16 @@ export function sessionToAiwgFortemiRecord(session, catalogTags = []) {
75
75
  updated_at: updatedAt,
76
76
  };
77
77
  }
78
- export function sessionEventToAiwgFortemiRecord(session, event) {
78
+ /**
79
+ * Map one catalog event. `images` carries the event's image references
80
+ * resolved against the blob store when the caller can provide them
81
+ * (export build passes `repository.resolveEventImages`); omitted means
82
+ * "not resolved", never "no images" -- the placeholder text in `text` is
83
+ * always present regardless (#3265). Bytes are never embedded here, only
84
+ * the reference plus its availability, so a consumer knows whether the
85
+ * digest is fetchable from the catalog or gone.
86
+ */
87
+ export function sessionEventToAiwgFortemiRecord(session, event, images = []) {
79
88
  const locator = sessionEventRecordLocator(session, event);
80
89
  const parentLocator = sessionRecordLocator(session);
81
90
  const updatedAt = event.occurredAt ?? new Date(0).toISOString();
@@ -110,6 +119,16 @@ export function sessionEventToAiwgFortemiRecord(session, event) {
110
119
  nativeId: event.nativeId,
111
120
  sequence: event.sequence,
112
121
  toolCallId: event.toolCallId,
122
+ ...(images.length > 0
123
+ ? {
124
+ images: images.map((image) => ({
125
+ digest: image.digest,
126
+ mediaType: image.mediaType,
127
+ byteLength: image.byteLength,
128
+ status: image.status,
129
+ })),
130
+ }
131
+ : {}),
113
132
  },
114
133
  }];
115
134
  const tags = [
@@ -117,6 +136,10 @@ export function sessionEventToAiwgFortemiRecord(session, event) {
117
136
  ...(event.role ? [tag('role', event.role)] : []),
118
137
  ...(event.toolName ? [tag('tool', event.toolName)] : []),
119
138
  ...(event.entities ?? []).map((entity) => tag('entity', entity)),
139
+ ...images.map((image) => tag('image', image.digest)),
140
+ ...images
141
+ .filter((image) => image.status === 'unavailable')
142
+ .map((image) => tag('image-unavailable', image.digest)),
120
143
  ];
121
144
  return {
122
145
  schema_version: SESSION_EXPORT_RECORD_SCHEMA_VERSION,
@@ -275,13 +298,20 @@ function exportManifestRecord(repoRef, generatedAt) {
275
298
  * session catalog's workspace id, not a git repo -- the field is repurposed
276
299
  * honestly rather than left to imply something untrue).
277
300
  */
278
- export function buildSessionAiwgFortemiIndexExport(repoRef, sessions, eventsBySessionId, outputs = [], catalogTagsBySessionId = new Map()) {
301
+ export function buildSessionAiwgFortemiIndexExport(repoRef, sessions, eventsBySessionId, outputs = [], catalogTagsBySessionId = new Map(),
302
+ /**
303
+ * Resolve image references per event when the caller's catalog is at
304
+ * hand (`(event) => repository.resolveEventImages(event)`). Absent means
305
+ * references stay unresolved in the records, never that images are
306
+ * missing -- availability is only asserted when actually resolved (#3265).
307
+ */
308
+ imageResolution) {
279
309
  const generatedAt = new Date().toISOString();
280
310
  const items = [exportManifestRecord(repoRef, generatedAt)];
281
311
  for (const session of sessions) {
282
312
  items.push(sessionToAiwgFortemiRecord(session, catalogTagsBySessionId.get(session.sessionId) ?? []));
283
313
  for (const event of eventsBySessionId.get(session.sessionId) ?? []) {
284
- items.push(sessionEventToAiwgFortemiRecord(session, event));
314
+ items.push(sessionEventToAiwgFortemiRecord(session, event, imageResolution?.(event) ?? []));
285
315
  }
286
316
  for (const output of outputs.filter((entry) => entry.sessionId === session.sessionId)) {
287
317
  items.push(sessionOutputToAiwgFortemiRecord(session, output));
@@ -0,0 +1,157 @@
1
+ // Host catalog for account-scoped session providers (#3232).
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { createSessionAdapterRegistry } from './adapter-registry.js';
5
+ /** Workspace id used for account-scoped sessions stored in the host catalog. */
6
+ export const HOST_CATALOG_WORKSPACE_ID = 'host';
7
+ /** DDL for the workspace-catalog reference table (created by SessionRepository). */
8
+ export const HOST_REFERENCE_TABLE_DDL = `
9
+ CREATE TABLE IF NOT EXISTS session_host_refs (
10
+ provider TEXT NOT NULL, native_session_id TEXT NOT NULL,
11
+ session_id TEXT NOT NULL, source_id TEXT NOT NULL,
12
+ host_catalog TEXT NOT NULL,
13
+ PRIMARY KEY(provider, native_session_id)
14
+ );
15
+ CREATE INDEX IF NOT EXISTS idx_session_host_refs_session
16
+ ON session_host_refs(session_id);
17
+ `;
18
+ const HOST_CATALOG_ENV = 'AIWG_SESSIONS_HOST_CATALOG';
19
+ /** User-scope (host) catalog path; the env override exists so tests never touch HOME. */
20
+ export function defaultHostCatalogPath(homeDir = homedir()) {
21
+ const override = process.env[HOST_CATALOG_ENV]?.trim();
22
+ if (override)
23
+ return override;
24
+ return join(homeDir, '.aiwg', 'sessions', 'host-catalog.sqlite');
25
+ }
26
+ /** Account-scoped adapters (Grok Bot today) route to the host catalog. */
27
+ export function isAccountScopedAdapter(adapter) {
28
+ return adapter.scopeClass === 'account';
29
+ }
30
+ /**
31
+ * Whether a discovery manifest holds any account-scoped source. Callers use
32
+ * this to avoid creating or opening the host catalog when every source is
33
+ * workspace-scoped. Unknown providers resolve to workspace scope.
34
+ */
35
+ export function manifestNeedsHostCatalog(sources) {
36
+ if (sources.length === 0)
37
+ return false;
38
+ const registry = createSessionAdapterRegistry();
39
+ return sources.some((source) => {
40
+ try {
41
+ return isAccountScopedAdapter(registry.get(source.provider));
42
+ }
43
+ catch {
44
+ return false;
45
+ }
46
+ });
47
+ }
48
+ /** Aggregate identity: one account-scoped conversation counts once per provider. */
49
+ export function hostReferenceKey(identity) {
50
+ return `${identity.provider}\0${identity.nativeSessionId}`;
51
+ }
52
+ /** Drop cross-catalog duplicates, keeping the first row for each provider+session. */
53
+ export function dedupeSessionIdentities(rows) {
54
+ const seen = new Set();
55
+ return rows.filter((row) => {
56
+ const key = hostReferenceKey(row);
57
+ if (seen.has(key))
58
+ return false;
59
+ seen.add(key);
60
+ return true;
61
+ });
62
+ }
63
+ /** Count sessions across catalogs without doubling account-scoped replicas. */
64
+ export function countUniqueSessionsAcross(lists) {
65
+ return dedupeSessionIdentities(lists.flat()).length;
66
+ }
67
+ /** Host sessions backing a workspace catalog's references (matched by provider+session). */
68
+ export function resolveHostReferences(references, hostSessions) {
69
+ const byKey = new Map(hostSessions.map((session) => [
70
+ hostReferenceKey({ provider: session.provider, nativeSessionId: session.nativeSessionId }),
71
+ session,
72
+ ]));
73
+ const resolved = [];
74
+ for (const reference of references) {
75
+ const session = byKey.get(hostReferenceKey(reference));
76
+ if (session)
77
+ resolved.push(session);
78
+ }
79
+ return resolved;
80
+ }
81
+ /**
82
+ * Workspace listing view: local rows plus referenced host sessions, with
83
+ * legacy duplicated copies (same provider+session already stored locally)
84
+ * counted once. Local rows win ties so workspace provenance is preserved.
85
+ */
86
+ export function mergeSessionLists(local, referenced) {
87
+ return dedupeSessionIdentities([...local, ...referenced]);
88
+ }
89
+ /** Sessions in a repository reduced to aggregate identities (legacy rows included). */
90
+ export function collectSessionIdentities(repository, workspaceId) {
91
+ return repository.listSessionIdentities(workspaceId);
92
+ }
93
+ /**
94
+ * Count sessions across workspace and host catalogs without doubling
95
+ * account-scoped replicas: two workspaces referencing the same host
96
+ * session plus a legacy workspace holding a copied row count once.
97
+ */
98
+ export function countUniqueSessionsAcrossCatalogs(views) {
99
+ return dedupeSessionIdentities(views.flatMap((view) => [...view.identities, ...view.references])).length;
100
+ }
101
+ /** Index visible identities by internal session id for merge lookups. */
102
+ export function indexIdentitiesBySessionId(identities) {
103
+ return new Map(identities.map((identity) => [identity.sessionId, identity]));
104
+ }
105
+ /**
106
+ * Merge local rows (search hits, analytics facts) with host-catalog rows,
107
+ * counting each session once by provider + native session id. Every local
108
+ * row is kept; a host row is dropped when the same session already has
109
+ * local rows (a legacy copied row wins over its host reference, mirroring
110
+ * mergeSessionLists, #3232) and host rows repeating across catalogs are
111
+ * deduped by hostRowKey. An unreachable host catalog contributes no rows,
112
+ * so local results are never lost.
113
+ */
114
+ export function mergeCrossCatalogRows(localRows, hostRows, localIdentityBySessionId, hostIdentityBySessionId, hostRowKey) {
115
+ const localKeys = new Set();
116
+ for (const row of localRows) {
117
+ const identity = localIdentityBySessionId.get(row.sessionId);
118
+ if (identity) {
119
+ localKeys.add(hostReferenceKey({ provider: row.provider, nativeSessionId: identity.nativeSessionId }));
120
+ }
121
+ }
122
+ const seenHost = new Set();
123
+ const keptHost = hostRows.filter((row) => {
124
+ const identity = hostIdentityBySessionId.get(row.sessionId);
125
+ if (identity
126
+ && localKeys.has(hostReferenceKey({ provider: row.provider, nativeSessionId: identity.nativeSessionId }))) {
127
+ return false;
128
+ }
129
+ const key = hostRowKey(row);
130
+ if (seenHost.has(key))
131
+ return false;
132
+ seenHost.add(key);
133
+ return true;
134
+ });
135
+ return [...localRows, ...keptHost];
136
+ }
137
+ /**
138
+ * Publish staged account-scoped imports in the host catalog and record
139
+ * workspace references instead of copies (#3232). Non-destructive: legacy
140
+ * workspace rows are left untouched and stay readable.
141
+ */
142
+ export function publishHostRoutedBatch(input) {
143
+ const accepted = new Set(input.sourceIds);
144
+ input.hostRepository.commitStagedBatch(input.batchRunId, [...accepted]);
145
+ for (const identity of input.hostRepository.listSessionIdentities(HOST_CATALOG_WORKSPACE_ID)) {
146
+ if (!accepted.has(identity.sourceId))
147
+ continue;
148
+ input.workspaceRepository.recordHostSessionReference({
149
+ provider: identity.provider,
150
+ nativeSessionId: identity.nativeSessionId,
151
+ sessionId: identity.sessionId,
152
+ sourceId: identity.sourceId,
153
+ hostCatalog: input.hostCatalogPath,
154
+ });
155
+ }
156
+ }
157
+ //# sourceMappingURL=host-catalog.js.map
@@ -0,0 +1,141 @@
1
+ // Content-addressed tool-output image references for the session catalog (#3265).
2
+ import { createHash } from 'node:crypto';
3
+ const IMAGE_DIGEST_PREFIX = 'sha256:';
4
+ export function sha256Bytes(value) {
5
+ return `${IMAGE_DIGEST_PREFIX}${createHash('sha256').update(value).digest('hex')}`;
6
+ }
7
+ export function isImageDigest(value) {
8
+ return typeof value === 'string' && /^sha256:[a-f0-9]{64}$/.test(value);
9
+ }
10
+ /** Human-readable byte size for image placeholders (`3.1 MB`, `68 B`). */
11
+ export function formatImageBytes(byteLength) {
12
+ if (!Number.isFinite(byteLength) || byteLength < 0)
13
+ return '0 B';
14
+ if (byteLength < 1024)
15
+ return `${Math.trunc(byteLength)} B`;
16
+ const kb = byteLength / 1024;
17
+ if (kb < 1024)
18
+ return `${kb.toFixed(1)} KB`;
19
+ return `${(kb / 1024).toFixed(1)} MB`;
20
+ }
21
+ /** Placeholder kept in `searchableText` (and FTS) instead of the image bytes. */
22
+ export function imagePlaceholder(ref) {
23
+ return `[image: ${ref.digest}, ${ref.mediaType}, ${formatImageBytes(ref.byteLength)}]`;
24
+ }
25
+ // The payload class excludes whitespace so a match stops at the first word
26
+ // boundary instead of swallowing following prose; shape (and a minimum
27
+ // length well below any real image) is validated again on decode.
28
+ const DATA_URL_PATTERN = /data:(image\/[a-zA-Z0-9+.-]+)(;[a-zA-Z0-9!#$&^_.+-]+)*;base64,([A-Za-z0-9+/=]+)/g;
29
+ const MIN_BASE64_PAYLOAD_CHARS = 32;
30
+ function decodeBase64Payload(payload) {
31
+ const compact = payload.replace(/\s+/g, '');
32
+ // `Buffer.from(x, 'base64')` never throws -- it skips invalid characters --
33
+ // so validate the shape first: base64 alphabet only, padding at the end,
34
+ // and a length that can carry at least one byte.
35
+ if (compact.length < 4 || compact.length % 4 === 1)
36
+ return null;
37
+ if (!/^(?:[A-Za-z0-9+/]+={0,2})$/.test(compact))
38
+ return null;
39
+ const bytes = Buffer.from(compact, 'base64');
40
+ if (bytes.length === 0)
41
+ return null;
42
+ return new Uint8Array(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength));
43
+ }
44
+ /**
45
+ * Replace inline base64 image data URLs in free text with content-hash
46
+ * placeholders. Only `image/*` media types are extracted; other data URLs
47
+ * (and remote `http(s)` image URLs, which are references already) pass
48
+ * through untouched.
49
+ */
50
+ export function extractInlineImagesFromText(text, source = {}) {
51
+ const images = [];
52
+ if (!text.includes('data:image/'))
53
+ return { text, images };
54
+ DATA_URL_PATTERN.lastIndex = 0;
55
+ const result = text.replace(DATA_URL_PATTERN, (match, mediaType, _params, payload) => {
56
+ if (payload.length < MIN_BASE64_PAYLOAD_CHARS)
57
+ return match;
58
+ const bytes = decodeBase64Payload(payload);
59
+ if (!bytes)
60
+ return match;
61
+ const ref = {
62
+ digest: sha256Bytes(bytes),
63
+ mediaType: String(mediaType).toLowerCase(),
64
+ byteLength: bytes.length,
65
+ ...(source.locatorClass !== undefined ? { locatorClass: source.locatorClass } : {}),
66
+ ...(source.offset !== undefined ? { offset: source.offset } : {}),
67
+ bytes,
68
+ };
69
+ images.push(ref);
70
+ return imagePlaceholder(ref);
71
+ });
72
+ return { text: result, images };
73
+ }
74
+ function isPlainObject(value) {
75
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
76
+ }
77
+ /**
78
+ * Extract inline images from a whole import batch. Returns replacement
79
+ * events (bytes swapped for references) plus the distinct blobs to persist.
80
+ * Idempotent: placeholder text from an earlier pass matches no pattern, so
81
+ * importer-extracted batches and direct writers converge on the same store
82
+ * state. Blobs travel beside the events because event JSON must only hold
83
+ * the content-hash reference, never the bytes.
84
+ */
85
+ export function extractBatchEventImages(batch) {
86
+ const blobs = new Map();
87
+ for (const carried of batch.imageBlobs ?? []) {
88
+ if (isImageDigest(carried.digest) && !blobs.has(carried.digest))
89
+ blobs.set(carried.digest, carried);
90
+ }
91
+ const events = batch.events.map((event) => {
92
+ const extracted = extractEventImages({
93
+ searchableText: event.searchableText,
94
+ extensions: event.extensions,
95
+ rawReference: event.rawReference,
96
+ });
97
+ for (const image of extracted.images) {
98
+ if (!blobs.has(image.digest))
99
+ blobs.set(image.digest, image);
100
+ }
101
+ if (extracted.images.length === 0)
102
+ return event;
103
+ const refs = [...(event.imageRefs ?? [])];
104
+ for (const ref of extracted.imageRefs) {
105
+ if (!refs.some((prior) => prior.digest === ref.digest))
106
+ refs.push(ref);
107
+ }
108
+ return {
109
+ ...event,
110
+ searchableText: extracted.searchableText,
111
+ extensions: extracted.extensions,
112
+ imageRefs: refs,
113
+ };
114
+ });
115
+ return { events, imageBlobs: [...blobs.values()] };
116
+ }
117
+ /** Extract inline images from one event's text; extensions keep their existing redaction. */
118
+ export function extractEventImages(input) {
119
+ const source = {
120
+ ...(input.rawReference?.locatorClass !== undefined ? { locatorClass: input.rawReference.locatorClass } : {}),
121
+ ...(input.rawReference?.offset !== undefined ? { offset: input.rawReference.offset } : {}),
122
+ };
123
+ // Only event text is lifted. Provider image blocks in `extensions` are
124
+ // left to native-extension sanitization, which redacts them to markers,
125
+ // so this change never retains image bytes the catalog used to drop.
126
+ const text = extractInlineImagesFromText(input.searchableText ?? '', source);
127
+ const images = text.images;
128
+ return {
129
+ searchableText: text.text,
130
+ extensions: isPlainObject(input.extensions) ? input.extensions : {},
131
+ imageRefs: images.map((image) => ({
132
+ digest: image.digest,
133
+ mediaType: image.mediaType,
134
+ byteLength: image.byteLength,
135
+ ...(image.locatorClass !== undefined ? { locatorClass: image.locatorClass } : {}),
136
+ ...(image.offset !== undefined ? { offset: image.offset } : {}),
137
+ })),
138
+ images,
139
+ };
140
+ }
141
+ //# sourceMappingURL=image-blobs.js.map
@@ -1,6 +1,7 @@
1
1
  import { SESSION_CONTRACT_VERSION, SessionEventSchema, SessionSchema, assertSupportedSchemaMajor, sha256, stableEventId, stableSessionId, SessionContractError, schemaIssueDetail, assertSessionResourceLimit, } from './contracts.js';
2
2
  import { assertImportByteLimit, DEFAULT_IMPORT_BYTE_LIMITS, resolveImportLimits } from './import-limits.js';
3
3
  import { redactSessionValue, sanitizeSessionEvent, sanitizeNativeExtensions } from './policy.js';
4
+ import { extractEventImages } from './image-blobs.js';
4
5
  import { fingerprintSourcePrefix } from './readers.js';
5
6
  import { DEFAULT_INACTIVITY_THRESHOLD_MS } from './lifecycle.js';
6
7
  import { classifySessionEventOrigin, deriveSessionIntent, } from './origin.js';
@@ -123,6 +124,7 @@ export class IncrementalSessionImporter {
123
124
  source: sanitizedSource, run,
124
125
  sessions: normalized.sessions, events: normalized.events,
125
126
  batchRunId: request.batchRunId,
127
+ imageBlobs: normalized.imageBlobs,
126
128
  }, { ...checkpoint }, request.publish ?? request.source.consistency !== 'complete');
127
129
  const memory = process.memoryUsage();
128
130
  peakHeap = Math.max(peakHeap, memory.heapUsed);
@@ -232,17 +234,30 @@ function recordIdentity(record) {
232
234
  }
233
235
  function normalizeBatch(request, records, digests, importRunId, sourceObservedAt) {
234
236
  const sessions = new Map();
237
+ // Extract inline images before sanitization: provider image blocks in
238
+ // extensions are redacted to markers below, so bytes must be lifted out
239
+ // first while the raw record is still intact (#3265).
240
+ const imageBlobs = new Map();
235
241
  const events = records.map((record, index) => {
236
242
  const sessionId = stableSessionId(request.source.provider, request.source.sourceId, record.nativeSessionId);
237
243
  const native = sanitizeNativeExtensions(record.extensions ?? {});
238
244
  const lineage = request.source.provider === 'codex' && native.value.lineage
239
245
  ? { lineage: native.value.lineage } : {};
246
+ const images = extractEventImages({
247
+ searchableText: record.text,
248
+ extensions: record.extensions,
249
+ rawReference: record.rawReference,
250
+ });
251
+ for (const image of images.images) {
252
+ if (!imageBlobs.has(image.digest))
253
+ imageBlobs.set(image.digest, image);
254
+ }
240
255
  const digest = digests[index];
241
256
  const origin = classifySessionEventOrigin(request.source.provider, record);
242
257
  const lifecycle = sessionLifecycle(request, record, sourceObservedAt);
243
258
  const lifecycleEvidence = sessionLifecycleEvidence(request, record, sourceObservedAt);
244
259
  const extensions = {
245
- [`native.${request.source.provider}`]: record.extensions ?? {},
260
+ [`native.${request.source.provider}`]: images.extensions,
246
261
  };
247
262
  const event = sanitizeSessionEvent(SessionEventSchema.parse({
248
263
  contractVersion: SESSION_CONTRACT_VERSION,
@@ -262,7 +277,8 @@ function normalizeBatch(request, records, digests, importRunId, sourceObservedAt
262
277
  model: record.model ?? null,
263
278
  entities: record.entities ?? [],
264
279
  extractionState: record.extractionState ?? null,
265
- searchableText: record.text, digest, rawReference: record.rawReference,
280
+ searchableText: images.searchableText, imageRefs: images.imageRefs,
281
+ digest, rawReference: record.rawReference,
266
282
  adapterVersion: request.adapter.adapterVersion,
267
283
  consistency: request.source.consistency,
268
284
  sensitivity: { classification: 'none', classes: [] },
@@ -309,7 +325,11 @@ function normalizeBatch(request, records, digests, importRunId, sourceObservedAt
309
325
  for (const session of sessions.values()) {
310
326
  session.intent = deriveSessionIntent(events.filter((event) => event.sessionId === session.sessionId));
311
327
  }
312
- return { sessions: redactSessionValue([...sessions.values()]).value, events };
328
+ return {
329
+ sessions: redactSessionValue([...sessions.values()]).value,
330
+ events,
331
+ imageBlobs: [...imageBlobs.values()],
332
+ };
313
333
  }
314
334
  const KNOWN_EVENT_KINDS = new Set(['message', 'tool-call', 'tool-result', 'artifact', 'attachment', 'summary']);
315
335
  function isKnownEventKind(kind) {
@@ -1,4 +1,5 @@
1
1
  export * from './contracts.js';
2
+ export * from './image-blobs.js';
2
3
  export * from './fixtures.js';
3
4
  export * from './policy.js';
4
5
  export * from './discovery.js';
@@ -19,6 +20,7 @@ export * from './importer.js';
19
20
  export * from './import-lease.js';
20
21
  export * from './batch-contracts.js';
21
22
  export * from './batch-import.js';
23
+ export * from './host-catalog.js';
22
24
  export * from './workspace-discovery.js';
23
25
  export * from './timeline.js';
24
26
  export * from './origin.js';
@@ -1,4 +1,4 @@
1
- import { existsSync, realpathSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, realpathSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { requireFeaturePackage } from '../features/runtime.js';
4
4
  import { readAiwgConfig } from '../config/aiwg-config.js';
@@ -6,6 +6,7 @@ import { createSessionAdapterRegistry } from './adapter-registry.js';
6
6
  import { resolveImportExclusions } from './import-exclusions.js';
7
7
  import { resolveImportLimits } from './import-limits.js';
8
8
  import { acquireImportLease } from './import-lease.js';
9
+ import { defaultHostCatalogPath, manifestNeedsHostCatalog } from './host-catalog.js';
9
10
  import { SessionRepository } from './repository.js';
10
11
  import { importDiscoveryManifest } from './batch-import.js';
11
12
  import { defaultDiscoveryManifestPath, discoverWorkspaceHistories, readDiscoveryManifest, writeDiscoveryManifest, } from './workspace-discovery.js';
@@ -69,8 +70,26 @@ export async function refreshSessionCatalog(workspace, full, waitMs, signal, wor
69
70
  return 'no authorized provider roots';
70
71
  }
71
72
  const lease = await acquireImportLease(database, `index-refresh-${process.pid}`, { waitMs });
73
+ let hostLease;
72
74
  try {
73
75
  const repository = new SessionRepository(database);
76
+ // Account-scoped providers import once into the host catalog; the
77
+ // hourly host refresh (#310) routes them there instead of copying
78
+ // them into every workspace catalog (#3232). The host catalog is only
79
+ // opened when a manifest actually holds an account-scoped source.
80
+ const hostCatalogPath = defaultHostCatalogPath();
81
+ const needsHost = manifests.some((entry) => manifestNeedsHostCatalog(entry.manifest.sources));
82
+ let hostRepository;
83
+ if (needsHost) {
84
+ mkdirSync(path.dirname(hostCatalogPath), { recursive: true, mode: 0o700 });
85
+ // The workspace lease above does not cover the shared host catalog,
86
+ // so overlapping refreshes from two workspaces would stage the same
87
+ // replicas concurrently (#3232). Serialize host writers with the
88
+ // same lock pattern; the shared checkpoint then turns the second
89
+ // writer into duplicate references instead of a double import.
90
+ hostLease = await acquireImportLease(hostCatalogPath, `host-refresh-${process.pid}`, { waitMs });
91
+ hostRepository = new SessionRepository(hostCatalogPath);
92
+ }
74
93
  try {
75
94
  if (full)
76
95
  repository.reindex();
@@ -82,7 +101,9 @@ export async function refreshSessionCatalog(workspace, full, waitMs, signal, wor
82
101
  continue;
83
102
  // Persist the exact discovered batch before its confirmed import.
84
103
  await writeDiscoveryManifest(entry.path, entry.manifest);
85
- const receipt = await importDiscoveryManifest({ manifest: entry.manifest, repository, signal, limits, exclude });
104
+ const receipt = await importDiscoveryManifest({
105
+ manifest: entry.manifest, repository, hostRepository, hostCatalogPath, signal, limits, exclude,
106
+ });
86
107
  if (receipt.totals.pending) {
87
108
  throw new Error(`session import incomplete: ${receipt.totals.rejected} rejected, ${receipt.totals.pending} pending`);
88
109
  }
@@ -94,10 +115,12 @@ export async function refreshSessionCatalog(workspace, full, waitMs, signal, wor
94
115
  }
95
116
  }
96
117
  finally {
118
+ hostRepository?.close();
97
119
  repository.close();
98
120
  }
99
121
  }
100
122
  finally {
123
+ await hostLease?.release();
101
124
  await lease.release();
102
125
  }
103
126
  return undefined;