@hellolynk-ai/codex-plugin 0.0.0-stage → 0.2.100
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/.agents/plugins/marketplace.json +20 -0
- package/LICENSE +92 -0
- package/README.md +101 -2
- package/bin/hellolynk-client-setup.mjs +2748 -0
- package/bin/hellolynk-mcp-config-shape.mjs +23 -0
- package/hellolynk-client-pack-manifest.json +619 -0
- package/hellolynk-client-pack-pin.json +4 -0
- package/package.json +58 -4
- package/plugins/hellolynk-codex/.app.json +3 -0
- package/plugins/hellolynk-codex/.codex-plugin/plugin.json +38 -0
- package/plugins/hellolynk-codex/.mcp.json +19 -0
- package/plugins/hellolynk-codex/assets/hellolynk-mark.png +0 -0
- package/plugins/hellolynk-codex/package.json +11 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-account.mjs +180 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-core.mjs +770 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-profile.mjs +191 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-host-auth.mjs +347 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-host-protected-store.mjs +2721 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-local-process-lock.mjs +157 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-neutral-host-installation-store.mjs +765 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-platform-credential-store.mjs +773 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-runtime-safety.cjs +471 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/platform/windows/hellolynk-host-protected-store.ps1 +2866 -0
- package/plugins/hellolynk-codex/runtime/connect-activate.mjs +837 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-agent-configurator.mjs +701 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-account.mjs +180 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-core.mjs +770 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-profile.mjs +191 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-entry.mjs +31 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-runtime.mjs +600 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update.mjs +453 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-contact-request-e2ee.mjs +867 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-device-enrollment.mjs +435 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-direct-text-e2ee-client.mjs +1964 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-direct-text-e2ee.mjs +1207 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-host-auth.mjs +347 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-host-protected-store.mjs +2721 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-host-transport-client.mjs +946 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-host.mjs +2374 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-human-confirmation-ledger.mjs +256 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-human-relationship-controller.mjs +1234 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-inbox-read-state.mjs +11 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-indexed-message-store.mjs +1558 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-instance-conversation.mjs +185 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-contact-requests.mjs +1733 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-contacts.mjs +1228 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-data-migration.mjs +766 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-data-protection.mjs +747 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-identity-trust.mjs +274 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-messaging.mjs +3043 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-peer-transport.mjs +103 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-process-lock.mjs +157 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-model-inbox.mjs +230 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-model-notices.cjs +60 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-addressing-tools.cjs +231 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-conversations.mjs +280 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-human-confirmation-broker.mjs +1036 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-inbox-runtime.mjs +553 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-notice.mjs +50 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-reply-routes.mjs +96 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-worker-lease.mjs +84 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-assembly.mjs +955 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-installation-store.mjs +765 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-launcher.mjs +665 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-process.mjs +4374 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-store-adapters.mjs +548 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-workbench-read-model.mjs +1365 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-platform-credential-store.mjs +773 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-relay-client-cli.mjs +232 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-release-retention.mjs +277 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-runtime-safety.cjs +471 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-workbench-action-grants.mjs +457 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-workbench-connection.mjs +234 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-bootstrap.mjs +64 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-release-entry.mjs +70 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-release-loader.mjs +156 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-release-manifest.json +358 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-release-pin.json +4 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/configure-agent.ps1 +281 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/credential-store.ps1 +844 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-host-protected-store.ps1 +2866 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-human-confirmation-ledger.ps1 +155 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-local-data-key.ps1 +300 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-native-human-confirmation-broker.ps1 +216 -0
- package/plugins/hellolynk-codex/runtime/workbench/candidate-bootstrap.mjs +54 -0
- package/plugins/hellolynk-codex/runtime/workbench/contract.mjs +1314 -0
- package/plugins/hellolynk-codex/runtime/workbench/hellolynk-workbench-build.json +215 -0
- package/plugins/hellolynk-codex/runtime/workbench/host-capabilities.mjs +80 -0
- package/plugins/hellolynk-codex/runtime/workbench/i18n.mjs +1127 -0
- package/plugins/hellolynk-codex/runtime/workbench/loopback-surface.mjs +740 -0
- package/plugins/hellolynk-codex/runtime/workbench/mcp-app-bundle.mjs +97 -0
- package/plugins/hellolynk-codex/runtime/workbench/service.mjs +485 -0
- package/plugins/hellolynk-codex/runtime/workbench/state.mjs +421 -0
- package/plugins/hellolynk-codex/runtime/workbench/transports.mjs +171 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/app.mjs +6120 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/connection-form.mjs +155 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/hellolynk-mark.png +0 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/http-transport.mjs +247 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/arrow-left.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/close.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/contacts.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/inbox.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/refresh.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/search.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/security.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/settings.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/index.html +139 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/loopback-entry.mjs +26 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/mcp-app-client.mjs +151 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/mcp-app-entry.mjs +452 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/styles.css +686 -0
- package/plugins/hellolynk-codex/server/codex-neutral-production.mjs +520 -0
- package/plugins/hellolynk-codex/server/mcp-proxy.mjs +306 -0
- package/plugins/hellolynk-codex/server/mcp-server.mjs +1193 -0
- package/plugins/hellolynk-codex/server/native-conversation-directory.mjs +33 -0
- package/plugins/hellolynk-codex/server/native-reply-routes.mjs +32 -0
- package/plugins/hellolynk-codex/server/native-task-client.mjs +382 -0
- package/plugins/hellolynk-codex/server/native-task-runtime.mjs +8 -0
- package/plugins/hellolynk-codex/server/neutral-host-adapter.mjs +649 -0
- package/plugins/hellolynk-codex/server/workbench-runtime.mjs +180 -0
- package/plugins/hellolynk-codex/skills/hellolynk-open/SKILL.md +11 -0
- package/plugins/hellolynk-codex/skills/hellolynk-open/agents/openai.yaml +4 -0
- package/tools/hellolynk-activate-neutral-host-clients.mjs +837 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const ID = { type: "string", minLength: 3, maxLength: 241, pattern: "^[A-Za-z0-9][A-Za-z0-9_.:-]{2,240}$" };
|
|
3
|
+
const targetSchema = { ...ID, description: "目标对话编号,仅本人指定了对话时填写。" };
|
|
4
|
+
const titleSchema = { type: "string", minLength: 1, maxLength: 256, description: "本人点名的完整对话标题。给其他账号的 AI 指定对话时使用。" };
|
|
5
|
+
const contactSchema = { type: "string", minLength: 1, maxLength: 160, description: "对方名字。" };
|
|
6
|
+
// The Host directory query. A model's `contact` is resolved to these IDs before any adapter call.
|
|
7
|
+
const directoryProperties = {
|
|
8
|
+
contactId: ID, recipientDelegateId: ID,
|
|
9
|
+
title: { type: "string", maxLength: 256, description: "对话标题筛选,不唯一时返回候选" },
|
|
10
|
+
project: { type: "string", maxLength: 256, description: "项目/工作区名筛选" },
|
|
11
|
+
cursor: { type: "string", maxLength: 512, description: "上次目录查询的 nextCursor;同一筛选继续下一页" },
|
|
12
|
+
targetConversationId: targetSchema,
|
|
13
|
+
};
|
|
14
|
+
const queryProperties = { contact: contactSchema, ...directoryProperties };
|
|
15
|
+
const queryDescription = "列出本人授权当前 AI 联系的对象。传 contact 可按名字筛选;定位本账号的 AI 对话时可传 title 或 project。给其他账号的 AI 定向发信用发送工具的 targetConversationTitle。";
|
|
16
|
+
// The only place the messaging rules are written. Every client's instructions and skill start with it.
|
|
17
|
+
const conversationGuidance = "HelloLynk 是本人授权的 AI 通讯。接着已有来往用 hellolynk_reply_authorized_message,沿用来信的 localMessageId 和 conversationKey;发起新通信用 hellolynk_send_authorized_message,contact 填对方名字。每条新消息用新的 clientMessageId;发送失败或结果未知时保留编号并停止,不重发;参数无效时改正后重新调用。收到本聊天的 HelloLynk 自动提醒时,用 hellolynk_claim_message 按 batchId 读一次。没有本聊天的提醒或本人查信要求时,不主动查信。用户要看全部来信时,打开本客户端的 HelloLynk 界面供本人查看。";
|
|
18
|
+
// What every client shows for the shared tools; nativeTool applies it.
|
|
19
|
+
const toolText = Object.freeze({
|
|
20
|
+
hellolynk_reply_authorized_message: { title: "回复来信", description: "接着当前对话的一封来信发送;不接手其他对话。" },
|
|
21
|
+
hellolynk_send_authorized_message: { title: "发起通信", description: "向本人授权的联系人发起新通信。contact 填对方名字;本人点名了对话时,targetConversationTitle 填完整标题。接着已有来往用 hellolynk_reply_authorized_message。" },
|
|
22
|
+
hellolynk_list_authorized_contacts: { title: "查找联系人", description: queryDescription },
|
|
23
|
+
hellolynk_claim_message: { title: "读取来信", description: "按自动提醒里的 batchId 一次读取整批来信(合计不超过 2 万字);手动读单条传 localMessageId 和 conversationKey。" },
|
|
24
|
+
hellolynk_list_pending_messages: { title: "待处理来信", description: "列出当前对话等待 AI 处理的来信,不含正文。" },
|
|
25
|
+
hellolynk_host_status: { title: "连接状态", description: "查看本机 HelloLynk 连接状态。" },
|
|
26
|
+
hellolynk_open_inbox: { title: "打开 HelloLynk", description: "打开 HelloLynk 界面。" },
|
|
27
|
+
hellolynk_get_unread_count: { title: "未读数", description: "查看当前对话的人类未读和 AI 待处理数量;两者含义不同。" },
|
|
28
|
+
hellolynk_list_authorized_inbox_messages: { title: "最近来信", description: "列出当前对话最近 10 条来信,不含正文;要更早的,把最后一条的 localMessageId 填进 before。" },
|
|
29
|
+
hellolynk_read_authorized_inbox_message: { title: "读取一条来信", description: "读取当前对话的一条来信正文。" },
|
|
30
|
+
hellolynk_list_authorized_contact_requests: { title: "联系申请", description: "列出联系申请。" },
|
|
31
|
+
hellolynk_read_authorized_contact_request: { title: "读取联系申请", description: "读取一条联系申请。" },
|
|
32
|
+
});
|
|
33
|
+
const paramText = Object.freeze({
|
|
34
|
+
clientMessageId: "本条消息的唯一编号。",
|
|
35
|
+
text: "消息正文,最多 20480 字节(中文约 6800 字)。",
|
|
36
|
+
localMessageId: "来信编号。",
|
|
37
|
+
conversationKey: "来信会话键。",
|
|
38
|
+
batchId: "自动提醒里的批次编号。",
|
|
39
|
+
before: "上一页最后一条的 localMessageId。",
|
|
40
|
+
});
|
|
41
|
+
const ALLOW_PATH = "请本人在 HelloLynk → 联系人 → 对方的 AI → AI 权限里点“允许”。";
|
|
42
|
+
const RELEASE_SUPERSEDED_TEXT = "本机 HelloLynk 正在切换到新版本,请稍后重试。";
|
|
43
|
+
const POWERSHELL7_MISSING_TEXT = "这台电脑没有 PowerShell 7(pwsh)。请安装 PowerShell 7 后重新打开客户端。";
|
|
44
|
+
const REPLY_TARGET_DELETED_TEXT = "原对话已删除,回复未送达。本人可在 HelloLynk 卡片确认新建对话补发。";
|
|
45
|
+
const resultText = Object.freeze({
|
|
46
|
+
sent: (status, clientMessageId) => `已发送(status=${status}),clientMessageId=${clientMessageId}。`,
|
|
47
|
+
contacts: count => count ? `已授权联系人 ${count} 个。` : `没有已授权的联系人。${ALLOW_PATH}`,
|
|
48
|
+
inbox: (count, older) => `${older ? "更早" : "最近"}来信 ${count} 条。`,
|
|
49
|
+
pending: count => `待处理来信 ${count} 条。`,
|
|
50
|
+
claimed: (count, expired) => `已读取 ${count} 条。${expired ? `另有 ${expired} 条较早来信超出单次读取上限,未处理。` : ""}`,
|
|
51
|
+
unread: count => `未读 ${count} 条。`,
|
|
52
|
+
ready: url => `HelloLynk 已就绪:${url}`,
|
|
53
|
+
});
|
|
54
|
+
const replyHintSchema = { type: "object", additionalProperties: false, required: ["tool", "arguments"], properties: {
|
|
55
|
+
tool: { type: "string", const: "hellolynk_reply_authorized_message" },
|
|
56
|
+
arguments: { type: "object", additionalProperties: false, required: ["localMessageId", "conversationKey"],
|
|
57
|
+
properties: { localMessageId: ID, conversationKey: ID } },
|
|
58
|
+
} };
|
|
59
|
+
// A convenience projection of this letter's Host identifiers, not a new route
|
|
60
|
+
// or permission. Never copy a sender-supplied reply object or invent send IDs.
|
|
61
|
+
function replyHint(message) {
|
|
62
|
+
if (message.automaticDeliveryState === "expired" || message.automationStatus === "expired") return {};
|
|
63
|
+
return { reply: { tool: "hellolynk_reply_authorized_message",
|
|
64
|
+
arguments: { localMessageId: message.localMessageId, conversationKey: message.conversationKey } } };
|
|
65
|
+
}
|
|
66
|
+
function addressingError(code) { return Object.assign(new Error(code), { code }); }
|
|
67
|
+
function fail() { throw addressingError("native_addressing_arguments_invalid"); }
|
|
68
|
+
function queryArgs(args = {}) {
|
|
69
|
+
if (!args || typeof args !== "object" || Array.isArray(args) || Object.keys(args).some(k => !Object.hasOwn(directoryProperties, k))) fail();
|
|
70
|
+
if (Object.keys(args).length === 0) return {};
|
|
71
|
+
for (const key of ["contactId", "recipientDelegateId"]) if (typeof args[key] !== "string" || !new RegExp(ID.pattern).test(args[key])) fail();
|
|
72
|
+
for (const [key, value] of Object.entries(args)) {
|
|
73
|
+
if (typeof value !== "string") fail();
|
|
74
|
+
const max = directoryProperties[key].maxLength;
|
|
75
|
+
// Only title/project use Unicode code points; keep other field limits unchanged.
|
|
76
|
+
const tooLong = key === "title" || key === "project"
|
|
77
|
+
? value.length > max * 2 || [...value].length > max
|
|
78
|
+
: value.length > max;
|
|
79
|
+
if (tooLong) fail();
|
|
80
|
+
}
|
|
81
|
+
if (args.targetConversationId != null) optionalTarget(args);
|
|
82
|
+
return { ...args };
|
|
83
|
+
}
|
|
84
|
+
function optionalTarget(args) {
|
|
85
|
+
if (Object.hasOwn(args, "targetConversationTitle")) {
|
|
86
|
+
const title = args.targetConversationTitle;
|
|
87
|
+
if (Object.hasOwn(args, "targetConversationId") || typeof title !== "string"
|
|
88
|
+
|| !title.trim() || title.includes("\0") || title.length > 512 || [...title].length > 256) fail();
|
|
89
|
+
return { targetConversationTitle: title.trim() };
|
|
90
|
+
}
|
|
91
|
+
if (!Object.hasOwn(args, "targetConversationId")) return {};
|
|
92
|
+
if (typeof args.targetConversationId !== "string" || !new RegExp(ID.pattern).test(args.targetConversationId)) fail();
|
|
93
|
+
return { targetConversationId: args.targetConversationId };
|
|
94
|
+
}
|
|
95
|
+
// A send names its recipient by `contact`, or by the exact contactId + recipientDelegateId pair.
|
|
96
|
+
function sendArgumentKeys(args) {
|
|
97
|
+
return ["clientMessageId", "text", ...(Object.hasOwn(args, "contact") ? ["contact"] : ["contactId", "recipientDelegateId"]),
|
|
98
|
+
...Object.keys(optionalTarget(args))];
|
|
99
|
+
}
|
|
100
|
+
// `contact` matches the person's or the AI's name exactly, ignoring case and outer spaces.
|
|
101
|
+
async function contactMatches(contact, listContacts) {
|
|
102
|
+
if (typeof contact !== "string" || !contact.trim() || contact.length > contactSchema.maxLength) fail();
|
|
103
|
+
const wanted = contact.trim().toLowerCase();
|
|
104
|
+
const result = await listContacts({});
|
|
105
|
+
if (!Array.isArray(result?.contacts)) throw addressingError("host_app_response_invalid");
|
|
106
|
+
return result.contacts.filter(item => [item?.displayName, item?.instanceDisplayName]
|
|
107
|
+
.some(name => typeof name === "string" && name.trim().toLowerCase() === wanted)).map(item => {
|
|
108
|
+
const candidate = { contactId: item.contactId, displayName: item.displayName, recipientDelegateId: item.recipientDelegateId, instanceDisplayName: item.instanceDisplayName };
|
|
109
|
+
if (Object.values(candidate).some(value => typeof value !== "string" || !value || value.length > 241 || /[\0-\x1f]/.test(value))) throw addressingError("host_app_response_invalid");
|
|
110
|
+
return candidate;
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
function onlyMatch(matches) {
|
|
114
|
+
if (!matches.length) throw addressingError("contact_not_found");
|
|
115
|
+
if (matches.length > 1) throw Object.assign(addressingError("contact_ambiguous"), { candidates: matches });
|
|
116
|
+
return { contactId: matches[0].contactId, recipientDelegateId: matches[0].recipientDelegateId };
|
|
117
|
+
}
|
|
118
|
+
// The IDs a send goes to. Resolving `contact` lists contacts and nothing more.
|
|
119
|
+
async function sendRecipient(args, listContacts) {
|
|
120
|
+
if (!Object.hasOwn(args, "contact")) return { contactId: args.contactId, recipientDelegateId: args.recipientDelegateId };
|
|
121
|
+
return onlyMatch(await contactMatches(args.contact, listContacts));
|
|
122
|
+
}
|
|
123
|
+
// The model's contact lookup. `contact` filters by name; with title, project, cursor or
|
|
124
|
+
// targetConversationId its one match is looked up in that AI's conversation directory.
|
|
125
|
+
async function findContacts(args, listContacts) {
|
|
126
|
+
if (!args || typeof args !== "object" || Array.isArray(args) || !Object.hasOwn(args, "contact")) return listContacts(queryArgs(args));
|
|
127
|
+
const { contact, ...query } = args;
|
|
128
|
+
if (Object.hasOwn(query, "contactId") || Object.hasOwn(query, "recipientDelegateId")) fail();
|
|
129
|
+
const matches = await contactMatches(contact, listContacts);
|
|
130
|
+
if (!Object.keys(query).length) {
|
|
131
|
+
if (!matches.length) throw addressingError("contact_not_found");
|
|
132
|
+
return { contacts: matches };
|
|
133
|
+
}
|
|
134
|
+
return listContacts(queryArgs({ ...onlyMatch(matches), ...query }));
|
|
135
|
+
}
|
|
136
|
+
// Describe the validated request after the send receipt has been accepted.
|
|
137
|
+
// This is the chosen route, not proof of native delivery or model execution.
|
|
138
|
+
function firstSendReceipt(args, status) {
|
|
139
|
+
const target = optionalTarget(args);
|
|
140
|
+
return {
|
|
141
|
+
destination: Object.keys(target).length ? { mode: "specified_conversation", ...target } : { mode: "independent_conversation" },
|
|
142
|
+
text: resultText.sent(status, args.clientMessageId),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
function nativeAddressingError(error, attempt = null) {
|
|
146
|
+
const reasons = {
|
|
147
|
+
__proto__: null,
|
|
148
|
+
model_inbox_context_unavailable: failureText("model_inbox_context_unavailable"),
|
|
149
|
+
model_inbox_other_conversation: failureText("model_inbox_other_conversation"),
|
|
150
|
+
model_inbox_owner_pending: failureText("model_inbox_owner_pending"),
|
|
151
|
+
model_inbox_unassigned: failureText("model_inbox_unassigned"),
|
|
152
|
+
model_inbox_scope_invalid: failureText("model_inbox_scope_invalid"),
|
|
153
|
+
model_inbox_unavailable: failureText("model_inbox_unavailable"),
|
|
154
|
+
native_directory_own_ai_only: "对话目录只供本账号查询。给其他账号的 AI 指定对话时,请在发送工具里填完整标题。",
|
|
155
|
+
native_target_not_authorized: "没有向这个指定对话发送的权限。",
|
|
156
|
+
native_directory_unavailable: "这个 AI 的对话目录当前不可用,接收端可能需要更新或重新打开。",
|
|
157
|
+
e2ee_peer_identity_pin_mismatch: "接收端设备身份与连接记录不一致,需重新连接 HelloLynk。",
|
|
158
|
+
recipient_e2ee_device_offline: "接收端当前未连接,请本人打开对应客户端。",
|
|
159
|
+
e2ee_prekey_claim_rate_limited: "这两端的加密准备名额暂时已用满,需要等待释放,不要连续重试。",
|
|
160
|
+
relay_request_body_too_large: "中继入口拒绝了过大的请求,正文未发送。",
|
|
161
|
+
native_target_unsupported_or_unavailable: "这个 AI 当前不支持指定原对话,或接收入口不可用。",
|
|
162
|
+
native_target_unavailable: "指定对话已归档、删除或当前不能接收。",
|
|
163
|
+
native_target_title_not_unique: "未发出:接收端没有找到唯一可用的同名对话。请核对完整标题;对方客户端没打开时也会这样。",
|
|
164
|
+
native_target_title_required: "未发出:给其他账号的 AI 指定对话时,请填完整标题。",
|
|
165
|
+
native_addressing_arguments_invalid: "联系人可填名字,或同时填联系人和 AI 的编号。指定对话时只填完整标题或对话编号其中一项。",
|
|
166
|
+
native_directory_cursor_invalid: "目录筛选或游标不匹配,请去掉 cursor 重新查询。",
|
|
167
|
+
native_directory_query_invalid: "目录查询参数无效,请只使用工具列出的筛选字段。",
|
|
168
|
+
native_conversation_id_invalid: "对话编号无效,请使用目录或本人提供的准确编号。",
|
|
169
|
+
contact_not_found: "没有叫这个名字的已授权联系人。",
|
|
170
|
+
contact_ambiguous: "匹配到多个 AI,请在 candidates 里选一个,用 contactId 和 recipientDelegateId 重新调用。",
|
|
171
|
+
};
|
|
172
|
+
const code = error?.code, reason = reasons[code];
|
|
173
|
+
// Recipient arguments are checked before anything is sent, so the same call can be corrected and repeated.
|
|
174
|
+
const corrected = Boolean(attempt) && ["input_validation", "preparation"].includes(attempt.phase)
|
|
175
|
+
&& ["contact_not_found", "contact_ambiguous", "native_addressing_arguments_invalid"].includes(code);
|
|
176
|
+
if (!reason || (attempt && !corrected && attempt.phase !== "adapter_call" && !code.startsWith("model_inbox_"))) return null;
|
|
177
|
+
return { content: [{ type: "text", text: reason }],
|
|
178
|
+
structuredContent: { ok: false, errorCode: code, ...(attempt ? { status: "not_sent", phase: corrected ? "input_validation" : attempt.phase,
|
|
179
|
+
...(attempt.clientMessageId ? { clientMessageId: attempt.clientMessageId } : {}), automaticRetryAllowed: corrected || (code === "model_inbox_owner_pending" && attempt.phase === "preparation") } : {}),
|
|
180
|
+
...(code === "contact_ambiguous" ? { candidates: error.candidates } : {}) }, isError: true };
|
|
181
|
+
}
|
|
182
|
+
// What a failed send or reply means. The phase comes only from this invocation:
|
|
183
|
+
// input_validation checks arguments, preparation runs before the adapter is called.
|
|
184
|
+
function writeFailure(code, phase) {
|
|
185
|
+
const called = phase === "adapter_call" || phase === "response_validation";
|
|
186
|
+
const outcome = (status, automaticRetryAllowed, text) => ({ status, automaticRetryAllowed, text });
|
|
187
|
+
if (phase === "adapter_call" && code === "host_app_authorization_required") return outcome("not_delivered", false, `未送达:尚未获准向此接收方发送。${ALLOW_PATH}`);
|
|
188
|
+
if (phase === "adapter_call" && code === "host_app_record_not_found") return outcome("not_delivered", false, "未送达:联系人、接收方或原消息已不存在。");
|
|
189
|
+
if (called && code === "host_app_reply_target_deleted") return outcome("not_delivered", false, REPLY_TARGET_DELETED_TEXT);
|
|
190
|
+
// Raised only while connecting to the local Host, before anything is sent.
|
|
191
|
+
if (code === "neutral_host_release_superseded") return outcome("not_sent", false, RELEASE_SUPERSEDED_TEXT);
|
|
192
|
+
if (code === "powershell7_missing") return outcome(called ? "send_failed_or_unknown" : "not_sent", false, POWERSHELL7_MISSING_TEXT);
|
|
193
|
+
if (phase === "input_validation") return outcome("not_sent", true, `参数无效:${code}。改正后重新调用,可沿用同一 clientMessageId。`);
|
|
194
|
+
if (!called) return outcome("not_sent", false, failureText(code));
|
|
195
|
+
return outcome("send_failed_or_unknown", false, `发送结果未知:${code}。请在 HelloLynk 通讯里看这条消息的状态,确认没发出再重发。`);
|
|
196
|
+
}
|
|
197
|
+
function failureText(code) {
|
|
198
|
+
return { __proto__: null,
|
|
199
|
+
model_inbox_context_unavailable: "无法确定本次调用所在的对话,尚未查信或处理来信。请在客户端原对话中重试。",
|
|
200
|
+
model_inbox_other_conversation: "这封来信属于其他对话,请回到对应对话处理。查看全部来信请打开 HelloLynk 界面。",
|
|
201
|
+
model_inbox_owner_pending: "来信的对话归属正在记录,尚未领取或发送。请稍后沿用原参数重试本次调用;回信沿用同一个 clientMessageId,不要重新发起一条消息。",
|
|
202
|
+
model_inbox_unassigned: "这封来信缺少可确认的对话归属,尚未领取或发送。",
|
|
203
|
+
model_inbox_scope_invalid: "查信只限当前对话,无需选择范围。查看全部来信请打开 HelloLynk 界面。",
|
|
204
|
+
model_inbox_unavailable: "当前客户端尚未接入按对话查信,请在统一更新后重试。",
|
|
205
|
+
host_app_reply_target_deleted: REPLY_TARGET_DELETED_TEXT,
|
|
206
|
+
host_native_inbox_upgrade_required: "本机 HelloLynk 版本过低。请关闭所有接入 HelloLynk 的客户端,再重新打开。",
|
|
207
|
+
neutral_host_release_superseded: RELEASE_SUPERSEDED_TEXT,
|
|
208
|
+
powershell7_missing: POWERSHELL7_MISSING_TEXT,
|
|
209
|
+
}[code] ?? `HelloLynk 操作失败:${code}。`;
|
|
210
|
+
}
|
|
211
|
+
function nativeTool(tool) {
|
|
212
|
+
const named = { ...tool, ...toolText[tool.name] };
|
|
213
|
+
const scoped = ["hellolynk_get_unread_count", "hellolynk_list_authorized_inbox_messages",
|
|
214
|
+
"hellolynk_read_authorized_inbox_message", "hellolynk_list_pending_messages"].includes(tool.name);
|
|
215
|
+
if (scoped) {
|
|
216
|
+
named.inputSchema = { ...tool.inputSchema, properties: { ...tool.inputSchema.properties,
|
|
217
|
+
...(tool.name === "hellolynk_list_pending_messages" ? { cursor: { type: "integer", minimum: 0, description: "上一页返回的 nextCursor。" } } : {}),
|
|
218
|
+
} };
|
|
219
|
+
if (named.outputSchema?.properties && !named.outputSchema.properties.contract) named.outputSchema = { ...named.outputSchema, properties: {
|
|
220
|
+
...named.outputSchema.properties, scope: { type: "string", const: "current" },
|
|
221
|
+
humanUnreadCount: { type: "integer", minimum: 0 }, aiPendingCount: { type: "integer", minimum: 0 }, nextCursor: { type: "integer", minimum: 0 },
|
|
222
|
+
} };
|
|
223
|
+
}
|
|
224
|
+
if (tool.name === "hellolynk_list_authorized_contacts") return { ...named, inputSchema: { type: "object", additionalProperties: false, required: [], properties: queryProperties } };
|
|
225
|
+
if (tool.name !== "hellolynk_send_authorized_message") return named;
|
|
226
|
+
return { ...named, inputSchema: { ...tool.inputSchema, required: ["clientMessageId", "text"],
|
|
227
|
+
properties: { contact: contactSchema, ...tool.inputSchema.properties, targetConversationId: targetSchema, targetConversationTitle: titleSchema } } };
|
|
228
|
+
}
|
|
229
|
+
module.exports = { queryArgs, optionalTarget, nativeTool, queryProperties, queryDescription, targetSchema, nativeAddressingError,
|
|
230
|
+
conversationGuidance, replyHint, replyHintSchema, firstSendReceipt, toolText, paramText, resultText, sendArgumentKeys,
|
|
231
|
+
sendRecipient, findContacts, writeFailure, failureText };
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
// This framing lives inside the existing E2EE ciphertext, never its public
|
|
6
|
+
// authenticated header. Ordinary text that starts with the marker is escaped.
|
|
7
|
+
// Older receivers reject unknown control kinds. A friend send requires a
|
|
8
|
+
// matching reply to its control request before it can transmit a message.
|
|
9
|
+
const PREFIX = "HelloLynk/native-content/1\n";
|
|
10
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{2,240}$/;
|
|
11
|
+
const REGION = { international: "intl", intl: "intl", china: "cn", cn: "cn" };
|
|
12
|
+
const error = code => Object.assign(new Error(code), { code });
|
|
13
|
+
export function nativeConversationId(value) {
|
|
14
|
+
if (typeof value !== "string" || !ID.test(value)) throw error("native_conversation_id_invalid");
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
export function nativeConversationTitle(value) {
|
|
18
|
+
if (typeof value !== "string" || value.length > 512 || [...value].length > 256
|
|
19
|
+
|| !value.trim() || value.includes("\0")) throw error("native_conversation_title_invalid");
|
|
20
|
+
return value.trim();
|
|
21
|
+
}
|
|
22
|
+
// Exact titles still compare whole titles, ignoring case and outer spaces.
|
|
23
|
+
// Spacing is the one tolerated difference: a run of spaces counts as one, and
|
|
24
|
+
// a space where Chinese or Japanese text meets Latin letters or digits is
|
|
25
|
+
// dropped, so "通过 HELLOLYNK 联系人" equals "通过HELLOLYNK联系人". Spaces
|
|
26
|
+
// between Latin words, or between two Chinese characters, still count.
|
|
27
|
+
const IDEOGRAPHIC = "\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}";
|
|
28
|
+
const ALPHANUMERIC = "\\p{Script=Latin}0-9";
|
|
29
|
+
const BOUNDARY_SPACE = new RegExp(`(?<=[${IDEOGRAPHIC}]) (?=[${ALPHANUMERIC}])|(?<=[${ALPHANUMERIC}]) (?=[${IDEOGRAPHIC}])`, "gu");
|
|
30
|
+
export function nativeTitleMatchKey(value) {
|
|
31
|
+
return value.normalize("NFC").trim().replace(/\s+/gu, " ").replace(BOUNDARY_SPACE, "").toLowerCase();
|
|
32
|
+
}
|
|
33
|
+
export function nativeConversationQuery(input = {}) {
|
|
34
|
+
if (!input || typeof input !== "object" || Array.isArray(input)
|
|
35
|
+
|| Object.keys(input).some(key => !["title", "project", "cursor", "targetConversationId", "exactTitle"].includes(key)))
|
|
36
|
+
throw error("native_directory_query_invalid");
|
|
37
|
+
if (Object.hasOwn(input, "exactTitle")) {
|
|
38
|
+
if (input.exactTitle !== true || Object.keys(input).length !== 2) throw error("native_directory_query_invalid");
|
|
39
|
+
return { title: nativeConversationTitle(input.title), exactTitle: true };
|
|
40
|
+
}
|
|
41
|
+
const result = {};
|
|
42
|
+
for (const key of ["title", "project"]) if (input[key] != null) {
|
|
43
|
+
// A code point uses at most two UTF-16 units; bound work before counting.
|
|
44
|
+
if (typeof input[key] !== "string" || input[key].length > 512 || [...input[key]].length > 256) throw error("native_directory_query_invalid");
|
|
45
|
+
result[key] = input[key].trim();
|
|
46
|
+
}
|
|
47
|
+
if (input.targetConversationId != null) result.targetConversationId = nativeConversationId(input.targetConversationId);
|
|
48
|
+
if (input.cursor != null) {
|
|
49
|
+
if (typeof input.cursor !== "string" || !/^[a-zA-Z0-9_-]{1,512}$/.test(input.cursor)) throw error("native_directory_cursor_invalid");
|
|
50
|
+
result.cursor = input.cursor;
|
|
51
|
+
}
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
export function encodeNativeContent(input) {
|
|
55
|
+
const { text, targetConversationId, nativeDirectory } = input;
|
|
56
|
+
if (targetConversationId != null && (nativeDirectory != null || input.replyToClientMessageId != null))
|
|
57
|
+
throw error("native_target_conflicts_with_reply");
|
|
58
|
+
if (nativeDirectory != null) {
|
|
59
|
+
if (text !== "" || input.replyToClientMessageId != null) throw error("native_directory_content_invalid");
|
|
60
|
+
return PREFIX + JSON.stringify({ nativeDirectory: directoryControl(nativeDirectory) });
|
|
61
|
+
}
|
|
62
|
+
if (targetConversationId != null || text.startsWith(PREFIX)) return PREFIX + JSON.stringify(
|
|
63
|
+
targetConversationId == null ? {} : { targetConversationId: nativeConversationId(targetConversationId) }) + "\n" + text;
|
|
64
|
+
return text;
|
|
65
|
+
}
|
|
66
|
+
export function decodeNativeContent(text) {
|
|
67
|
+
if (!text.startsWith(PREFIX)) return { text };
|
|
68
|
+
try { return decodeNativeFrame(text); }
|
|
69
|
+
catch { return { text: "", rejectedNativeContent: true }; }
|
|
70
|
+
}
|
|
71
|
+
function decodeNativeFrame(text) {
|
|
72
|
+
const framed = text.slice(PREFIX.length), separator = framed.indexOf("\n");
|
|
73
|
+
let content;
|
|
74
|
+
try { content = JSON.parse(separator < 0 ? framed : framed.slice(0, separator)); } catch { throw error("native_content_invalid"); }
|
|
75
|
+
if (!content || typeof content !== "object" || Array.isArray(content)) throw error("native_content_invalid");
|
|
76
|
+
if (Object.hasOwn(content, "nativeDirectory")) {
|
|
77
|
+
if (separator >= 0 || Object.keys(content).length !== 1) throw error("native_content_invalid");
|
|
78
|
+
return { text: "", nativeDirectory: directoryControl(content.nativeDirectory) };
|
|
79
|
+
}
|
|
80
|
+
const body = separator < 0 ? "" : framed.slice(separator + 1);
|
|
81
|
+
if (!body.length || Buffer.byteLength(body, "utf8") > 20 * 1024
|
|
82
|
+
|| Object.keys(content).some(k => k !== "targetConversationId"))
|
|
83
|
+
throw error("native_content_invalid");
|
|
84
|
+
return { text: body, ...(content.targetConversationId == null ? {}
|
|
85
|
+
: { targetConversationId: nativeConversationId(content.targetConversationId) }) };
|
|
86
|
+
}
|
|
87
|
+
function directoryControl(value) {
|
|
88
|
+
if (value?.kind === "friend_target_request") {
|
|
89
|
+
if (Object.keys(value).some(key => !["kind", "requestId", "clientMessageId", "title"].includes(key)))
|
|
90
|
+
throw error("native_directory_content_invalid");
|
|
91
|
+
return { kind: value.kind, requestId: nativeConversationId(value.requestId),
|
|
92
|
+
clientMessageId: nativeConversationId(value.clientMessageId), title: nativeConversationTitle(value.title) };
|
|
93
|
+
}
|
|
94
|
+
if (value?.kind === "friend_target_response") {
|
|
95
|
+
if (Object.keys(value).some(key => !["kind", "requestId", "targetConversationId"].includes(key)))
|
|
96
|
+
throw error("native_directory_content_invalid");
|
|
97
|
+
const requestId = nativeConversationId(value.requestId);
|
|
98
|
+
return { kind: value.kind, requestId,
|
|
99
|
+
targetConversationId: value.targetConversationId === null ? null : nativeConversationId(value.targetConversationId) };
|
|
100
|
+
}
|
|
101
|
+
if (!value || !["request", "response"].includes(value.kind)
|
|
102
|
+
|| Object.keys(value).some(key => !["kind", "requestId", value.kind === "request" ? "query" : "result"].includes(key)))
|
|
103
|
+
throw error("native_directory_content_invalid");
|
|
104
|
+
const requestId = nativeConversationId(value.requestId);
|
|
105
|
+
if (value.kind === "request") return { kind: "request", requestId, query: nativeConversationQuery(value.query) };
|
|
106
|
+
return { kind: "response", requestId, result: nativeDirectoryResult(value.result) };
|
|
107
|
+
}
|
|
108
|
+
function textField(value, max = 256) { return typeof value === "string" ? value.slice(0, max) : ""; }
|
|
109
|
+
export function nativeConversationEntry(value) {
|
|
110
|
+
// Explicit projection: SDK previews, summaries, prompts and paths never leave
|
|
111
|
+
// the adapter through this catalog, even if a native list supplies them.
|
|
112
|
+
return { targetConversationId: nativeConversationId(value.targetConversationId), title: textField(value.title),
|
|
113
|
+
project: textField(value.project), state: ["idle", "active", "notLoaded", "archived", "deleted", "unavailable"].includes(value.state)
|
|
114
|
+
? value.state : "unavailable" };
|
|
115
|
+
}
|
|
116
|
+
export function nativeDirectoryResult(value) {
|
|
117
|
+
if (!value || !Array.isArray(value.conversations) || value.conversations.length > 20)
|
|
118
|
+
throw error("native_directory_result_invalid");
|
|
119
|
+
const coverage = ["complete", "partial", "unavailable"].includes(value.coverage) ? value.coverage : "unavailable";
|
|
120
|
+
const result = { conversations: value.conversations.map(nativeConversationEntry), coverage,
|
|
121
|
+
available: value.available === true, exactTargetSupported: value.exactTargetSupported === true,
|
|
122
|
+
observedAt: typeof value.observedAt === "string" && Number.isFinite(Date.parse(value.observedAt)) ? value.observedAt : null,
|
|
123
|
+
nextCursor: value.nextCursor == null ? null : nativeConversationQuery({ cursor: value.nextCursor }).cursor };
|
|
124
|
+
if (value.reason && /^[a-z][a-z0-9_]{2,100}$/.test(value.reason)) result.reason = value.reason;
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
export function nativeConversationDirectoryLocation(scope) {
|
|
128
|
+
if (!path.isAbsolute(scope?.runtimeRoot || "") || !REGION[scope.region]) throw error("native_directory_scope_invalid");
|
|
129
|
+
const owner = [REGION[scope.region], ...["principalId", "delegateId", "adapterId"].map(k => nativeConversationId(scope[k]))];
|
|
130
|
+
const hash = crypto.createHash("sha256").update(JSON.stringify(owner)).digest("hex");
|
|
131
|
+
return { filename: path.join(scope.runtimeRoot, "native-conversations", hash + ".json"), owner };
|
|
132
|
+
}
|
|
133
|
+
const location = nativeConversationDirectoryLocation;
|
|
134
|
+
async function read(file) { try { return JSON.parse(await fs.readFile(file, "utf8")); } catch (e) { if (["ENOENT", "EPERM", "EBUSY"].includes(e.code)) return null; throw e; } }
|
|
135
|
+
async function atomic(file, value) {
|
|
136
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
137
|
+
const temporary = file + "." + crypto.randomUUID() + ".tmp";
|
|
138
|
+
try { await fs.writeFile(temporary, JSON.stringify(value), { flag: "wx" }); await fs.rename(temporary, file); }
|
|
139
|
+
finally { await fs.rm(temporary, { force: true }).catch(() => {}); }
|
|
140
|
+
}
|
|
141
|
+
export async function readNativeConversationDirectory(scope) {
|
|
142
|
+
const { filename, owner } = location(scope), value = await read(filename);
|
|
143
|
+
if (value && JSON.stringify(value.owner) !== JSON.stringify(owner)) throw error("native_directory_scope_mismatch");
|
|
144
|
+
return value;
|
|
145
|
+
}
|
|
146
|
+
export async function publishNativeConversationDirectory(scope, value) {
|
|
147
|
+
const { filename, owner } = location(scope);
|
|
148
|
+
const conversations = [...new Map(value.conversations.map(nativeConversationEntry).map(item => [item.targetConversationId, item])).values()];
|
|
149
|
+
await atomic(filename, { owner, conversations, coverage: value.coverage,
|
|
150
|
+
exactTargetSupported: value.exactTargetSupported === true, observedAt: new Date().toISOString(),
|
|
151
|
+
requestId: value.requestId || null });
|
|
152
|
+
}
|
|
153
|
+
export function filterNativeConversations(snapshot, query = {}, available = false) {
|
|
154
|
+
query = nativeConversationQuery(query);
|
|
155
|
+
if (query.exactTitle) {
|
|
156
|
+
const supported = available && snapshot?.exactTargetSupported === true
|
|
157
|
+
&& (snapshot.coverage === "complete" || snapshot.exactTitleSupported === true);
|
|
158
|
+
const wanted = nativeTitleMatchKey(query.title);
|
|
159
|
+
// Archived or deleted chats cannot receive, so they neither match nor make
|
|
160
|
+
// a usable title ambiguous. A chat in an unknown state still counts.
|
|
161
|
+
const matches = supported ? snapshot.conversations.filter(row => typeof row.title === "string"
|
|
162
|
+
&& row.title !== row.targetConversationId && !["archived", "deleted"].includes(row.state)
|
|
163
|
+
&& nativeTitleMatchKey(row.title) === wanted) : [];
|
|
164
|
+
return nativeDirectoryResult({ conversations: matches.length === 1 ? matches : [],
|
|
165
|
+
available: Boolean(supported), exactTargetSupported: Boolean(supported),
|
|
166
|
+
coverage: supported ? "complete" : "unavailable", observedAt: snapshot?.observedAt });
|
|
167
|
+
}
|
|
168
|
+
const signature = crypto.createHash("sha256").update(JSON.stringify([query.title || "", query.project || "", query.targetConversationId || ""])).digest("hex").slice(0, 16);
|
|
169
|
+
let after = "";
|
|
170
|
+
if (query.cursor) {
|
|
171
|
+
let cursor; try { cursor = JSON.parse(Buffer.from(query.cursor, "base64url").toString("utf8")); } catch { throw error("native_directory_cursor_invalid"); }
|
|
172
|
+
if (!Array.isArray(cursor) || cursor[0] !== signature) throw error("native_directory_cursor_invalid");
|
|
173
|
+
after = nativeConversationId(cursor[1]);
|
|
174
|
+
}
|
|
175
|
+
if (!snapshot) return nativeDirectoryResult({ conversations: [], coverage: "unavailable", available: false,
|
|
176
|
+
exactTargetSupported: false, reason: "native_directory_unavailable" });
|
|
177
|
+
const rows = snapshot.conversations.filter(row => (!query.targetConversationId || row.targetConversationId === query.targetConversationId)
|
|
178
|
+
&& (!query.title || row.title.toLocaleLowerCase().includes(query.title.toLocaleLowerCase()))
|
|
179
|
+
&& (!query.project || row.project.toLocaleLowerCase().includes(query.project.toLocaleLowerCase())))
|
|
180
|
+
.sort((a, b) => a.targetConversationId.localeCompare(b.targetConversationId));
|
|
181
|
+
const page = rows.filter(row => !after || row.targetConversationId.localeCompare(after) > 0).slice(0, 21);
|
|
182
|
+
let count = Math.min(page.length, 20), result;
|
|
183
|
+
do {
|
|
184
|
+
result = nativeDirectoryResult({ conversations: page.slice(0, count), coverage: snapshot.coverage, available,
|
|
185
|
+
exactTargetSupported: snapshot.exactTargetSupported, observedAt: snapshot.observedAt,
|
|
186
|
+
nextCursor: page.length > count ? Buffer.from(JSON.stringify([signature, page[count - 1].targetConversationId])).toString("base64url") : null });
|
|
187
|
+
// Keep an encrypted directory page within the same edge budget as a user
|
|
188
|
+
// message. Large escaped titles reduce this page, not the full directory.
|
|
189
|
+
} while (Buffer.byteLength(JSON.stringify(result), "utf8") > 20 * 1024 && --count > 0);
|
|
190
|
+
return result;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Transient requests are serviced together by the existing receiver timer.
|
|
194
|
+
// Separate replies avoid one exact lookup overwriting another; no model runs.
|
|
195
|
+
export async function queryLocalNativeConversations(scope, input, options = {}) {
|
|
196
|
+
const query = nativeConversationQuery(input), { filename } = location(scope);
|
|
197
|
+
filterNativeConversations(null, query); // Reject mismatched cursors before creating a request.
|
|
198
|
+
const requestId = "directory_" + crypto.randomUUID();
|
|
199
|
+
const requestFile = path.join(filename + ".requests", requestId + ".json"), responseFile = requestFile + ".response";
|
|
200
|
+
const deadline = Date.now() + (options.waitMs ?? 6500);
|
|
201
|
+
await atomic(requestFile, { requestId, query, deadline });
|
|
202
|
+
try {
|
|
203
|
+
do {
|
|
204
|
+
const result = await read(responseFile);
|
|
205
|
+
if (result) return nativeDirectoryResult(result);
|
|
206
|
+
if (Date.now() >= deadline) break;
|
|
207
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
208
|
+
} while (true);
|
|
209
|
+
return filterNativeConversations(await readNativeConversationDirectory(scope), query, false);
|
|
210
|
+
} finally {
|
|
211
|
+
await fs.rm(requestFile, { force: true }).catch(() => {});
|
|
212
|
+
await fs.rm(responseFile, { force: true }).catch(() => {});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
export function createNativeDirectoryPublisher(native) {
|
|
216
|
+
let currentOwner = null, flight, cacheWrite = Promise.resolve();
|
|
217
|
+
async function publish(scope, stillCurrent) {
|
|
218
|
+
if (typeof native?.listConversations !== "function") return;
|
|
219
|
+
const { filename } = location(scope), directory = filename + ".requests";
|
|
220
|
+
const names = await fs.readdir(directory).catch(e => { if (e.code === "ENOENT") return []; throw e; });
|
|
221
|
+
const requests = names.filter(name => /^directory_[a-f0-9-]{36}\.json$/.test(name));
|
|
222
|
+
async function collect(query) {
|
|
223
|
+
const value = await native.listConversations(query, scope);
|
|
224
|
+
if (!await stillCurrent()) return null;
|
|
225
|
+
// Match the full live title before projecting or truncating metadata.
|
|
226
|
+
if (query.exactTitle) return filterNativeConversations(value, query, true);
|
|
227
|
+
// Native reads are independent. Serialize the short shared-cache rename
|
|
228
|
+
// because Windows can reject simultaneous replacement of one file.
|
|
229
|
+
cacheWrite = cacheWrite.catch(() => {}).then(async () => {
|
|
230
|
+
if (!await stillCurrent()) return false;
|
|
231
|
+
if (!query.targetConversationId || value.coverage === "complete") return publishNativeConversationDirectory(scope, value);
|
|
232
|
+
const prior = await readNativeConversationDirectory(scope);
|
|
233
|
+
if (!prior) return; // A partial answer cannot replace an unreadable cache.
|
|
234
|
+
if (!await stillCurrent()) return false;
|
|
235
|
+
// An exact lookup must not replace the whole offline catalog with one row.
|
|
236
|
+
return publishNativeConversationDirectory(scope, { ...value, coverage: prior?.coverage ?? value.coverage,
|
|
237
|
+
conversations: [...(prior?.conversations || []).filter(row => row.targetConversationId !== query.targetConversationId),
|
|
238
|
+
...value.conversations.map(row => {
|
|
239
|
+
const cached = prior?.conversations.find(item => item.targetConversationId === row.targetConversationId);
|
|
240
|
+
// Lifecycle-only replies do not erase a previously observed name/project.
|
|
241
|
+
return cached && row.title === row.targetConversationId && !row.project ? { ...cached, state: row.state } : row;
|
|
242
|
+
})] });
|
|
243
|
+
});
|
|
244
|
+
if ((await cacheWrite) === false) return null;
|
|
245
|
+
return filterNativeConversations({ ...value, conversations: value.conversations.map(nativeConversationEntry),
|
|
246
|
+
observedAt: new Date().toISOString() }, query, true);
|
|
247
|
+
}
|
|
248
|
+
if (filename !== currentOwner) {
|
|
249
|
+
if (!await stillCurrent()) return;
|
|
250
|
+
currentOwner = filename;
|
|
251
|
+
// Cancellation belongs to the old receiver lease, not a completed first
|
|
252
|
+
// snapshot. Retry when current again; ordinary failures still scan once.
|
|
253
|
+
if (!requests.length && await collect({}) === null) currentOwner = null;
|
|
254
|
+
}
|
|
255
|
+
await Promise.all(requests.map(async name => {
|
|
256
|
+
const requestFile = path.join(directory, name), responseFile = requestFile + ".response";
|
|
257
|
+
const request = await read(requestFile);
|
|
258
|
+
if (!request) return;
|
|
259
|
+
if (request.deadline < Date.now()) {
|
|
260
|
+
await fs.rm(requestFile, { force: true }).catch(() => {});
|
|
261
|
+
await fs.rm(responseFile, { force: true }).catch(() => {}); return;
|
|
262
|
+
}
|
|
263
|
+
if (await read(responseFile)) return;
|
|
264
|
+
const query = nativeConversationQuery(request.query);
|
|
265
|
+
let result;
|
|
266
|
+
try { result = await collect(query); }
|
|
267
|
+
catch { result = filterNativeConversations(await readNativeConversationDirectory(scope), query, false); }
|
|
268
|
+
if (!result || !await stillCurrent() || !await read(requestFile)) return;
|
|
269
|
+
if (!await stillCurrent()) return;
|
|
270
|
+
await atomic(responseFile, result);
|
|
271
|
+
// A query may have timed out during the native read/write.
|
|
272
|
+
if (!await read(requestFile)) await fs.rm(responseFile, { force: true }).catch(() => {});
|
|
273
|
+
}));
|
|
274
|
+
}
|
|
275
|
+
return (scope, stillCurrent = async () => true) => {
|
|
276
|
+
if (flight) return flight;
|
|
277
|
+
flight = publish(scope, stillCurrent).finally(() => { flight = null; });
|
|
278
|
+
return flight;
|
|
279
|
+
};
|
|
280
|
+
}
|