sxng-cli 1.2.6 → 1.2.7

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 (91) hide show
  1. package/README.md +63 -46
  2. package/dist/claims/deterministic-checks.d.ts +1 -0
  3. package/dist/claims/deterministic-checks.d.ts.map +1 -1
  4. package/dist/claims/deterministic-checks.js +3 -4
  5. package/dist/claims/deterministic-checks.js.map +1 -1
  6. package/dist/claims/store.d.ts +6 -0
  7. package/dist/claims/store.d.ts.map +1 -1
  8. package/dist/claims/store.js +31 -6
  9. package/dist/claims/store.js.map +1 -1
  10. package/dist/claims/types.d.ts +7 -2
  11. package/dist/claims/types.d.ts.map +1 -1
  12. package/dist/commands/claim.d.ts +3 -3
  13. package/dist/commands/claim.d.ts.map +1 -1
  14. package/dist/commands/claim.js +70 -60
  15. package/dist/commands/claim.js.map +1 -1
  16. package/dist/commands/evidence.d.ts +3 -4
  17. package/dist/commands/evidence.d.ts.map +1 -1
  18. package/dist/commands/evidence.js +164 -144
  19. package/dist/commands/evidence.js.map +1 -1
  20. package/dist/commands/extract.d.ts +3 -3
  21. package/dist/commands/extract.d.ts.map +1 -1
  22. package/dist/commands/extract.js +58 -62
  23. package/dist/commands/extract.js.map +1 -1
  24. package/dist/commands/graph-add.d.ts +3 -13
  25. package/dist/commands/graph-add.d.ts.map +1 -1
  26. package/dist/commands/graph-add.js +78 -138
  27. package/dist/commands/graph-add.js.map +1 -1
  28. package/dist/commands/json-input.d.ts +18 -0
  29. package/dist/commands/json-input.d.ts.map +1 -0
  30. package/dist/commands/json-input.js +50 -0
  31. package/dist/commands/json-input.js.map +1 -0
  32. package/dist/commands/results-add.d.ts +5 -9
  33. package/dist/commands/results-add.d.ts.map +1 -1
  34. package/dist/commands/results-add.js +66 -32
  35. package/dist/commands/results-add.js.map +1 -1
  36. package/dist/commands/review.d.ts +1 -1
  37. package/dist/commands/review.d.ts.map +1 -1
  38. package/dist/commands/review.js +63 -55
  39. package/dist/commands/review.js.map +1 -1
  40. package/dist/commands/verdict.d.ts.map +1 -1
  41. package/dist/commands/verdict.js +13 -2
  42. package/dist/commands/verdict.js.map +1 -1
  43. package/dist/deep/dedupe.js +1 -1
  44. package/dist/deep/dedupe.js.map +1 -1
  45. package/dist/deep/extractor.d.ts +3 -1
  46. package/dist/deep/extractor.d.ts.map +1 -1
  47. package/dist/deep/extractor.js +74 -19
  48. package/dist/deep/extractor.js.map +1 -1
  49. package/dist/deep/graph-explore.d.ts +1 -1
  50. package/dist/deep/graph-explore.d.ts.map +1 -1
  51. package/dist/deep/graph-explore.js +14 -12
  52. package/dist/deep/graph-explore.js.map +1 -1
  53. package/dist/deep/graph-preprocess.d.ts +2 -1
  54. package/dist/deep/graph-preprocess.d.ts.map +1 -1
  55. package/dist/deep/graph-preprocess.js +10 -10
  56. package/dist/deep/graph-preprocess.js.map +1 -1
  57. package/dist/deep/graph-sampling.d.ts.map +1 -1
  58. package/dist/deep/graph-sampling.js +3 -2
  59. package/dist/deep/graph-sampling.js.map +1 -1
  60. package/dist/deep/graph.d.ts +9 -2
  61. package/dist/deep/graph.d.ts.map +1 -1
  62. package/dist/deep/graph.js +7 -6
  63. package/dist/deep/graph.js.map +1 -1
  64. package/dist/deep/local-doc/indexer.d.ts.map +1 -1
  65. package/dist/deep/local-doc/indexer.js +34 -1
  66. package/dist/deep/local-doc/indexer.js.map +1 -1
  67. package/dist/deep/local-doc/searcher.d.ts +4 -2
  68. package/dist/deep/local-doc/searcher.d.ts.map +1 -1
  69. package/dist/deep/local-doc/searcher.js +18 -4
  70. package/dist/deep/local-doc/searcher.js.map +1 -1
  71. package/dist/deep/path-extraction.js +2 -2
  72. package/dist/deep/path-extraction.js.map +1 -1
  73. package/dist/deep/quality-assess.d.ts +4 -4
  74. package/dist/deep/quality-assess.d.ts.map +1 -1
  75. package/dist/deep/quality-assess.js +11 -11
  76. package/dist/deep/quality-assess.js.map +1 -1
  77. package/dist/deep/quality-assess.sync-conflict-20260728-195206-JAULAPO.d.ts +46 -0
  78. package/dist/deep/quality-assess.sync-conflict-20260728-195206-JAULAPO.d.ts.map +1 -0
  79. package/dist/deep/quality-assess.sync-conflict-20260728-195206-JAULAPO.js +122 -0
  80. package/dist/deep/quality-assess.sync-conflict-20260728-195206-JAULAPO.js.map +1 -0
  81. package/dist/deep/search-strategy.d.ts.map +1 -1
  82. package/dist/deep/search-strategy.js +13 -6
  83. package/dist/deep/search-strategy.js.map +1 -1
  84. package/dist/deep/session.d.ts +115 -56
  85. package/dist/deep/session.d.ts.map +1 -1
  86. package/dist/deep/session.js +554 -253
  87. package/dist/deep/session.js.map +1 -1
  88. package/dist/runCli.d.ts.map +1 -1
  89. package/dist/runCli.js +111 -44
  90. package/dist/runCli.js.map +1 -1
  91. package/package.json +1 -1
@@ -1,309 +1,610 @@
1
1
  /**
2
- * Session management for multi-round search accumulation
3
- *
4
- * A session directory contains:
5
- * - results.json: Accumulated search result pool (deduped by URL)
6
- * - graph.json: graphology knowledge graph (structural + semantic layers)
2
+ * Session state is the authority for discovery, extracted bodies, and approval.
3
+ * Every mutation uses an atomic same-directory write so an interrupted command
4
+ * cannot turn a valid session into an empty one.
7
5
  */
8
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
9
- import { join } from 'path';
10
- import { DirectedGraph } from 'graphology';
11
- import { deserializeGraph, serializeGraph, graphStats, buildStructuralEdges } from './graph.js';
6
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'fs';
7
+ import { dirname, join, relative, resolve } from 'path';
8
+ import { MultiDirectedGraph } from 'graphology';
9
+ import { buildStructuralEdges, deserializeGraph, graphStats, resultId, serializeGraph } from './graph.js';
12
10
  import { resultUrlKey } from './dedupe.js';
13
11
  import { getDefaultSessionRoot, loadSessionMeta } from '../commands/session.js';
14
- /** Resolve session path. Supports:
15
- * - "new": auto-create under default root with unique name
16
- * - pure name (no separators): resolve to default session root
17
- * - full path: return as-is
18
- */
19
- export function resolveSessionPath(sessionValue) {
20
- if (sessionValue === 'new') {
21
- const root = getDefaultSessionRoot();
22
- if (!existsSync(root)) {
23
- mkdirSync(root, { recursive: true });
24
- }
25
- const name = `ds_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
26
- return join(root, name);
12
+ import { assertClaimsStoreReadable, loadClaims, loadEvidences, loadReviews, saveClaims, saveEvidences, saveReviews } from '../claims/store.js';
13
+ export class SessionStateError extends Error {
14
+ code;
15
+ constructor(code, message) {
16
+ super(message);
17
+ this.code = code;
27
18
  }
28
- // Pure name without path separators: resolve to default sessions dir
29
- if (!sessionValue.includes('/') && !sessionValue.includes('\\')) {
30
- const root = getDefaultSessionRoot();
31
- return join(root, sessionValue);
32
- }
33
- return sessionValue;
34
19
  }
35
- /** Ensure session directory exists and write initial meta */
36
- export function initSessionDir(sessionDir, owner, description, query) {
37
- if (!existsSync(sessionDir)) {
38
- mkdirSync(sessionDir, { recursive: true });
39
- }
40
- // Write or update meta.json
41
- const metaFile = join(sessionDir, 'meta.json');
42
- const existing = loadSessionMeta(sessionDir);
43
- const now = Date.now();
44
- const meta = {
45
- owner: owner || existing?.owner || '',
46
- description: description || existing?.description || '',
47
- createdAt: existing?.createdAt || now,
48
- updatedAt: now,
49
- query: query || existing?.query || '',
50
- };
20
+ const SESSION_LOCK_STALE_MS = 30_000;
21
+ function atomicWriteJson(file, value) {
22
+ const temp = join(dirname(file), `.${file.split(/[\\/]/).pop()}.${process.pid}.${Date.now()}.tmp`);
23
+ writeFileSync(temp, JSON.stringify(value, null, 2), 'utf-8');
51
24
  try {
52
- writeFileSync(metaFile, JSON.stringify(meta, null, 2), 'utf-8');
25
+ renameSync(temp, file);
26
+ }
27
+ catch (error) {
28
+ try {
29
+ rmSync(temp, { force: true });
30
+ }
31
+ catch { /* preserve the original write error */ }
32
+ throw error;
53
33
  }
54
- catch { /* meta write failure is non-critical */ }
55
34
  }
56
- /** Load accumulated results from session, or return empty */
57
- export function loadSessionResults(sessionDir) {
58
- const file = join(sessionDir, 'results.json');
59
- if (!existsSync(file))
60
- return [];
35
+ function withSessionLock(sessionDir, action) {
36
+ mkdirSync(sessionDir, { recursive: true });
37
+ const lockDir = join(sessionDir, '.lock');
38
+ let acquired = false;
61
39
  try {
62
- const raw = readFileSync(file, 'utf-8');
63
- const parsed = JSON.parse(raw);
64
- if (parsed.status === 'ok' && parsed.data?.results) {
65
- return parsed.data.results;
40
+ try {
41
+ mkdirSync(lockDir);
42
+ acquired = true;
66
43
  }
67
- return [];
44
+ catch (error) {
45
+ try {
46
+ const age = Date.now() - statSync(lockDir).mtimeMs;
47
+ if (age > SESSION_LOCK_STALE_MS)
48
+ rmSync(lockDir, { recursive: true, force: true });
49
+ else
50
+ throw error;
51
+ mkdirSync(lockDir);
52
+ acquired = true;
53
+ }
54
+ catch {
55
+ throw new SessionStateError('SESSION_BUSY', `Session is busy: ${sessionDir}`);
56
+ }
57
+ }
58
+ return action();
68
59
  }
69
- catch {
70
- return [];
60
+ finally {
61
+ if (acquired) {
62
+ try {
63
+ rmSync(lockDir, { recursive: true, force: true });
64
+ }
65
+ catch { /* lock cleanup is best effort */ }
66
+ }
71
67
  }
72
68
  }
73
- /** Append new results to session results (dedup by normalized URL).
74
- * New results are marked as 'pending' and will not be injected into graph
75
- * until approved by Agent quality assessment.
76
- */
77
- export function appendSessionResults(sessionDir, newResults, options) {
78
- // Ensure session directory exists
79
- if (!existsSync(sessionDir)) {
80
- mkdirSync(sessionDir, { recursive: true });
69
+ /** Keep related session artifacts consistent while one command updates them. */
70
+ export function mutateSessionState(sessionDir, action) {
71
+ return withSessionLock(sessionDir, () => {
72
+ repairInvalidDependents(sessionDir, loadSessionResults(sessionDir));
73
+ return action();
74
+ });
75
+ }
76
+ function originKey(origin) {
77
+ return `${origin.tool}\0${origin.engine ?? ''}\0${origin.query}\0${origin.round ?? ''}`;
78
+ }
79
+ function mergeOrigins(target, additions, round) {
80
+ const normalized = additions.map(origin => ({
81
+ ...origin,
82
+ ...(round !== undefined && origin.round === undefined ? { round } : {}),
83
+ }));
84
+ const merged = new Map(target.origins.map(origin => [originKey(origin), origin]));
85
+ for (const origin of normalized)
86
+ merged.set(originKey(origin), origin);
87
+ const next = [...merged.values()];
88
+ const changed = JSON.stringify(next) !== JSON.stringify(target.origins);
89
+ target.origins = next;
90
+ return changed;
91
+ }
92
+ function isDirectOrigin(origin) {
93
+ return origin.tool === 'user' || origin.tool === 'agent';
94
+ }
95
+ function needsRound(result) {
96
+ return (result.origins ?? []).some(origin => !isDirectOrigin(origin) && origin.round === undefined);
97
+ }
98
+ function inferredContentType(result) {
99
+ if (result.contentType)
100
+ return result.contentType;
101
+ return typeof result.content === 'string' && typeof result.extractedAt === 'number' ? 'extracted' : 'search';
102
+ }
103
+ function makeResult(input, round) {
104
+ const contentType = inferredContentType(input);
105
+ const now = Date.now();
106
+ const result = {
107
+ ...input,
108
+ id: resultId(input.url),
109
+ revision: 1,
110
+ url: input.url,
111
+ title: input.title,
112
+ contentType,
113
+ origins: [],
114
+ status: 'pending',
115
+ };
116
+ if (contentType === 'search') {
117
+ delete result.content;
118
+ delete result.extractor;
119
+ delete result.extractedAt;
81
120
  }
82
- const existing = loadSessionResults(sessionDir);
83
- const currentRounds = loadSessionRounds(sessionDir) || 0;
84
- const round = options?.skipRoundIncrement
85
- ? Math.max(1, currentRounds)
86
- : Math.max(1, currentRounds + 1);
87
- const urlMap = new Map();
88
- for (const r of existing) {
89
- urlMap.set(resultUrlKey(r), r);
121
+ else {
122
+ result.content = input.content ?? '';
123
+ result.extractor = input.extractor || 'default';
124
+ result.extractedAt = input.extractedAt ?? now;
90
125
  }
91
- const mergeOrigins = (target, origins) => {
92
- if (!origins?.length)
126
+ mergeOrigins(result, input.origins ?? [], round);
127
+ return result;
128
+ }
129
+ function touch(result) {
130
+ result.revision += 1;
131
+ }
132
+ function pruneGraphProvenance(graph, resultIdValue) {
133
+ let changed = false;
134
+ const nodesToDrop = [];
135
+ graph.forEachNode((node, attrs) => {
136
+ if (!attrs.sourceResultIds?.includes(resultIdValue))
93
137
  return;
94
- const combined = [...(target.origins || []), ...origins];
95
- target.origins = Array.from(new Map(combined.map(origin => [`${origin.round ?? round}\0${origin.query}`, { ...origin, round: origin.round ?? round }])).values());
96
- };
97
- // Add new results (dedup: keep first occurrence)
98
- let added = 0;
99
- const approvedResults = [];
100
- for (const r of newResults) {
101
- const norm = resultUrlKey(r);
102
- const existingResult = urlMap.get(norm);
103
- if (existingResult) {
104
- const originCount = existingResult.origins?.length ?? 0;
105
- mergeOrigins(existingResult, r.origins);
106
- if (existingResult.status === 'approved' && (existingResult.origins?.length ?? 0) > originCount) {
107
- approvedResults.push(existingResult);
108
- }
109
- continue;
138
+ const sourceResultIds = attrs.sourceResultIds.filter(id => id !== resultIdValue);
139
+ if (sourceResultIds.length) {
140
+ graph.mergeNodeAttributes(node, { sourceResultIds });
141
+ changed = true;
110
142
  }
111
- // Mark new results as pending
112
- r.status = 'pending';
113
- mergeOrigins(r, r.origins);
114
- urlMap.set(norm, r);
115
- added++;
143
+ else
144
+ nodesToDrop.push(node);
145
+ });
146
+ for (const node of nodesToDrop) {
147
+ graph.dropNode(node);
148
+ changed = true;
116
149
  }
117
- const all = Array.from(urlMap.values());
118
- const rounds = round;
119
- const file = join(sessionDir, 'results.json');
120
- try {
121
- writeFileSync(file, JSON.stringify({
122
- status: 'ok',
123
- data: { results: all, rounds },
124
- }, null, 2), 'utf-8');
150
+ const edgesToDrop = [];
151
+ graph.forEachEdge((edge, attrs) => {
152
+ if (!attrs.sourceResultIds?.includes(resultIdValue))
153
+ return;
154
+ const sourceResultIds = attrs.sourceResultIds.filter(id => id !== resultIdValue);
155
+ if (sourceResultIds.length) {
156
+ graph.replaceEdgeAttributes(edge, { ...attrs, sourceResultIds });
157
+ changed = true;
158
+ }
159
+ else
160
+ edgesToDrop.push(edge);
161
+ });
162
+ for (const edge of edgesToDrop) {
163
+ graph.dropEdge(edge);
164
+ changed = true;
125
165
  }
126
- catch (e) {
127
- throw new Error(`Failed to write session results to ${file}: ${e instanceof Error ? e.message : String(e)}`);
166
+ if (graph.hasNode(resultIdValue)) {
167
+ graph.dropNode(resultIdValue);
168
+ changed = true;
128
169
  }
129
- return { added, total: all.length, approvedResults };
170
+ // Path nodes derive from semantic entities and are invalid once an input entity disappears.
171
+ const pathsToDrop = [];
172
+ graph.forEachNode((node, attrs) => {
173
+ if (attrs.type === 'path' && attrs.entities?.some(entity => !graph.hasNode(entity)))
174
+ pathsToDrop.push(node);
175
+ });
176
+ for (const node of pathsToDrop) {
177
+ graph.dropNode(node);
178
+ changed = true;
179
+ }
180
+ const structuralNodesToDrop = [];
181
+ graph.forEachNode((node, attrs) => {
182
+ if ((attrs.type === 'query' || attrs.type === 'domain') && graph.degree(node) === 0)
183
+ structuralNodesToDrop.push(node);
184
+ });
185
+ for (const node of structuralNodesToDrop) {
186
+ graph.dropNode(node);
187
+ changed = true;
188
+ }
189
+ return changed;
130
190
  }
131
- /** Load graph from session, or create empty */
132
- export function loadSessionGraph(sessionDir) {
133
- const file = join(sessionDir, 'graph.json');
191
+ function writeResults(sessionDir, results, rounds) {
192
+ atomicWriteJson(join(sessionDir, 'results.json'), { status: 'ok', data: { results, rounds } });
193
+ }
194
+ function readResultsFile(sessionDir) {
195
+ const file = join(sessionDir, 'results.json');
134
196
  if (!existsSync(file))
135
- return new DirectedGraph();
197
+ return undefined;
136
198
  try {
137
- const raw = readFileSync(file, 'utf-8');
138
- const parsed = JSON.parse(raw);
139
- const graphData = parsed.status === 'ok' && parsed.data?.graph
140
- ? parsed.data.graph
141
- : (parsed.nodes && parsed.edges ? parsed : null);
142
- if (graphData) {
143
- return deserializeGraph(graphData);
199
+ const parsed = JSON.parse(readFileSync(file, 'utf-8'));
200
+ if (parsed.status !== 'ok' || !Array.isArray(parsed.data?.results) || !Number.isSafeInteger(parsed.data.rounds)) {
201
+ throw new Error('invalid results envelope');
144
202
  }
145
- return new DirectedGraph();
203
+ return parsed;
146
204
  }
147
- catch {
148
- return new DirectedGraph();
205
+ catch (error) {
206
+ throw new SessionStateError('SESSION_CORRUPTED', `Cannot read session results: ${file}`);
149
207
  }
150
208
  }
151
- /** Save graph to session */
152
- export function saveSessionGraph(sessionDir, graph) {
153
- const serialized = serializeGraph(graph);
154
- const stats = graphStats(graph);
155
- const file = join(sessionDir, 'graph.json');
156
- try {
157
- writeFileSync(file, JSON.stringify({
158
- status: 'ok',
159
- data: { graph: serialized, stats },
160
- }, null, 2), 'utf-8');
161
- }
162
- catch (e) {
163
- throw new Error(`Failed to write session graph to ${file}: ${e instanceof Error ? e.message : String(e)}`);
209
+ function touchMeta(sessionDir) {
210
+ const existing = loadSessionMeta(sessionDir);
211
+ if (!existing)
212
+ return;
213
+ atomicWriteJson(join(sessionDir, 'meta.json'), { ...existing, updatedAt: Date.now() });
214
+ }
215
+ /** A result may enter evidence and semantic workflows only with a captured body. */
216
+ export function hasVerifiedContent(result) {
217
+ return result.contentType === 'extracted'
218
+ && typeof result.content === 'string'
219
+ && result.content.trim().length > 0
220
+ && typeof result.extractedAt === 'number'
221
+ && Number.isFinite(result.extractedAt)
222
+ && result.extractedAt > 0
223
+ && typeof result.extractor === 'string'
224
+ && result.extractor.length > 0;
225
+ }
226
+ export function resolveSessionPath(sessionValue) {
227
+ if (sessionValue === 'new') {
228
+ const root = getDefaultSessionRoot();
229
+ mkdirSync(root, { recursive: true });
230
+ return join(root, `ds_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`);
164
231
  }
232
+ return !sessionValue.includes('/') && !sessionValue.includes('\\')
233
+ ? join(getDefaultSessionRoot(), sessionValue)
234
+ : sessionValue;
235
+ }
236
+ export function initSessionDir(sessionDir, owner, description, query) {
237
+ mkdirSync(sessionDir, { recursive: true });
238
+ mkdirSync(join(sessionDir, 'agent-inputs'), { recursive: true });
239
+ const existing = loadSessionMeta(sessionDir);
240
+ const now = Date.now();
241
+ const meta = {
242
+ owner: owner || existing?.owner || '',
243
+ description: description || existing?.description || '',
244
+ createdAt: existing?.createdAt || now,
245
+ updatedAt: now,
246
+ query: query || existing?.query || '',
247
+ };
248
+ atomicWriteJson(join(sessionDir, 'meta.json'), meta);
165
249
  }
166
- /** Get pending results (not yet approved by Agent) */
250
+ /** Agent write inputs must belong to exactly the session being mutated. */
251
+ export function validateSessionInputFile(sessionDir, inputFile) {
252
+ const inputsDir = resolve(sessionDir, 'agent-inputs');
253
+ const candidate = resolve(inputFile);
254
+ const rel = relative(inputsDir, candidate);
255
+ if (rel === '' || rel.startsWith('..') || rel.includes(':') || !existsSync(candidate))
256
+ return undefined;
257
+ return candidate;
258
+ }
259
+ export function loadSessionResults(sessionDir) {
260
+ const data = readResultsFile(sessionDir);
261
+ return data?.data.results ?? [];
262
+ }
263
+ export function loadSessionRounds(sessionDir) {
264
+ return readResultsFile(sessionDir)?.data.rounds ?? 0;
265
+ }
266
+ export function appendSessionResults(sessionDir, newResults, options) {
267
+ return withSessionLock(sessionDir, () => {
268
+ const state = readResultsFile(sessionDir);
269
+ const results = state?.data.results ?? [];
270
+ const currentRounds = state?.data.rounds ?? 0;
271
+ const incrementRound = !options?.skipRoundIncrement && newResults.some(needsRound);
272
+ const round = incrementRound ? currentRounds + 1 : currentRounds;
273
+ const byKey = new Map(results.map(result => [resultUrlKey(result), result]));
274
+ let added = 0;
275
+ let updated = 0;
276
+ const approvedResults = [];
277
+ for (const input of newResults) {
278
+ const key = resultUrlKey(input);
279
+ const existing = byKey.get(key);
280
+ const importRound = needsRound(input) ? Math.max(1, round) : undefined;
281
+ if (!existing) {
282
+ const created = makeResult(input, importRound);
283
+ byKey.set(key, created);
284
+ added++;
285
+ continue;
286
+ }
287
+ let changed = mergeOrigins(existing, input.origins ?? [], importRound);
288
+ if (inferredContentType(input) === 'extracted') {
289
+ const nextContent = input.content ?? '';
290
+ const bodyChanged = existing.contentType !== 'extracted' || existing.content !== nextContent;
291
+ const nextExtractedAt = input.extractedAt ?? Date.now();
292
+ if (bodyChanged) {
293
+ existing.contentType = 'extracted';
294
+ existing.content = nextContent;
295
+ changed = true;
296
+ }
297
+ if (existing.extractor !== (input.extractor || 'default')) {
298
+ existing.extractor = input.extractor || 'default';
299
+ changed = true;
300
+ }
301
+ if (existing.extractedAt !== nextExtractedAt) {
302
+ existing.extractedAt = nextExtractedAt;
303
+ changed = true;
304
+ }
305
+ // An imported body is a fresh Agent input, even when its text is unchanged.
306
+ if (existing.skippedAt !== undefined) {
307
+ existing.skippedAt = undefined;
308
+ changed = true;
309
+ }
310
+ if (existing.failureCount !== undefined || existing.lastFailedAt !== undefined || existing.lastFailure !== undefined) {
311
+ existing.failureCount = undefined;
312
+ existing.lastFailedAt = undefined;
313
+ existing.lastFailure = undefined;
314
+ changed = true;
315
+ }
316
+ if (existing.status !== 'pending') {
317
+ existing.status = 'pending';
318
+ changed = true;
319
+ }
320
+ }
321
+ if (input.title && input.title !== existing.title) {
322
+ existing.title = input.title;
323
+ changed = true;
324
+ }
325
+ if (input.excerpt !== undefined && input.excerpt !== existing.excerpt) {
326
+ existing.excerpt = input.excerpt;
327
+ changed = true;
328
+ }
329
+ if (changed) {
330
+ touch(existing);
331
+ updated++;
332
+ if (existing.status === 'approved')
333
+ approvedResults.push(existing);
334
+ }
335
+ }
336
+ const all = [...byKey.values()];
337
+ writeResults(sessionDir, all, round);
338
+ repairInvalidDependents(sessionDir, all);
339
+ touchMeta(sessionDir);
340
+ return { added, updated, total: all.length, round, approvedResults };
341
+ });
342
+ }
343
+ export function getPendingExtractionResults(sessionDir) {
344
+ return loadSessionResults(sessionDir).filter(result => result.contentType === 'search'
345
+ && !result.skippedAt
346
+ && (result.failureCount ?? 0) < 2);
347
+ }
348
+ /** Extracted records awaiting an approval decision. */
167
349
  export function getPendingResults(sessionDir) {
168
- const results = loadSessionResults(sessionDir);
169
- return results.filter(r => !r.status || r.status === 'pending');
350
+ return loadSessionResults(sessionDir).filter(result => result.status === 'pending'
351
+ && !result.skippedAt
352
+ && hasVerifiedContent(result));
170
353
  }
171
- /** Get approved results (ready for graph injection) */
172
354
  export function getApprovedResults(sessionDir) {
173
- const results = loadSessionResults(sessionDir);
174
- return results.filter(r => r.status === 'approved');
355
+ return loadSessionResults(sessionDir).filter(result => result.status === 'approved' && !result.skippedAt && hasVerifiedContent(result));
175
356
  }
176
- /** Approve results by their indices (0-based) in the pending list.
177
- * Returns the number of approved results.
178
- */
179
- export function approveResults(sessionDir, indices) {
180
- const results = loadSessionResults(sessionDir);
181
- const pending = results.filter(r => !r.status || r.status === 'pending');
182
- // Keep local chunk fragments so selecting one chunk does not approve its siblings.
183
- const approvedResultKeys = new Set();
184
- for (const idx of indices) {
185
- if (idx >= 0 && idx < pending.length) {
186
- approvedResultKeys.add(resultUrlKey(pending[idx]));
357
+ function repairInvalidDependents(sessionDir, results) {
358
+ const invalidReasons = new Map(results
359
+ .filter(result => result.skippedAt || result.status !== 'approved' || !hasVerifiedContent(result))
360
+ .map(result => [result.id, result.skippedAt ? 'sourceSkipped' : 'contentChanged']));
361
+ if (!invalidReasons.size)
362
+ return;
363
+ try {
364
+ assertClaimsStoreReadable(sessionDir);
365
+ const evidences = loadEvidences(sessionDir);
366
+ const affectedClaims = new Set();
367
+ const now = Date.now();
368
+ let evidenceChanged = false;
369
+ for (const evidence of evidences) {
370
+ const reason = invalidReasons.get(evidence.resultId);
371
+ if (reason) {
372
+ affectedClaims.add(evidence.claimId);
373
+ }
374
+ if (reason && !evidence.invalid) {
375
+ evidence.invalid = true;
376
+ evidence.invalidatedAt = now;
377
+ evidence.invalidationReason = reason;
378
+ evidenceChanged = true;
379
+ }
187
380
  }
188
- }
189
- // Update status
190
- let approved = 0;
191
- const approvedResults = [];
192
- for (const r of results) {
193
- if (approvedResultKeys.has(resultUrlKey(r))) {
194
- r.status = 'approved';
195
- approved++;
196
- approvedResults.push(r);
381
+ if (affectedClaims.size) {
382
+ if (evidenceChanged)
383
+ saveEvidences(sessionDir, evidences);
384
+ const claims = loadClaims(sessionDir);
385
+ for (const claim of claims)
386
+ if (affectedClaims.has(claim.id))
387
+ claim.status = 'verifying';
388
+ saveClaims(sessionDir, claims);
389
+ saveReviews(sessionDir, loadReviews(sessionDir).filter(review => !affectedClaims.has(review.claimId)));
197
390
  }
198
391
  }
199
- // Save back
200
- const file = join(sessionDir, 'results.json');
201
- const rounds = loadSessionRounds(sessionDir);
392
+ catch { /* Preserve corrupt claim artifacts for their dedicated repair commands. */ }
202
393
  try {
203
- writeFileSync(file, JSON.stringify({
204
- status: 'ok',
205
- data: { results, rounds },
206
- }, null, 2), 'utf-8');
394
+ const { graph, missing } = readSessionGraph(sessionDir);
395
+ if (!missing) {
396
+ let graphChanged = false;
397
+ for (const resultIdValue of invalidReasons.keys())
398
+ graphChanged = pruneGraphProvenance(graph, resultIdValue) || graphChanged;
399
+ if (graphChanged)
400
+ writeSessionGraph(sessionDir, graph);
401
+ }
207
402
  }
208
- catch (e) {
209
- throw new Error(`Failed to write session results to ${file}: ${e instanceof Error ? e.message : String(e)}`);
403
+ catch (error) {
404
+ if (!(error instanceof SessionStateError))
405
+ throw error;
210
406
  }
211
- return { approved, total: results.length, approvedResults };
212
407
  }
213
- /** Inject approved results into graph (structural layer).
214
- * Uses query from session meta.json if not provided.
215
- */
216
- export function injectApprovedResults(sessionDir, approved) {
217
- if (approved.length === 0) {
218
- return { nodesAdded: 0, edgesAdded: 0 };
408
+ export function approveResults(sessionDir, selections) {
409
+ return withSessionLock(sessionDir, () => {
410
+ const state = readResultsFile(sessionDir);
411
+ const results = state?.data.results ?? [];
412
+ repairInvalidDependents(sessionDir, results);
413
+ const selected = new Set();
414
+ for (const selection of selections) {
415
+ const result = results.find(item => item.id === selection.id);
416
+ if (!result)
417
+ return { approved: 0, total: results.length, approvedResults: [], error: { code: 'RESULT_NOT_FOUND', message: `Result not found: ${selection.id}`, id: selection.id } };
418
+ if (result.revision !== selection.revision)
419
+ return { approved: 0, total: results.length, approvedResults: [], error: { code: 'RESULT_REVISION_CONFLICT', message: `Result revision changed: ${selection.id}`, id: selection.id, revision: result.revision } };
420
+ if (result.skippedAt)
421
+ return { approved: 0, total: results.length, approvedResults: [], error: { code: 'RESULT_SKIPPED', message: `Result is skipped: ${selection.id}`, id: selection.id } };
422
+ if (!hasVerifiedContent(result))
423
+ return { approved: 0, total: results.length, approvedResults: [], error: { code: 'RESULT_NOT_VERIFIED', message: `Result has no extracted body: ${selection.id}`, id: selection.id } };
424
+ selected.add(result.id);
425
+ }
426
+ const approvedResults = [];
427
+ for (const result of results) {
428
+ if (selected.has(result.id) && result.status !== 'approved') {
429
+ result.status = 'approved';
430
+ touch(result);
431
+ approvedResults.push(result);
432
+ }
433
+ }
434
+ writeResults(sessionDir, results, state?.data.rounds ?? 0);
435
+ touchMeta(sessionDir);
436
+ return { approved: approvedResults.length, total: results.length, approvedResults };
437
+ });
438
+ }
439
+ export function setSkipped(sessionDir, selections, skipped) {
440
+ return withSessionLock(sessionDir, () => {
441
+ const state = readResultsFile(sessionDir);
442
+ const results = state?.data.results ?? [];
443
+ const selected = [];
444
+ const selectedIds = new Set();
445
+ for (const selection of selections) {
446
+ const result = results.find(item => item.id === selection.id);
447
+ if (!result)
448
+ return { changed: 0, results: [], error: { code: 'RESULT_NOT_FOUND', message: `Result not found: ${selection.id}`, id: selection.id } };
449
+ if (result.revision !== selection.revision)
450
+ return { changed: 0, results: [], error: { code: 'RESULT_REVISION_CONFLICT', message: `Result revision changed: ${selection.id}`, id: selection.id, revision: result.revision } };
451
+ if (!selectedIds.has(result.id)) {
452
+ selectedIds.add(result.id);
453
+ selected.push(result);
454
+ }
455
+ }
456
+ let changed = 0;
457
+ for (const result of selected) {
458
+ const shouldChange = skipped ? result.skippedAt === undefined : result.skippedAt !== undefined;
459
+ if (shouldChange) {
460
+ const wasSkipped = result.skippedAt !== undefined;
461
+ result.skippedAt = skipped ? Date.now() : undefined;
462
+ if (wasSkipped && !skipped && result.status === 'approved')
463
+ result.status = 'pending';
464
+ touch(result);
465
+ changed++;
466
+ }
467
+ }
468
+ writeResults(sessionDir, results, state?.data.rounds ?? 0);
469
+ repairInvalidDependents(sessionDir, results);
470
+ touchMeta(sessionDir);
471
+ return { changed, results: selected };
472
+ });
473
+ }
474
+ export function recordExtractionOutcome(sessionDir, outcomes) {
475
+ return withSessionLock(sessionDir, () => {
476
+ const state = readResultsFile(sessionDir);
477
+ const results = state?.data.results ?? [];
478
+ const byKey = new Map(results.map(result => [resultUrlKey(result), result]));
479
+ let updated = 0;
480
+ let failed = 0;
481
+ for (const outcome of outcomes) {
482
+ const result = byKey.get(resultUrlKey({ url: outcome.url }));
483
+ if (!result)
484
+ continue;
485
+ if (outcome.failure) {
486
+ result.failureCount = (result.failureCount ?? 0) + 1;
487
+ result.lastFailedAt = Date.now();
488
+ result.lastFailure = outcome.failure;
489
+ touch(result);
490
+ failed++;
491
+ continue;
492
+ }
493
+ if (typeof outcome.content !== 'string' || !outcome.content.trim())
494
+ continue;
495
+ const wasSkipped = result.skippedAt !== undefined;
496
+ const changedBody = result.contentType !== 'extracted' || result.content !== outcome.content;
497
+ result.contentType = 'extracted';
498
+ result.content = outcome.content;
499
+ result.extractor = outcome.extractor || 'default';
500
+ result.extractedAt = outcome.extractedAt ?? Date.now();
501
+ result.title = outcome.title || result.title;
502
+ if (outcome.excerpt !== undefined)
503
+ result.excerpt = outcome.excerpt;
504
+ if (outcome.byline !== undefined)
505
+ result.byline = outcome.byline;
506
+ if (outcome.siteName !== undefined)
507
+ result.siteName = outcome.siteName;
508
+ if (outcome.length !== undefined)
509
+ result.contentLength = outcome.length;
510
+ result.skippedAt = undefined;
511
+ result.failureCount = undefined;
512
+ result.lastFailedAt = undefined;
513
+ result.lastFailure = undefined;
514
+ if ((changedBody || wasSkipped) && result.status === 'approved')
515
+ result.status = 'pending';
516
+ touch(result);
517
+ updated++;
518
+ }
519
+ writeResults(sessionDir, results, state?.data.rounds ?? 0);
520
+ repairInvalidDependents(sessionDir, results);
521
+ touchMeta(sessionDir);
522
+ return { updated, failed, total: results.length };
523
+ });
524
+ }
525
+ /** Compatibility name for callers that already provide extraction output. */
526
+ export function mergeExtractedContent(sessionDir, extracted) {
527
+ const result = recordExtractionOutcome(sessionDir, extracted.map(item => item.error
528
+ ? { url: item.url, failure: { code: 'tool', message: item.error } }
529
+ : item));
530
+ return { updated: result.updated, total: result.total };
531
+ }
532
+ function readSessionGraph(sessionDir) {
533
+ const file = join(sessionDir, 'graph.json');
534
+ if (!existsSync(file))
535
+ return { graph: new MultiDirectedGraph(), missing: true };
536
+ try {
537
+ const parsed = JSON.parse(readFileSync(file, 'utf-8'));
538
+ const graphData = parsed.status === 'ok' && parsed.data?.graph ? parsed.data.graph : null;
539
+ if (!graphData)
540
+ throw new Error('invalid graph envelope');
541
+ return { graph: deserializeGraph(graphData), missing: false };
219
542
  }
220
- const graph = loadSessionGraph(sessionDir);
221
- const beforeNodes = graph.order;
222
- const beforeEdges = graph.size;
543
+ catch {
544
+ throw new SessionStateError('SESSION_CORRUPTED', `Cannot read session graph: ${file}`);
545
+ }
546
+ }
547
+ export function loadSessionGraph(sessionDir) {
548
+ return withSessionLock(sessionDir, () => {
549
+ repairInvalidDependents(sessionDir, loadSessionResults(sessionDir));
550
+ return readSessionGraph(sessionDir).graph;
551
+ });
552
+ }
553
+ function writeSessionGraph(sessionDir, graph) {
554
+ atomicWriteJson(join(sessionDir, 'graph.json'), { status: 'ok', data: { graph: serializeGraph(graph), stats: graphStats(graph) } });
555
+ touchMeta(sessionDir);
556
+ }
557
+ function buildApprovedStructuralEdges(graph, approved) {
223
558
  const groups = new Map();
224
559
  for (const result of approved) {
225
- for (const origin of result.origins || []) {
226
- const key = `${origin.round ?? 0}\0${origin.query}`;
560
+ for (const origin of result.origins) {
561
+ const key = `${origin.round ?? ''}\0${origin.query}`;
227
562
  const group = groups.get(key) ?? { query: origin.query, round: origin.round, results: [] };
228
- group.results.push({ url: result.url, title: result.title, source: result.source });
563
+ group.results.push({ url: result.url, title: result.title, origins: result.origins });
229
564
  groups.set(key, group);
230
565
  }
231
566
  }
232
- for (const group of groups.values()) {
567
+ for (const group of groups.values())
233
568
  buildStructuralEdges(graph, group.query, group.results, group.round);
234
- }
235
- saveSessionGraph(sessionDir, graph);
236
- return { nodesAdded: graph.order - beforeNodes, edgesAdded: graph.size - beforeEdges };
237
569
  }
238
- /** Count pending results */
570
+ /** Serialize graph read-modify-write operations under one session lock. */
571
+ export function mutateSessionGraph(sessionDir, action, options) {
572
+ return withSessionLock(sessionDir, () => {
573
+ repairInvalidDependents(sessionDir, loadSessionResults(sessionDir));
574
+ const { graph, missing } = readSessionGraph(sessionDir);
575
+ // Results are authoritative; a missing derived graph can safely regain its structural layer.
576
+ const approved = getApprovedResults(sessionDir);
577
+ if (missing && options?.reconcileStructural)
578
+ buildApprovedStructuralEdges(graph, approved);
579
+ const value = action(graph, approved);
580
+ writeSessionGraph(sessionDir, graph);
581
+ return value;
582
+ });
583
+ }
584
+ export function injectApprovedResults(sessionDir, approved) {
585
+ const verified = approved.filter(hasVerifiedContent);
586
+ if (!verified.length)
587
+ return { nodesAdded: 0, edgesAdded: 0 };
588
+ return mutateSessionGraph(sessionDir, graph => {
589
+ const beforeNodes = graph.order;
590
+ const beforeEdges = graph.size;
591
+ // Rebuild all approved structure when graph.json is absent, then add this approval.
592
+ buildApprovedStructuralEdges(graph, getApprovedResults(sessionDir));
593
+ return { nodesAdded: graph.order - beforeNodes, edgesAdded: graph.size - beforeEdges };
594
+ });
595
+ }
239
596
  export function countPendingResults(sessionDir) {
240
597
  return getPendingResults(sessionDir).length;
241
598
  }
242
599
  export function updateSessionGraph(sessionDir, query, results, round) {
243
- const graph = loadSessionGraph(sessionDir);
244
- const beforeNodes = graph.order;
245
- const beforeEdges = graph.size;
246
- buildStructuralEdges(graph, query, results, round);
247
- saveSessionGraph(sessionDir, graph);
248
- return {
249
- nodesAdded: graph.order - beforeNodes,
250
- edgesAdded: graph.size - beforeEdges,
251
- };
252
- }
253
- /** Merge extracted content into session results (update content field by URL match) */
254
- export function mergeExtractedContent(sessionDir, extracted) {
255
- const results = loadSessionResults(sessionDir);
256
- const urlMap = new Map();
257
- for (const r of results) {
258
- urlMap.set(resultUrlKey(r), r);
259
- }
260
- let updated = 0;
261
- for (const ex of extracted) {
262
- if (ex.error)
263
- continue;
264
- const norm = resultUrlKey({ url: ex.url, source: ex.url.startsWith('file:') ? 'local' : undefined });
265
- const existing = urlMap.get(norm);
266
- if (existing) {
267
- existing.content = ex.content;
268
- if (ex.excerpt)
269
- existing.excerpt = ex.excerpt;
270
- if (ex.byline)
271
- existing.byline = ex.byline;
272
- if (ex.siteName)
273
- existing.siteName = ex.siteName;
274
- if (ex.length)
275
- existing.contentLength = ex.length;
276
- if (ex.extractedAt !== undefined)
277
- existing.extractedAt = ex.extractedAt;
278
- updated++;
279
- }
280
- }
281
- const all = Array.from(urlMap.values());
282
- const file = join(sessionDir, 'results.json');
283
- const rounds = loadSessionRounds(sessionDir) || results.length;
284
- try {
285
- writeFileSync(file, JSON.stringify({
286
- status: 'ok',
287
- data: { results: all, rounds },
288
- }, null, 2), 'utf-8');
289
- }
290
- catch (e) {
291
- throw new Error(`Failed to write session results to ${file}: ${e instanceof Error ? e.message : String(e)}`);
292
- }
293
- return { updated, total: all.length };
294
- }
295
- /** Get current round number from results file */
296
- export function loadSessionRounds(sessionDir) {
297
- const file = join(sessionDir, 'results.json');
298
- if (!existsSync(file))
299
- return 0;
300
- try {
301
- const raw = readFileSync(file, 'utf-8');
302
- const parsed = JSON.parse(raw);
303
- return parsed.data?.rounds || 0;
304
- }
305
- catch {
306
- return 0;
307
- }
600
+ const verified = results.filter(hasVerifiedContent);
601
+ if (!verified.length)
602
+ return { nodesAdded: 0, edgesAdded: 0 };
603
+ return mutateSessionGraph(sessionDir, graph => {
604
+ const beforeNodes = graph.order;
605
+ const beforeEdges = graph.size;
606
+ buildStructuralEdges(graph, query, verified.map(result => ({ url: result.url, title: result.title, origins: result.origins })), round);
607
+ return { nodesAdded: graph.order - beforeNodes, edgesAdded: graph.size - beforeEdges };
608
+ });
308
609
  }
309
610
  //# sourceMappingURL=session.js.map