@adia-ai/a2ui-retrieval 0.6.1 → 0.6.4
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.
- package/CHANGELOG.md +26 -0
- package/embedding/chunk-embedding-retriever.ts +156 -0
- package/embedding/embedding-provider.ts +111 -0
- package/embedding/index.ts +10 -0
- package/feedback/dialog-recorder.ts +172 -0
- package/feedback/feedback-analyzer.ts +250 -0
- package/feedback/feedback-store.ts +229 -0
- package/feedback/feedback.ts +201 -0
- package/feedback/gap-registry.ts +137 -0
- package/feedback/index.ts +14 -0
- package/intent/clarity.ts +224 -0
- package/intent/decomposer.ts +229 -0
- package/intent/index.ts +20 -0
- package/intent/intent-alignment.ts +267 -0
- package/intent/intent-categorizer.ts +104 -0
- package/intent/intent-gate.ts +151 -0
- package/intent/prompt-analyzer.ts +231 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,32 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
|
|
|
6
6
|
|
|
7
7
|
_No pending changes._
|
|
8
8
|
|
|
9
|
+
## [0.6.4] - 2026-05-18
|
|
10
|
+
|
|
11
|
+
### v0.6.4 — Lockstep ride-along
|
|
12
|
+
|
|
13
|
+
TS Phase 5: a2ui.schema.json schema-driven codegen (§359). No source changes in this package.
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
## [0.6.3] - 2026-05-18
|
|
18
|
+
|
|
19
|
+
### v0.6.3 — Lockstep ride-along
|
|
20
|
+
|
|
21
|
+
TS Phase 5: runtime real declarations (§358) + compose branded IDs.
|
|
22
|
+
No source changes in this package. Internal dep ranges stay at `^0.6.0`.
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
## [0.6.2] - 2026-05-18
|
|
27
|
+
|
|
28
|
+
### v0.6.2 §356 — TypeScript-first Phase 4: `@adia-ai/a2ui-retrieval` sources → `.ts`
|
|
29
|
+
|
|
30
|
+
19 `.ts` source files. Closes TS-MIG-001 `skipLibCheck` override in
|
|
31
|
+
`@adia-ai/a2ui-compose`. `tsconfig.build.json` added. **No BREAKING changes.**
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
9
35
|
## [0.6.1] - 2026-05-18
|
|
10
36
|
|
|
11
37
|
### v0.6.1 — Lockstep ride-along
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chunk embedding retriever — loads the build-time chunk embedding index
|
|
3
|
+
* and scores a query against every chunk via cosine similarity.
|
|
4
|
+
*
|
|
5
|
+
* Sibling to `embedding-retriever.js` (pattern embeddings); same provider
|
|
6
|
+
* conventions, same graceful-degradation contract.
|
|
7
|
+
*
|
|
8
|
+
* Index: packages/a2ui/corpus/chunk-embeddings.json (built by
|
|
9
|
+
* scripts/build/embeddings-chunks.mjs). When missing or empty, the
|
|
10
|
+
* retriever is effectively a no-op — callers see an empty score map and
|
|
11
|
+
* should fall back to keyword-only ranking.
|
|
12
|
+
*
|
|
13
|
+
* Used by chunk-library.searchChunks() to blend semantic + keyword scores.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { detectProvider, cosine, voyage, openai } from './embedding-provider.js';
|
|
17
|
+
import type { EmbedFn } from './embedding-provider.js';
|
|
18
|
+
|
|
19
|
+
// `process` is not in scope under "types": []
|
|
20
|
+
declare const process: { versions?: { node?: string } } | undefined;
|
|
21
|
+
|
|
22
|
+
const IS_NODE = typeof process !== 'undefined' && !!process.versions?.node;
|
|
23
|
+
|
|
24
|
+
type ChunkEntry = {
|
|
25
|
+
name: string;
|
|
26
|
+
vector: number[];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type ChunkIndex = {
|
|
30
|
+
chunks?: ChunkEntry[];
|
|
31
|
+
provider?: string;
|
|
32
|
+
model?: string;
|
|
33
|
+
dims?: number;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
let _index: ChunkIndex | null = null;
|
|
37
|
+
let _indexByName: Map<string, Float32Array> | null = null; // Map<chunk-name, Float32Array>
|
|
38
|
+
let _loadPromise: Promise<ChunkIndex | null> | null = null;
|
|
39
|
+
let _embedFn: EmbedFn | null = null;
|
|
40
|
+
let _available: boolean | null = null;
|
|
41
|
+
|
|
42
|
+
async function _loadIndex(): Promise<ChunkIndex | null> {
|
|
43
|
+
if (_index !== null) return _index;
|
|
44
|
+
if (_loadPromise) return _loadPromise;
|
|
45
|
+
_loadPromise = (async () => {
|
|
46
|
+
try {
|
|
47
|
+
if (IS_NODE) {
|
|
48
|
+
const fs = await import(/* @vite-ignore */ 'node:fs/promises');
|
|
49
|
+
const path = await import(/* @vite-ignore */ 'node:path');
|
|
50
|
+
const url = await import(/* @vite-ignore */ 'node:url');
|
|
51
|
+
let p: string | null = null;
|
|
52
|
+
try {
|
|
53
|
+
const { createRequire } = await import(/* @vite-ignore */ 'node:module');
|
|
54
|
+
const require = createRequire(import.meta.url);
|
|
55
|
+
p = require.resolve('@adia-ai/a2ui-corpus/chunk-embeddings');
|
|
56
|
+
} catch {
|
|
57
|
+
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
|
58
|
+
p = path.resolve(here, '../../corpus/chunk-embeddings.json');
|
|
59
|
+
}
|
|
60
|
+
const raw = await fs.readFile(p, 'utf8');
|
|
61
|
+
_index = JSON.parse(raw) as ChunkIndex;
|
|
62
|
+
} else {
|
|
63
|
+
const url = new URL('../../corpus/chunk-embeddings.json', import.meta.url);
|
|
64
|
+
const res = await fetch(url).catch(() => null);
|
|
65
|
+
_index = res?.ok ? await res.json().catch(() => null) as ChunkIndex : null;
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
_index = null;
|
|
69
|
+
}
|
|
70
|
+
if (_index?.chunks?.length) {
|
|
71
|
+
_indexByName = new Map();
|
|
72
|
+
for (const c of _index.chunks) {
|
|
73
|
+
if (c?.name && Array.isArray(c.vector)) {
|
|
74
|
+
_indexByName.set(c.name, Float32Array.from(c.vector));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return _index;
|
|
79
|
+
})();
|
|
80
|
+
return _loadPromise;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function _resolveEmbed(providerName: string | undefined, model: string | undefined): EmbedFn | null {
|
|
84
|
+
// The index's recorded (provider, model) is the source of truth — query
|
|
85
|
+
// embeddings MUST be generated by the same model the corpus was indexed with.
|
|
86
|
+
let fn: EmbedFn | null = null;
|
|
87
|
+
if (providerName === 'voyage') fn = voyage({ model });
|
|
88
|
+
else if (providerName === 'openai') fn = openai({ model });
|
|
89
|
+
else {
|
|
90
|
+
// No provider recorded in index header (legacy index?). Fall back to auto-detect.
|
|
91
|
+
const auto = detectProvider();
|
|
92
|
+
return auto?.embed ?? null;
|
|
93
|
+
}
|
|
94
|
+
if (!fn && typeof console !== 'undefined') {
|
|
95
|
+
console.warn(
|
|
96
|
+
`[chunk-embedding-retriever] index was built with provider=${providerName} model=${model}, ` +
|
|
97
|
+
`but the corresponding API key is not set. Embeddings will be unavailable; falling back to ` +
|
|
98
|
+
`keyword-only retrieval. Set the matching API key, or rebuild the index with the available ` +
|
|
99
|
+
`provider via \`npm run build:embeddings:chunks\`.`
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return fn;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function available(): Promise<boolean> {
|
|
106
|
+
if (_available !== null) return _available;
|
|
107
|
+
const idx = await _loadIndex();
|
|
108
|
+
if (!idx || !idx.chunks?.length) {
|
|
109
|
+
_available = false;
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
_embedFn = _resolveEmbed(idx.provider, idx.model);
|
|
113
|
+
_available = !!_embedFn;
|
|
114
|
+
return _available;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Embed a query and return a Map<chunk-name → cosine-score>.
|
|
119
|
+
* Returns an empty Map when unavailable (no index or no API key).
|
|
120
|
+
*/
|
|
121
|
+
export async function scoreAll(query: string): Promise<Map<string, number>> {
|
|
122
|
+
if (!query || typeof query !== 'string') return new Map();
|
|
123
|
+
if (!(await available())) return new Map();
|
|
124
|
+
|
|
125
|
+
let qVec: Float32Array;
|
|
126
|
+
try {
|
|
127
|
+
const [v] = await _embedFn!([query]);
|
|
128
|
+
qVec = v!;
|
|
129
|
+
} catch (e) {
|
|
130
|
+
if (typeof console !== 'undefined') console.warn('[chunk-embedding-retriever]', (e as Error).message);
|
|
131
|
+
return new Map();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const out = new Map<string, number>();
|
|
135
|
+
if (!_indexByName) return out;
|
|
136
|
+
for (const [name, vec] of _indexByName) {
|
|
137
|
+
out.set(name, cosine(qVec, vec));
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function size(): Promise<number> {
|
|
143
|
+
const idx = await _loadIndex();
|
|
144
|
+
return idx?.chunks?.length ?? 0;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export type ChunkProviderInfo = {
|
|
148
|
+
provider: string | undefined;
|
|
149
|
+
model: string | undefined;
|
|
150
|
+
dims: number | undefined;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
export async function providerInfo(): Promise<ChunkProviderInfo | null> {
|
|
154
|
+
const idx = await _loadIndex();
|
|
155
|
+
return idx ? { provider: idx.provider, model: idx.model, dims: idx.dims } : null;
|
|
156
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pluggable embedding provider. Two implementations:
|
|
3
|
+
*
|
|
4
|
+
* voyage() — Voyage AI, voyage-3-lite by default (1024 dims).
|
|
5
|
+
* Env: VOYAGE_API_KEY. Anthropic-recommended; generous free tier.
|
|
6
|
+
*
|
|
7
|
+
* openai() — OpenAI text-embedding-3-small (1536 dims).
|
|
8
|
+
* Env: OPENAI_API_KEY. Cheap ($0.02/1M tokens).
|
|
9
|
+
*
|
|
10
|
+
* detectProvider() picks Voyage if available, else OpenAI, else null.
|
|
11
|
+
* A null provider means "embeddings unavailable" — callers should fall back
|
|
12
|
+
* to keyword retrieval cleanly.
|
|
13
|
+
*
|
|
14
|
+
* Both implementations batch inputs to minimize round-trips. Every provider
|
|
15
|
+
* returns a Promise<Float32Array[]> of the same length as the input.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const VOYAGE_URL = 'https://api.voyageai.com/v1/embeddings';
|
|
19
|
+
const OPENAI_URL = 'https://api.openai.com/v1/embeddings';
|
|
20
|
+
|
|
21
|
+
// `process` is not in scope under "types": []
|
|
22
|
+
declare const process: { env?: Record<string, string | undefined> } | undefined;
|
|
23
|
+
|
|
24
|
+
function getEnv(key: string): string {
|
|
25
|
+
if (typeof process !== 'undefined' && process.env?.[key]) return process.env[key] as string;
|
|
26
|
+
try {
|
|
27
|
+
const env = (import.meta as { env?: Record<string, string | undefined> }).env;
|
|
28
|
+
if (env?.[`VITE_${key}`]) return env[`VITE_${key}`] as string;
|
|
29
|
+
if (env?.[key]) return env[key] as string;
|
|
30
|
+
} catch {}
|
|
31
|
+
return '';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type EmbedFn = (texts: string[]) => Promise<Float32Array[]>;
|
|
35
|
+
|
|
36
|
+
export type ProviderInfo = {
|
|
37
|
+
name: string;
|
|
38
|
+
model: string;
|
|
39
|
+
embed: EmbedFn;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Voyage AI embedding provider.
|
|
44
|
+
* Returns an embed function, or null if no key.
|
|
45
|
+
*/
|
|
46
|
+
export function voyage({ apiKey, model = 'voyage-3-lite' }: { apiKey?: string; model?: string } = {}): EmbedFn | null {
|
|
47
|
+
const key = apiKey ?? getEnv('VOYAGE_API_KEY');
|
|
48
|
+
if (!key) return null;
|
|
49
|
+
|
|
50
|
+
return async function embed(texts: string[]): Promise<Float32Array[]> {
|
|
51
|
+
if (!Array.isArray(texts) || texts.length === 0) return [];
|
|
52
|
+
const res = await fetch(VOYAGE_URL, {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers: {
|
|
55
|
+
'content-type': 'application/json',
|
|
56
|
+
'authorization': `Bearer ${key}`,
|
|
57
|
+
},
|
|
58
|
+
body: JSON.stringify({ input: texts, model, input_type: 'document' }),
|
|
59
|
+
});
|
|
60
|
+
if (!res.ok) throw new Error(`Voyage ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
61
|
+
const data = await res.json() as { data: Array<{ embedding: number[] }> };
|
|
62
|
+
return data.data.map(d => new Float32Array(d.embedding));
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* OpenAI text-embedding-3-small provider.
|
|
68
|
+
* Returns an embed function, or null if no key.
|
|
69
|
+
*/
|
|
70
|
+
export function openai({ apiKey, model = 'text-embedding-3-small' }: { apiKey?: string; model?: string } = {}): EmbedFn | null {
|
|
71
|
+
const key = apiKey ?? getEnv('OPENAI_API_KEY');
|
|
72
|
+
if (!key) return null;
|
|
73
|
+
|
|
74
|
+
return async function embed(texts: string[]): Promise<Float32Array[]> {
|
|
75
|
+
if (!Array.isArray(texts) || texts.length === 0) return [];
|
|
76
|
+
const res = await fetch(OPENAI_URL, {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
headers: {
|
|
79
|
+
'content-type': 'application/json',
|
|
80
|
+
'authorization': `Bearer ${key}`,
|
|
81
|
+
},
|
|
82
|
+
body: JSON.stringify({ input: texts, model }),
|
|
83
|
+
});
|
|
84
|
+
if (!res.ok) throw new Error(`OpenAI ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
85
|
+
const data = await res.json() as { data: Array<{ embedding: number[] }> };
|
|
86
|
+
return data.data.map(d => new Float32Array(d.embedding));
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Auto-detect the best available provider. Voyage first (cheaper, dense vectors
|
|
92
|
+
* at 1024 dims), then OpenAI, then null.
|
|
93
|
+
*/
|
|
94
|
+
export function detectProvider(): ProviderInfo | null {
|
|
95
|
+
const v = voyage(); if (v) return { name: 'voyage', model: 'voyage-3-lite', embed: v };
|
|
96
|
+
const o = openai(); if (o) return { name: 'openai', model: 'text-embedding-3-small', embed: o };
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Cosine similarity between two Float32Arrays of the same length. */
|
|
101
|
+
export function cosine(a: Float32Array, b: Float32Array): number {
|
|
102
|
+
if (!a || !b || a.length !== b.length) return 0;
|
|
103
|
+
let dot = 0, na = 0, nb = 0;
|
|
104
|
+
for (let i = 0; i < a.length; i++) {
|
|
105
|
+
dot += (a[i] ?? 0) * (b[i] ?? 0);
|
|
106
|
+
na += (a[i] ?? 0) * (a[i] ?? 0);
|
|
107
|
+
nb += (b[i] ?? 0) * (b[i] ?? 0);
|
|
108
|
+
}
|
|
109
|
+
if (na === 0 || nb === 0) return 0;
|
|
110
|
+
return dot / (Math.sqrt(na) * Math.sqrt(nb));
|
|
111
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @adia-ai/a2ui-retrieval/embedding — embedding-provider + chunk retriever surface.
|
|
3
|
+
*
|
|
4
|
+
* Since §65 (v0.4.7), the legacy pattern-embedding retriever was retired
|
|
5
|
+
* along with its only consumer (concept-mapper.js, dead post-§64). The
|
|
6
|
+
* canonical retrieval surface post-§40 is chunk-embedding-retriever.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export * from './embedding-provider.js';
|
|
10
|
+
export * from './chunk-embedding-retriever.js';
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dialog Recorder — write every generation turn to disk for debugging,
|
|
3
|
+
* regression analysis, and training-data bootstrapping.
|
|
4
|
+
*
|
|
5
|
+
* Output: logs/dialogs/<sessionId>/<NNN>-<turnId>.json
|
|
6
|
+
* - <sessionId>: storeId / executionId of the multi-turn session, or
|
|
7
|
+
* 'standalone-<isodate>' for one-shot generations.
|
|
8
|
+
* - <NNN>: zero-padded turn index within the session (000, 001, …)
|
|
9
|
+
* - <turnId>: short slug from the intent + a random suffix for uniqueness
|
|
10
|
+
*
|
|
11
|
+
* Gated by ADIA_LOG_DIALOGS=1 — opt-in, zero overhead when disabled. Browser
|
|
12
|
+
* environment is also a no-op (no fs); only Node-side generation paths log.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// `process` is not in scope under "types": []
|
|
16
|
+
declare const process: { versions?: { node?: string }; env?: Record<string, string | undefined> } | undefined;
|
|
17
|
+
|
|
18
|
+
const IS_NODE = typeof process !== 'undefined' && !!process.versions?.node;
|
|
19
|
+
const ENABLED = IS_NODE && (process?.env?.['ADIA_LOG_DIALOGS'] === '1' || process?.env?.['ADIA_LOG_DIALOGS'] === 'true');
|
|
20
|
+
|
|
21
|
+
// Lazy-loaded Node modules (only when ENABLED)
|
|
22
|
+
let _fs: typeof import('node:fs/promises') | null = null;
|
|
23
|
+
let _path: typeof import('node:path') | null = null;
|
|
24
|
+
let _url: typeof import('node:url') | null = null;
|
|
25
|
+
let _logsRoot: string | null = null;
|
|
26
|
+
|
|
27
|
+
async function _ensureModules(): Promise<void> {
|
|
28
|
+
if (_fs) return;
|
|
29
|
+
_fs = await import(/* @vite-ignore */ 'node:fs/promises');
|
|
30
|
+
_path = await import(/* @vite-ignore */ 'node:path');
|
|
31
|
+
_url = await import(/* @vite-ignore */ 'node:url');
|
|
32
|
+
// logs/ lives at the repo root: packages/a2ui/retrieval/feedback → up 4 → repo root
|
|
33
|
+
const __dirname = _path.dirname(_url.fileURLToPath(import.meta.url));
|
|
34
|
+
_logsRoot = _path.resolve(__dirname, '..', '..', '..', '..', 'logs', 'dialogs');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// In-memory turn counter per session
|
|
38
|
+
const _turnCounter = new Map<string, number>();
|
|
39
|
+
const _sessionMetaWritten = new Set<string>();
|
|
40
|
+
|
|
41
|
+
export type TurnRecord = {
|
|
42
|
+
sessionId?: string;
|
|
43
|
+
intent?: string;
|
|
44
|
+
mode?: string;
|
|
45
|
+
engine?: string;
|
|
46
|
+
model?: string;
|
|
47
|
+
analysis?: {
|
|
48
|
+
steelman?: string;
|
|
49
|
+
concepts?: string[];
|
|
50
|
+
impliedComponents?: string[];
|
|
51
|
+
styleHints?: string[];
|
|
52
|
+
};
|
|
53
|
+
currentCanvas?: unknown;
|
|
54
|
+
patterns?: Array<{ name?: string; score?: number; confidence?: number; keywords?: string[] | null }>;
|
|
55
|
+
systemPrompt?: string;
|
|
56
|
+
rawLLMResponse?: string;
|
|
57
|
+
messages?: unknown[];
|
|
58
|
+
validation?: unknown;
|
|
59
|
+
drift?: unknown;
|
|
60
|
+
suggestions?: string[];
|
|
61
|
+
timing?: unknown;
|
|
62
|
+
tokens?: unknown;
|
|
63
|
+
engineDebug?: unknown;
|
|
64
|
+
isIteration?: boolean;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Record one generation turn. Safe to call unconditionally — when the env var
|
|
69
|
+
* is unset, this is a no-op that returns immediately.
|
|
70
|
+
*
|
|
71
|
+
* @returns The path written, or null when logging is disabled.
|
|
72
|
+
*/
|
|
73
|
+
export async function recordTurn(record: TurnRecord): Promise<string | null> {
|
|
74
|
+
if (!ENABLED) return null;
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
await _ensureModules();
|
|
78
|
+
|
|
79
|
+
const sessionId = record.sessionId ?? `standalone-${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
|
80
|
+
const turnIdx = (_turnCounter.get(sessionId) ?? 0);
|
|
81
|
+
_turnCounter.set(sessionId, turnIdx + 1);
|
|
82
|
+
|
|
83
|
+
const slug = String(record.intent ?? 'turn')
|
|
84
|
+
.toLowerCase()
|
|
85
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
86
|
+
.replace(/^-+|-+$/g, '')
|
|
87
|
+
.slice(0, 32) || 'turn';
|
|
88
|
+
const rand = Math.random().toString(36).slice(2, 6);
|
|
89
|
+
const fileName = `${String(turnIdx).padStart(3, '0')}-${slug}-${rand}.json`;
|
|
90
|
+
|
|
91
|
+
const sessionDir = _path!.join(_logsRoot!, sessionId);
|
|
92
|
+
await _fs!.mkdir(sessionDir, { recursive: true });
|
|
93
|
+
|
|
94
|
+
if (!_sessionMetaWritten.has(sessionId) && turnIdx === 0) {
|
|
95
|
+
_sessionMetaWritten.add(sessionId);
|
|
96
|
+
const sessionMeta = {
|
|
97
|
+
sessionId,
|
|
98
|
+
startedAt: new Date().toISOString(),
|
|
99
|
+
originIntent: record.intent ?? null,
|
|
100
|
+
engine: record.engine ?? null,
|
|
101
|
+
mode: record.mode ?? null,
|
|
102
|
+
model: record.model ?? null,
|
|
103
|
+
originAnalysis: record.analysis ? {
|
|
104
|
+
steelman: record.analysis.steelman ?? null,
|
|
105
|
+
concepts: record.analysis.concepts ?? [],
|
|
106
|
+
impliedComponents: record.analysis.impliedComponents ?? [],
|
|
107
|
+
styleHints: record.analysis.styleHints ?? [],
|
|
108
|
+
} : null,
|
|
109
|
+
};
|
|
110
|
+
await _fs!.writeFile(
|
|
111
|
+
_path!.join(sessionDir, '_session.json'),
|
|
112
|
+
JSON.stringify(sessionMeta, null, 2) + '\n',
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const payload = {
|
|
117
|
+
// ── Identity ────────────────────────────────────────────────
|
|
118
|
+
sessionId,
|
|
119
|
+
turnIndex: turnIdx,
|
|
120
|
+
timestamp: new Date().toISOString(),
|
|
121
|
+
isIteration: !!record.isIteration,
|
|
122
|
+
|
|
123
|
+
// ── Request ─────────────────────────────────────────────────
|
|
124
|
+
intent: record.intent,
|
|
125
|
+
mode: record.mode ?? null,
|
|
126
|
+
engine: record.engine ?? null,
|
|
127
|
+
model: record.model ?? null,
|
|
128
|
+
currentCanvas: record.currentCanvas ?? null,
|
|
129
|
+
|
|
130
|
+
// ── Pipeline-side reasoning ─────────────────────────────────
|
|
131
|
+
analysis: record.analysis ?? null,
|
|
132
|
+
patterns: (record.patterns ?? []).slice(0, 10).map(p => ({
|
|
133
|
+
name: p.name,
|
|
134
|
+
score: p.score ?? p.confidence ?? null,
|
|
135
|
+
keywords: p.keywords ?? null,
|
|
136
|
+
})),
|
|
137
|
+
|
|
138
|
+
// ── LLM I/O ─────────────────────────────────────────────────
|
|
139
|
+
systemPrompt: record.systemPrompt ?? null,
|
|
140
|
+
rawLLMResponse: record.rawLLMResponse ?? null,
|
|
141
|
+
|
|
142
|
+
// ── Result ──────────────────────────────────────────────────
|
|
143
|
+
messages: record.messages ?? [],
|
|
144
|
+
validation: record.validation ?? null,
|
|
145
|
+
drift: record.drift ?? null,
|
|
146
|
+
suggestions: record.suggestions ?? [],
|
|
147
|
+
|
|
148
|
+
// ── Telemetry ───────────────────────────────────────────────
|
|
149
|
+
timing: record.timing ?? null,
|
|
150
|
+
tokens: record.tokens ?? null,
|
|
151
|
+
engineDebug: record.engineDebug ?? null,
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const filePath = _path!.join(sessionDir, fileName);
|
|
155
|
+
await _fs!.writeFile(filePath, JSON.stringify(payload, null, 2) + '\n');
|
|
156
|
+
return filePath;
|
|
157
|
+
} catch (err) {
|
|
158
|
+
// Logging must NEVER break a generation. Swallow + warn once per turn.
|
|
159
|
+
console.warn('[dialog-recorder] failed to record turn:', (err as Error).message);
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** True when logging is on. Useful for guarding expensive capture work. */
|
|
165
|
+
export function isRecording(): boolean {
|
|
166
|
+
// ENABLED is already gated by IS_NODE at module init. The A2UI_COMPOSE_TRACE
|
|
167
|
+
// bypass must guard `process` again — without it, browser callers throw
|
|
168
|
+
// `ReferenceError: process is not defined` after a successful LLM round-trip.
|
|
169
|
+
if (ENABLED) return true;
|
|
170
|
+
const IS_NODE_RT = typeof process !== 'undefined' && process.versions?.node;
|
|
171
|
+
return IS_NODE_RT ? !!(process?.env?.['A2UI_COMPOSE_TRACE']) : false;
|
|
172
|
+
}
|