Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,38 @@
*/

import { spawn as nodeSpawn } from 'node:child_process';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { randomUUID } from 'node:crypto';
import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { resolveProfileDir } from '../../profile/profile-dir.js';

const SCRIPT_BASENAME = 'compile-system-prompt-l0.mjs';

/**
* F257 #2 — one L-series (L1-L7) segment as rendered by the actual L0 compiler.
* The raw per-segment content is the authoritative artifact the provider delivers;
* hash/token/version formatting is derived downstream (see l0-manifest-trace.ts).
*/
export interface L0SegmentContent {
segmentId: string;
content: string;
}

// ── L0 cache ────────────────────────────────────────────────────────
// The compiled L0 depends on static inputs (shared-rules.md, cat config,
// teammate roster) that don't change during a session. Caching avoids
// spawning a subprocess on every invoke(). The cache is populated at
// startup via warmL0Cache() and invalidated on hot-reload via clearL0Cache().
const l0Cache = new Map<string, string>();

// F257 #2 — per-segment L1-L7 manifest, populated in LOCKSTEP with l0Cache inside
// doCompileL0() (same subprocess, same generation guard, cleared together). This
// lets the injection trace be sourced from the SAME compiled artifact the provider
// delivers, instead of an out-of-band reconstruction that can diverge.
const l0ManifestCache = new Map<string, L0SegmentContent[]>();

// In-flight Promise dedup — Phase G AC-G10 (砚砚 Design Gate position 1).
// Without this, two concurrent calls on a cold cache (e.g. invoke provider
// + Prompt X-Ray capture inside the same invocation hot path) both spawn
Expand Down Expand Up @@ -70,13 +88,15 @@ function isL0GenerationCurrent(catId: string, generation: { global: number; cat:
export function clearL0Cache(catId?: string): void {
if (catId) {
l0Cache.delete(catId);
l0ManifestCache.delete(catId);
bumpL0Generation(catId);
// Also drop any in-flight promise — next call will re-spawn fresh. The
// generation guard prevents the older promise from repopulating l0Cache
// when it eventually resolves after this clear.
l0InflightPromises.delete(catId);
} else {
l0Cache.clear();
l0ManifestCache.clear();
bumpL0Generation();
l0InflightPromises.clear();
}
Expand Down Expand Up @@ -228,7 +248,20 @@ async function doCompileL0(
// write (routes) MUST resolve identically or the nurturing loop silently breaks (a primer
// written to one path while the injector reads another).
const profileDir = resolveProfileDir(cwd, scriptPath);
const args = [scriptPath, '--cat', catId, '--profile-dir', profileDir, ...(outPath ? ['--out', outPath] : [])];
// F257 #2: always request the per-segment L1-L7 manifest to a temp file so the
// injection trace can be sourced from this exact compiled artifact. Orthogonal to
// --out; the string compile stays authoritative + fail-closed.
const manifestPath = join(tmpdir(), `cat-cafe-l0-manifest-${catId}-${randomUUID()}.json`);
const args = [
scriptPath,
'--cat',
catId,
'--profile-dir',
profileDir,
'--manifest-out',
manifestPath,
...(outPath ? ['--out', outPath] : []),
];

const stdout = await new Promise<string>((resolvePromise, rejectPromise) => {
const child = spawnFn(process.execPath, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
Expand Down Expand Up @@ -271,8 +304,56 @@ async function doCompileL0(
}
}

// F257 #2: read the per-segment manifest (best-effort — a manifest failure must
// NEVER fail the critical fail-closed string compile above). An empty manifest is a
// visible "L not observed" signal downstream, not a silent healthy-zero.
const manifest = readL0Manifest(manifestPath);

if (isL0GenerationCurrent(catId, compileGeneration)) {
l0Cache.set(catId, result);
l0ManifestCache.set(catId, manifest);
}
return result;
}

/** Parse + clean up the temp L-segment manifest written by the compiler. */
function readL0Manifest(manifestPath: string): L0SegmentContent[] {
try {
if (!existsSync(manifestPath)) return [];
const parsed: unknown = JSON.parse(readFileSync(manifestPath, 'utf8'));
if (!Array.isArray(parsed)) return [];
return parsed
.filter(
(e): e is { id: string; content: string } =>
!!e &&
typeof e === 'object' &&
typeof (e as { id?: unknown }).id === 'string' &&
typeof (e as { content?: unknown }).content === 'string',
)
.map((e) => ({ segmentId: e.id, content: e.content }));
} catch {
return [];
} finally {
try {
if (existsSync(manifestPath)) unlinkSync(manifestPath);
} catch {
/* temp cleanup best-effort */
}
}
}

/**
* F257 #2 — get the per-segment L1-L7 manifest for a cat, sourced from the SAME
* compiled artifact the provider delivers. Cache-first; on a cold cache it triggers
* the shared subprocess compile (which populates both string + manifest caches under
* the generation guard), so the provider's later compile is a cache hit — no
* redundant work. Returns [] when the compile produced no manifest (visible signal).
*/
export async function getL0ManifestViaSubprocess(options: CompileL0Options): Promise<L0SegmentContent[]> {
const cached = l0ManifestCache.get(options.catId);
if (cached) return cached;
// Manifest cold ⇒ string cold too (they are set together in doCompileL0), so this
// actually compiles rather than hitting compileL0ViaSubprocess's string cache-first.
await compileL0ViaSubprocess(options);
return l0ManifestCache.get(options.catId) ?? [];
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
prepareGuideContext,
} from '../../../../guides/GuideRoutingInterceptor.js';
import { triggerRecallCorrelation } from '../../../../memory/recall-correlation-hook.js';
import { persistNativeL0SessionTrace } from '../../../../prompt-hooks/native-l0-trace.js';
import { drainCapturedTraces, refreshOverrideSnapshot } from '../../../../prompt-hooks/PipelinePromptBuilder.js';
import { getTraceStore } from '../../../../prompt-hooks/trace-bootstrap.js';
// F257: Pipeline trace bridge — richer per-hook traces, replaces redundant v0 re-collection
Expand Down Expand Up @@ -397,33 +398,48 @@ export async function* routeParallel(
const traceStore = getTraceStore();
if (traceStore && !preTraceSignal?.aborted) {
const traceTurnId = crypto.randomUUID();
// F257: try pipeline bridge first — richer per-hook data
const bridgeResult = buildFromPipeline(pipelineSessionTrace.session, pipelineTurnTrace.turn, {
turnId: traceTurnId,
threadId,
catId: catId as string,
hasNativeL0,
});
if (bridgeResult) {
traceStore.persist(bridgeResult.summary, bridgeResult.detail).catch((err) => {
log.warn({ err, threadId, catId }, '[F257] pipeline trace persist failed (fire-and-forget)');
if (hasNativeL0) {
// F257 #2: native-L0 identity (L1-L7) is delivered by the native L0 compiler,
// not the session pipeline. Source the trace from that ACTUAL compiled artifact
// (cache-first, shares the provider's compile) — fire-and-forget so it never
// taxes the model critical path; visible warning if the manifest is empty.
void persistNativeL0SessionTrace({
traceStore,
catId: catId as string,
threadId,
turnId: traceTurnId,
turnResult: pipelineTurnTrace.turn,
log,
});
} else {
// v0 fallback: re-collect traces via annotateSegments (legacy path)
const traceModePrompt = modeSystemPromptByCat?.[catId as string] ?? modeSystemPrompt ?? '';
const traceTurnContent = [invocationContext, traceModePrompt, bootstrapCtx, mcpInstructions]
.filter(Boolean)
.join('\n\n---\n\n');
const collected = collectTrace(catId as string, staticIdentity, traceTurnContent, hasNativeL0, {
mcpAvailable,
packBlocks,
});
const traceMeta = { turnId: traceTurnId, threadId, catId: catId as string };
const summary = buildTraceSummary(collected, traceMeta);
const detail = buildTraceDetail(collected, traceMeta);
traceStore.persist(summary, detail).catch((err) => {
log.warn({ err, threadId, catId }, '[F237] injection trace persist failed (fire-and-forget)');
// Non-native: session identity IS pipeline-delivered (S-series captured trace).
const bridgeResult = buildFromPipeline(pipelineSessionTrace.session, pipelineTurnTrace.turn, {
turnId: traceTurnId,
threadId,
catId: catId as string,
hasNativeL0,
});
if (bridgeResult) {
traceStore.persist(bridgeResult.summary, bridgeResult.detail).catch((err) => {
log.warn({ err, threadId, catId }, '[F257] pipeline trace persist failed (fire-and-forget)');
});
} else {
// v0 fallback: re-collect traces via annotateSegments (legacy/unknown-cat path)
const traceModePrompt = modeSystemPromptByCat?.[catId as string] ?? modeSystemPrompt ?? '';
const traceTurnContent = [invocationContext, traceModePrompt, bootstrapCtx, mcpInstructions]
.filter(Boolean)
.join('\n\n---\n\n');
const collected = collectTrace(catId as string, staticIdentity, traceTurnContent, hasNativeL0, {
mcpAvailable,
packBlocks,
});
const traceMeta = { turnId: traceTurnId, threadId, catId: catId as string };
const summary = buildTraceSummary(collected, traceMeta);
const detail = buildTraceDetail(collected, traceMeta);
traceStore.persist(summary, detail).catch((err) => {
log.warn({ err, threadId, catId }, '[F237] injection trace persist failed (fire-and-forget)');
});
}
}
}
// v0 collectTrace → buildStaticIdentity(annotateSegments: true) re-populates
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ import {
prepareGuideContext,
} from '../../../../guides/GuideRoutingInterceptor.js';
import { triggerRecallCorrelation } from '../../../../memory/recall-correlation-hook.js';
import { persistNativeL0SessionTrace } from '../../../../prompt-hooks/native-l0-trace.js';
import { drainCapturedTraces, refreshOverrideSnapshot } from '../../../../prompt-hooks/PipelinePromptBuilder.js';
import { getTraceStore } from '../../../../prompt-hooks/trace-bootstrap.js';
// F257: Pipeline trace bridge — richer per-hook traces, replaces redundant v0 re-collection
Expand Down Expand Up @@ -918,33 +919,48 @@ export async function* routeSerial(
const traceStore = getTraceStore();
if (traceStore) {
const traceTurnId = crypto.randomUUID();
// F257: try pipeline bridge first — richer per-hook data
const bridgeResult = buildFromPipeline(pipelineSessionTrace.session, pipelineTurnTrace.turn, {
turnId: traceTurnId,
threadId,
catId: catId as string,
hasNativeL0,
});
if (bridgeResult) {
traceStore.persist(bridgeResult.summary, bridgeResult.detail).catch((err) => {
log.warn({ err, threadId, catId }, '[F257] pipeline trace persist failed (fire-and-forget)');
if (hasNativeL0) {
// F257 #2: native-L0 identity (L1-L7) is delivered by the native L0 compiler,
// not the session pipeline. Source the trace from that ACTUAL compiled artifact
// (cache-first, shares the provider's compile) — fire-and-forget so it never
// taxes the model critical path; visible warning if the manifest is empty.
void persistNativeL0SessionTrace({
traceStore,
catId: catId as string,
threadId,
turnId: traceTurnId,
turnResult: pipelineTurnTrace.turn,
log,
});
} else {
// v0 fallback: re-collect traces via annotateSegments (legacy path)
const traceModePrompt = modeSystemPromptByCat?.[catId as string] ?? modeSystemPrompt ?? '';
const traceTurnContent = [invocationContext, traceModePrompt, bootstrapContext, mcpInstructions]
.filter(Boolean)
.join('\n\n---\n\n');
const traceV0 = collectTrace(catId as string, staticIdentity, traceTurnContent, hasNativeL0, {
mcpAvailable,
packBlocks,
});
const traceMeta = { turnId: traceTurnId, threadId, catId: catId as string };
const summary = buildTraceSummary(traceV0, traceMeta);
const detail = buildTraceDetail(traceV0, traceMeta);
traceStore.persist(summary, detail).catch((err) => {
log.warn({ err, threadId, catId }, '[F237] injection trace persist failed (fire-and-forget)');
// Non-native: session identity IS pipeline-delivered (S-series captured trace).
const bridgeResult = buildFromPipeline(pipelineSessionTrace.session, pipelineTurnTrace.turn, {
turnId: traceTurnId,
threadId,
catId: catId as string,
hasNativeL0,
});
if (bridgeResult) {
traceStore.persist(bridgeResult.summary, bridgeResult.detail).catch((err) => {
log.warn({ err, threadId, catId }, '[F257] pipeline trace persist failed (fire-and-forget)');
});
} else {
// v0 fallback: re-collect traces via annotateSegments (legacy/unknown-cat path)
const traceModePrompt = modeSystemPromptByCat?.[catId as string] ?? modeSystemPrompt ?? '';
const traceTurnContent = [invocationContext, traceModePrompt, bootstrapContext, mcpInstructions]
.filter(Boolean)
.join('\n\n---\n\n');
const traceV0 = collectTrace(catId as string, staticIdentity, traceTurnContent, hasNativeL0, {
mcpAvailable,
packBlocks,
});
const traceMeta = { turnId: traceTurnId, threadId, catId: catId as string };
const summary = buildTraceSummary(traceV0, traceMeta);
const detail = buildTraceDetail(traceV0, traceMeta);
traceStore.persist(summary, detail).catch((err) => {
log.warn({ err, threadId, catId }, '[F237] injection trace persist failed (fire-and-forget)');
});
}
}
}
// v0 collectTrace → buildStaticIdentity(annotateSegments: true) re-populates
Expand Down
84 changes: 84 additions & 0 deletions packages/api/src/domains/prompt-hooks/l0-manifest-trace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* F257 #2 — L0 manifest → session trace adapter.
*
* Converts the per-segment L1-L7 manifest emitted by the ACTUAL L0 compiler
* (`getL0ManifestViaSubprocess`) into a session `PipelineResult`, so the existing
* trace bridge (`buildFromPipeline` → `eventsToSegments`) persists it as per-segment
* `ObservedSegment`s — no second persistence format.
*
* Why this and not the (rejected) `collectNativeL0SessionTrace`: that reran the API
* hook pipeline (a separate code path) and could report OVERRIDDEN L content the
* override-blind native compiler never delivered. This adapter's input IS the compiled
* artifact, so hash/char/token describe exactly what the provider received. Version is
* the only field not in the artifact; it resolves from the hook registry (same source
* the segment lifeline uses), defaulting to 1 for the always-on L hooks.
*/

import type { TraceEvent, TraceEventFired } from '@cat-cafe/shared';
import { estimateTokens } from '../../utils/token-counter.js';
import type { L0SegmentContent } from '../cats/services/agents/providers/l0-compiler.js';
import type { PipelineResult } from './HookPipeline.js';
import { getCachedRegistry } from './PipelinePromptBuilder.js';
import { hashContent } from './trace-collector.js';

/** The native L0 identity is exactly these segments, in this order (compiler-emitted). */
const CANONICAL_L_SEGMENTS = ['L1', 'L2', 'L3', 'L4', 'L5', 'L6', 'L7'] as const;

/**
* F257 #2 (2b R2 P1-1): validate the manifest as ONE atomic L1-L7 artifact.
* Returns null when valid, else a human-readable reason.
*
* The native L0 identity is delivered as a whole, so the trace must trust it atomically:
* a partial / empty / reordered / foreign / duplicate / blank-content manifest means the
* producer (compiler / CLI) regressed, and recording it as healthy `fired` data would
* recreate the original incident (Console shows an apparently-injected iron-law segment
* that was actually dropped or empty). Any violation → reject the WHOLE manifest into the
* visible producer-failure path, never a partial success.
*/
export function validateL0Manifest(manifest: readonly L0SegmentContent[]): string | null {
if (manifest.length !== CANONICAL_L_SEGMENTS.length) {
return `expected exactly ${CANONICAL_L_SEGMENTS.length} L segments, got ${manifest.length}`;
}
for (let i = 0; i < CANONICAL_L_SEGMENTS.length; i++) {
const seg = manifest[i];
if (!seg || seg.segmentId !== CANONICAL_L_SEGMENTS[i]) {
// Catches missing / duplicate / foreign / reordered in one canonical-order check.
return `segment[${i}] must be ${CANONICAL_L_SEGMENTS[i]}, got "${seg?.segmentId}"`;
}
if (typeof seg.content !== 'string' || seg.content.trim().length === 0) {
return `${seg.segmentId} has blank content`;
}
}
return null;
}

/**
* Build a session-stage `PipelineResult` from the real L0 compiler manifest, or null when
* the manifest fails atomic validation (see validateL0Manifest) — callers then emit a
* visible "L not observed" signal instead of persisting a partial/false healthy trace.
*/
export function l0ManifestToSessionResult(manifest: readonly L0SegmentContent[]): PipelineResult | null {
if (validateL0Manifest(manifest) !== null) return null;
const registry = getCachedRegistry();
const timestamp = Date.now();

const patches = manifest.map((seg, i) => ({
hookId: seg.segmentId,
content: seg.content,
order: (i + 1) * 100,
}));

const events: TraceEvent[] = manifest.map(
(seg): TraceEventFired => ({
hookId: seg.segmentId,
stage: 'session-init',
timestamp,
status: 'fired',
version: registry?.getHook(seg.segmentId)?.manifest.version ?? 1,
contentHash: hashContent(seg.content),
tokenEstimate: estimateTokens(seg.content),
}),
);

return { patches, events };
}
Loading