shennian 0.2.87 → 0.2.89

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 (73) hide show
  1. package/dist/assets/wechat-channel/macos/manifest.json +13 -0
  2. package/dist/assets/wechat-channel/macos/shennian-wechat-channel-helper +0 -0
  3. package/dist/src/agents/adapter.d.ts +6 -0
  4. package/dist/src/agents/codex-control.d.ts +35 -0
  5. package/dist/src/agents/codex-control.js +188 -0
  6. package/dist/src/agents/codex-utils.d.ts +5 -0
  7. package/dist/src/agents/codex-utils.js +5 -0
  8. package/dist/src/agents/codex.d.ts +8 -0
  9. package/dist/src/agents/codex.js +55 -2
  10. package/dist/src/agents/model-registry/discovery.js +2 -1
  11. package/dist/src/channels/base.d.ts +4 -13
  12. package/dist/src/channels/runtime.d.ts +1 -3
  13. package/dist/src/channels/runtime.js +32 -5
  14. package/dist/src/channels/secret-registry.d.ts +1 -4
  15. package/dist/src/channels/wechat-channel/anchor.d.ts +10 -0
  16. package/dist/src/channels/wechat-channel/anchor.js +65 -0
  17. package/dist/src/channels/wechat-channel/client.d.ts +74 -0
  18. package/dist/src/channels/wechat-channel/client.js +96 -0
  19. package/dist/src/channels/wechat-channel/cooldown.d.ts +15 -0
  20. package/dist/src/channels/wechat-channel/cooldown.js +38 -0
  21. package/dist/src/channels/wechat-channel/fingerprint.d.ts +28 -0
  22. package/dist/src/channels/wechat-channel/fingerprint.js +71 -0
  23. package/dist/src/channels/wechat-channel/helper-assets.d.ts +28 -0
  24. package/dist/src/channels/wechat-channel/helper-assets.js +68 -0
  25. package/dist/src/channels/wechat-channel/helper-client.d.ts +25 -0
  26. package/dist/src/channels/wechat-channel/helper-client.js +149 -0
  27. package/dist/src/channels/wechat-channel/helper-protocol.d.ts +84 -0
  28. package/dist/src/channels/wechat-channel/helper-protocol.js +115 -0
  29. package/dist/src/channels/wechat-channel/index.d.ts +16 -0
  30. package/dist/src/channels/wechat-channel/index.js +19 -0
  31. package/dist/src/channels/wechat-channel/ledger.d.ts +33 -0
  32. package/dist/src/channels/wechat-channel/ledger.js +54 -0
  33. package/dist/src/channels/wechat-channel/media-resolver.d.ts +32 -0
  34. package/dist/src/channels/wechat-channel/media-resolver.js +181 -0
  35. package/dist/src/channels/wechat-channel/message-key.d.ts +19 -0
  36. package/dist/src/channels/wechat-channel/message-key.js +105 -0
  37. package/dist/src/channels/wechat-channel/observer.d.ts +64 -0
  38. package/dist/src/channels/wechat-channel/observer.js +118 -0
  39. package/dist/src/channels/wechat-channel/outbound-ledger.d.ts +66 -0
  40. package/dist/src/channels/wechat-channel/outbound-ledger.js +112 -0
  41. package/dist/src/channels/wechat-channel/preflight.d.ts +37 -0
  42. package/dist/src/channels/wechat-channel/preflight.js +48 -0
  43. package/dist/src/channels/wechat-channel/runner.d.ts +34 -0
  44. package/dist/src/channels/wechat-channel/runner.js +84 -0
  45. package/dist/src/channels/wechat-channel/runtime.d.ts +45 -0
  46. package/dist/src/channels/wechat-channel/runtime.js +66 -0
  47. package/dist/src/channels/wechat-channel/scheduler.d.ts +30 -0
  48. package/dist/src/channels/wechat-channel/scheduler.js +152 -0
  49. package/dist/src/channels/wechat-rpa/macos-flow.d.ts +0 -28
  50. package/dist/src/channels/wechat-rpa/macos-flow.js +1 -134
  51. package/dist/src/channels/wechat-rpa.d.ts +21 -0
  52. package/dist/src/channels/wechat-rpa.js +39 -61
  53. package/dist/src/commands/manager.d.ts +1 -1
  54. package/dist/src/commands/manager.js +5 -10
  55. package/dist/src/fs/text-decoder.d.ts +10 -0
  56. package/dist/src/fs/text-decoder.js +110 -0
  57. package/dist/src/manager/runtime.js +4 -6
  58. package/dist/src/native-fusion/service.d.ts +10 -0
  59. package/dist/src/native-fusion/service.js +27 -0
  60. package/dist/src/session/handlers/chat.js +18 -2
  61. package/dist/src/session/handlers/fs.js +39 -3
  62. package/dist/src/session/handlers/session-refresh.js +12 -0
  63. package/dist/src/session/handlers/tool-detail.d.ts +3 -0
  64. package/dist/src/session/handlers/tool-detail.js +218 -0
  65. package/dist/src/session/manager.d.ts +3 -0
  66. package/dist/src/session/manager.js +58 -0
  67. package/dist/src/session/types.d.ts +4 -0
  68. package/package.json +2 -2
  69. package/dist/scripts/wechat-rpa-download-candidates.mjs +0 -105
  70. package/dist/scripts/wechat-rpa-win-visual.mjs +0 -1735
  71. package/dist/scripts/wechat-rpa-win.mjs +0 -352
  72. package/dist/src/channels/wechat-rpa/windows-visual-flow.d.ts +0 -40
  73. package/dist/src/channels/wechat-rpa/windows-visual-flow.js +0 -189
@@ -0,0 +1,218 @@
1
+ // @arch docs/architecture/data-retention-and-sync-policy.md#tool-结果
2
+ // @test src/__tests__/session-manager.test.ts
3
+ import { readMessages } from '../store.js';
4
+ const DEFAULT_MAX_CHARS = 2_000;
5
+ const MAX_MAX_CHARS = 8_000;
6
+ const SENSITIVE_KEY_RE = /(?:token|api[-_]?key|password|passwd|pwd|secret|authorization|cookie|credential|private[-_]?key)/i;
7
+ const SENSITIVE_ASSIGNMENT_RE = /\b([A-Z0-9_]*(?:TOKEN|KEY|SECRET|PASSWORD|PASSWD|PWD|AUTH)[A-Z0-9_]*)=("[^"]*"|'[^']*'|[^\s]+)/gi;
8
+ const BEARER_RE = /\bBearer\s+[A-Za-z0-9._~+/=-]+/gi;
9
+ const OPENAI_KEY_RE = /\bsk-[A-Za-z0-9_-]{12,}\b/g;
10
+ function asRecord(value) {
11
+ return value && typeof value === 'object' && !Array.isArray(value)
12
+ ? value
13
+ : null;
14
+ }
15
+ function normalizeDetailRef(value) {
16
+ if (!value)
17
+ return null;
18
+ if (typeof value === 'string') {
19
+ try {
20
+ return normalizeDetailRef(JSON.parse(value));
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ }
26
+ const record = asRecord(value);
27
+ if (!record)
28
+ return null;
29
+ const runId = typeof record.runId === 'string' ? record.runId : undefined;
30
+ const sourceSeq = typeof record.sourceSeq === 'number'
31
+ ? record.sourceSeq
32
+ : typeof record.seq === 'number'
33
+ ? record.seq
34
+ : undefined;
35
+ const toolIndex = typeof record.toolIndex === 'number' ? record.toolIndex : undefined;
36
+ return { ...(runId ? { runId } : {}), ...(sourceSeq != null ? { sourceSeq } : {}), ...(toolIndex != null ? { toolIndex } : {}) };
37
+ }
38
+ function parseRunIdFromMessageId(messageId) {
39
+ const match = /^agent-(.+)-\d+$/.exec(messageId);
40
+ return match?.[1] ?? null;
41
+ }
42
+ function parseSeqFromMessageId(messageId) {
43
+ const match = /^agent-.+-(\d+)$/.exec(messageId);
44
+ return match ? Number(match[1]) : null;
45
+ }
46
+ function clampMaxChars(value) {
47
+ const parsed = typeof value === 'number' ? value : Number(value);
48
+ if (!Number.isFinite(parsed) || parsed <= 0)
49
+ return DEFAULT_MAX_CHARS;
50
+ return Math.min(Math.max(200, Math.floor(parsed)), MAX_MAX_CHARS);
51
+ }
52
+ function redactString(value, maxChars) {
53
+ const redacted = value
54
+ .replace(SENSITIVE_ASSIGNMENT_RE, '$1=[REDACTED]')
55
+ .replace(BEARER_RE, 'Bearer [REDACTED]')
56
+ .replace(OPENAI_KEY_RE, 'sk-[REDACTED]');
57
+ return redacted.length > maxChars ? `${redacted.slice(0, maxChars)}…` : redacted;
58
+ }
59
+ function sanitizeValue(value, maxChars, depth = 0) {
60
+ if (value == null)
61
+ return value;
62
+ if (typeof value === 'string')
63
+ return redactString(value, maxChars);
64
+ if (typeof value === 'number' || typeof value === 'boolean')
65
+ return value;
66
+ if (depth >= 4)
67
+ return '[Truncated]';
68
+ if (Array.isArray(value)) {
69
+ return value.slice(0, 20).map((item) => sanitizeValue(item, maxChars, depth + 1));
70
+ }
71
+ if (typeof value === 'object') {
72
+ const output = {};
73
+ for (const [key, raw] of Object.entries(value).slice(0, 50)) {
74
+ if (SENSITIVE_KEY_RE.test(key)) {
75
+ output[key] = '[REDACTED]';
76
+ continue;
77
+ }
78
+ output[key] = sanitizeValue(raw, maxChars, depth + 1);
79
+ }
80
+ return output;
81
+ }
82
+ return String(value);
83
+ }
84
+ function extractCommand(args, maxChars) {
85
+ const record = asRecord(args);
86
+ const raw = record?.command;
87
+ if (typeof raw === 'string')
88
+ return redactString(raw, maxChars);
89
+ if (Array.isArray(raw)) {
90
+ const rendered = raw.map((item) => String(item)).join(' ');
91
+ return redactString(rendered, maxChars);
92
+ }
93
+ return undefined;
94
+ }
95
+ function parseLocalToolCandidates(sessionId) {
96
+ const messages = readMessages(sessionId);
97
+ const candidates = [];
98
+ for (const message of messages) {
99
+ if (message.role !== 'agent' || !message.payload.trim().startsWith('{'))
100
+ continue;
101
+ try {
102
+ const parsed = JSON.parse(message.payload);
103
+ const type = parsed.type;
104
+ if (type !== 'tool' && type !== 'tool_use' && type !== 'tool_result')
105
+ continue;
106
+ const name = typeof parsed.name === 'string' && parsed.name.trim()
107
+ ? parsed.name.trim()
108
+ : 'tool';
109
+ candidates.push({
110
+ id: message.id,
111
+ ts: message.ts,
112
+ name,
113
+ args: parsed.args,
114
+ result: parsed.result,
115
+ status: type === 'tool_result' || Object.prototype.hasOwnProperty.call(parsed, 'result')
116
+ ? 'completed'
117
+ : parsed.status === 'completed' || parsed.status === 'failed' || parsed.status === 'running'
118
+ ? parsed.status
119
+ : 'running',
120
+ detailRef: normalizeDetailRef(parsed.detailRef),
121
+ });
122
+ }
123
+ catch {
124
+ // Ignore malformed local transcript lines.
125
+ }
126
+ }
127
+ return candidates;
128
+ }
129
+ function scoreCandidate(candidate, req, detailRef) {
130
+ let score = 0;
131
+ if (candidate.id === req.messageId)
132
+ score += 100;
133
+ if (req.name && candidate.name === req.name)
134
+ score += 20;
135
+ const requestRunId = detailRef?.runId ?? req.runId ?? parseRunIdFromMessageId(req.messageId);
136
+ const requestSeq = detailRef?.sourceSeq ?? req.sourceSeq ?? parseSeqFromMessageId(req.messageId) ?? undefined;
137
+ if (requestRunId) {
138
+ const candidateRunId = candidate.detailRef?.runId ?? parseRunIdFromMessageId(candidate.id);
139
+ if (candidateRunId === requestRunId)
140
+ score += 20;
141
+ }
142
+ if (requestSeq != null) {
143
+ const candidateSeq = candidate.detailRef?.sourceSeq ?? parseSeqFromMessageId(candidate.id);
144
+ if (candidateSeq === requestSeq)
145
+ score += 80;
146
+ }
147
+ if (candidate.args != null)
148
+ score += 5;
149
+ return score;
150
+ }
151
+ function findLocalToolDetail(params) {
152
+ const detailRef = normalizeDetailRef(params.detailRef) ?? normalizeDetailRef({
153
+ runId: params.runId,
154
+ sourceSeq: params.sourceSeq,
155
+ toolIndex: params.toolIndex,
156
+ });
157
+ const candidates = parseLocalToolCandidates(params.sessionId);
158
+ if (candidates.length === 0)
159
+ return null;
160
+ const ranked = candidates
161
+ .map((candidate) => ({ candidate, score: scoreCandidate(candidate, params, detailRef) }))
162
+ .filter((item) => item.score > 0)
163
+ .sort((left, right) => right.score - left.score || right.candidate.ts - left.candidate.ts);
164
+ return ranked[0]?.candidate ?? null;
165
+ }
166
+ export async function handleSessionToolDetail(runtime, req) {
167
+ const params = req.params;
168
+ const sessionId = typeof params.sessionId === 'string' ? params.sessionId : '';
169
+ const messageId = typeof params.messageId === 'string' ? params.messageId : '';
170
+ if (!sessionId || !messageId) {
171
+ runtime.client.sendRes({
172
+ type: 'res',
173
+ id: req.id,
174
+ ok: false,
175
+ error: 'sessionId and messageId are required',
176
+ });
177
+ return;
178
+ }
179
+ const request = {
180
+ sessionId,
181
+ messageId,
182
+ ...(typeof params.name === 'string' ? { name: params.name } : {}),
183
+ ...(typeof params.toolIndex === 'number' ? { toolIndex: params.toolIndex } : {}),
184
+ ...(typeof params.runId === 'string' ? { runId: params.runId } : {}),
185
+ ...(typeof params.sourceSeq === 'number' ? { sourceSeq: params.sourceSeq } : {}),
186
+ ...(params.detailRef !== undefined ? { detailRef: params.detailRef } : {}),
187
+ maxChars: clampMaxChars(params.maxChars),
188
+ };
189
+ const maxChars = clampMaxChars(request.maxChars);
190
+ const match = findLocalToolDetail(request);
191
+ if (!match || match.args == null) {
192
+ const payload = {
193
+ detailStatus: 'not_found',
194
+ messageId,
195
+ toolIndex: request.toolIndex ?? 0,
196
+ name: request.name,
197
+ resultOmitted: true,
198
+ source: 'unavailable',
199
+ reason: 'tool detail is not available on this machine',
200
+ };
201
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: true, payload });
202
+ return;
203
+ }
204
+ const argsSummary = match.args == null ? undefined : sanitizeValue(match.args, maxChars);
205
+ const command = extractCommand(match.args, maxChars);
206
+ const payload = {
207
+ detailStatus: 'available',
208
+ messageId,
209
+ toolIndex: request.toolIndex ?? 0,
210
+ name: match.name,
211
+ status: match.status,
212
+ ...(argsSummary !== undefined ? { argsSummary } : {}),
213
+ ...(command ? { command } : {}),
214
+ resultOmitted: true,
215
+ source: 'local-session-store',
216
+ };
217
+ runtime.client.sendRes({ type: 'res', id: req.id, ok: true, payload });
218
+ }
@@ -22,8 +22,11 @@ export declare class SessionManager {
22
22
  private pendingTransfers;
23
23
  private managerRuntime;
24
24
  private chatQueue;
25
+ private activityProbeTimer;
25
26
  constructor(client: CliRelayClient, nativeFusion?: NativeSessionFusionService | null, cliVersion?: string | undefined);
26
27
  private getRuntime;
28
+ private publishSessionActivity;
29
+ private publishManagedActivitySnapshots;
27
30
  private reloadCustomAgents;
28
31
  handleReq(req: ReqFrame): Promise<void>;
29
32
  private evictIdleSessions;
@@ -8,6 +8,7 @@ import { handleAgentsRefresh, handleModelsRefresh } from './handlers/agents.js';
8
8
  import { handleAgentConfigClear, handleAgentConfigGet, handleAgentConfigTest, handleAgentConfigUpsert, } from './handlers/agent-config.js';
9
9
  import { handleChatAbort, handleChatSend } from './handlers/chat.js';
10
10
  import { handleSessionRefresh } from './handlers/session-refresh.js';
11
+ import { handleSessionToolDetail } from './handlers/tool-detail.js';
11
12
  import { handleSessionTitleSet } from './handlers/title.js';
12
13
  import { cleanupPendingTransfers, handleFsLs, handleFsRead, handleFsRename, handleFsWrite, handleFsTransfer, handleFsTransferAbort, handleFsTransferChunk, handleFsTransferFinish, handleFsTransferStart, handleFsExportMarkdownPdf, handleFsArchiveZip, } from './handlers/fs.js';
13
14
  import { handleSkillDoctor, handleSkillInstall, handleSkillList, handleSkillSetup, handleSkillUse, } from './handlers/skills.js';
@@ -41,6 +42,7 @@ export class SessionManager {
41
42
  pendingTransfers = new Map();
42
43
  managerRuntime;
43
44
  chatQueue;
45
+ activityProbeTimer = null;
44
46
  constructor(client, nativeFusion = null, cliVersion) {
45
47
  this.client = client;
46
48
  this.nativeFusion = nativeFusion;
@@ -56,6 +58,12 @@ export class SessionManager {
56
58
  setManagerRuntimeService(this.managerRuntime);
57
59
  void this.managerRuntime.start();
58
60
  this.reloadCustomAgents();
61
+ this.activityProbeTimer = setInterval(() => {
62
+ void this.publishManagedActivitySnapshots().catch((error) => {
63
+ console.error('[session.activity] managed probe failed', error);
64
+ });
65
+ }, 15_000);
66
+ this.activityProbeTimer.unref?.();
59
67
  }
60
68
  getRuntime() {
61
69
  return {
@@ -71,8 +79,51 @@ export class SessionManager {
71
79
  nativeFusion: this.nativeFusion,
72
80
  managerRuntime: this.managerRuntime,
73
81
  chatQueue: this.chatQueue,
82
+ activityPublisher: {
83
+ publish: (sessionId, activity) => this.publishSessionActivity(sessionId, activity),
84
+ },
74
85
  };
75
86
  }
87
+ publishSessionActivity(sessionId, activity) {
88
+ this.client.sendEvent({
89
+ type: 'event',
90
+ event: 'session.activity',
91
+ payload: { sessionId, activity },
92
+ });
93
+ }
94
+ async publishManagedActivitySnapshots() {
95
+ for (const [sessionId, session] of this.sessions.entries()) {
96
+ if (!session.currentRunId || !session.adapter.getStatus)
97
+ continue;
98
+ const status = await session.adapter.getStatus().catch(() => null);
99
+ if (!status?.active || !status.runPhase)
100
+ continue;
101
+ const nowIso = new Date().toISOString();
102
+ this.publishSessionActivity(sessionId, {
103
+ sessionId,
104
+ runId: status.runId || session.currentRunId,
105
+ runPhase: status.runPhase,
106
+ startedAt: new Date(session.lastActiveAt).toISOString(),
107
+ updatedAt: nowIso,
108
+ canStop: status.canStop ?? true,
109
+ });
110
+ const seq = session.heartbeatSeq++;
111
+ this.client.sendAgentEvent({
112
+ type: 'event',
113
+ event: 'agent',
114
+ payload: {
115
+ state: 'heartbeat',
116
+ sessionId,
117
+ runId: status.runId || session.currentRunId,
118
+ seq,
119
+ runPhase: status.runPhase,
120
+ canStop: status.canStop ?? true,
121
+ },
122
+ seq,
123
+ id: `agent-status-${status.runId || session.currentRunId}-${Date.now()}`,
124
+ });
125
+ }
126
+ }
76
127
  reloadCustomAgents() {
77
128
  for (const agentType of getRegisteredAgents()) {
78
129
  if (agentType.startsWith('custom:')) {
@@ -109,6 +160,9 @@ export class SessionManager {
109
160
  case 'session.refresh':
110
161
  await handleSessionRefresh(runtime, req);
111
162
  break;
163
+ case 'session.tool.detail':
164
+ await handleSessionToolDetail(runtime, req);
165
+ break;
112
166
  case 'session.title.set':
113
167
  await handleSessionTitleSet(runtime, req);
114
168
  break;
@@ -245,6 +299,10 @@ export class SessionManager {
245
299
  return resolveAuthorizedPath(p, createAuthorizedFsRoot(rootPath));
246
300
  }
247
301
  cleanup() {
302
+ if (this.activityProbeTimer) {
303
+ clearInterval(this.activityProbeTimer);
304
+ this.activityProbeTimer = null;
305
+ }
248
306
  for (const [, session] of this.sessions) {
249
307
  if (session.heartbeatTimer) {
250
308
  clearInterval(session.heartbeatTimer);
@@ -48,6 +48,9 @@ export type ChatQueueService = {
48
48
  noteTerminal(sessionId: string): void;
49
49
  getSnapshot(sessionId: string): import('@shennian/wire').ChatQueueSnapshot;
50
50
  };
51
+ export type SessionActivityPublisher = {
52
+ publish(sessionId: string, activity: import('@shennian/wire').SessionActivitySnapshot | null): void;
53
+ };
51
54
  export type SessionManagerRuntime = {
52
55
  client: CliRelayClient;
53
56
  pendingTransfers: Map<string, PendingTransfer>;
@@ -61,4 +64,5 @@ export type SessionManagerRuntime = {
61
64
  nativeFusion: NativeSessionFusionService | null;
62
65
  managerRuntime: ManagerRuntimeService | null;
63
66
  chatQueue: ChatQueueService | null;
67
+ activityPublisher?: SessionActivityPublisher | null;
64
68
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shennian",
3
- "version": "0.2.87",
3
+ "version": "0.2.89",
4
4
  "description": "Shennian — AI Agent Control Plane CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -51,7 +51,7 @@
51
51
  },
52
52
  "scripts": {
53
53
  "build": "tsc && node scripts/copy-wechat-rpa-assets.mjs && node -e \"require('node:fs').chmodSync('dist/bin/shennian.js', 0o755)\"",
54
- "build:publish": "node -e \"const fs=require('node:fs'); fs.rmSync('dist', { recursive: true, force: true }); fs.rmSync('.tsbuildinfo.publish', { force: true })\" && tsc -p tsconfig.publish.json && node scripts/copy-wechat-rpa-assets.mjs && node -e \"require('node:fs').chmodSync('dist/bin/shennian.js', 0o755)\"",
54
+ "build:publish": "node -e \"const fs=require('node:fs'); fs.rmSync('dist', { recursive: true, force: true }); fs.rmSync('.tsbuildinfo.publish', { force: true })\" && tsc -p tsconfig.publish.json && node scripts/copy-wechat-rpa-assets.mjs && node -e \"require('node:fs').chmodSync('dist/bin/shennian.js', 0o755)\" && node scripts/check-publish-artifact.mjs",
55
55
  "dev": "tsc --watch"
56
56
  }
57
57
  }
@@ -1,105 +0,0 @@
1
- // @arch docs/features/wechat-rpa-channel.md
2
- // @test packages/cli/src/__tests__/wechat-rpa-download-candidates.test.ts
3
-
4
- import path from 'node:path'
5
- import { normalizeReplyText } from './wechat-rpa-confirmation.mjs'
6
-
7
- export function selectDownloadedAttachment(before, after, startedAt, attachment) {
8
- const changed = Array.from(after.values())
9
- .filter((file) => file.mtimeMs >= startedAt - 1_000)
10
- .filter((file) => isPlausibleDownloadedAttachment(file, attachment))
11
- .filter((file) => {
12
- const prev = before.get(file.path)
13
- return !prev || prev.size !== file.size || prev.mtimeMs !== file.mtimeMs
14
- })
15
- if (!changed.length) return null
16
- const expectedName = normalizeReplyText(attachment?.name || '')
17
- return changed
18
- .map((file) => {
19
- const base = normalizeReplyText(path.basename(file.path))
20
- const expectedHead = expectedName.slice(0, Math.min(expectedName.length, 16))
21
- const nameScore = expectedHead && base.includes(expectedHead) ? 10 : 0
22
- return { ...file, score: nameScore + file.mtimeMs / 1_000_000_000_000 }
23
- })
24
- .sort((a, b) => b.score - a.score || b.mtimeMs - a.mtimeMs)[0] || null
25
- }
26
-
27
- export function selectCachedAttachment(candidates, attachment) {
28
- const expectedName = normalizeReplyText(attachment?.name || '')
29
- if (expectedName.length < 4) return null
30
- const expectedExt = path.extname(String(attachment?.name || '')).toLowerCase()
31
- const expectedHead = expectedName.slice(0, Math.min(expectedName.length, 24))
32
- const matched = Array.from(candidates.values())
33
- .filter((file) => isPlausibleDownloadedAttachment(file, attachment))
34
- .filter((file) => {
35
- const base = normalizeReplyText(path.basename(file.path))
36
- if (base === expectedName) return true
37
- return expectedHead.length >= 8 && base.includes(expectedHead)
38
- })
39
- if (!matched.length) return null
40
- return matched
41
- .map((file) => ({
42
- ...file,
43
- score: (path.extname(file.path).toLowerCase() === expectedExt ? 10 : 0) + file.mtimeMs / 1_000_000_000_000,
44
- }))
45
- .sort((a, b) => b.score - a.score || b.mtimeMs - a.mtimeMs)[0] || null
46
- }
47
-
48
- const INTERNAL_EXTENSIONS = new Set([
49
- '.db',
50
- '.ini',
51
- '.log',
52
- '.plist',
53
- '.shm',
54
- '.sqlite',
55
- '.statistic',
56
- '.tmp',
57
- '.wal',
58
- '.xlog',
59
- ])
60
-
61
- const ATTACHMENT_EXTENSIONS = new Set([
62
- '.7z',
63
- '.aac',
64
- '.avi',
65
- '.csv',
66
- '.doc',
67
- '.docx',
68
- '.gif',
69
- '.heic',
70
- '.jpeg',
71
- '.jpg',
72
- '.key',
73
- '.m4a',
74
- '.m4v',
75
- '.md',
76
- '.mov',
77
- '.mp3',
78
- '.mp4',
79
- '.numbers',
80
- '.pages',
81
- '.pdf',
82
- '.png',
83
- '.ppt',
84
- '.pptx',
85
- '.rar',
86
- '.rtf',
87
- '.txt',
88
- '.wav',
89
- '.webp',
90
- '.xls',
91
- '.xlsx',
92
- '.zip',
93
- ])
94
-
95
- export function isPlausibleDownloadedAttachment(file, attachment) {
96
- const base = path.basename(file?.path || '')
97
- if (!base || base.startsWith('.')) return false
98
- if (!Number.isFinite(file?.size) || file.size <= 0) return false
99
- const ext = path.extname(base).toLowerCase()
100
- if (!ext || INTERNAL_EXTENSIONS.has(ext)) return false
101
- const expectedExt = path.extname(String(attachment?.name || '')).toLowerCase()
102
- if (expectedExt && ext !== expectedExt) return false
103
- if (ATTACHMENT_EXTENSIONS.has(ext)) return true
104
- return Boolean(expectedExt)
105
- }