fraim-hub 2.0.318 → 2.0.320
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/dist/src/ai-hub/atomic-json-file.js +2 -20
- package/dist/src/ai-hub/conversation-search-index.js +171 -297
- package/dist/src/ai-hub/conversation-search-projection.js +38 -21
- package/dist/src/ai-hub/conversation-search.js +94 -68
- package/dist/src/ai-hub/conversation-store-lock.js +8 -35
- package/dist/src/ai-hub/conversation-store.js +157 -860
- package/dist/src/ai-hub/hosts.js +47 -77
- package/dist/src/ai-hub/hub-runtime-file.js +1 -1
- package/dist/src/ai-hub/process-liveness.js +21 -1
- package/dist/src/ai-hub/server.js +283 -1166
- package/dist/src/ai-hub/stale-bucket-sweep.js +4 -25
- package/dist/src/cli/mcp/command-resolution.js +18 -4
- package/dist/src/cli/mcp/fraim-mcp-latest-launcher.js +11 -1
- package/dist/src/cli/utils/managed-agent-paths.js +32 -10
- package/package.json +3 -4
- package/public/ai-hub/script.js +200 -530
- package/public/ai-hub/styles.css +2 -13
- package/dist/src/ai-hub/conversation-history.js +0 -363
- package/dist/src/ai-hub/conversation-ui-state.js +0 -90
- package/dist/src/ai-hub/history-migration.js +0 -514
- package/dist/src/ai-hub/history-policy.js +0 -37
- package/dist/src/ai-hub/history-writer-compatibility.js +0 -74
- package/dist/src/ai-hub/host-output-stream.js +0 -225
|
@@ -14,8 +14,8 @@ const path_1 = __importDefault(require("path"));
|
|
|
14
14
|
// same write-temp-then-rename dance, and the same Windows-specific retry around it. Keeping two
|
|
15
15
|
// copies meant a future fix to the retry behaviour would land in one and silently not the other,
|
|
16
16
|
// which is exactly the drift the store's #820 durability work exists to prevent.
|
|
17
|
-
const RENAME_ATTEMPTS =
|
|
18
|
-
const RENAME_RETRY_DELAY_MS =
|
|
17
|
+
const RENAME_ATTEMPTS = 25;
|
|
18
|
+
const RENAME_RETRY_DELAY_MS = 10;
|
|
19
19
|
let tempSeq = 0;
|
|
20
20
|
/**
|
|
21
21
|
* Rename, retrying the Windows contention codes.
|
|
@@ -66,25 +66,7 @@ function writeFileAtomic(filePath, data, encoding) {
|
|
|
66
66
|
fs_1.default.writeFileSync(tempPath, data);
|
|
67
67
|
}
|
|
68
68
|
try {
|
|
69
|
-
const fd = fs_1.default.openSync(tempPath, 'r+');
|
|
70
|
-
try {
|
|
71
|
-
fs_1.default.fsyncSync(fd);
|
|
72
|
-
}
|
|
73
|
-
finally {
|
|
74
|
-
fs_1.default.closeSync(fd);
|
|
75
|
-
}
|
|
76
69
|
renameWithRetry(tempPath, filePath);
|
|
77
|
-
// POSIX supports directory fsync for rename durability. Windows does not expose
|
|
78
|
-
// a directory flush through Node; file data is flushed, rename is atomic.
|
|
79
|
-
if (process.platform !== 'win32') {
|
|
80
|
-
const directory = fs_1.default.openSync(path_1.default.dirname(filePath), 'r');
|
|
81
|
-
try {
|
|
82
|
-
fs_1.default.fsyncSync(directory);
|
|
83
|
-
}
|
|
84
|
-
finally {
|
|
85
|
-
fs_1.default.closeSync(directory);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
70
|
}
|
|
89
71
|
catch (error) {
|
|
90
72
|
// Do not leave the temp file behind for a scanner to trip over.
|
|
@@ -7,14 +7,7 @@ exports.ConversationSearchIndex = void 0;
|
|
|
7
7
|
const fs_1 = __importDefault(require("fs"));
|
|
8
8
|
const path_1 = __importDefault(require("path"));
|
|
9
9
|
const conversation_search_projection_1 = require("./conversation-search-projection");
|
|
10
|
-
const conversation_search_1 = require("./conversation-search");
|
|
11
|
-
const history_policy_1 = require("./history-policy");
|
|
12
10
|
const conversation_search_state_1 = require("./conversation-search-state");
|
|
13
|
-
const atomic_json_file_1 = require("./atomic-json-file");
|
|
14
|
-
const MAX_CACHE_FILES = 64;
|
|
15
|
-
const MAX_CACHE_BYTES = 4 * 1024 * 1024;
|
|
16
|
-
const MAX_PROJECTION_BYTES = 512 * 1024;
|
|
17
|
-
const MAX_PENDING_BUCKETS = 32;
|
|
18
11
|
function timestampValue(value) {
|
|
19
12
|
if (typeof value === 'number')
|
|
20
13
|
return Number.isFinite(value) ? value : 0;
|
|
@@ -27,332 +20,213 @@ function timestampValue(value) {
|
|
|
27
20
|
}
|
|
28
21
|
return 0;
|
|
29
22
|
}
|
|
30
|
-
function text(value) {
|
|
23
|
+
function text(value) {
|
|
24
|
+
return typeof value === 'string' ? value : '';
|
|
25
|
+
}
|
|
31
26
|
class ConversationSearchIndex {
|
|
32
27
|
constructor(store) {
|
|
33
28
|
this.store = store;
|
|
34
29
|
this.fileCache = new Map();
|
|
35
|
-
this.
|
|
36
|
-
this.pendingBuckets = new Set();
|
|
30
|
+
this.pendingByBucket = new Map();
|
|
37
31
|
this.building = false;
|
|
38
32
|
this.disposed = false;
|
|
39
|
-
this.searching = false;
|
|
40
|
-
this.waitingSearches = [];
|
|
41
|
-
this.shutdown = new AbortController();
|
|
42
|
-
}
|
|
43
|
-
historyDiagnostics() {
|
|
44
|
-
return { cacheEntries: this.fileCache.size, cacheBytes: this.cacheBytes, active: Number(this.searching), waiting: this.waitingSearches.length, rebuilding: Number(this.building), pendingBuckets: this.pendingBuckets.size };
|
|
45
33
|
}
|
|
34
|
+
/** Stop the background indexer. Called from server shutdown so it cannot outlive the Hub. */
|
|
46
35
|
dispose() {
|
|
47
36
|
this.disposed = true;
|
|
48
|
-
this.
|
|
49
|
-
this.pendingBuckets.clear();
|
|
50
|
-
this.fileCache.clear();
|
|
51
|
-
this.cacheBytes = 0;
|
|
52
|
-
}
|
|
53
|
-
collect(targets, signal) {
|
|
54
|
-
const corpus = {
|
|
55
|
-
items: this.iterate(targets, () => corpus, signal),
|
|
56
|
-
conversationsSearched: 0, pendingConversations: 0, headerOnlyScopes: [],
|
|
57
|
-
};
|
|
58
|
-
return corpus;
|
|
59
|
-
}
|
|
60
|
-
/** One live scan, two waiters. An aborted read keeps its slot until I/O unwinds. */
|
|
61
|
-
async search(targets, parsed, options) {
|
|
62
|
-
const signal = AbortSignal.any([options.signal, this.shutdown.signal]);
|
|
63
|
-
this.checkCancellation(signal);
|
|
64
|
-
const work = (async () => {
|
|
65
|
-
const release = await this.acquireSearch(signal);
|
|
66
|
-
try {
|
|
67
|
-
this.checkCancellation(signal);
|
|
68
|
-
const corpus = this.collect(targets, signal);
|
|
69
|
-
const outcome = await (0, conversation_search_1.searchConversations)(corpus.items, parsed, { limit: options.limit });
|
|
70
|
-
this.checkCancellation(signal);
|
|
71
|
-
return { corpus, outcome };
|
|
72
|
-
}
|
|
73
|
-
finally {
|
|
74
|
-
release();
|
|
75
|
-
}
|
|
76
|
-
})();
|
|
77
|
-
return new Promise((resolve, reject) => {
|
|
78
|
-
const cancelled = () => reject(signal.reason || new history_policy_1.HistoryStoreError('history_search_cancelled', 503));
|
|
79
|
-
signal.addEventListener('abort', cancelled, { once: true });
|
|
80
|
-
if (signal.aborted)
|
|
81
|
-
cancelled();
|
|
82
|
-
work.then(resolve, reject).finally(() => signal.removeEventListener('abort', cancelled));
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
|
-
acquireSearch(signal) {
|
|
86
|
-
this.checkCancellation(signal);
|
|
87
|
-
if (this.searching && this.waitingSearches.length >= 2)
|
|
88
|
-
return Promise.reject(new history_policy_1.HistoryStoreError('history_search_busy', 503));
|
|
89
|
-
return new Promise((resolve, reject) => {
|
|
90
|
-
const cancelled = () => {
|
|
91
|
-
const index = this.waitingSearches.indexOf(grant);
|
|
92
|
-
if (index >= 0)
|
|
93
|
-
this.waitingSearches.splice(index, 1);
|
|
94
|
-
reject(signal.reason || new history_policy_1.HistoryStoreError('history_search_cancelled', 503));
|
|
95
|
-
};
|
|
96
|
-
const grant = () => {
|
|
97
|
-
signal.removeEventListener('abort', cancelled);
|
|
98
|
-
this.searching = true;
|
|
99
|
-
let released = false;
|
|
100
|
-
resolve(() => {
|
|
101
|
-
if (released)
|
|
102
|
-
return;
|
|
103
|
-
released = true;
|
|
104
|
-
this.searching = false;
|
|
105
|
-
this.waitingSearches.shift()?.();
|
|
106
|
-
});
|
|
107
|
-
};
|
|
108
|
-
if (!this.searching)
|
|
109
|
-
grant();
|
|
110
|
-
else {
|
|
111
|
-
this.waitingSearches.push(grant);
|
|
112
|
-
signal.addEventListener('abort', cancelled, { once: true });
|
|
113
|
-
}
|
|
114
|
-
});
|
|
37
|
+
this.pendingByBucket.clear();
|
|
115
38
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
122
|
-
async *iterate(targets, state, signal) {
|
|
123
|
-
this.checkCancellation(signal);
|
|
124
|
-
const corpus = state();
|
|
125
|
-
const degraded = (label) => {
|
|
126
|
-
if (!corpus.headerOnlyScopes.includes(label))
|
|
127
|
-
corpus.headerOnlyScopes.push(label);
|
|
128
|
-
};
|
|
39
|
+
collect(targets) {
|
|
40
|
+
const items = [];
|
|
41
|
+
const headerOnlyScopes = [];
|
|
42
|
+
let conversationsSearched = 0;
|
|
43
|
+
let pendingConversations = 0;
|
|
129
44
|
for (const target of targets) {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const entry = read.entry;
|
|
137
|
-
if (read.unreadable)
|
|
138
|
-
degraded(target.projectLabel);
|
|
139
|
-
if (!entry) {
|
|
140
|
-
corpus.pendingConversations += 1;
|
|
141
|
-
needsBuild = true;
|
|
142
|
-
}
|
|
143
|
-
else if (timestampValue(header.lastUpdatedAt) > timestampValue(entry.lastUpdatedAt))
|
|
144
|
-
needsBuild = true;
|
|
145
|
-
const item = this.toSearchable(target, header, entry || undefined);
|
|
146
|
-
if (header.hasArchivedArtifacts) {
|
|
147
|
-
try {
|
|
148
|
-
item.artifacts = (0, conversation_search_projection_1.artifactSearchNames)({ artifacts: this.store.readControlDocument(target.bucketKey, header.id, 'artifacts') });
|
|
149
|
-
}
|
|
150
|
-
catch {
|
|
151
|
-
degraded(target.projectLabel);
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
const history = header.history;
|
|
155
|
-
if (entry && (entry.threadTruncated || history?.archiveTail || header.hasMessageBlobs)) {
|
|
156
|
-
item.threadTruncated = false;
|
|
157
|
-
item.messages = this.historyMessages(target, header, item, degraded, signal);
|
|
158
|
-
}
|
|
159
|
-
yield item;
|
|
160
|
-
}
|
|
45
|
+
const headers = this.safeHeaders(target.bucketKey);
|
|
46
|
+
if (headers.length === 0)
|
|
47
|
+
continue;
|
|
48
|
+
const projection = this.readProjection(target.bucketKey);
|
|
49
|
+
if (projection.unreadable > 0 && !headerOnlyScopes.includes(target.projectLabel)) {
|
|
50
|
+
headerOnlyScopes.push(target.projectLabel);
|
|
161
51
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
52
|
+
// Two different conditions, deliberately counted differently. A conversation with NO entry
|
|
53
|
+
// has had no thread text searched, so it is genuinely pending and R30 must say so. A
|
|
54
|
+
// conversation whose entry is merely stamped older than its header did have its thread
|
|
55
|
+
// searched; rebuilding it is a refresh, not an incomplete result, so it must not make the UI
|
|
56
|
+
// claim it is still indexing.
|
|
57
|
+
const rebuildIds = [];
|
|
58
|
+
for (const header of headers) {
|
|
59
|
+
conversationsSearched += 1;
|
|
60
|
+
const entry = projection.entries.get(header.id);
|
|
61
|
+
if (!entry) {
|
|
62
|
+
pendingConversations += 1;
|
|
63
|
+
rebuildIds.push(header.id);
|
|
64
|
+
}
|
|
65
|
+
else if (timestampValue(header.lastUpdatedAt) > timestampValue(entry.lastUpdatedAt)) {
|
|
66
|
+
rebuildIds.push(header.id);
|
|
67
|
+
}
|
|
68
|
+
items.push(this.toSearchable(target, header, entry));
|
|
165
69
|
}
|
|
166
|
-
if (
|
|
167
|
-
|
|
70
|
+
if (rebuildIds.length > 0)
|
|
71
|
+
this.queueBuild(target.bucketKey, rebuildIds);
|
|
72
|
+
}
|
|
73
|
+
return { items, conversationsSearched, pendingConversations, headerOnlyScopes };
|
|
74
|
+
}
|
|
75
|
+
// ---- internals ----
|
|
76
|
+
safeHeaders(bucketKey) {
|
|
77
|
+
try {
|
|
78
|
+
return this.store.loadProjectHeaders(bucketKey);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return [];
|
|
168
82
|
}
|
|
169
83
|
}
|
|
170
84
|
toSearchable(target, header, entry) {
|
|
171
85
|
return {
|
|
172
|
-
id: header.id,
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
86
|
+
id: header.id,
|
|
87
|
+
projectPath: text(header.projectPath),
|
|
88
|
+
bucketKey: target.bucketKey,
|
|
89
|
+
scope: target.scope,
|
|
90
|
+
projectLabel: target.projectLabel,
|
|
91
|
+
title: text(header.title),
|
|
92
|
+
jobId: text(header.jobId),
|
|
93
|
+
jobTitle: text(header.jobTitle),
|
|
94
|
+
employeeLabel: (0, conversation_search_projection_1.conversationSpeakerLabel)(header),
|
|
95
|
+
personaKey: text(header.personaKey),
|
|
96
|
+
issueNumber: text(header.issueNumber),
|
|
97
|
+
state: (0, conversation_search_state_1.conversationSearchState)(header),
|
|
98
|
+
lastUpdatedAt: timestampValue(header.lastUpdatedAt),
|
|
99
|
+
artifacts: entry ? entry.artifacts : [],
|
|
100
|
+
messages: entry ? entry.messages : [],
|
|
101
|
+
threadTruncated: entry ? entry.threadTruncated : false,
|
|
179
102
|
threadIndexed: Boolean(entry),
|
|
180
103
|
};
|
|
181
104
|
}
|
|
182
|
-
|
|
183
|
-
|
|
105
|
+
/**
|
|
106
|
+
* Read a bucket's projection, reusing already-parsed sidecars.
|
|
107
|
+
*
|
|
108
|
+
* The memo is keyed per FILE on mtime plus byte size, not per directory. A directory-level memo
|
|
109
|
+
* (dir mtime + file count) looked cheaper but is unsafe: replacing an existing sidecar leaves the
|
|
110
|
+
* file count unchanged, so a coarse mtime clock could serve stale thread text for a conversation
|
|
111
|
+
* that was just patched, and R28 says a run that just finished must be findable. Per-file keys
|
|
112
|
+
* mean an overwritten sidecar is re-parsed unless its mtime AND its length are both identical, and
|
|
113
|
+
* `collect` additionally treats any entry older than its header as pending so even that residual
|
|
114
|
+
* case self-heals rather than going unnoticed.
|
|
115
|
+
*/
|
|
116
|
+
readProjection(bucketKey) {
|
|
117
|
+
const dir = (0, conversation_search_projection_1.searchProjectionDirPath)(this.store.bucketDirectory(bucketKey));
|
|
118
|
+
let files;
|
|
184
119
|
try {
|
|
185
|
-
|
|
186
|
-
this.checkCancellation(signal);
|
|
187
|
-
ordinal += 1;
|
|
188
|
-
const rawRole = text(raw.role).toLowerCase();
|
|
189
|
-
const role = ['manager', 'user', 'human'].includes(rawRole) ? 'manager'
|
|
190
|
-
: ['employee', 'assistant', 'agent'].includes(rawRole) ? 'employee' : 'system';
|
|
191
|
-
const who = role === 'manager' ? 'You' : role === 'employee' ? (0, conversation_search_projection_1.conversationSpeakerLabel)(header) : 'System';
|
|
192
|
-
const content = raw.contentRef;
|
|
193
|
-
if (!content?.hash) {
|
|
194
|
-
yield { role, who, text: text(raw.text), messageKey: ordinal };
|
|
195
|
-
continue;
|
|
196
|
-
}
|
|
197
|
-
const stream = this.store.openMessageBlob(target.bucketKey, header.id, content.hash, Number(raw.sequence) || undefined);
|
|
198
|
-
const cancelRead = () => stream.destroy(new history_policy_1.HistoryStoreError('history_search_cancelled', 503));
|
|
199
|
-
signal?.addEventListener('abort', cancelRead, { once: true });
|
|
200
|
-
stream.setEncoding('utf8');
|
|
201
|
-
let overlap = '';
|
|
202
|
-
try {
|
|
203
|
-
for await (const chunk of stream) {
|
|
204
|
-
this.checkCancellation(signal);
|
|
205
|
-
const combined = overlap + String(chunk);
|
|
206
|
-
yield { role, who, text: combined, messageKey: ordinal, overlapText: overlap };
|
|
207
|
-
// Includes at least the longest supported phrase plus its preceding character.
|
|
208
|
-
overlap = combined.slice(-(conversation_search_1.MAX_SEARCH_QUERY_LENGTH + 1));
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
finally {
|
|
212
|
-
signal?.removeEventListener('abort', cancelRead);
|
|
213
|
-
stream.destroy();
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
if (typeof header.messageCount === 'number' && ordinal !== header.messageCount) {
|
|
217
|
-
throw new Error('history_incomplete');
|
|
218
|
-
}
|
|
120
|
+
files = fs_1.default.readdirSync(dir);
|
|
219
121
|
}
|
|
220
122
|
catch {
|
|
221
|
-
|
|
222
|
-
item.threadTruncated = true;
|
|
223
|
-
item.threadIndexed = false;
|
|
224
|
-
degraded(target.projectLabel);
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
/** Stat-check one bounded file, and LRU only those entries that fit both budgets. */
|
|
228
|
-
async readProjection(bucketKey, id) {
|
|
229
|
-
const filePath = (0, conversation_search_projection_1.searchProjectionEntryPath)(this.store.bucketDirectory(bucketKey), id);
|
|
230
|
-
let stat;
|
|
231
|
-
try {
|
|
232
|
-
stat = await fs_1.default.promises.stat(filePath);
|
|
233
|
-
}
|
|
234
|
-
catch (error) {
|
|
235
|
-
this.dropCached(filePath);
|
|
236
|
-
return { entry: null, unreadable: error.code !== 'ENOENT' };
|
|
237
|
-
}
|
|
238
|
-
const cached = this.fileCache.get(filePath);
|
|
239
|
-
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
|
|
240
|
-
this.fileCache.delete(filePath);
|
|
241
|
-
this.fileCache.set(filePath, cached);
|
|
242
|
-
return { entry: cached.entry, unreadable: !cached.entry };
|
|
123
|
+
return { entries: new Map(), unreadable: 0, missing: true };
|
|
243
124
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
const
|
|
251
|
-
|
|
125
|
+
const entries = new Map();
|
|
126
|
+
let unreadable = 0;
|
|
127
|
+
const seen = new Set();
|
|
128
|
+
for (const file of files) {
|
|
129
|
+
if (!file.endsWith('.json'))
|
|
130
|
+
continue;
|
|
131
|
+
const filePath = path_1.default.join(dir, file);
|
|
132
|
+
seen.add(filePath);
|
|
133
|
+
let mtimeMs = -1;
|
|
134
|
+
let size = -1;
|
|
252
135
|
try {
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
}
|
|
261
|
-
if (offset > stat.size || offset > MAX_PROJECTION_BYTES)
|
|
262
|
-
throw new Error('projection_changed');
|
|
263
|
-
contents = buffer.toString('utf8', 0, offset);
|
|
136
|
+
const stat = fs_1.default.statSync(filePath);
|
|
137
|
+
mtimeMs = stat.mtimeMs;
|
|
138
|
+
size = stat.size;
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
unreadable += 1;
|
|
142
|
+
continue;
|
|
264
143
|
}
|
|
265
|
-
|
|
266
|
-
|
|
144
|
+
const cached = this.fileCache.get(filePath);
|
|
145
|
+
if (cached && cached.mtimeMs === mtimeMs && cached.size === size) {
|
|
146
|
+
if (cached.entry)
|
|
147
|
+
entries.set(cached.entry.id, cached.entry);
|
|
148
|
+
else
|
|
149
|
+
unreadable += 1;
|
|
150
|
+
continue;
|
|
267
151
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
152
|
+
let parsed = null;
|
|
153
|
+
try {
|
|
154
|
+
const candidate = JSON.parse(fs_1.default.readFileSync(filePath, 'utf8'));
|
|
155
|
+
if (candidate && typeof candidate.id === 'string' && Array.isArray(candidate.messages))
|
|
156
|
+
parsed = candidate;
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
parsed = null;
|
|
160
|
+
}
|
|
161
|
+
this.fileCache.set(filePath, { mtimeMs, size, entry: parsed });
|
|
162
|
+
if (parsed)
|
|
163
|
+
entries.set(parsed.id, parsed);
|
|
164
|
+
else
|
|
165
|
+
unreadable += 1;
|
|
271
166
|
}
|
|
272
|
-
|
|
273
|
-
this.fileCache.
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
this.dropCached(this.fileCache.keys().next().value);
|
|
167
|
+
// Drop memo entries for sidecars that are gone, so a pruned conversation cannot linger.
|
|
168
|
+
for (const filePath of Array.from(this.fileCache.keys())) {
|
|
169
|
+
if (filePath.startsWith(dir) && !seen.has(filePath))
|
|
170
|
+
this.fileCache.delete(filePath);
|
|
277
171
|
}
|
|
278
|
-
return {
|
|
279
|
-
}
|
|
280
|
-
dropCached(filePath) {
|
|
281
|
-
const prior = this.fileCache.get(filePath);
|
|
282
|
-
if (prior)
|
|
283
|
-
this.cacheBytes -= prior.size;
|
|
284
|
-
this.fileCache.delete(filePath);
|
|
172
|
+
return { entries, unreadable, missing: false };
|
|
285
173
|
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
}
|
|
289
|
-
async queueBuild(bucketKey) {
|
|
290
|
-
if (this.disposed || this.pendingBuckets.has(bucketKey))
|
|
291
|
-
return;
|
|
292
|
-
// The marker survives shutdown; the next query resumes discovery even if the bounded
|
|
293
|
-
// in-memory bucket queue is full. No lifetime-sized list of conversation IDs is kept.
|
|
294
|
-
try {
|
|
295
|
-
if (!fs_1.default.existsSync(this.rebuildCursorPath(bucketKey))) {
|
|
296
|
-
(0, atomic_json_file_1.writeJsonAtomic)(this.rebuildCursorPath(bucketKey), { version: 1, lastId: null });
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
catch {
|
|
174
|
+
queueBuild(bucketKey, ids) {
|
|
175
|
+
if (this.disposed)
|
|
300
176
|
return;
|
|
177
|
+
let set = this.pendingByBucket.get(bucketKey);
|
|
178
|
+
if (!set) {
|
|
179
|
+
set = new Set();
|
|
180
|
+
this.pendingByBucket.set(bucketKey, set);
|
|
301
181
|
}
|
|
302
|
-
|
|
182
|
+
for (const id of ids)
|
|
183
|
+
set.add(id);
|
|
184
|
+
this.startBuildLoop();
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* One conversation per event-loop turn. A self-rescheduling `setImmediate` defers the next
|
|
188
|
+
* conversation to the following loop iteration, so the poll phase runs in between and ordinary
|
|
189
|
+
* Hub requests are answered while a large store indexes for the first time (R31).
|
|
190
|
+
*/
|
|
191
|
+
startBuildLoop() {
|
|
192
|
+
if (this.building || this.disposed)
|
|
303
193
|
return;
|
|
304
|
-
this.pendingBuckets.add(bucketKey);
|
|
305
|
-
if (!this.building)
|
|
306
|
-
void this.buildLoop();
|
|
307
|
-
}
|
|
308
|
-
async buildLoop() {
|
|
309
194
|
this.building = true;
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
let cursor = null;
|
|
315
|
-
try {
|
|
316
|
-
const file = this.rebuildCursorPath(bucketKey);
|
|
317
|
-
if ((await fs_1.default.promises.stat(file)).size <= 4096) {
|
|
318
|
-
const value = JSON.parse(await fs_1.default.promises.readFile(file, 'utf8'));
|
|
319
|
-
if (typeof value.lastId === 'string')
|
|
320
|
-
cursor = value.lastId;
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
catch { /* Missing or invalid derived cursor restarts a bounded scan. */ }
|
|
324
|
-
// If the anchor was deleted, restart once from the beginning. Neither pass
|
|
325
|
-
// materializes IDs; existing directory order supplies the resume position.
|
|
326
|
-
for (let pass = 0; pass < 2; pass += 1) {
|
|
327
|
-
let pastCursor = cursor === null;
|
|
328
|
-
for await (const header of this.store.iterateProjectHeaders(bucketKey)) {
|
|
329
|
-
if (this.disposed)
|
|
330
|
-
return;
|
|
331
|
-
if (!pastCursor) {
|
|
332
|
-
if (header.id === cursor)
|
|
333
|
-
pastCursor = true;
|
|
334
|
-
continue;
|
|
335
|
-
}
|
|
336
|
-
const { entry } = await this.readProjection(bucketKey, header.id);
|
|
337
|
-
if (!entry || timestampValue(header.lastUpdatedAt) > timestampValue(entry.lastUpdatedAt)) {
|
|
338
|
-
await this.store.indexConversationForSearch(bucketKey, header.id);
|
|
339
|
-
}
|
|
340
|
-
(0, atomic_json_file_1.writeJsonAtomic)(this.rebuildCursorPath(bucketKey), { version: 1, lastId: header.id });
|
|
341
|
-
await new Promise((resolve) => setImmediate(resolve));
|
|
342
|
-
}
|
|
343
|
-
if (pastCursor)
|
|
344
|
-
break;
|
|
345
|
-
cursor = null;
|
|
346
|
-
}
|
|
347
|
-
await fs_1.default.promises.rm(this.rebuildCursorPath(bucketKey), { force: true });
|
|
348
|
-
}
|
|
349
|
-
catch { /* Leave the repair marker for another query/restart. */ }
|
|
350
|
-
this.pendingBuckets.delete(bucketKey);
|
|
195
|
+
const step = () => {
|
|
196
|
+
if (this.disposed) {
|
|
197
|
+
this.building = false;
|
|
198
|
+
return;
|
|
351
199
|
}
|
|
200
|
+
const next = this.takeNextPending();
|
|
201
|
+
if (!next) {
|
|
202
|
+
this.building = false;
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
this.store.indexConversationForSearch(next.bucketKey, next.id);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
/* a single unreadable conversation must not stall the whole index */
|
|
210
|
+
}
|
|
211
|
+
const handle = setImmediate(step);
|
|
212
|
+
if (typeof handle.unref === 'function')
|
|
213
|
+
handle.unref();
|
|
214
|
+
};
|
|
215
|
+
const handle = setImmediate(step);
|
|
216
|
+
if (typeof handle.unref === 'function')
|
|
217
|
+
handle.unref();
|
|
218
|
+
}
|
|
219
|
+
takeNextPending() {
|
|
220
|
+
for (const [bucketKey, ids] of this.pendingByBucket) {
|
|
221
|
+
for (const id of ids) {
|
|
222
|
+
ids.delete(id);
|
|
223
|
+
if (ids.size === 0)
|
|
224
|
+
this.pendingByBucket.delete(bucketKey);
|
|
225
|
+
return { bucketKey, id };
|
|
226
|
+
}
|
|
227
|
+
this.pendingByBucket.delete(bucketKey);
|
|
352
228
|
}
|
|
353
|
-
|
|
354
|
-
this.building = false;
|
|
355
|
-
}
|
|
229
|
+
return null;
|
|
356
230
|
}
|
|
357
231
|
}
|
|
358
232
|
exports.ConversationSearchIndex = ConversationSearchIndex;
|
|
@@ -91,42 +91,59 @@ function artifactSearchNames(conv) {
|
|
|
91
91
|
function buildConversationSearchEntry(conv) {
|
|
92
92
|
const rawMessages = Array.isArray(conv.messages) ? conv.messages : [];
|
|
93
93
|
const employeeLabel = conversationSpeakerLabel(conv);
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
let retainedChars = 0;
|
|
98
|
-
let threadMessageCount = 0;
|
|
99
|
-
for (const value of rawMessages) {
|
|
100
|
-
const raw = value;
|
|
94
|
+
const candidates = [];
|
|
95
|
+
for (let index = 0; index < rawMessages.length; index += 1) {
|
|
96
|
+
const raw = rawMessages[index];
|
|
101
97
|
if (!raw || typeof raw !== 'object')
|
|
102
98
|
continue;
|
|
103
|
-
const
|
|
104
|
-
if (!
|
|
99
|
+
const text = cleanText(raw.text);
|
|
100
|
+
if (!text.trim())
|
|
105
101
|
continue;
|
|
106
|
-
threadMessageCount += 1;
|
|
107
102
|
const role = normalizeRole(raw.role);
|
|
108
|
-
const
|
|
109
|
-
|
|
103
|
+
const clipped = text.length > exports.MAX_RETAINED_MESSAGE_CHARS;
|
|
104
|
+
candidates.push({
|
|
105
|
+
index,
|
|
106
|
+
clipped,
|
|
110
107
|
message: {
|
|
111
108
|
role,
|
|
112
|
-
who: role === 'manager' ? MANAGER_SPEAKER_LABEL : role === 'employee' ? employeeLabel : 'System',
|
|
113
|
-
text:
|
|
109
|
+
who: role === 'manager' ? MANAGER_SPEAKER_LABEL : (role === 'employee' ? employeeLabel : 'System'),
|
|
110
|
+
text: clipped ? text.slice(0, exports.MAX_RETAINED_MESSAGE_CHARS) : text,
|
|
114
111
|
},
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
const keptIndexes = new Set();
|
|
115
|
+
let budget = exports.MAX_RETAINED_THREAD_CHARS;
|
|
116
|
+
let clippedAny = false;
|
|
117
|
+
const take = (candidate) => {
|
|
118
|
+
if (keptIndexes.has(candidate.index))
|
|
119
|
+
return true;
|
|
120
|
+
if (candidate.message.text.length > budget)
|
|
121
|
+
return false;
|
|
122
|
+
keptIndexes.add(candidate.index);
|
|
123
|
+
budget -= candidate.message.text.length;
|
|
124
|
+
if (candidate.clipped)
|
|
125
|
+
clippedAny = true;
|
|
126
|
+
return true;
|
|
127
|
+
};
|
|
128
|
+
if (candidates.length > 0) {
|
|
129
|
+
// The kickoff always wins the first slice of the budget.
|
|
130
|
+
take(candidates[0]);
|
|
131
|
+
for (let cursor = candidates.length - 1; cursor >= 1; cursor -= 1) {
|
|
132
|
+
if (!take(candidates[cursor]))
|
|
133
|
+
break;
|
|
120
134
|
}
|
|
121
135
|
}
|
|
136
|
+
const retained = candidates
|
|
137
|
+
.filter((candidate) => keptIndexes.has(candidate.index))
|
|
138
|
+
.sort((a, b) => a.index - b.index);
|
|
122
139
|
return {
|
|
123
140
|
version: exports.SEARCH_PROJECTION_VERSION,
|
|
124
141
|
id: cleanText(conv.id),
|
|
125
142
|
lastUpdatedAt: conv.lastUpdatedAt ?? conv.createdAt ?? 0,
|
|
126
143
|
messages: retained.map((candidate) => candidate.message),
|
|
127
144
|
artifacts: artifactSearchNames(conv),
|
|
128
|
-
threadTruncated:
|
|
129
|
-
threadMessageCount,
|
|
145
|
+
threadTruncated: clippedAny || retained.length < candidates.length,
|
|
146
|
+
threadMessageCount: candidates.length,
|
|
130
147
|
};
|
|
131
148
|
}
|
|
132
149
|
// ---- disk layout -----------------------------------------------------------
|