@rikaidev/yomi 0.4.2 → 0.6.0

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.
Files changed (86) hide show
  1. package/README.md +46 -4
  2. package/dist/ambient/ambient-event-graph.js +172 -0
  3. package/dist/ambient/ambient-observer.js +71 -0
  4. package/dist/ambient/event-graph-reducer.js +300 -0
  5. package/dist/ambient/ingestion-adapter.js +203 -0
  6. package/dist/ambient/journal/crypto.js +58 -0
  7. package/dist/ambient/journal/journal-store.js +284 -0
  8. package/dist/ambient/journal/types.js +7 -0
  9. package/dist/ambient/summaries/retriever.js +55 -0
  10. package/dist/ambient/summaries/summary-engine.js +128 -0
  11. package/dist/ambient/summaries/types.js +4 -0
  12. package/dist/attention/policy.js +101 -0
  13. package/dist/auth/credential-store.js +47 -17
  14. package/dist/auth/keychain.js +7 -54
  15. package/dist/cli/query.js +875 -0
  16. package/dist/cli/work-item.js +24 -0
  17. package/dist/insight/relationships.js +1 -1
  18. package/dist/line/auth/protocol/pwless-login-flow.js +12 -0
  19. package/dist/line/auth/pwless/index.js +1 -1
  20. package/dist/line/client/auth-service/client.js +7 -8
  21. package/dist/line/client/buddy-service/client.js +73 -0
  22. package/dist/line/client/index.js +28 -3
  23. package/dist/line/client/obs-media-client.js +38 -0
  24. package/dist/line/client/parsers.js +1 -0
  25. package/dist/line/client/square-service/client.js +298 -0
  26. package/dist/line/client/sticker-meta.js +32 -0
  27. package/dist/line/client/sync-service/client.js +31 -3
  28. package/dist/line/client/talk-service/command-client.js +9 -1
  29. package/dist/line/client/talk-service/contact-query.js +8 -1
  30. package/dist/line/client/talk-service/message-box-query.js +17 -0
  31. package/dist/line/client/talk-service/message-id-query.js +27 -3
  32. package/dist/line/client/talk-service/query-client.js +32 -26
  33. package/dist/line/client/talk-service/requests.js +10 -11
  34. package/dist/line/core/auth-session-runtime.js +17 -2
  35. package/dist/line/core/chat-event.js +35 -0
  36. package/dist/line/core/chat-runtime-service.js +18 -23
  37. package/dist/line/core/config.js +1 -0
  38. package/dist/line/core/directory-runtime-service.js +10 -2
  39. package/dist/line/core/e2ee/recovery/bootstrap-keys.js +8 -16
  40. package/dist/line/core/flex-message.js +453 -0
  41. package/dist/line/core/mention.js +1 -1
  42. package/dist/line/core/message-preview.js +60 -0
  43. package/dist/line/core/message-query-service.js +0 -20
  44. package/dist/line/core/service.js +1 -3
  45. package/dist/mcp/handlers/contacts.js +12 -6
  46. package/dist/mcp/handlers/conversations.js +91 -4
  47. package/dist/mcp/handlers/login.js +1 -1
  48. package/dist/mcp/handlers/message-actions.js +1 -1
  49. package/dist/mcp/handlers/search.js +1 -1
  50. package/dist/mcp/handlers/send.js +8 -11
  51. package/dist/mcp/handlers/shared.js +1 -1
  52. package/dist/mcp/integrator.js +168 -0
  53. package/dist/mcp/media.js +135 -4
  54. package/dist/mcp/names.js +78 -6
  55. package/dist/mcp/phi-guard.js +2 -2
  56. package/dist/search/bigram.js +2 -2
  57. package/dist/search/collector.js +1 -1
  58. package/dist/search/default-embedder.js +12 -3
  59. package/dist/search/embedder.js +1 -1
  60. package/dist/version.js +1 -1
  61. package/dist/work-item/adapter.js +1 -0
  62. package/dist/work-item/destination-contracts.js +1 -0
  63. package/dist/work-item/event-context.js +73 -0
  64. package/dist/work-item/gold-contract.js +101 -0
  65. package/dist/work-item/gold-coverage.js +96 -0
  66. package/dist/work-item/gold-derivation.js +67 -0
  67. package/dist/work-item/gold-split.js +143 -0
  68. package/dist/work-item/index.js +23 -0
  69. package/dist/work-item/line-adapter.js +111 -0
  70. package/dist/work-item/llama-cpp-work-event-adapter.js +239 -0
  71. package/dist/work-item/local-provider.js +38 -0
  72. package/dist/work-item/local-work-event-provider.js +56 -0
  73. package/dist/work-item/onnx-work-event-adapter.js +223 -0
  74. package/dist/work-item/provider.js +21 -0
  75. package/dist/work-item/registry.js +19 -0
  76. package/dist/work-item/service.js +85 -0
  77. package/dist/work-item/store.js +97 -0
  78. package/dist/work-item/types.js +79 -0
  79. package/dist/work-item/work-event-graph-engine.js +63 -0
  80. package/dist/work-item/work-event-inference.js +267 -0
  81. package/dist/work-item/work-event-matcher.js +105 -0
  82. package/dist/work-item/work-event-model-manifest.js +39 -0
  83. package/dist/work-item/work-event-store.js +125 -0
  84. package/dist/work-item/work-event.js +123 -0
  85. package/package.json +26 -2
  86. package/run.mjs +371 -2
@@ -0,0 +1,128 @@
1
+ import { createHash } from 'node:crypto';
2
+ export const CURRENT_SUMMARY_ENGINE_VERSION = 1;
3
+ const EPISODE_GAP_MS = 45 * 60 * 1000; // 45 minutes
4
+ const ROLLUP_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
5
+ function computeDigest(content) {
6
+ return createHash('sha256').update(content).digest('hex').slice(0, 16);
7
+ }
8
+ function inferDialogueActs(events) {
9
+ const acts = new Set();
10
+ for (const event of events) {
11
+ const text = event.text ?? '';
12
+ const isQuestion = /[??]/.test(text);
13
+ if (isQuestion) {
14
+ acts.add('ask');
15
+ }
16
+ if (['image', 'video', 'file'].includes(event.contentType)) {
17
+ acts.add('deliver');
18
+ }
19
+ if (event.mentionsSelf || (event.metadata.mentionMids?.length ?? 0) > 0) {
20
+ acts.add('directive');
21
+ }
22
+ if (event.contentType === 'sticker') {
23
+ acts.add('acknowledge');
24
+ }
25
+ if (text.length > 0 && !isQuestion) {
26
+ acts.add('inform');
27
+ }
28
+ }
29
+ return [...acts];
30
+ }
31
+ export function buildEpisodeSummaries(events, engineVersion = CURRENT_SUMMARY_ENGINE_VERSION) {
32
+ if (events.length === 0)
33
+ return [];
34
+ const byChat = new Map();
35
+ for (const event of events) {
36
+ const list = byChat.get(event.chatId) ?? [];
37
+ list.push(event);
38
+ byChat.set(event.chatId, list);
39
+ }
40
+ const summaries = [];
41
+ for (const [chatId, chatEvents] of byChat) {
42
+ const sorted = [...chatEvents].sort((a, b) => a.sentAt - b.sentAt);
43
+ let currentEpisode = [];
44
+ const flush = () => {
45
+ if (currentEpisode.length === 0)
46
+ return;
47
+ const minSentAt = currentEpisode[0].sentAt;
48
+ const maxSentAt = currentEpisode[currentEpisode.length - 1].sentAt;
49
+ const messageIds = currentEpisode.map((e) => e.messageId);
50
+ const participantNames = [
51
+ ...new Set(currentEpisode
52
+ .map((e) => e.senderName)
53
+ .filter((n) => Boolean(n))),
54
+ ];
55
+ const ownerHints = [
56
+ ...new Set(currentEpisode.flatMap((e) => e.metadata.mentionNames ?? [])),
57
+ ];
58
+ const hasMentionsSelf = currentEpisode.some((e) => e.mentionsSelf);
59
+ const hasQuestions = currentEpisode.some((e) => /[??]/.test(e.text ?? ''));
60
+ const hasMedia = currentEpisode.some((e) => ['image', 'video', 'file', 'audio'].includes(e.contentType));
61
+ const dialogueActs = inferDialogueActs(currentEpisode);
62
+ const hasSelfQuestion = currentEpisode.some((e) => e.isSelf && /[??]/.test(e.text ?? ''));
63
+ const selfQuestionIdx = currentEpisode.findIndex((e) => e.isSelf && /[??]/.test(e.text ?? ''));
64
+ const hasReplyAfterSelfQuestion = selfQuestionIdx >= 0 &&
65
+ currentEpisode.slice(selfQuestionIdx + 1).some((e) => !e.isSelf);
66
+ const responseState = hasReplyAfterSelfQuestion
67
+ ? 'received'
68
+ : hasSelfQuestion
69
+ ? 'waiting'
70
+ : 'observing';
71
+ const digest = computeDigest(`${chatId}:${minSentAt}:${messageIds.join(',')}`);
72
+ const episodeId = `ep-${chatId.slice(0, 8)}-${minSentAt}`;
73
+ summaries.push({
74
+ episodeId,
75
+ chatId,
76
+ minSentAt,
77
+ maxSentAt,
78
+ messageIds,
79
+ participantNames,
80
+ dialogueActs,
81
+ ownerHints,
82
+ responseState,
83
+ hasMentionsSelf,
84
+ hasQuestions,
85
+ hasMedia,
86
+ candidateCount: hasMentionsSelf || hasSelfQuestion ? 1 : 0,
87
+ digest,
88
+ engineVersion,
89
+ });
90
+ currentEpisode = [];
91
+ };
92
+ for (const event of sorted) {
93
+ if (currentEpisode.length > 0 &&
94
+ event.sentAt - currentEpisode[currentEpisode.length - 1].sentAt >
95
+ EPISODE_GAP_MS) {
96
+ flush();
97
+ }
98
+ currentEpisode.push(event);
99
+ }
100
+ flush();
101
+ }
102
+ return summaries.sort((a, b) => a.minSentAt - b.minSentAt);
103
+ }
104
+ export function buildRollupSummary(windowStart, windowEnd, episodes, engineVersion = CURRENT_SUMMARY_ENGINE_VERSION) {
105
+ const matching = episodes.filter((ep) => ep.maxSentAt >= windowStart && ep.minSentAt <= windowEnd);
106
+ const activeChatIds = [...new Set(matching.map((ep) => ep.chatId))];
107
+ const episodeIds = matching.map((ep) => ep.episodeId);
108
+ const totalEvents = matching.reduce((sum, ep) => sum + ep.messageIds.length, 0);
109
+ const unresolvedFrontierCount = matching.filter((ep) => ep.responseState === 'waiting' || ep.hasMentionsSelf).length;
110
+ const topics = matching
111
+ .filter((ep) => ep.dialogueActs.length > 0)
112
+ .map((ep) => `${ep.chatId}:${ep.dialogueActs.join('+')}`)
113
+ .slice(0, 10);
114
+ const rollupId = `rollup-${Math.floor(windowStart / ROLLUP_INTERVAL_MS)}`;
115
+ const digest = computeDigest(`${rollupId}:${episodeIds.join(',')}:${totalEvents}`);
116
+ return {
117
+ rollupId,
118
+ windowStart,
119
+ windowEnd,
120
+ activeChatIds,
121
+ episodeIds,
122
+ totalEvents,
123
+ unresolvedFrontierCount,
124
+ topics,
125
+ digest,
126
+ engineVersion,
127
+ };
128
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Types for Hierarchical Summaries (10-minute Episodes & 6-hour Rollups).
3
+ */
4
+ export {};
@@ -0,0 +1,101 @@
1
+ import { getDefaultEmbedder } from '../search/default-embedder.js';
2
+ // Natural-language intent descriptions, not trigger-word lists. The local
3
+ // transformer embeds both descriptions and messages; vector similarity does
4
+ // the classification even when no words literally overlap.
5
+ const INTENT_ANCHORS = {
6
+ reply: [
7
+ '對方直接向我提出問題、請求協助或要求我做決定,正在等待我的答覆',
8
+ '工作交辦或需要我採取具體行動並回覆結果',
9
+ ],
10
+ deadline: [
11
+ '有明確期限、會議時間、預約或即將到期的工作,需要及時處理',
12
+ '緊急事故、風險或延誤,若不處理會造成後果',
13
+ ],
14
+ transaction: [
15
+ '與我有關的帳單、付款、訂單、物流、航班、預約或帳號安全狀態異常',
16
+ '交易失敗、行程變更或服務中斷,需要本人確認',
17
+ ],
18
+ important: [
19
+ '與目前工作或生活直接相關的重要進度與公告,值得本人閱讀但不必回覆',
20
+ '關鍵人物提供需要掌握的決策背景或狀態更新',
21
+ ],
22
+ ordinary: [
23
+ '一般聊天、問候、貼圖、轉傳或沒有下一步的日常資訊',
24
+ '內容與本人沒有直接關係,也不需要採取行動',
25
+ ],
26
+ promotion: [
27
+ '品牌官方帳號寄送的行銷廣告、優惠活動、導購內容或宣傳文案',
28
+ '用吸引人的疑問句包裝促銷活動,但並非真的在等待本人回答',
29
+ ],
30
+ };
31
+ function dot(a, b) {
32
+ let value = 0;
33
+ for (let i = 0; i < Math.min(a.length, b.length); i++)
34
+ value += a[i] * b[i];
35
+ return value;
36
+ }
37
+ /** Transformer-embedding semantic classification followed only by explicit
38
+ * product controls such as mute, mention and manual priority. */
39
+ export async function scoreAttentionBatch(inputs, embedder = getDefaultEmbedder()) {
40
+ if (inputs.length === 0)
41
+ return [];
42
+ const intents = Object.keys(INTENT_ANCHORS);
43
+ const anchors = intents.flatMap((intent) => INTENT_ANCHORS[intent].map((text) => ({ intent, text })));
44
+ const vectors = await embedder.embed([
45
+ ...anchors.map((anchor) => anchor.text),
46
+ ...inputs.map((input) => input.text || '無文字訊息'),
47
+ ]);
48
+ const anchorVectors = vectors.slice(0, anchors.length);
49
+ const messageVectors = vectors.slice(anchors.length);
50
+ return inputs.map((input, inputIndex) => {
51
+ if (input.isMuted)
52
+ return { score: -100, tier: 'filtered', reason: 'muted' };
53
+ if (input.explicitPriority)
54
+ return { score: 70, tier: 'now', reason: 'manual_priority' };
55
+ if (input.hasMention)
56
+ return { score: 65, tier: 'now', reason: 'direct_mention' };
57
+ if (input.isOfficial &&
58
+ /^\[yomi:(?:image|rich|sticker|video|audio|file|extended_image)\]$/.test(input.text.trim())) {
59
+ return { score: 0, tier: 'filtered', reason: 'ordinary' };
60
+ }
61
+ const similarities = new Map();
62
+ anchors.forEach((anchor, anchorIndex) => {
63
+ const similarity = dot(messageVectors[inputIndex], anchorVectors[anchorIndex]);
64
+ similarities.set(anchor.intent, Math.max(similarities.get(anchor.intent) ?? -Infinity, similarity));
65
+ });
66
+ const ranked = [...intents].sort((a, b) => (similarities.get(b) ?? 0) - (similarities.get(a) ?? 0));
67
+ const intent = ranked[0];
68
+ if (input.isOfficial && intent !== 'transaction') {
69
+ return {
70
+ score: -40,
71
+ tier: 'filtered',
72
+ reason: intent === 'promotion' ? 'promotion' : 'ordinary',
73
+ };
74
+ }
75
+ const scoreByIntent = {
76
+ reply: 55,
77
+ deadline: 60,
78
+ transaction: 50,
79
+ important: 25,
80
+ ordinary: 0,
81
+ promotion: -40,
82
+ };
83
+ const score = scoreByIntent[intent];
84
+ const tier = score >= 55
85
+ ? 'now'
86
+ : score >= 35
87
+ ? 'today'
88
+ : score >= 20
89
+ ? 'know'
90
+ : 'filtered';
91
+ const reasons = {
92
+ reply: 'reply_expected',
93
+ deadline: 'deadline',
94
+ transaction: 'transaction',
95
+ important: 'important',
96
+ ordinary: 'ordinary',
97
+ promotion: 'promotion',
98
+ };
99
+ return { score, tier, reason: reasons[intent] };
100
+ });
101
+ }
@@ -13,9 +13,8 @@
13
13
  * rotated auth token during `resumeSession`), not only by a direct login.
14
14
  *
15
15
  * Keychain account: namespace (e.g. 'line')
16
- * KeychainService service name: see keychain.ts — writes go to Yomi's own
17
- * canonical namespace; a legacy namespace from before this split is read
18
- * as a fallback and migrated forward, never written to or deleted.
16
+ * KeychainService service name: see keychain.ts — stored in Yomi's canonical
17
+ * namespace (`dev.rikai.yomi.credentials`), shared across Yomi MCP and Yomi Desktop.
19
18
  *
20
19
  * The Keychain is machine-global: it is NOT scoped by YOMI_DATA_DIR, so on
21
20
  * macOS an automated run that only redirected the data dir would still find —
@@ -87,7 +86,7 @@ class VolatileCacheStore {
87
86
  cache = new Map();
88
87
  loaded = false;
89
88
  dirty = false;
90
- writing = false;
89
+ flushPromise = null;
91
90
  constructor(filePath) {
92
91
  this.filePath = filePath;
93
92
  }
@@ -140,11 +139,10 @@ class VolatileCacheStore {
140
139
  /** Coalesce bursts of writes into a single atomic flush cycle. */
141
140
  scheduleFlush() {
142
141
  this.dirty = true;
143
- if (this.writing) {
142
+ if (this.flushPromise) {
144
143
  return;
145
144
  }
146
- this.writing = true;
147
- void (async () => {
145
+ this.flushPromise = (async () => {
148
146
  const fs = await import('node:fs/promises');
149
147
  while (this.dirty) {
150
148
  this.dirty = false;
@@ -170,8 +168,13 @@ class VolatileCacheStore {
170
168
  // Best-effort cache persistence; in-memory copy still serves reads.
171
169
  }
172
170
  }
173
- this.writing = false;
174
- })();
171
+ })().finally(() => {
172
+ this.flushPromise = null;
173
+ });
174
+ }
175
+ async flush() {
176
+ while (this.flushPromise)
177
+ await this.flushPromise;
175
178
  }
176
179
  }
177
180
  /**
@@ -197,8 +200,13 @@ export class CredentialStore {
197
200
  this.filePath = fallbackFilePath;
198
201
  this.cache = new Map();
199
202
  this.loaded = false;
203
+ // Desktop background bridges are deliberately read-only. They must use
204
+ // the user-isolated credential file and never invoke `security` to mutate
205
+ // the Keychain during a 15s poll (which causes repeated macOS prompts).
200
206
  this.secureStorageEnabled =
201
- process.platform === 'darwin' && process.env.YOMI_NO_KEYCHAIN !== '1';
207
+ process.platform === 'darwin' &&
208
+ process.env.YOMI_NO_KEYCHAIN !== '1' &&
209
+ process.env.YOMI_READ_ONLY_SESSION !== '1';
202
210
  this.keychainService = this.secureStorageEnabled
203
211
  ? getKeychainService()
204
212
  : null;
@@ -217,20 +225,31 @@ export class CredentialStore {
217
225
  * @returns Serialized credential blob or null when missing.
218
226
  */
219
227
  async readPersistedBlob() {
228
+ try {
229
+ const fs = await import('node:fs/promises');
230
+ const fileData = await fs.readFile(this.filePath, 'utf-8');
231
+ if (fileData?.trim()) {
232
+ return fileData;
233
+ }
234
+ }
235
+ catch { }
220
236
  if (this.secureStorageEnabled && this.keychainService) {
221
237
  const result = await this.keychainService.getCredential(this.keychainAccount);
222
238
  if (result.success && result.password) {
239
+ try {
240
+ const fs = await import('node:fs/promises');
241
+ await fs.mkdir(dirname(this.filePath), {
242
+ recursive: true,
243
+ mode: 0o700,
244
+ });
245
+ await fs.writeFile(this.filePath, result.password, { mode: 0o600 });
246
+ }
247
+ catch { }
223
248
  return result.password;
224
249
  }
225
250
  return null;
226
251
  }
227
- try {
228
- const fs = await import('node:fs/promises');
229
- return await fs.readFile(this.filePath, 'utf-8');
230
- }
231
- catch {
232
- return null;
233
- }
252
+ return null;
234
253
  }
235
254
  /**
236
255
  * Load all credentials into cache from keychain or file.
@@ -263,6 +282,12 @@ export class CredentialStore {
263
282
  }
264
283
  if (this.secureStorageEnabled && this.keychainService) {
265
284
  await this.keychainService.setCredential(this.keychainAccount, blob);
285
+ try {
286
+ const fs = await import('node:fs/promises');
287
+ await fs.mkdir(dirname(this.filePath), { recursive: true, mode: 0o700 });
288
+ await fs.writeFile(this.filePath, blob, { mode: 0o600 });
289
+ }
290
+ catch { }
266
291
  this.lastPersistedBlob = blob;
267
292
  return;
268
293
  }
@@ -340,6 +365,11 @@ export class CredentialStore {
340
365
  return persisted;
341
366
  });
342
367
  }
368
+ /** Await durable and volatile write queues, primarily for orderly shutdown. */
369
+ async flush() {
370
+ await this.persistQueue;
371
+ await this.volatile.flush();
372
+ }
343
373
  /**
344
374
  * Delete a credential and persist.
345
375
  * @param key - Credential key to delete.
@@ -8,20 +8,12 @@
8
8
  import { spawn } from 'node:child_process';
9
9
  import { createCliLogger } from '../util/log.js';
10
10
  /**
11
- * Yomi's own Keychain service name. Yomi performs a first-party
11
+ * Yomi's Keychain service name. Yomi performs a first-party
12
12
  * passwordless LINE login (see the `login` MCP tool) and persists the
13
13
  * resulting session here — auth token, certificate, refresh token, mid,
14
- * and the E2EE NaCl keypair. All writes go through this namespace only.
14
+ * and the E2EE NaCl keypair. Shared between Yomi MCP and Yomi Desktop.
15
15
  */
16
- const SERVICE_NAME = 'com.yomi.credentials';
17
- /**
18
- * Legacy keychain service name, read-only. Predates Yomi owning its own
19
- * credentials; kept solely so a live session created before this split
20
- * keeps working without forcing the operator through a phone-PIN
21
- * re-login. Entries here are read and migrated forward into
22
- * `SERVICE_NAME`, never written to and never deleted.
23
- */
24
- const LEGACY_SERVICE_NAME = 'com.inboxd.credentials';
16
+ const SERVICE_NAME = 'dev.rikai.yomi.credentials';
25
17
  const authLog = createCliLogger('AUTH');
26
18
  /**
27
19
  * Wrapper for macOS `security` keychain CLI commands.
@@ -49,6 +41,7 @@ class KeychainCommands {
49
41
  '-w',
50
42
  password,
51
43
  '-U',
44
+ '-A',
52
45
  ]);
53
46
  let stderr = '';
54
47
  child.stderr.on('data', (data) => {
@@ -166,20 +159,12 @@ class KeychainCommands {
166
159
  /**
167
160
  * Service for managing macOS Keychain credentials.
168
161
  *
169
- * Reads try the canonical namespace first, then fall back to the legacy
170
- * namespace and migrate a hit forward. Writes and deletes only ever touch
171
- * the canonical namespace — the legacy entry is never deleted.
162
+ * Direct reads, writes, and deletes on the canonical Yomi namespace.
172
163
  */
173
164
  export class KeychainService {
174
165
  commands;
175
- legacyCommands;
176
- // Accounts whose canonical entry this process has already seen (read or
177
- // written). Once seen, a later read miss is treated as transient and must
178
- // NOT fall back to the legacy namespace — see getCredential.
179
- canonicalSeen = new Set();
180
166
  constructor() {
181
167
  this.commands = new KeychainCommands(SERVICE_NAME);
182
- this.legacyCommands = new KeychainCommands(LEGACY_SERVICE_NAME);
183
168
  }
184
169
  /**
185
170
  * Stores a credential in Keychain.
@@ -189,52 +174,20 @@ export class KeychainService {
189
174
  * @returns Promise resolving to credential result.
190
175
  */
191
176
  async setCredential(account, password) {
192
- // `add-generic-password -U` updates the item in place when it exists, so
193
- // this is a single atomic keychain op. Do NOT delete-then-add: that leaves
194
- // a window where the entry is absent, during which a concurrent read falls
195
- // through to the legacy namespace and resurrects stale credentials over the
196
- // current ones (the root of the "session silently logged out" bug).
197
- const result = await this.commands.set(account, password);
198
- if (result.success) {
199
- this.canonicalSeen.add(account);
200
- }
201
- return result;
177
+ return await this.commands.set(account, password);
202
178
  }
203
179
  /**
204
180
  * Retrieves a credential from Keychain.
205
181
  *
206
- * Tries the canonical namespace first. Only if this process has NEVER seen
207
- * the canonical entry does it fall back to the legacy namespace and migrate a
208
- * hit forward (one-time). Once the canonical entry has been seen, a later
209
- * miss is transient (item mid-update) and returns the miss as-is rather than
210
- * resurrecting a stale legacy copy on top of the live session.
211
- *
212
182
  * @param account - Account name.
213
183
  * @returns Promise resolving to credential result.
214
184
  */
215
185
  async getCredential(account) {
216
- const primary = await this.commands.get(account);
217
- if (primary.success) {
218
- this.canonicalSeen.add(account);
219
- return primary;
220
- }
221
- if (this.canonicalSeen.has(account)) {
222
- return primary;
223
- }
224
- const legacy = await this.legacyCommands.get(account);
225
- if (legacy.success && legacy.password) {
226
- await this.commands.set(account, legacy.password);
227
- this.canonicalSeen.add(account);
228
- authLog.info('keychain.migrated_from_legacy', { account });
229
- }
230
- return legacy;
186
+ return await this.commands.get(account);
231
187
  }
232
188
  /**
233
189
  * Deletes a credential from Keychain.
234
190
  *
235
- * Operates on the canonical namespace only. The legacy namespace is
236
- * never deleted, even here.
237
- *
238
191
  * @param account - Account name.
239
192
  * @returns Promise resolving to credential result.
240
193
  */