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.
- package/README.md +63 -46
- package/dist/claims/deterministic-checks.d.ts +1 -0
- package/dist/claims/deterministic-checks.d.ts.map +1 -1
- package/dist/claims/deterministic-checks.js +3 -4
- package/dist/claims/deterministic-checks.js.map +1 -1
- package/dist/claims/store.d.ts +6 -0
- package/dist/claims/store.d.ts.map +1 -1
- package/dist/claims/store.js +31 -6
- package/dist/claims/store.js.map +1 -1
- package/dist/claims/types.d.ts +7 -2
- package/dist/claims/types.d.ts.map +1 -1
- package/dist/commands/claim.d.ts +3 -3
- package/dist/commands/claim.d.ts.map +1 -1
- package/dist/commands/claim.js +70 -60
- package/dist/commands/claim.js.map +1 -1
- package/dist/commands/evidence.d.ts +3 -4
- package/dist/commands/evidence.d.ts.map +1 -1
- package/dist/commands/evidence.js +164 -144
- package/dist/commands/evidence.js.map +1 -1
- package/dist/commands/extract.d.ts +3 -3
- package/dist/commands/extract.d.ts.map +1 -1
- package/dist/commands/extract.js +58 -62
- package/dist/commands/extract.js.map +1 -1
- package/dist/commands/graph-add.d.ts +3 -13
- package/dist/commands/graph-add.d.ts.map +1 -1
- package/dist/commands/graph-add.js +78 -138
- package/dist/commands/graph-add.js.map +1 -1
- package/dist/commands/json-input.d.ts +18 -0
- package/dist/commands/json-input.d.ts.map +1 -0
- package/dist/commands/json-input.js +50 -0
- package/dist/commands/json-input.js.map +1 -0
- package/dist/commands/results-add.d.ts +5 -9
- package/dist/commands/results-add.d.ts.map +1 -1
- package/dist/commands/results-add.js +66 -32
- package/dist/commands/results-add.js.map +1 -1
- package/dist/commands/review.d.ts +1 -1
- package/dist/commands/review.d.ts.map +1 -1
- package/dist/commands/review.js +63 -55
- package/dist/commands/review.js.map +1 -1
- package/dist/commands/verdict.d.ts.map +1 -1
- package/dist/commands/verdict.js +13 -2
- package/dist/commands/verdict.js.map +1 -1
- package/dist/deep/dedupe.js +1 -1
- package/dist/deep/dedupe.js.map +1 -1
- package/dist/deep/extractor.d.ts +3 -1
- package/dist/deep/extractor.d.ts.map +1 -1
- package/dist/deep/extractor.js +74 -19
- package/dist/deep/extractor.js.map +1 -1
- package/dist/deep/graph-explore.d.ts +1 -1
- package/dist/deep/graph-explore.d.ts.map +1 -1
- package/dist/deep/graph-explore.js +14 -12
- package/dist/deep/graph-explore.js.map +1 -1
- package/dist/deep/graph-preprocess.d.ts +2 -1
- package/dist/deep/graph-preprocess.d.ts.map +1 -1
- package/dist/deep/graph-preprocess.js +10 -10
- package/dist/deep/graph-preprocess.js.map +1 -1
- package/dist/deep/graph-sampling.d.ts.map +1 -1
- package/dist/deep/graph-sampling.js +3 -2
- package/dist/deep/graph-sampling.js.map +1 -1
- package/dist/deep/graph.d.ts +9 -2
- package/dist/deep/graph.d.ts.map +1 -1
- package/dist/deep/graph.js +7 -6
- package/dist/deep/graph.js.map +1 -1
- package/dist/deep/local-doc/indexer.d.ts.map +1 -1
- package/dist/deep/local-doc/indexer.js +34 -1
- package/dist/deep/local-doc/indexer.js.map +1 -1
- package/dist/deep/local-doc/searcher.d.ts +4 -2
- package/dist/deep/local-doc/searcher.d.ts.map +1 -1
- package/dist/deep/local-doc/searcher.js +18 -4
- package/dist/deep/local-doc/searcher.js.map +1 -1
- package/dist/deep/path-extraction.js +2 -2
- package/dist/deep/path-extraction.js.map +1 -1
- package/dist/deep/quality-assess.d.ts +4 -4
- package/dist/deep/quality-assess.d.ts.map +1 -1
- package/dist/deep/quality-assess.js +11 -11
- package/dist/deep/quality-assess.js.map +1 -1
- package/dist/deep/quality-assess.sync-conflict-20260728-195206-JAULAPO.d.ts +46 -0
- package/dist/deep/quality-assess.sync-conflict-20260728-195206-JAULAPO.d.ts.map +1 -0
- package/dist/deep/quality-assess.sync-conflict-20260728-195206-JAULAPO.js +122 -0
- package/dist/deep/quality-assess.sync-conflict-20260728-195206-JAULAPO.js.map +1 -0
- package/dist/deep/search-strategy.d.ts.map +1 -1
- package/dist/deep/search-strategy.js +13 -6
- package/dist/deep/search-strategy.js.map +1 -1
- package/dist/deep/session.d.ts +115 -56
- package/dist/deep/session.d.ts.map +1 -1
- package/dist/deep/session.js +554 -253
- package/dist/deep/session.js.map +1 -1
- package/dist/runCli.d.ts.map +1 -1
- package/dist/runCli.js +111 -44
- package/dist/runCli.js.map +1 -1
- package/package.json +1 -1
package/dist/deep/session.js
CHANGED
|
@@ -1,309 +1,610 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Session
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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,
|
|
9
|
-
import { join } from 'path';
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
return parsed.data.results;
|
|
40
|
+
try {
|
|
41
|
+
mkdirSync(lockDir);
|
|
42
|
+
acquired = true;
|
|
66
43
|
}
|
|
67
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
/**
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
143
|
+
else
|
|
144
|
+
nodesToDrop.push(node);
|
|
145
|
+
});
|
|
146
|
+
for (const node of nodesToDrop) {
|
|
147
|
+
graph.dropNode(node);
|
|
148
|
+
changed = true;
|
|
116
149
|
}
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
127
|
-
|
|
166
|
+
if (graph.hasNode(resultIdValue)) {
|
|
167
|
+
graph.dropNode(resultIdValue);
|
|
168
|
+
changed = true;
|
|
128
169
|
}
|
|
129
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
197
|
+
return undefined;
|
|
136
198
|
try {
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
203
|
+
return parsed;
|
|
146
204
|
}
|
|
147
|
-
catch {
|
|
148
|
-
|
|
205
|
+
catch (error) {
|
|
206
|
+
throw new SessionStateError('SESSION_CORRUPTED', `Cannot read session results: ${file}`);
|
|
149
207
|
}
|
|
150
208
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
169
|
-
|
|
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
|
-
|
|
174
|
-
return results.filter(r => r.status === 'approved');
|
|
355
|
+
return loadSessionResults(sessionDir).filter(result => result.status === 'approved' && !result.skippedAt && hasVerifiedContent(result));
|
|
175
356
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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 (
|
|
209
|
-
|
|
403
|
+
catch (error) {
|
|
404
|
+
if (!(error instanceof SessionStateError))
|
|
405
|
+
throw error;
|
|
210
406
|
}
|
|
211
|
-
return { approved, total: results.length, approvedResults };
|
|
212
407
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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 ??
|
|
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,
|
|
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
|
-
/**
|
|
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
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|