@ailuntz/minilink 0.9.11 → 0.9.12
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/package.json +1 -1
- package/src/history.mjs +102 -0
- package/src/provider-switch.mjs +10 -2
package/package.json
CHANGED
package/src/history.mjs
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { existsSync, lstatSync, realpathSync, readFileSync, writeFileSync, renameSync, unlinkSync, mkdirSync, copyFileSync, chmodSync, constants } from 'node:fs';
|
|
2
|
+
import { basename, join, sep } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
5
|
+
import { codexHome } from './config.mjs';
|
|
6
|
+
|
|
7
|
+
const defaultBackups = home => join(home, 'minilink-history-backups');
|
|
8
|
+
|
|
9
|
+
function atomicBytes(path, data, mode) {
|
|
10
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
11
|
+
try { writeFileSync(temporary, data, { mode, flag: 'wx' }); renameSync(temporary, path); }
|
|
12
|
+
finally { if (existsSync(temporary)) unlinkSync(temporary); }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function checkedPath(path, home, threadId) {
|
|
16
|
+
if (!existsSync(path)) return false;
|
|
17
|
+
if (!lstatSync(path).isFile()) throw new Error('任务历史不是普通文件,无法安全切换提供商。');
|
|
18
|
+
const actual = realpathSync(path), root = realpathSync(home);
|
|
19
|
+
if (![join(root, 'sessions') + sep, join(root, 'archived_sessions') + sep].some(prefix => actual.startsWith(prefix))) throw new Error('任务历史不在 CODEX_HOME 中,无法安全切换提供商。');
|
|
20
|
+
if (!basename(actual).endsWith(`${threadId}.jsonl`)) throw new Error('任务历史与任务 ID 不匹配,无法安全切换提供商。');
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Native Codex replays provider reasoning items from its JSONL journal. Some
|
|
25
|
+
// Responses-compatible providers persist reasoning_text and opaque encrypted
|
|
26
|
+
// content that another provider rejects. Normalize only those items, keeping
|
|
27
|
+
// every line's byte length so the native journal offsets remain valid.
|
|
28
|
+
export function repairThreadHistory(path, { threadId, home = codexHome(), backupDir = defaultBackups(home) } = {}) {
|
|
29
|
+
if (!existsSync(path)) return null;
|
|
30
|
+
if (!/^[0-9a-f-]{36}$/.test(threadId || '')) throw new Error('任务 ID 无效,无法备份历史。');
|
|
31
|
+
if (!checkedPath(path, home, threadId)) return null;
|
|
32
|
+
const before = readFileSync(path), after = Buffer.from(before), patches = [];
|
|
33
|
+
let start = 0;
|
|
34
|
+
while (start < before.length) {
|
|
35
|
+
const newline = before.indexOf(10, start);
|
|
36
|
+
const end = newline < 0 ? before.length : newline;
|
|
37
|
+
const original = before.subarray(start, end);
|
|
38
|
+
if (original.includes(Buffer.from('"type":"reasoning"'))) {
|
|
39
|
+
let record;
|
|
40
|
+
try { record = JSON.parse(original.toString('utf8')); }
|
|
41
|
+
catch { throw new Error('任务历史包含无法解析的记录,未修改原文件。'); }
|
|
42
|
+
if (record.type === 'response_item' && record.payload?.type === 'reasoning' && Array.isArray(record.payload.content) && record.payload.content.length) {
|
|
43
|
+
record.payload.content = [];
|
|
44
|
+
delete record.payload.encrypted_content;
|
|
45
|
+
const compact = Buffer.from(JSON.stringify(record));
|
|
46
|
+
if (compact.length > original.length) throw new Error('任务历史记录无法等长修复,未修改原文件。');
|
|
47
|
+
const replacement = Buffer.alloc(original.length, 32);
|
|
48
|
+
compact.copy(replacement);
|
|
49
|
+
replacement.copy(after, start);
|
|
50
|
+
patches.push({ start, original: Buffer.from(original), replacement });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
start = end + 1;
|
|
54
|
+
}
|
|
55
|
+
const databasePath = join(home, 'thread_history_1.sqlite');
|
|
56
|
+
const database = existsSync(databasePath) ? new DatabaseSync(databasePath) : null;
|
|
57
|
+
let rows = [];
|
|
58
|
+
try {
|
|
59
|
+
if (database) rows = database.prepare("SELECT rowid, item_json FROM thread_items WHERE thread_id = ? AND item_type = 'reasoning'").all(threadId).flatMap(row => {
|
|
60
|
+
const item = JSON.parse(row.item_json);
|
|
61
|
+
if (!Array.isArray(item.content) || !item.content.length) return [];
|
|
62
|
+
item.content = [];
|
|
63
|
+
delete item.encrypted_content;
|
|
64
|
+
return [{ rowid: row.rowid, original: row.item_json, replacement: JSON.stringify(item) }];
|
|
65
|
+
});
|
|
66
|
+
if (!patches.length && !rows.length) return null;
|
|
67
|
+
mkdirSync(backupDir, { recursive: true, mode: 0o700 });
|
|
68
|
+
const stem = `${threadId}-${randomUUID()}`;
|
|
69
|
+
const backup = join(backupDir, `${stem}.jsonl`);
|
|
70
|
+
const databaseBackup = join(backupDir, `${stem}.sqlite-reasoning.json`);
|
|
71
|
+
copyFileSync(path, backup, constants.COPYFILE_EXCL);
|
|
72
|
+
chmodSync(backup, 0o600);
|
|
73
|
+
writeFileSync(databaseBackup, JSON.stringify({ databasePath, rows: rows.map(({ rowid, original }) => ({ rowid, original })) }), { mode: 0o600, flag: 'wx' });
|
|
74
|
+
const mode = lstatSync(path).mode & 0o777;
|
|
75
|
+
const updateRows = values => {
|
|
76
|
+
if (!existsSync(databasePath) || !values.length) return;
|
|
77
|
+
const connection = new DatabaseSync(databasePath);
|
|
78
|
+
connection.exec('BEGIN IMMEDIATE');
|
|
79
|
+
try {
|
|
80
|
+
const update = connection.prepare('UPDATE thread_items SET item_json = ? WHERE rowid = ?');
|
|
81
|
+
for (const row of values) update.run(row.replacement ?? row.original, row.rowid);
|
|
82
|
+
connection.exec('COMMIT');
|
|
83
|
+
} catch (error) { connection.exec('ROLLBACK'); throw error; }
|
|
84
|
+
finally { connection.close(); }
|
|
85
|
+
};
|
|
86
|
+
try { updateRows(rows); atomicBytes(path, after, mode); }
|
|
87
|
+
catch (error) { updateRows(rows.map(row => ({ rowid: row.rowid, original: row.original }))); throw error; }
|
|
88
|
+
return {
|
|
89
|
+
backup, databaseBackup,
|
|
90
|
+
count: Math.max(patches.length, rows.length),
|
|
91
|
+
restore() {
|
|
92
|
+
const current = readFileSync(path), restored = Buffer.from(current);
|
|
93
|
+
for (const patch of patches) {
|
|
94
|
+
if (!current.subarray(patch.start, patch.start + patch.replacement.length).equals(patch.replacement)) throw new Error('任务历史已被其他进程修改,请使用备份恢复。');
|
|
95
|
+
patch.original.copy(restored, patch.start);
|
|
96
|
+
}
|
|
97
|
+
updateRows(rows.map(row => ({ rowid: row.rowid, original: row.original })));
|
|
98
|
+
atomicBytes(path, restored, mode);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
} finally { database?.close(); }
|
|
102
|
+
}
|
package/src/provider-switch.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { isDeepStrictEqual } from 'node:util';
|
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { decodeAlias } from './model-menu.mjs';
|
|
4
4
|
import { isRoutedProvider, findRoute, isChatProvider } from './config.mjs';
|
|
5
|
+
import { repairThreadHistory } from './history.mjs';
|
|
5
6
|
|
|
6
7
|
// Private requests use the same initialized app-server connection. Their replies
|
|
7
8
|
// and temporary archive lifecycle events must not appear as Desktop requests.
|
|
@@ -64,7 +65,12 @@ export class ProviderSwitch {
|
|
|
64
65
|
change = { effort: effort.reasoningEffort }; restore = { effort: loaded.reasoningEffort };
|
|
65
66
|
} else if (model?.serviceTiers?.length) {
|
|
66
67
|
change = { serviceTier: model.serviceTiers[0].id }; restore = { serviceTier: null };
|
|
67
|
-
} else
|
|
68
|
+
} else {
|
|
69
|
+
// A one-effort catalog (for example Doubao Lite) has no alternate
|
|
70
|
+
// reasoning level. A temporary service tier setting still elicits the
|
|
71
|
+
// complete native settings notification without running a model turn.
|
|
72
|
+
change = { serviceTier: 'fast' }; restore = { serviceTier: null };
|
|
73
|
+
}
|
|
68
74
|
}
|
|
69
75
|
let waiter;
|
|
70
76
|
const notification = new Promise((resolve, reject) => {
|
|
@@ -154,7 +160,7 @@ export class ProviderSwitch {
|
|
|
154
160
|
}
|
|
155
161
|
async switchThread(thread, provider, model, requested = {}) {
|
|
156
162
|
const id = thread.id;
|
|
157
|
-
let snapshot, loaded, archived = false, closed = false, targetLoaded = false;
|
|
163
|
+
let snapshot, loaded, archived = false, closed = false, targetLoaded = false, historyRepair;
|
|
158
164
|
this.hidden.add(id);
|
|
159
165
|
try {
|
|
160
166
|
loaded = await this.rpc('thread/resume', { threadId: id, excludeTurns: true });
|
|
@@ -171,6 +177,7 @@ export class ProviderSwitch {
|
|
|
171
177
|
// immediately restores the same thread before resuming with the new provider.
|
|
172
178
|
await this.rpc('thread/archive', { threadId: id }); archived = true; closed = true;
|
|
173
179
|
await this.rpc('thread/unarchive', { threadId: id }); archived = false;
|
|
180
|
+
historyRepair = repairThreadHistory(thread.path, { threadId: id });
|
|
174
181
|
const resumed = await this.rpc('thread/resume', this.resumeParams(id, desired, loaded, provider, model));
|
|
175
182
|
targetLoaded = true;
|
|
176
183
|
if (resumed.thread.id !== id || resumed.modelProvider !== provider) throw new Error('恢复后的提供商不匹配。');
|
|
@@ -183,6 +190,7 @@ export class ProviderSwitch {
|
|
|
183
190
|
try {
|
|
184
191
|
if (targetLoaded) { await this.rpc('thread/archive', { threadId: id }); archived = true; }
|
|
185
192
|
if (archived) { await this.rpc('thread/unarchive', { threadId: id }); archived = false; }
|
|
193
|
+
historyRepair?.restore();
|
|
186
194
|
const original = await this.rpc('thread/resume', this.resumeParams(id, snapshot, loaded, snapshot.modelProvider, snapshot.model));
|
|
187
195
|
this.settings.delete(id);
|
|
188
196
|
await this.snapshot(id, original);
|