@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ailuntz/minilink",
3
- "version": "0.9.11",
3
+ "version": "0.9.12",
4
4
  "description": "Use your Codex profiles in the Desktop model menu.",
5
5
  "type": "module",
6
6
  "license": "MIT AND Apache-2.0",
@@ -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
+ }
@@ -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 throw new Error('原生接口未提供可安全读取的完整设置,请先在原菜单调整一次推理强度再试。');
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);