fraim-hub 2.0.253 → 2.0.255
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 +64 -0
- package/dist/src/ai-hub/conversation-search-index.js +232 -0
- package/dist/src/ai-hub/conversation-search-projection.js +229 -0
- package/dist/src/ai-hub/conversation-search-state.js +76 -0
- package/dist/src/ai-hub/conversation-search.js +345 -0
- package/dist/src/ai-hub/conversation-store.js +43 -32
- package/dist/src/ai-hub/office-sideload.js +12 -6
- package/dist/src/ai-hub/server.js +100 -0
- package/package.json +2 -2
- package/public/ai-hub/index.html +72 -5
- package/public/ai-hub/script.js +852 -52
- package/public/ai-hub/styles.css +240 -68
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.renameWithRetry = renameWithRetry;
|
|
7
|
+
exports.writeJsonAtomic = writeJsonAtomic;
|
|
8
|
+
const fs_1 = __importDefault(require("fs"));
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
// Atomic JSON file write for the conversation store and its derived caches.
|
|
11
|
+
//
|
|
12
|
+
// Extracted in issue #1065. Both the conversation store and the searchable projection need the
|
|
13
|
+
// same write-temp-then-rename dance, and the same Windows-specific retry around it. Keeping two
|
|
14
|
+
// copies meant a future fix to the retry behaviour would land in one and silently not the other,
|
|
15
|
+
// which is exactly the drift the store's #820 durability work exists to prevent.
|
|
16
|
+
const RENAME_ATTEMPTS = 25;
|
|
17
|
+
const RENAME_RETRY_DELAY_MS = 10;
|
|
18
|
+
let tempSeq = 0;
|
|
19
|
+
/**
|
|
20
|
+
* Rename, retrying the Windows contention codes.
|
|
21
|
+
*
|
|
22
|
+
* Windows can throw EPERM/EBUSY/EACCES when a reader briefly holds the destination open. Callers
|
|
23
|
+
* already hold the per-bucket advisory lock against other writers, so a short bounded retry is
|
|
24
|
+
* enough; a genuine error still propagates.
|
|
25
|
+
*/
|
|
26
|
+
function renameWithRetry(from, to) {
|
|
27
|
+
for (let attempt = 0; attempt < RENAME_ATTEMPTS; attempt += 1) {
|
|
28
|
+
try {
|
|
29
|
+
fs_1.default.renameSync(from, to);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
const code = error.code;
|
|
34
|
+
if ((code === 'EPERM' || code === 'EBUSY' || code === 'EACCES') && attempt < RENAME_ATTEMPTS - 1) {
|
|
35
|
+
// Synchronous sleep without a busy spin: the store's write path is synchronous.
|
|
36
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, RENAME_RETRY_DELAY_MS);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Serialize `value` to `filePath` atomically: write a uniquely-named temp file beside it, then
|
|
45
|
+
* rename over the destination. A reader therefore sees either the old file or the new one, never a
|
|
46
|
+
* half-written one. The temp name carries the pid and a per-process sequence so two processes
|
|
47
|
+
* writing the same destination cannot collide on the temp file itself.
|
|
48
|
+
*/
|
|
49
|
+
function writeJsonAtomic(filePath, value) {
|
|
50
|
+
fs_1.default.mkdirSync(path_1.default.dirname(filePath), { recursive: true });
|
|
51
|
+
const tempPath = `${filePath}.${process.pid}.${tempSeq++}.tmp`;
|
|
52
|
+
fs_1.default.writeFileSync(tempPath, JSON.stringify(value), 'utf8');
|
|
53
|
+
try {
|
|
54
|
+
renameWithRetry(tempPath, filePath);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
// Do not leave the temp file behind for a scanner to trip over.
|
|
58
|
+
try {
|
|
59
|
+
fs_1.default.rmSync(tempPath, { force: true });
|
|
60
|
+
}
|
|
61
|
+
catch { /* best effort */ }
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.ConversationSearchIndex = void 0;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const conversation_search_projection_1 = require("./conversation-search-projection");
|
|
10
|
+
const conversation_search_state_1 = require("./conversation-search-state");
|
|
11
|
+
function timestampValue(value) {
|
|
12
|
+
if (typeof value === 'number')
|
|
13
|
+
return Number.isFinite(value) ? value : 0;
|
|
14
|
+
if (typeof value === 'string') {
|
|
15
|
+
const parsed = Date.parse(value);
|
|
16
|
+
if (Number.isFinite(parsed))
|
|
17
|
+
return parsed;
|
|
18
|
+
const numeric = Number(value);
|
|
19
|
+
return Number.isFinite(numeric) ? numeric : 0;
|
|
20
|
+
}
|
|
21
|
+
return 0;
|
|
22
|
+
}
|
|
23
|
+
function text(value) {
|
|
24
|
+
return typeof value === 'string' ? value : '';
|
|
25
|
+
}
|
|
26
|
+
class ConversationSearchIndex {
|
|
27
|
+
constructor(store) {
|
|
28
|
+
this.store = store;
|
|
29
|
+
this.fileCache = new Map();
|
|
30
|
+
this.pendingByBucket = new Map();
|
|
31
|
+
this.building = false;
|
|
32
|
+
this.disposed = false;
|
|
33
|
+
}
|
|
34
|
+
/** Stop the background indexer. Called from server shutdown so it cannot outlive the Hub. */
|
|
35
|
+
dispose() {
|
|
36
|
+
this.disposed = true;
|
|
37
|
+
this.pendingByBucket.clear();
|
|
38
|
+
}
|
|
39
|
+
collect(targets) {
|
|
40
|
+
const items = [];
|
|
41
|
+
const headerOnlyScopes = [];
|
|
42
|
+
let conversationsSearched = 0;
|
|
43
|
+
let pendingConversations = 0;
|
|
44
|
+
for (const target of targets) {
|
|
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);
|
|
51
|
+
}
|
|
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));
|
|
69
|
+
}
|
|
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 [];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
toSearchable(target, header, entry) {
|
|
85
|
+
return {
|
|
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,
|
|
102
|
+
threadIndexed: Boolean(entry),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
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;
|
|
119
|
+
try {
|
|
120
|
+
files = fs_1.default.readdirSync(dir);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return { entries: new Map(), unreadable: 0, missing: true };
|
|
124
|
+
}
|
|
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;
|
|
135
|
+
try {
|
|
136
|
+
const stat = fs_1.default.statSync(filePath);
|
|
137
|
+
mtimeMs = stat.mtimeMs;
|
|
138
|
+
size = stat.size;
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
unreadable += 1;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
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;
|
|
151
|
+
}
|
|
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;
|
|
166
|
+
}
|
|
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);
|
|
171
|
+
}
|
|
172
|
+
return { entries, unreadable, missing: false };
|
|
173
|
+
}
|
|
174
|
+
queueBuild(bucketKey, ids) {
|
|
175
|
+
if (this.disposed)
|
|
176
|
+
return;
|
|
177
|
+
let set = this.pendingByBucket.get(bucketKey);
|
|
178
|
+
if (!set) {
|
|
179
|
+
set = new Set();
|
|
180
|
+
this.pendingByBucket.set(bucketKey, set);
|
|
181
|
+
}
|
|
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)
|
|
193
|
+
return;
|
|
194
|
+
this.building = true;
|
|
195
|
+
const step = () => {
|
|
196
|
+
if (this.disposed) {
|
|
197
|
+
this.building = false;
|
|
198
|
+
return;
|
|
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);
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
exports.ConversationSearchIndex = ConversationSearchIndex;
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.MAX_RETAINED_THREAD_CHARS = exports.MAX_RETAINED_MESSAGE_CHARS = exports.SEARCH_PROJECTION_VERSION = exports.SEARCH_PROJECTION_DIR_NAME = void 0;
|
|
7
|
+
exports.conversationSpeakerLabel = conversationSpeakerLabel;
|
|
8
|
+
exports.artifactSearchNames = artifactSearchNames;
|
|
9
|
+
exports.buildConversationSearchEntry = buildConversationSearchEntry;
|
|
10
|
+
exports.searchProjectionDirPath = searchProjectionDirPath;
|
|
11
|
+
exports.searchProjectionFileName = searchProjectionFileName;
|
|
12
|
+
exports.searchProjectionEntryPath = searchProjectionEntryPath;
|
|
13
|
+
exports.writeConversationSearchEntry = writeConversationSearchEntry;
|
|
14
|
+
exports.readConversationSearchEntries = readConversationSearchEntries;
|
|
15
|
+
exports.pruneConversationSearchEntries = pruneConversationSearchEntries;
|
|
16
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
17
|
+
const fs_1 = __importDefault(require("fs"));
|
|
18
|
+
const path_1 = __importDefault(require("path"));
|
|
19
|
+
const atomic_json_file_1 = require("./atomic-json-file");
|
|
20
|
+
// Issue #1065: the searchable projection.
|
|
21
|
+
//
|
|
22
|
+
// Search reads the manager-visible thread and the header facts; it never reads the raw host
|
|
23
|
+
// event log, which is 91% of the stored bytes and is tool-call noise (measured: one ordinary
|
|
24
|
+
// term matched 25 conversations against titles + thread text, and 181 of 224 against the raw
|
|
25
|
+
// JSON including events). Header facts are already in the bucket's `index.json`, so this
|
|
26
|
+
// projection is purely additive: it carries the bounded thread text plus artifact names.
|
|
27
|
+
//
|
|
28
|
+
// Layout follows the established `index.json` discipline (see conversation-store.ts): a derived,
|
|
29
|
+
// rebuildable cache that is never the source of truth. It is stored one file per conversation
|
|
30
|
+
// under `<bucketDir>/search/`, so a write costs one conversation rather than a whole-bucket
|
|
31
|
+
// rewrite, and so project removal (which recursively deletes the bucket directory) removes the
|
|
32
|
+
// projection by construction rather than through a second code path.
|
|
33
|
+
exports.SEARCH_PROJECTION_DIR_NAME = 'search';
|
|
34
|
+
exports.SEARCH_PROJECTION_VERSION = 1;
|
|
35
|
+
/** Per-message cap. A single run message can be megabytes; only its head is searchable. */
|
|
36
|
+
exports.MAX_RETAINED_MESSAGE_CHARS = 8000;
|
|
37
|
+
/**
|
|
38
|
+
* Per-conversation cap. Conversation size is extremely skewed (p50 1.9 KB, p99 4.1 MB), so a
|
|
39
|
+
* per-conversation bound is what stops one pathological run from dominating the corpus.
|
|
40
|
+
*/
|
|
41
|
+
exports.MAX_RETAINED_THREAD_CHARS = 64 * 1024;
|
|
42
|
+
const MANAGER_SPEAKER_LABEL = 'You';
|
|
43
|
+
function cleanText(value) {
|
|
44
|
+
return typeof value === 'string' ? value : '';
|
|
45
|
+
}
|
|
46
|
+
/** The employee's display name, preferring the persona snapshot the conversation carries. */
|
|
47
|
+
function conversationSpeakerLabel(conv) {
|
|
48
|
+
const snapshot = conv.personaSnapshot;
|
|
49
|
+
const display = cleanText(snapshot?.displayName).trim();
|
|
50
|
+
if (display)
|
|
51
|
+
return display;
|
|
52
|
+
const agentLabel = cleanText(conv.configuredAgentLabel).trim();
|
|
53
|
+
if (agentLabel)
|
|
54
|
+
return agentLabel;
|
|
55
|
+
const personaKey = cleanText(conv.personaKey).trim();
|
|
56
|
+
if (personaKey)
|
|
57
|
+
return personaKey.replace(/^custom:/, '');
|
|
58
|
+
return cleanText(conv.agentName).trim() || 'Employee';
|
|
59
|
+
}
|
|
60
|
+
function normalizeRole(raw) {
|
|
61
|
+
const role = cleanText(raw).trim().toLowerCase();
|
|
62
|
+
if (role === 'manager' || role === 'user' || role === 'human')
|
|
63
|
+
return 'manager';
|
|
64
|
+
if (role === 'employee' || role === 'assistant' || role === 'agent')
|
|
65
|
+
return 'employee';
|
|
66
|
+
return 'system';
|
|
67
|
+
}
|
|
68
|
+
function artifactSearchNames(conv) {
|
|
69
|
+
const raw = Array.isArray(conv.artifacts) ? conv.artifacts : [];
|
|
70
|
+
const out = [];
|
|
71
|
+
for (const artifact of raw) {
|
|
72
|
+
if (!artifact || typeof artifact !== 'object')
|
|
73
|
+
continue;
|
|
74
|
+
const value = artifact;
|
|
75
|
+
for (const field of ['label', 'path', 'url', 'localPath', 'name']) {
|
|
76
|
+
const text = cleanText(value[field]).trim();
|
|
77
|
+
if (text && !out.includes(text))
|
|
78
|
+
out.push(text);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Build the searchable projection for one conversation.
|
|
85
|
+
*
|
|
86
|
+
* Retention rule (R29): the first message is always kept, because the kickoff instruction is
|
|
87
|
+
* what a manager is most likely to remember. The remaining budget is filled newest-first (a
|
|
88
|
+
* run's conclusion is the next most memorable thing) and the retained set is then re-ordered
|
|
89
|
+
* chronologically so snippets read in thread order.
|
|
90
|
+
*/
|
|
91
|
+
function buildConversationSearchEntry(conv) {
|
|
92
|
+
const rawMessages = Array.isArray(conv.messages) ? conv.messages : [];
|
|
93
|
+
const employeeLabel = conversationSpeakerLabel(conv);
|
|
94
|
+
const candidates = [];
|
|
95
|
+
for (let index = 0; index < rawMessages.length; index += 1) {
|
|
96
|
+
const raw = rawMessages[index];
|
|
97
|
+
if (!raw || typeof raw !== 'object')
|
|
98
|
+
continue;
|
|
99
|
+
const text = cleanText(raw.text);
|
|
100
|
+
if (!text.trim())
|
|
101
|
+
continue;
|
|
102
|
+
const role = normalizeRole(raw.role);
|
|
103
|
+
const clipped = text.length > exports.MAX_RETAINED_MESSAGE_CHARS;
|
|
104
|
+
candidates.push({
|
|
105
|
+
index,
|
|
106
|
+
clipped,
|
|
107
|
+
message: {
|
|
108
|
+
role,
|
|
109
|
+
who: role === 'manager' ? MANAGER_SPEAKER_LABEL : (role === 'employee' ? employeeLabel : 'System'),
|
|
110
|
+
text: clipped ? text.slice(0, exports.MAX_RETAINED_MESSAGE_CHARS) : text,
|
|
111
|
+
},
|
|
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;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const retained = candidates
|
|
137
|
+
.filter((candidate) => keptIndexes.has(candidate.index))
|
|
138
|
+
.sort((a, b) => a.index - b.index);
|
|
139
|
+
return {
|
|
140
|
+
version: exports.SEARCH_PROJECTION_VERSION,
|
|
141
|
+
id: cleanText(conv.id),
|
|
142
|
+
lastUpdatedAt: conv.lastUpdatedAt ?? conv.createdAt ?? 0,
|
|
143
|
+
messages: retained.map((candidate) => candidate.message),
|
|
144
|
+
artifacts: artifactSearchNames(conv),
|
|
145
|
+
threadTruncated: clippedAny || retained.length < candidates.length,
|
|
146
|
+
threadMessageCount: candidates.length,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
// ---- disk layout -----------------------------------------------------------
|
|
150
|
+
function searchProjectionDirPath(bucketDir) {
|
|
151
|
+
return path_1.default.join(bucketDir, exports.SEARCH_PROJECTION_DIR_NAME);
|
|
152
|
+
}
|
|
153
|
+
/** Same hashing as the conversation shard so the two files are trivially correlated. */
|
|
154
|
+
function searchProjectionFileName(conversationId) {
|
|
155
|
+
return `${crypto_1.default.createHash('sha256').update(conversationId).digest('hex').slice(0, 40)}.json`;
|
|
156
|
+
}
|
|
157
|
+
function searchProjectionEntryPath(bucketDir, conversationId) {
|
|
158
|
+
return path_1.default.join(searchProjectionDirPath(bucketDir), searchProjectionFileName(conversationId));
|
|
159
|
+
}
|
|
160
|
+
/** Best-effort: the projection is a cache, so a failed write must never fail the conversation write. */
|
|
161
|
+
function writeConversationSearchEntry(bucketDir, conv) {
|
|
162
|
+
const id = cleanText(conv.id);
|
|
163
|
+
if (!id)
|
|
164
|
+
return;
|
|
165
|
+
try {
|
|
166
|
+
(0, atomic_json_file_1.writeJsonAtomic)(searchProjectionEntryPath(bucketDir, id), buildConversationSearchEntry(conv));
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
/* the projection self-heals on the next read */
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function readConversationSearchEntries(bucketDir) {
|
|
173
|
+
const dir = searchProjectionDirPath(bucketDir);
|
|
174
|
+
let files;
|
|
175
|
+
try {
|
|
176
|
+
files = fs_1.default.readdirSync(dir);
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return { entries: new Map(), unreadable: 0, missing: true };
|
|
180
|
+
}
|
|
181
|
+
const entries = new Map();
|
|
182
|
+
let unreadable = 0;
|
|
183
|
+
for (const file of files) {
|
|
184
|
+
if (!file.endsWith('.json') || file.endsWith('.tmp'))
|
|
185
|
+
continue;
|
|
186
|
+
try {
|
|
187
|
+
const parsed = JSON.parse(fs_1.default.readFileSync(path_1.default.join(dir, file), 'utf8'));
|
|
188
|
+
if (parsed && typeof parsed.id === 'string' && Array.isArray(parsed.messages)) {
|
|
189
|
+
entries.set(parsed.id, parsed);
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
unreadable += 1;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
unreadable += 1;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return { entries, unreadable, missing: false };
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Drop projection entries for conversations that no longer exist (C1). Called from the same
|
|
203
|
+
* whole-bucket rewrite that prunes conversation shards, so deletion reaches every copy.
|
|
204
|
+
*/
|
|
205
|
+
function pruneConversationSearchEntries(bucketDir, desiredIds) {
|
|
206
|
+
const dir = searchProjectionDirPath(bucketDir);
|
|
207
|
+
let files;
|
|
208
|
+
try {
|
|
209
|
+
files = fs_1.default.readdirSync(dir);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const keep = new Set();
|
|
215
|
+
for (const id of desiredIds)
|
|
216
|
+
keep.add(searchProjectionFileName(id));
|
|
217
|
+
for (const file of files) {
|
|
218
|
+
if (!file.endsWith('.json'))
|
|
219
|
+
continue;
|
|
220
|
+
if (keep.has(file))
|
|
221
|
+
continue;
|
|
222
|
+
try {
|
|
223
|
+
fs_1.default.rmSync(path_1.default.join(dir, file), { force: true });
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
/* best-effort */
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.conversationSearchState = conversationSearchState;
|
|
4
|
+
exports.conversationSearchStateLabel = conversationSearchStateLabel;
|
|
5
|
+
exports.conversationSearchStateDot = conversationSearchStateDot;
|
|
6
|
+
exports.resolveSearchState = resolveSearchState;
|
|
7
|
+
exports.isKnownSearchState = isKnownSearchState;
|
|
8
|
+
const SEARCH_STATE_ALIASES = {
|
|
9
|
+
working: 'working',
|
|
10
|
+
running: 'working',
|
|
11
|
+
waiting: 'waiting',
|
|
12
|
+
complete: 'complete',
|
|
13
|
+
completed: 'complete',
|
|
14
|
+
done: 'complete',
|
|
15
|
+
stopped: 'stopped',
|
|
16
|
+
failed: 'failed',
|
|
17
|
+
error: 'failed',
|
|
18
|
+
idle: 'idle',
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Server-side mirror of the Hub client's `conversationUiState`, so `is:` filters and the result
|
|
22
|
+
* row's state agree with what the employee tree shows. The client's `error` state is named
|
|
23
|
+
* `failed` here because that is the vocabulary the spec's `is:` facet uses.
|
|
24
|
+
*/
|
|
25
|
+
function conversationSearchState(conv) {
|
|
26
|
+
if (!conv)
|
|
27
|
+
return 'idle';
|
|
28
|
+
if (conv.status === 'running')
|
|
29
|
+
return 'working';
|
|
30
|
+
switch (conv.pauseReason) {
|
|
31
|
+
case 'working': return 'working';
|
|
32
|
+
case 'done': return 'complete';
|
|
33
|
+
case 'error': return 'failed';
|
|
34
|
+
case 'awaiting_review': return 'waiting';
|
|
35
|
+
case 'awaiting_user': return 'waiting';
|
|
36
|
+
case 'stopped': return 'stopped';
|
|
37
|
+
default: break;
|
|
38
|
+
}
|
|
39
|
+
if (conv.stopped && conv.status === 'failed')
|
|
40
|
+
return 'stopped';
|
|
41
|
+
if (conv.status === 'failed')
|
|
42
|
+
return 'waiting';
|
|
43
|
+
if (conv.status === 'completed' && conv.reviewApproved && conv.pauseReason === undefined)
|
|
44
|
+
return 'complete';
|
|
45
|
+
if (conv.status === 'completed')
|
|
46
|
+
return 'waiting';
|
|
47
|
+
return 'idle';
|
|
48
|
+
}
|
|
49
|
+
function conversationSearchStateLabel(state) {
|
|
50
|
+
switch (state) {
|
|
51
|
+
case 'working': return 'Working';
|
|
52
|
+
case 'waiting': return 'Waiting on you';
|
|
53
|
+
case 'complete': return 'Done';
|
|
54
|
+
case 'stopped': return 'Stopped';
|
|
55
|
+
case 'failed': return 'Failed';
|
|
56
|
+
default: return 'Idle';
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function conversationSearchStateDot(state) {
|
|
60
|
+
switch (state) {
|
|
61
|
+
case 'working': return 'amber';
|
|
62
|
+
case 'waiting': return 'red';
|
|
63
|
+
case 'complete': return 'green';
|
|
64
|
+
case 'stopped': return 'amber-static';
|
|
65
|
+
case 'failed': return 'red';
|
|
66
|
+
default: return 'grey';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** Resolve an `is:` token to a state, or null when the token names no known state. */
|
|
70
|
+
function resolveSearchState(token) {
|
|
71
|
+
return SEARCH_STATE_ALIASES[token] ?? null;
|
|
72
|
+
}
|
|
73
|
+
/** Whether a value is one of the states `is:` accepts. Drives the unrecognised-filter notice. */
|
|
74
|
+
function isKnownSearchState(value) {
|
|
75
|
+
return Object.prototype.hasOwnProperty.call(SEARCH_STATE_ALIASES, value);
|
|
76
|
+
}
|