narraeon 0.5.2 → 0.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/node/runtime/history/HistoryQuery.js +550 -0
- package/dist/node/runtime/history/HistoryRendering.js +86 -0
- package/dist/node/runtime/play/PlayCallChain.js +35 -294
- package/dist/node/runtime/play/PlayCallChainError.js +6 -0
- package/dist/node/runtime/play/PlayContinuity.js +19 -14
- package/dist/node/runtime/play/PlayDocumentTools.js +58 -125
- package/dist/node/runtime/play/PlayTraceRetention.js +264 -0
- package/dist/node/runtime/prompt/FileNativePromptCompiler.js +97 -68
- package/dist/node/runtime/prompt/FileNativeToolRegistry.js +58 -14
- package/dist/node/shared/default-play-prompts.js +10 -6
- package/dist/node/shared/default-preset-host.js +24 -16
- package/dist/node/shared/history-use-guidance.js +6 -0
- package/dist/node/shared/ordered-play-prompts.js +7 -2
- package/dist/web/assets/index-B1Kd7NCp.js +1223 -0
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/dist/web/assets/index-CVllMgyP.js +0 -1207
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual, } from "node:crypto";
|
|
2
|
+
// A restart rejects old process-bound cursors explicitly, without guessing a
|
|
3
|
+
// snapshot or trusting client-provided query declarations. Frozen requests and
|
|
4
|
+
// their already completed tool receipts are restored independently.
|
|
5
|
+
const cursorSecret = randomBytes(32);
|
|
6
|
+
export class HistoryQuery {
|
|
7
|
+
messages;
|
|
8
|
+
snapshotId;
|
|
9
|
+
#world;
|
|
10
|
+
constructor(world, inputs) {
|
|
11
|
+
this.#world = world;
|
|
12
|
+
const refs = inputs.map(({ id }) => historyMessageRef(id));
|
|
13
|
+
this.messages = inputs.map((message, index) => Object.freeze({
|
|
14
|
+
...message,
|
|
15
|
+
ref: refs[index],
|
|
16
|
+
position: index + 1,
|
|
17
|
+
isOpening: message.isOpening === true,
|
|
18
|
+
previous: refs[index - 1] ?? null,
|
|
19
|
+
next: refs[index + 1] ?? null,
|
|
20
|
+
length: Array.from(message.text).length,
|
|
21
|
+
}));
|
|
22
|
+
this.snapshotId = createHash("sha256")
|
|
23
|
+
.update(JSON.stringify(inputs.map(({ id, role, isOpening, text }) => [
|
|
24
|
+
id,
|
|
25
|
+
role,
|
|
26
|
+
isOpening === true,
|
|
27
|
+
text,
|
|
28
|
+
])))
|
|
29
|
+
.digest("hex");
|
|
30
|
+
}
|
|
31
|
+
list(args) {
|
|
32
|
+
try {
|
|
33
|
+
const input = argumentsObject(args, [
|
|
34
|
+
"before",
|
|
35
|
+
"after",
|
|
36
|
+
"limit",
|
|
37
|
+
"cursor",
|
|
38
|
+
]);
|
|
39
|
+
let start;
|
|
40
|
+
let end;
|
|
41
|
+
let limit;
|
|
42
|
+
let query;
|
|
43
|
+
let anchor;
|
|
44
|
+
if (input.cursor !== undefined) {
|
|
45
|
+
cursorOnly(input);
|
|
46
|
+
const cursor = this.#decodeCursor(input.cursor, "history_list");
|
|
47
|
+
query = cursor.query;
|
|
48
|
+
limit = Number(query.limit);
|
|
49
|
+
anchor = typeof query.anchor === "string" ? query.anchor : null;
|
|
50
|
+
if (cursor.direction === "earlier") {
|
|
51
|
+
end = cursor.offset;
|
|
52
|
+
start = Math.max(0, end - limit);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
start = cursor.offset;
|
|
56
|
+
end = Math.min(this.messages.length, start + limit);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
limit = integer(input.limit, 20, 1, 100, "limit");
|
|
61
|
+
if (input.before !== undefined && input.after !== undefined)
|
|
62
|
+
invalid("Use only one of before or after, or omit both for the latest page.");
|
|
63
|
+
anchor =
|
|
64
|
+
input.before === undefined && input.after === undefined
|
|
65
|
+
? null
|
|
66
|
+
: this.#find(input.before ?? input.after).ref;
|
|
67
|
+
if (input.after !== undefined) {
|
|
68
|
+
start = this.#find(input.after).position;
|
|
69
|
+
end = Math.min(this.messages.length, start + limit);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
end =
|
|
73
|
+
input.before === undefined
|
|
74
|
+
? this.messages.length
|
|
75
|
+
: this.#find(input.before).position - 1;
|
|
76
|
+
start = Math.max(0, end - limit);
|
|
77
|
+
}
|
|
78
|
+
query = {
|
|
79
|
+
limit,
|
|
80
|
+
anchor,
|
|
81
|
+
mode: input.before !== undefined
|
|
82
|
+
? "before"
|
|
83
|
+
: input.after !== undefined
|
|
84
|
+
? "after"
|
|
85
|
+
: "latest",
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
ok: true,
|
|
90
|
+
kind: "list",
|
|
91
|
+
total: this.messages.length,
|
|
92
|
+
messages: this.messages.slice(start, end),
|
|
93
|
+
anchor,
|
|
94
|
+
earlierCursor: start === 0
|
|
95
|
+
? null
|
|
96
|
+
: this.#cursor("history_list", query, start, "earlier"),
|
|
97
|
+
laterCursor: end === this.messages.length
|
|
98
|
+
? null
|
|
99
|
+
: this.#cursor("history_list", query, end, "later"),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
return queryFailure(error);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
search(args) {
|
|
107
|
+
return this.#search(args, "history_search");
|
|
108
|
+
}
|
|
109
|
+
legacyList(args, tool) {
|
|
110
|
+
try {
|
|
111
|
+
const input = argumentsObject(args, ["order", "limit", "cursor"]);
|
|
112
|
+
if (input.order !== "newest_first" && input.order !== "oldest_first")
|
|
113
|
+
invalid("Legacy history listing requires newest_first or oldest_first order.");
|
|
114
|
+
const limit = integer(input.limit, 20, 1, 100, "limit");
|
|
115
|
+
const query = { order: input.order, limit };
|
|
116
|
+
let offset = 0;
|
|
117
|
+
if (input.cursor !== undefined && input.cursor !== null) {
|
|
118
|
+
const cursor = this.#decodeCursor(input.cursor, `legacy_${tool}`);
|
|
119
|
+
if (JSON.stringify(cursor.query) !== JSON.stringify(query))
|
|
120
|
+
invalid("Keep the original legacy order and limit, or run a new query.");
|
|
121
|
+
offset = cursor.offset;
|
|
122
|
+
}
|
|
123
|
+
const ordered = input.order === "newest_first"
|
|
124
|
+
? [...this.messages].reverse()
|
|
125
|
+
: this.messages;
|
|
126
|
+
const page = ordered.slice(offset, offset + limit);
|
|
127
|
+
return {
|
|
128
|
+
ok: true,
|
|
129
|
+
kind: "legacy_list",
|
|
130
|
+
total: this.messages.length,
|
|
131
|
+
order: input.order,
|
|
132
|
+
offset,
|
|
133
|
+
messages: page,
|
|
134
|
+
nextCursor: offset + page.length >= ordered.length
|
|
135
|
+
? null
|
|
136
|
+
: this.#cursor(`legacy_${tool}`, query, offset + page.length, "later"),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
return queryFailure(error);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
legacySearch(args) {
|
|
144
|
+
try {
|
|
145
|
+
const input = argumentsObject(args, [
|
|
146
|
+
"query",
|
|
147
|
+
"caseSensitive",
|
|
148
|
+
"within",
|
|
149
|
+
"limit",
|
|
150
|
+
"cursor",
|
|
151
|
+
]);
|
|
152
|
+
const { cursor, query, within, ...options } = input;
|
|
153
|
+
if (within !== undefined && typeof within !== "string")
|
|
154
|
+
invalid("Legacy within must be a Runtime history handle.");
|
|
155
|
+
const request = { queries: [query], ...options, order: "oldest_first" };
|
|
156
|
+
if (cursor !== undefined && cursor !== null) {
|
|
157
|
+
const saved = this.#decodeCursor(cursor, "legacy_context_search");
|
|
158
|
+
if (normalizeLiteral(String(query), options.caseSensitive === true) !==
|
|
159
|
+
normalizeLiteral(String(saved.query.queries[0]), options.caseSensitive === true) ||
|
|
160
|
+
(within ?? null) !== saved.query.within ||
|
|
161
|
+
integer(options.limit, 10, 1, 50, "limit") !== saved.query.limit ||
|
|
162
|
+
(options.caseSensitive === true) !== saved.query.caseSensitive)
|
|
163
|
+
invalid("Keep the original legacy search parameters, or run a new query.");
|
|
164
|
+
return this.#search({ cursor }, "legacy_context_search");
|
|
165
|
+
}
|
|
166
|
+
return this.#search(request, "legacy_context_search", typeof within === "string" ? within : null);
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
return queryFailure(error);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
#search(args, tool, within = null) {
|
|
173
|
+
try {
|
|
174
|
+
const input = argumentsObject(args, [
|
|
175
|
+
"queries",
|
|
176
|
+
"match",
|
|
177
|
+
"order",
|
|
178
|
+
"caseSensitive",
|
|
179
|
+
"limit",
|
|
180
|
+
"after",
|
|
181
|
+
"before",
|
|
182
|
+
"cursor",
|
|
183
|
+
]);
|
|
184
|
+
let query;
|
|
185
|
+
let offset = 0;
|
|
186
|
+
if (input.cursor !== undefined) {
|
|
187
|
+
cursorOnly(input);
|
|
188
|
+
const cursor = this.#decodeCursor(input.cursor, tool);
|
|
189
|
+
query = cursor.query;
|
|
190
|
+
offset = cursor.offset;
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
if (!Array.isArray(input.queries) ||
|
|
194
|
+
input.queries.length < 1 ||
|
|
195
|
+
input.queries.length > 5 ||
|
|
196
|
+
input.queries.some((word) => typeof word !== "string" ||
|
|
197
|
+
word.trim().length === 0 ||
|
|
198
|
+
Array.from(word).length > 256))
|
|
199
|
+
invalid("queries must contain 1–5 nonempty literal strings, each at most 256 Unicode code points.");
|
|
200
|
+
if (input.caseSensitive !== undefined &&
|
|
201
|
+
typeof input.caseSensitive !== "boolean")
|
|
202
|
+
invalid("caseSensitive must be boolean.");
|
|
203
|
+
if (input.match !== undefined &&
|
|
204
|
+
input.match !== "any" &&
|
|
205
|
+
input.match !== "all")
|
|
206
|
+
invalid("match must be any or all (within one message).");
|
|
207
|
+
if (input.order !== undefined &&
|
|
208
|
+
input.order !== "newest_first" &&
|
|
209
|
+
input.order !== "oldest_first")
|
|
210
|
+
invalid("order must be newest_first or oldest_first.");
|
|
211
|
+
const caseSensitive = input.caseSensitive === true;
|
|
212
|
+
const seen = new Set();
|
|
213
|
+
const queries = input.queries.filter((word) => {
|
|
214
|
+
const normalized = normalizeLiteral(word, caseSensitive);
|
|
215
|
+
if (seen.has(normalized))
|
|
216
|
+
return false;
|
|
217
|
+
seen.add(normalized);
|
|
218
|
+
return true;
|
|
219
|
+
});
|
|
220
|
+
const start = input.after === undefined ? 0 : this.#find(input.after).position;
|
|
221
|
+
const end = input.before === undefined
|
|
222
|
+
? this.messages.length
|
|
223
|
+
: this.#find(input.before).position - 1;
|
|
224
|
+
if (input.after !== undefined &&
|
|
225
|
+
input.before !== undefined &&
|
|
226
|
+
start > end)
|
|
227
|
+
invalid("after must precede before in timeline order, regardless of search order.");
|
|
228
|
+
query = {
|
|
229
|
+
queries,
|
|
230
|
+
caseSensitive,
|
|
231
|
+
match: input.match ?? "any",
|
|
232
|
+
order: input.order ?? "newest_first",
|
|
233
|
+
limit: integer(input.limit, 10, 1, 50, "limit"),
|
|
234
|
+
start,
|
|
235
|
+
end,
|
|
236
|
+
within,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
const queries = query.queries;
|
|
240
|
+
const caseSensitive = query.caseSensitive === true;
|
|
241
|
+
const match = query.match;
|
|
242
|
+
const order = query.order;
|
|
243
|
+
const start = Number(query.start);
|
|
244
|
+
const end = Number(query.end);
|
|
245
|
+
const limit = Number(query.limit);
|
|
246
|
+
const found = this.messages.slice(start, end).flatMap((message) => {
|
|
247
|
+
if (typeof query.within === "string" &&
|
|
248
|
+
!historyScopeMatches(message, query.within))
|
|
249
|
+
return [];
|
|
250
|
+
const hit = searchMessage(message, queries, caseSensitive);
|
|
251
|
+
return hit.hits.length > 0 &&
|
|
252
|
+
(match === "any" || hit.hits.length === queries.length)
|
|
253
|
+
? [hit]
|
|
254
|
+
: [];
|
|
255
|
+
});
|
|
256
|
+
if (order === "newest_first")
|
|
257
|
+
found.reverse();
|
|
258
|
+
const page = found.slice(offset, offset + limit);
|
|
259
|
+
return {
|
|
260
|
+
ok: true,
|
|
261
|
+
kind: "search",
|
|
262
|
+
total: this.messages.length,
|
|
263
|
+
range: start === end
|
|
264
|
+
? { start: null, end: null }
|
|
265
|
+
: { start: start + 1, end },
|
|
266
|
+
queries,
|
|
267
|
+
caseSensitive,
|
|
268
|
+
match,
|
|
269
|
+
order,
|
|
270
|
+
within: typeof query.within === "string" ? query.within : null,
|
|
271
|
+
legacy: tool === "legacy_context_search",
|
|
272
|
+
matches: found.length,
|
|
273
|
+
messages: page,
|
|
274
|
+
nextCursor: offset + page.length >= found.length
|
|
275
|
+
? null
|
|
276
|
+
: this.#cursor(tool, query, offset + page.length, "later"),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
return queryFailure(error);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
read(args) {
|
|
284
|
+
try {
|
|
285
|
+
const input = argumentsObject(args, ["latest", "ref", "before", "after"]);
|
|
286
|
+
let start;
|
|
287
|
+
let end;
|
|
288
|
+
let center = null;
|
|
289
|
+
if (input.latest !== undefined) {
|
|
290
|
+
if (Object.keys(input).some((key) => key !== "latest"))
|
|
291
|
+
invalid("Use {latest} alone, or {ref,before,after} for an anchor window.");
|
|
292
|
+
const latest = integer(input.latest, 4, 1, 21, "latest");
|
|
293
|
+
end = this.messages.length;
|
|
294
|
+
start = Math.max(0, end - latest);
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
const message = this.#find(input.ref);
|
|
298
|
+
const before = integer(input.before, 0, 0, 20, "before");
|
|
299
|
+
const after = integer(input.after, 0, 0, 20, "after");
|
|
300
|
+
if (before + after > 20)
|
|
301
|
+
invalid("before + after must be at most 20 messages. Extend in small windows if needed.");
|
|
302
|
+
center = message.ref;
|
|
303
|
+
start = Math.max(0, message.position - 1 - before);
|
|
304
|
+
end = Math.min(this.messages.length, message.position + after);
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
ok: true,
|
|
308
|
+
kind: "read",
|
|
309
|
+
total: this.messages.length,
|
|
310
|
+
mode: center === null ? "latest" : "anchor",
|
|
311
|
+
center,
|
|
312
|
+
messages: this.messages.slice(start, end),
|
|
313
|
+
earlier: this.messages[start - 1]?.ref ?? null,
|
|
314
|
+
later: this.messages[end]?.ref ?? null,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
catch (error) {
|
|
318
|
+
return queryFailure(error);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
#find(ref) {
|
|
322
|
+
if (typeof ref !== "string" || !ref.startsWith("@history-message-"))
|
|
323
|
+
invalid("Use a history ref supplied by Runtime in an injection or a tool result.");
|
|
324
|
+
const message = this.messages.find((message) => message.ref === ref);
|
|
325
|
+
if (message === undefined)
|
|
326
|
+
throw new HistoryQueryError("history_ref_not_in_current_timeline", "This ref is not on the current timeline; it may still exist at an old endpoint. Query the current history again.");
|
|
327
|
+
return message;
|
|
328
|
+
}
|
|
329
|
+
#cursor(tool, query, offset, direction) {
|
|
330
|
+
const payload = {
|
|
331
|
+
version: 1,
|
|
332
|
+
world: this.#world,
|
|
333
|
+
snapshot: this.snapshotId,
|
|
334
|
+
tool,
|
|
335
|
+
query,
|
|
336
|
+
offset,
|
|
337
|
+
direction,
|
|
338
|
+
};
|
|
339
|
+
const encoded = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
340
|
+
const signature = createHmac("sha256", cursorSecret)
|
|
341
|
+
.update(encoded)
|
|
342
|
+
.digest("base64url");
|
|
343
|
+
return `${encoded}.${signature}`;
|
|
344
|
+
}
|
|
345
|
+
#decodeCursor(value, tool) {
|
|
346
|
+
const bad = () => new HistoryQueryError("history_invalid_cursor", "Invalid, obsolete, cross-world or cross-tool cursor. Run the original query again; continue using only the returned cursor.");
|
|
347
|
+
if (typeof value !== "string")
|
|
348
|
+
throw bad();
|
|
349
|
+
const parts = value.split(".");
|
|
350
|
+
if (parts.length !== 2)
|
|
351
|
+
throw bad();
|
|
352
|
+
const [encoded, signature] = parts;
|
|
353
|
+
const expected = createHmac("sha256", cursorSecret)
|
|
354
|
+
.update(encoded)
|
|
355
|
+
.digest();
|
|
356
|
+
const received = Buffer.from(signature, "base64url");
|
|
357
|
+
if (!/^[A-Za-z0-9_-]+$/u.test(encoded) ||
|
|
358
|
+
signature !== received.toString("base64url") ||
|
|
359
|
+
received.length !== expected.length ||
|
|
360
|
+
!timingSafeEqual(received, expected))
|
|
361
|
+
throw bad();
|
|
362
|
+
let payload;
|
|
363
|
+
try {
|
|
364
|
+
payload = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
throw bad();
|
|
368
|
+
}
|
|
369
|
+
if (payload.version !== 1 ||
|
|
370
|
+
payload.world !== this.#world ||
|
|
371
|
+
payload.tool !== tool)
|
|
372
|
+
throw bad();
|
|
373
|
+
if (payload.snapshot !== this.snapshotId)
|
|
374
|
+
throw new HistoryQueryError("history_changed", "The current history changed. Run the original query again and use its new cursor; do not combine pages from different snapshots.");
|
|
375
|
+
return payload;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
export function historyMessageRef(id) {
|
|
379
|
+
return `@history-message-${id.replace(/\.md$/u, "").replace(/^@?history-message-/u, "")}`;
|
|
380
|
+
}
|
|
381
|
+
/** Decode storage identity once at the boundary; never infer order from it. */
|
|
382
|
+
export function historyInputs(entries) {
|
|
383
|
+
return entries.map(({ path, contents }) => {
|
|
384
|
+
const id = path.replace(/\.md$/u, "");
|
|
385
|
+
return {
|
|
386
|
+
id,
|
|
387
|
+
text: contents,
|
|
388
|
+
role: /(?:\.|-)player(?:-|$)/u.test(id) ? "player" : "narrator",
|
|
389
|
+
isOpening: id.endsWith("message.genesis.narrator"),
|
|
390
|
+
};
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
class HistoryQueryError extends Error {
|
|
394
|
+
code;
|
|
395
|
+
recovery;
|
|
396
|
+
constructor(code, recovery) {
|
|
397
|
+
super(recovery);
|
|
398
|
+
this.code = code;
|
|
399
|
+
this.recovery = recovery;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
function queryFailure(error) {
|
|
403
|
+
if (error instanceof HistoryQueryError)
|
|
404
|
+
return { ok: false, code: error.code, recovery: error.recovery };
|
|
405
|
+
throw error;
|
|
406
|
+
}
|
|
407
|
+
function invalid(message) {
|
|
408
|
+
throw new HistoryQueryError("history_invalid_arguments", message);
|
|
409
|
+
}
|
|
410
|
+
function argumentsObject(args, allowed) {
|
|
411
|
+
if (args === null ||
|
|
412
|
+
typeof args !== "object" ||
|
|
413
|
+
Array.isArray(args) ||
|
|
414
|
+
Object.keys(args).some((key) => !allowed.includes(key)))
|
|
415
|
+
invalid(`Use only ${allowed.join(", ")}.`);
|
|
416
|
+
return args;
|
|
417
|
+
}
|
|
418
|
+
function cursorOnly(input) {
|
|
419
|
+
if (Object.keys(input).length !== 1)
|
|
420
|
+
invalid("Continuation needs only {cursor}; omit all first-query arguments.");
|
|
421
|
+
}
|
|
422
|
+
function integer(value, fallback, min, max, name) {
|
|
423
|
+
if (value === undefined)
|
|
424
|
+
return fallback;
|
|
425
|
+
if (typeof value !== "number" ||
|
|
426
|
+
!Number.isInteger(value) ||
|
|
427
|
+
value < min ||
|
|
428
|
+
value > max)
|
|
429
|
+
invalid(`${name} must be an integer from ${min} to ${max}.`);
|
|
430
|
+
return value;
|
|
431
|
+
}
|
|
432
|
+
function normalizeLiteral(value, caseSensitive) {
|
|
433
|
+
const normalized = value.normalize("NFKC");
|
|
434
|
+
return caseSensitive ? normalized : normalized.toLocaleLowerCase("und");
|
|
435
|
+
}
|
|
436
|
+
function searchMessage(message, queries, caseSensitive) {
|
|
437
|
+
// Grapheme clusters keep combining sequences together during normalization.
|
|
438
|
+
// Each normalized UTF-16 unit maps to the original cluster's code-point
|
|
439
|
+
// interval; expansions (ligatures, full-width letters, dotted I) stay exact.
|
|
440
|
+
const source = Array.from(message.text);
|
|
441
|
+
const mapping = [];
|
|
442
|
+
let normalized = "";
|
|
443
|
+
let originalOffset = 0;
|
|
444
|
+
for (const { segment } of new Intl.Segmenter("und", {
|
|
445
|
+
granularity: "grapheme",
|
|
446
|
+
}).segment(message.text)) {
|
|
447
|
+
const end = originalOffset + Array.from(segment).length;
|
|
448
|
+
const transformed = normalizeLiteral(segment, caseSensitive);
|
|
449
|
+
normalized += segment.normalize("NFKC");
|
|
450
|
+
transformed
|
|
451
|
+
.split("")
|
|
452
|
+
.forEach(() => mapping.push({ start: originalOffset, end }));
|
|
453
|
+
originalOffset = end;
|
|
454
|
+
}
|
|
455
|
+
// Case conversion uses the entire normalized text so contextual lowercasing
|
|
456
|
+
// (such as final Greek sigma) matches the existing literal-search rules.
|
|
457
|
+
if (!caseSensitive)
|
|
458
|
+
normalized = normalized.toLocaleLowerCase("und");
|
|
459
|
+
const perQuery = queries.map((query) => {
|
|
460
|
+
const needle = normalizeLiteral(query, caseSensitive);
|
|
461
|
+
const ranges = [];
|
|
462
|
+
let offset = 0;
|
|
463
|
+
while (offset <= normalized.length - needle.length) {
|
|
464
|
+
const found = normalized.indexOf(needle, offset);
|
|
465
|
+
if (found < 0)
|
|
466
|
+
break;
|
|
467
|
+
ranges.push({
|
|
468
|
+
start: mapping[found].start,
|
|
469
|
+
end: mapping[found + needle.length - 1].end,
|
|
470
|
+
});
|
|
471
|
+
offset = found + needle.length;
|
|
472
|
+
}
|
|
473
|
+
return { query, ranges };
|
|
474
|
+
});
|
|
475
|
+
const allRanges = perQuery
|
|
476
|
+
.flatMap(({ ranges }) => ranges)
|
|
477
|
+
.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
478
|
+
const windows = [];
|
|
479
|
+
const add = (range) => {
|
|
480
|
+
const window = {
|
|
481
|
+
start: Math.max(0, range.start - 80),
|
|
482
|
+
end: Math.min(source.length, range.end + 80),
|
|
483
|
+
};
|
|
484
|
+
const overlaps = windows.filter((existing) => existing.start <= window.end && window.start <= existing.end);
|
|
485
|
+
if (overlaps.length === 0 && windows.length === 3)
|
|
486
|
+
return false;
|
|
487
|
+
for (const existing of overlaps) {
|
|
488
|
+
window.start = Math.min(window.start, existing.start);
|
|
489
|
+
window.end = Math.max(window.end, existing.end);
|
|
490
|
+
windows.splice(windows.indexOf(existing), 1);
|
|
491
|
+
}
|
|
492
|
+
windows.push(window);
|
|
493
|
+
return true;
|
|
494
|
+
};
|
|
495
|
+
// Prioritize the first occurrence of each distinct query before filling any
|
|
496
|
+
// remaining fragment slots with other matches in source order.
|
|
497
|
+
for (const { ranges } of perQuery)
|
|
498
|
+
if (ranges[0] !== undefined)
|
|
499
|
+
add(ranges[0]);
|
|
500
|
+
for (const range of allRanges) {
|
|
501
|
+
if (windows.length >= 3)
|
|
502
|
+
break;
|
|
503
|
+
const candidate = {
|
|
504
|
+
start: Math.max(0, range.start - 80),
|
|
505
|
+
end: Math.min(source.length, range.end + 80),
|
|
506
|
+
};
|
|
507
|
+
// Supplemental hits fill separate fragment slots. Do not repeatedly grow
|
|
508
|
+
// one window through a chain of nearby hits into a complete long body.
|
|
509
|
+
if (windows.some((window) => window.start <= candidate.end && candidate.start <= window.end))
|
|
510
|
+
continue;
|
|
511
|
+
add(range);
|
|
512
|
+
}
|
|
513
|
+
windows.sort((a, b) => a.start - b.start);
|
|
514
|
+
return {
|
|
515
|
+
message,
|
|
516
|
+
hits: perQuery
|
|
517
|
+
.filter(({ ranges }) => ranges.length > 0)
|
|
518
|
+
.map(({ query, ranges }) => ({ query, count: ranges.length })),
|
|
519
|
+
fragments: windows.map(({ start, end }) => ({
|
|
520
|
+
text: source.slice(start, end).join(""),
|
|
521
|
+
start: start + 1,
|
|
522
|
+
end,
|
|
523
|
+
omittedBefore: start > 0,
|
|
524
|
+
omittedAfter: end < source.length,
|
|
525
|
+
})),
|
|
526
|
+
omittedMatches: allRanges.some((range) => !windows.some((window) => range.start >= window.start && range.end <= window.end)),
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
function historyScopeMatches(message, within) {
|
|
530
|
+
if (historyMessageRef(message.id) === historyMessageRef(within))
|
|
531
|
+
return true;
|
|
532
|
+
const commit = /(?:^|\.)message\.([^.]+)\./u.exec(message.id)?.[1] ??
|
|
533
|
+
message.id.split("-")[0];
|
|
534
|
+
return within.replace(/^@/u, "") === `history-commit-${commit}`;
|
|
535
|
+
}
|
|
536
|
+
/** Resolve exact author/legacy selections without changing Authority order. */
|
|
537
|
+
export function historySelectionMatches(material, key) {
|
|
538
|
+
if (material.kind === "history_message")
|
|
539
|
+
return historyIdentity(key) === historyIdentity(material.message);
|
|
540
|
+
const ref = material.commit;
|
|
541
|
+
return ref.startsWith("commit:")
|
|
542
|
+
? historyIdentity(key).startsWith(`message.${ref.slice(7)}.`)
|
|
543
|
+
: ref === "genesis"
|
|
544
|
+
? historyIdentity(key).startsWith("message.genesis.")
|
|
545
|
+
: key.startsWith(ref.replace(/^@?history-commit-/u, "history-message-").concat("-"));
|
|
546
|
+
}
|
|
547
|
+
function historyIdentity(ref) {
|
|
548
|
+
const value = ref.replace(/^@?(?:history-message-)?/u, "");
|
|
549
|
+
return (/(?:^|\.)(message\.(?:genesis|[0-9]+)(?:\.[0-9]+)?\.(?:player|narrator))$/u.exec(value)?.[1] ?? value);
|
|
550
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/** Shared metadata keeps injected refs and tool results directly navigable. */
|
|
2
|
+
export function historyMessageLabel(message, locale) {
|
|
3
|
+
const zh = locale === "zh-CN";
|
|
4
|
+
const role = historyRoleLabel(message, locale);
|
|
5
|
+
return `${zh ? "位置" : "position"} ${message.position} · role=${message.role} · ${role}${message.isOpening ? (zh ? " · 开场白 isOpening=true" : " · Opening isOpening=true") : ""} · ${message.ref}`;
|
|
6
|
+
}
|
|
7
|
+
export function historyRoleLabel(message, locale) {
|
|
8
|
+
if (message.isOpening)
|
|
9
|
+
return locale === "zh-CN" ? "开场白" : "Opening";
|
|
10
|
+
if (message.role === "player")
|
|
11
|
+
return locale === "zh-CN" ? "玩家原文" : "Player input";
|
|
12
|
+
return locale === "zh-CN" ? "主持叙事" : "Host narrative";
|
|
13
|
+
}
|
|
14
|
+
export function renderHistoryMessage(message, locale) {
|
|
15
|
+
return `## ${historyMessageLabel(message, locale)}\n\n${locale === "zh-CN" ? "[原文开始]" : "[Original text begins]"}\n${message.text}\n${locale === "zh-CN" ? "[原文结束;正文完整]" : "[Original text ends; body complete]"}`;
|
|
16
|
+
}
|
|
17
|
+
export function renderHistoryInjectionNotice(total, selected, kind, locale) {
|
|
18
|
+
const zh = locale === "zh-CN";
|
|
19
|
+
const positions = selected.map(({ position }) => position);
|
|
20
|
+
const coverage = positions.length === 0 ? (zh ? "空" : "empty") : positions.join(", ");
|
|
21
|
+
const selection = kind === "recent"
|
|
22
|
+
? zh
|
|
23
|
+
? "近期选取;更早历史可查询"
|
|
24
|
+
: "recent selection; earlier history can be queried"
|
|
25
|
+
: kind === "additional"
|
|
26
|
+
? zh
|
|
27
|
+
? "作者挑选材料,可能不连续;保留作者声明顺序"
|
|
28
|
+
: "selected materials may be non-contiguous; author declaration order retained"
|
|
29
|
+
: zh
|
|
30
|
+
? "检查点后完整选取;原生对话或其他材料中的消息不重复注入,剩余项可能不连续"
|
|
31
|
+
: "complete selection after the checkpoint; messages already in native conversation or other materials are not injected again, so remaining positions may have gaps";
|
|
32
|
+
return zh
|
|
33
|
+
? `本次注入时的当前时间线快照:总计 ${total} 条(开场白如存在也计入);位置 ${coverage}。${selection}。每条正文完整;覆盖范围不等于全部历史。历史内部按 Authority 接受顺序由旧到新,提交顺序不是故事日期。以下是已发生的历史材料,用于接续与核对,不是本轮待执行的玩家行动;最后一条即使是玩家原文也不是本轮新请求。未注入的更早内容不代表没有发生。ref 可直接用于 history_read 或 history_list before/after,无需再次列举。`
|
|
34
|
+
: `Current timeline snapshot at injection: ${total} messages (opening included if present); positions ${coverage}. ${selection}. Each body is complete; selected coverage is separate from all-history coverage. History is oldest to newest in Authority acceptance order, not story dates. These are already committed historical materials for continuity and checking, not pending player actions; even a final player entry is not the current request. Earlier material not injected does not mean it never happened. Use each ref directly with history_read or history_list before/after without listing again.`;
|
|
35
|
+
}
|
|
36
|
+
export function renderHistoryResult(result, locale = "en", legacyRead = false) {
|
|
37
|
+
const zh = locale === "zh-CN";
|
|
38
|
+
if (!result.ok)
|
|
39
|
+
return {
|
|
40
|
+
ok: false,
|
|
41
|
+
failureKind: "protocol",
|
|
42
|
+
markdown: `# ${zh ? "Runtime 历史查询失败" : "Runtime history query rejected"}\n\nCode: ${result.code}\n${zh ? "恢复操作" : "Recovery"}: ${localizedRecovery(result, zh)}`,
|
|
43
|
+
};
|
|
44
|
+
const header = `Scope: ${zh ? "当前时间线" : "current timeline"}\n${zh ? "本次读取快照消息总数" : "Snapshot total messages"}: ${result.total}\n${zh ? "排序依据:Authority 接受顺序,不代表故事内日期" : "Order basis: Authority acceptance order, not story dates"}`;
|
|
45
|
+
const range = (messages) => messages.length === 0
|
|
46
|
+
? zh
|
|
47
|
+
? "空区间"
|
|
48
|
+
: "empty range"
|
|
49
|
+
: `${messages[0].position}..${messages[messages.length - 1].position}`;
|
|
50
|
+
const cursorLine = (name, cursor) => `${name}: ${cursor ?? "null"}`;
|
|
51
|
+
if (result.kind === "legacy_list")
|
|
52
|
+
return {
|
|
53
|
+
ok: true,
|
|
54
|
+
markdown: `# Directory listing\n\n${header}\nOrder: ${result.order}\n${result.messages.map((message) => `- ${message.ref}, position ${message.position}, role=${message.role}${message.isOpening ? ", isOpening=true" : ""}, ${message.length} Unicode code points`).join("\n") || "(empty)"}\n\n---\nThis page: ${result.offset}..${result.offset + result.messages.length} / ${result.total} items\nComplete: ${result.nextCursor === null ? "yes" : "no"}${result.nextCursor === null ? "" : `\nNext-page cursor: ${result.nextCursor}`}`,
|
|
55
|
+
};
|
|
56
|
+
if (result.kind === "list")
|
|
57
|
+
return {
|
|
58
|
+
ok: true,
|
|
59
|
+
markdown: `# ${zh ? "已提交历史列表" : "Committed history listing"}\n\n${header}\nOrder: oldest_first (${zh ? "页内始终由旧到新" : "every page oldest to newest"})\n${result.messages
|
|
60
|
+
.map((message) => {
|
|
61
|
+
const preview = Array.from(message.text).slice(0, 120).join("");
|
|
62
|
+
return `### ${historyMessageLabel(message, locale)}\n${message.length} Unicode code points\n[${zh ? "部分原文预览" : "Partial original preview"}]\n${preview}\n[${message.length > 120 ? (zh ? "预览有省略" : "preview omitted remaining text") : zh ? "预览结束" : "preview ends"}]`;
|
|
63
|
+
})
|
|
64
|
+
.join("\n\n") || (zh ? "(空页)" : "(empty page)")}\n\n---\n${zh ? "本页位置/条数" : "Page positions / count"}: ${range(result.messages)} / ${result.messages.length}\n${cursorLine("earlierCursor", result.earlierCursor)}\n${cursorLine("laterCursor", result.laterCursor)}\n${zh ? "续页只传 {cursor}。" : "Continue with only {cursor}."}${result.messages.length === 0 && result.anchor !== null ? `\n${zh ? "可从锚点转向浏览" : "Browse from the anchor in the other direction"}: {before:"${result.anchor}"} / {after:"${result.anchor}"}` : ""}`,
|
|
65
|
+
};
|
|
66
|
+
if (result.kind === "read")
|
|
67
|
+
return {
|
|
68
|
+
ok: true,
|
|
69
|
+
markdown: `# ${zh ? "完整历史原文" : "Complete history originals"}\n\n${header}\nMode: ${result.mode}${result.center === null ? "" : `\nCenter: ${result.center}`}\nOrder: oldest_first\n${zh ? "实际位置" : "Actual positions"}: ${range(result.messages)}\n${zh ? "每条正文完整,无摘要、无截断" : "Each body is complete, without summary or truncation"}\n\n${result.messages.map((message) => renderHistoryMessage(message, locale)).join("\n\n") || (zh ? "(历史为空)" : "(empty history)")}\n\n---\n${cursorLine("earlier", result.earlier)}\n${cursorLine("later", result.later)}\n${result.earlier === null ? (zh ? "已到历史开头。" : "At the history beginning.") : legacyRead ? `context_read {ref:"${result.earlier}"}` : zh ? "向前不重复读取:history_read {ref:earlier,before:N}。" : "Read earlier without repeating this window: history_read {ref:earlier,before:N}."}\n${result.later === null ? (zh ? "已到历史结尾。" : "At the history end.") : legacyRead ? `context_read {ref:"${result.later}"}` : zh ? "向后不重复读取:history_read {ref:later,after:N}。" : "Read later without repeating this window: history_read {ref:later,after:N}."}\n${legacyRead ? (zh ? "按需逐条读取相邻原文。" : "Read adjacent originals one at a time as needed.") : zh ? "按需小步扩展;21 条消息不代表固定输出体积。" : "Expand in small windows as needed; 21 messages do not imply a fixed output size."}`,
|
|
70
|
+
};
|
|
71
|
+
return {
|
|
72
|
+
ok: true,
|
|
73
|
+
markdown: `# ${zh ? "历史字面搜索" : "History literal search"}\n\n${header}${result.within === null ? "" : `\nLegacy selected scope: ${result.within}`}\n${zh ? "搜索位置范围(排除边界)" : "Search position range (boundaries excluded)"}: ${result.range.start === null ? "empty" : `${result.range.start}..${result.range.end}`}\nOrder: ${result.order}\nMatch: ${result.match} (${zh ? "同一消息内" : "within one message"})\nNormalization: NFKC${result.caseSensitive ? " (case sensitive)" : " + case folding (locale und)"}\n${zh ? "查询词" : "Queries"}: ${JSON.stringify(result.queries)}\n${zh ? "命中消息总数" : "Total matching messages"}: ${result.matches}\n\n${result.messages.map(({ message, hits, fragments, omittedMatches }) => `### ${historyMessageLabel(message, locale)}\n${message.length} Unicode code points\n${zh ? "逐词命中次数" : "Matches per query"}: ${hits.map(({ query, count }) => `${JSON.stringify(query)}: ${count}`).join("; ")}\n${fragments.map((fragment) => `[${zh ? "部分原文片段" : "Partial original fragment"}: ${fragment.start}..${fragment.end}${fragment.omittedBefore ? "; prefix omitted" : ""}${fragment.omittedAfter ? "; suffix omitted" : ""}]\n${fragment.text}\n[${zh ? "片段结束" : "fragment ends"}]`).join("\n")}\n${omittedMatches ? (zh ? "另有命中未展示;可完整读取核实。" : "Additional matches omitted; read the complete original to verify.") : zh ? "全部命中均在所示片段内。" : "All matches are covered by these fragments."}`).join("\n\n") || (result.legacy ? (zh ? "零字面命中不证明事件没有发生;可改用其他 query 或读取相邻原文。" : "Zero literal matches do not prove an event never happened. Try another query or read adjacent originals.") : zh ? "零字面命中不证明事件没有发生;可改词、从 all 放宽为 any 或读取相邻上下文。" : "Zero literal matches do not prove an event never happened. Try other words, relax all to any, or read nearby context.")}\n\n---\n${cursorLine(result.legacy ? "Next-page cursor" : "nextCursor", result.nextCursor)}\n${result.legacy ? (zh ? "续页保留原source=history、query、caseSensitive、within与limit并使用返回cursor。" : "Legacy continuation keeps the original source=history, query, caseSensitive, within and limit with the returned cursor.") : zh ? "续页只传 {cursor};已有原文足以判断时停止检索。all 仅限同一消息;相邻消息可改用 any 后读前后文。" : "Continue with only {cursor}; stop when the originals suffice. all applies to one message; use any then read context for terms in adjacent messages."}`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function localizedRecovery(result, zh) {
|
|
77
|
+
if (!zh)
|
|
78
|
+
return result.recovery;
|
|
79
|
+
if (result.code === "history_changed")
|
|
80
|
+
return "当前历史已变化,请重新执行原查询,使用新游标;不要拼接不同快照的页面。";
|
|
81
|
+
if (result.code === "history_invalid_cursor")
|
|
82
|
+
return "游标非法、过期、属于其他世界或工具,或来自不支持的旧版本。请重新执行原查询;续页只传 Runtime 返回的 cursor。";
|
|
83
|
+
if (result.code === "history_ref_not_in_current_timeline")
|
|
84
|
+
return "该 ref 不在当前时间线中,可能仍保存在旧端点;请重新查询当前历史。";
|
|
85
|
+
return `参数不符合要求,请按工具定义修正。${result.recovery}`;
|
|
86
|
+
}
|