@finddatatechnology/yongyi-client 0.0.0-stage → 0.1.1

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/README.md CHANGED
@@ -1,3 +1,120 @@
1
- # Temporary Holding Version
1
+ # @finddatatechnology/yongyi-client
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 永忆(YongYi / fd-memory)客户端 SDK:把壹座与 agent-runner 里经过旗舰验证的接入件下放为独立 npm 包,任何 harness 集成方与自有产品条线拿到同一套接入件,消灭跨仓漂移拷贝。
4
+
5
+ **零运行时依赖**——只用 Node 内置 fetch 打永忆内核的 HTTP 面(`/digest`、`/pages`、`/admin/mint`、`/observe`),与内核之间是网络契约,不是代码契约。
6
+
7
+ - 模块:`ladder`(注入阶梯)、`discipline`(纪律文本单源)、`facts`(五类事实捕获)、`tokens`(令牌铸造缓存)、`observe`(/observe 上报)、`http`(出站死线包装)。
8
+ - 契约:降级开放——注入与捕获的任何失败都以缺席告终,绝不向宿主回合抛错;铸造失败则原样抛给调用方计数处理,绝不静默假装成功。
9
+ - 字节同源:注入块与壹座现行输出逐字节一致(金样固化于 `test/goldens/`,由 paas 现役实现实跑产出)。
10
+
11
+ ```bash
12
+ npm install @finddatatechnology/yongyi-client
13
+ ```
14
+
15
+ ## ladder — 注入阶梯
16
+
17
+ digest 面优先(服务端渲染块),失败降级标题列表,再降级缺席;digest 读死线默认 9000ms(必须高于内核 embed 预算帽 8000ms)。
18
+
19
+ ```js
20
+ import { clusterMemoryFor, memoryInjectionFor, chatMemoryInjectionFor } from "@finddatatechnology/yongyi-client";
21
+
22
+ const env = {
23
+ CELL_MEMORY_ENABLED: "1",
24
+ CELL_MEMORY_URL: "http://127.0.0.1:8080",
25
+ CELL_MEMORY_PLATFORM_TOKEN: "<平台令牌>",
26
+ CELL_MEMORY_DATA: "/var/lib/my-harness", // 令牌磁盘缓存根(0600)
27
+ };
28
+ const cm = await clusterMemoryFor(env, "owner@example.com");
29
+ if (cm) {
30
+ // 集群车道:prompt 追加记忆块;source ∈ digest | titles | none
31
+ const { prompt, source } = await memoryInjectionFor(cm, "cluster-42", userPrompt, { k: 5, budget: 1500 });
32
+ // 聊天车道:项目固定 default;CELL_MEMORY_CHAT_DIGEST=0 为总开关(经 opts.env 传入)
33
+ const chat = await chatMemoryInjectionFor(cm, userPrompt, { env });
34
+ }
35
+ ```
36
+
37
+ 任何失败(服务不可达、digest 超时)都返回空块与 `source: "none"`,prompt 保持未注入形态。
38
+
39
+ ## discipline — 纪律文本单源
40
+
41
+ 双语(zh + en)纪律文本的单源导出。消费者经本接口取文本用于自己的 persona 注入,不得手抄副本;文本更新随 SDK 版本发布。
42
+
43
+ ```js
44
+ import { MEMORY_DISCIPLINE } from "@finddatatechnology/yongyi-client";
45
+ personaSuffix += MEMORY_DISCIPLINE; // 与壹座现行注入逐字节一致
46
+ ```
47
+
48
+ ## facts — 五类事实捕获
49
+
50
+ 封闭五类:`turn`、`health` 落成员 own 分区(`d/<pack>/<member>`);`decision-opened`、`decision-resolved`、`swap` 落团队 shared 分区。每页携带一等 metadata `{fleetId, memberId, kind}`;进程内有界幂等去重窗(512,键含内容);未知类型计失败而非误落分区。
51
+
52
+ ```js
53
+ import { fleetMemoryFor, createFleetMemoryCapture, createHealthFlipTracker } from "@finddatatechnology/yongyi-client";
54
+
55
+ const fm = await fleetMemoryFor(env, "owner@example.com");
56
+ if (fm) {
57
+ const capture = createFleetMemoryCapture({ memory: fm });
58
+ const healthFlip = createHealthFlipTracker();
59
+ if (healthFlip("fleet-9", "writer-a", "failed")) {
60
+ await capture("fleet-9", "writer-a", "health", "writer-a 开始失败", "digest HTTP 503 ×3");
61
+ }
62
+ await capture("fleet-9", "writer-a", "decision-resolved", "采用方案 B", "理由……");
63
+ // capture 失败只进计数器:capture.failures / capture.captured
64
+ }
65
+ ```
66
+
67
+ ## tokens — 令牌铸造与缓存
68
+
69
+ 经平台令牌铸造 person(`u/`)与 deployment(`d/<pack>/<member>`)令牌;磁盘缓存 0600、重启稳定、同身份只铸一次(namespace 校验后复用)。
70
+
71
+ ```js
72
+ import { cellMemoryConfig, personTokenFor, memberTokenFor, memoryMcpEntry } from "@finddatatechnology/yongyi-client";
73
+
74
+ const cfg = cellMemoryConfig(env); // 四件套缺一即 null(整体关闭)
75
+ const person = await personTokenFor(cfg, "owner@example.com"); // 失败 → throw,调用方计数
76
+ const member = await memberTokenFor(cfg, { packId: "fleet-9", memberId: "writer-a", ownerEmail: "owner@example.com" });
77
+ const mcpEntry = memoryMcpEntry(cfg.url, person); // dsh-mcp-client 加载行(http 分支)
78
+ ```
79
+
80
+ ## observe — /observe 上报
81
+
82
+ 事件投递到内核 `POST /observe`(封闭集 `turn_outcome | tool_call | health_flip`)。幂等键缺省自动 `crypto.randomUUID()`;`observeFireAndForget` 失败即丢、无重试、有界超时(默认 10s),永不阻塞宿主。
83
+
84
+ ```js
85
+ import { observeFireAndForget, observeOnce } from "@finddatatechnology/yongyi-client";
86
+
87
+ observeFireAndForget({
88
+ url: "http://127.0.0.1:8080", token: person,
89
+ type: "turn_outcome", title: "回合收尾", body: "完成 X,下一步 Y",
90
+ harness: "claude-code", // 来源标注(跨 harness 合流)
91
+ });
92
+ // 要结果(如 hook 转发器收尾前确认)用 await observeOnce(...)
93
+ ```
94
+
95
+ ## 挂载行形状(参照)
96
+
97
+ dsh / ACP 载体的记忆挂载行形状(见 `paas/agent-runner/memory-mount.js` 的 `dshMemoryMount` / `acpMemoryMount`):
98
+
99
+ ```js
100
+ // dsh:dsh-mcp-client 加载行
101
+ { id: "mcp-fd-memory", name: "@deepseek-ai/dsh-mcp-client",
102
+ config: { serverName: "fd-memory", failOnStartupError: false, transport: "streamable-http",
103
+ url: "<url>/mcp", headers: { Authorization: "Bearer <token>" } } }
104
+ // ACP:SDK wire 行(headers 为 {name,value} 数组)
105
+ { type: "http", name: "fd-memory", url: "<url>/mcp",
106
+ headers: [{ name: "Authorization", value: "Bearer <token>" }] }
107
+ ```
108
+
109
+ ## 构建与测试
110
+
111
+ ```bash
112
+ npm install # 开发依赖:typescript、@types/node(运行时零依赖)
113
+ npm run build # tsc -p tsconfig.json → dist/
114
+ npm test # 编译 src+test → dist-test/,node --test 全绿(mock fetch,无网络)
115
+ npm pack --dry-run # 文件清单:dist/ + README.md,无测试产物泄漏
116
+ ```
117
+
118
+ ## License
119
+
120
+ MIT
@@ -0,0 +1 @@
1
+ export declare const MEMORY_DISCIPLINE = "\n\n## \u5DE5\u4F5C\u8BB0\u5FC6\uFF08fd-memory\uFF09/ Working memory (fd-memory)\n\nMCP \u670D\u52A1\u5668 fd-memory \u5DF2\u6302\u8F7D\uFF08\u5DE5\u5177\u540D mcp__fd-memory__*\uFF09\u3002 / An MCP server \"fd-memory\" is mounted (tools appear as mcp__fd-memory__*).\n\n- \u4F1A\u8BDD\u6536\u5C3E\u505A\u6210\u4E86\u6709\u610F\u4E49\u7684\u5DE5\u4F5C\u65F6\uFF0C\u8C03\u7528 memory_handoff_begin \u5199\u4EA4\u63A5\uFF1A\u5B8C\u6210\u4E86\u4EC0\u4E48\u3001\u63A5\u53E3\u5F62\u72B6\u3001\u4E0B\u4E00\u6B65\u3001\u5751\u3001\u6D89\u53CA\u7684\u6587\u4EF6\u3002\n When you finish meaningful work in a session, call memory_handoff_begin before stopping: what is done, interface shapes, next steps, known pitfalls, files touched.\n- \u5F00\u573A\u82E5\u53EF\u80FD\u5728\u7EED\u4F5C\u5148\u524D\u5DE5\u4F5C\uFF0C\u5148 memory_handoff_list\uFF1B\u6709\u7ED9\u81EA\u5DF1\u7684 open handoff \u5C31\u7528 memory_handoff_accept \u8BA4\u9886\u540E\u7EED\u4F5C\uFF0C\u4E0D\u8981\u91CD\u63A8\u80CC\u666F\u3002\n When you start and may be resuming prior work, call memory_handoff_list first; if an open handoff is addressed to you, claim it with memory_handoff_accept and continue from it instead of re-deriving the background.\n- \u5F53\u7528\u6237\u660E\u786E\u8BF4\u300C\u8BB0\u4F4F\u8FD9\u4E2A\uFF0F\u522B\u5FD8\u4E86\u300D\uFF0C\u6216\u4F60\u5B66\u5230\u8DE8\u4F1A\u8BDD\u4ECD\u6709\u4EF7\u503C\u7684\u504F\u597D\u6216\u4E8B\u5B9E\u65F6\uFF0C\u8C03\u7528 memory_write_page \u8BB0\u4E00\u6761 note\uFF1B\u4E4B\u540E\u7684\u4F1A\u8BDD\u5F00\u573A\u4F1A\u81EA\u52A8\u5E26\u4E0A\u6700\u8FD1\u7684 note\u3002\u5199\u4E4B\u524D\u5148 memory_query \u67E5\u4E00\u4E0B\u662F\u5426\u5DF2\u6709\u540C\u4E3B\u9898 note\u2014\u2014\u66F4\u65B0\u5C31\u5199\u4E00\u6761\u65B0\u7684\u66F4\u6B63 note\uFF08\u6807\u660E\u300C\u66F4\u6B63\u300D\uFF09\uFF0C\u84B8\u998F\u5C42\u4F1A\u81EA\u52A8\u63A5\u66FF\u65E7\u7ED3\u8BBA\uFF0C\u4F46 note \u539F\u6587\u4F1A\u4E00\u76F4\u90FD\u5728\uFF0C\u91CD\u590D\u5199\u540C\u4E00\u6761\u53EA\u4F1A\u7A00\u91CA\u5F00\u573A\u8BB0\u5FC6\u3002\n When the user explicitly asks you to remember something, or you learn a preference or fact worth carrying into later sessions, call memory_write_page; recent notes are surfaced automatically at the start of later sessions. Before writing, memory_query for an existing note on the topic \u2014 to update, write a new correction note (marked as such); distillation supersedes the old conclusion automatically, but the raw notes persist, and rewriting the same note only dilutes the opening memory.\n- \u8BFB\u8D44\u6599\u5E93\u7684\u6587\u6863\u65F6\uFF08library \u5DE5\u5177\uFF09\uFF0C\u628A\u4E0E\u672C\u804C\u76F8\u5173\u7684\u8981\u70B9\u6D88\u5316\u6210\u81EA\u5DF1\u7684\u8BB0\u5FC6\uFF1A\u8C03\u7528 memory_write_page \u5199\u8FDB\u81EA\u5DF1\u7684\u5206\u533A\uFF0C\u6B63\u6587\u6CE8\u660E\u51FA\u5904\uFF08\u6765\u81EA\u54EA\u4EFD\u6587\u6863\uFF09\u3002\u6587\u6863\u662F\u539F\u6599\u3001\u8BB0\u5FC6\u662F\u6D88\u5316\u8FC7\u7684\u7ECF\u9A8C\u2014\u2014\u53EA\u6709\u4F60\u7406\u89E3\u5E76\u8F6C\u8FF0\u8FC7\u7684\u5185\u5BB9\u624D\u503C\u5F97\u5199\uFF1B\u539F\u6837\u7167\u6284\u6587\u6863\u4E0D\u7B97\u8BB0\u5FC6\u3002\n When reading library documents (the library tools), distill what matters for YOUR role into your own memory via memory_write_page, citing the source document in the note body. Documents are raw material; memory is processed experience \u2014 only what you understood and restated is worth writing; verbatim copying is not memory.\n";
@@ -0,0 +1,10 @@
1
+ // ── 工作记忆纪律文本 · 单源(自 paas/memory-discipline.js 下放) ─────────────
2
+ //
3
+ // 双语单源(zh + en,一行对一行):舰队侧(dsh persona 行 / ACP workspace
4
+ // CLAUDE.md)与 cell 侧(预设 persona 后缀)都追加本字符串,两面的引导永不
5
+ // 漂移。工具名无论中英都是 ASCII。消费者 MUST 经本接口取文本,不得手抄副本;
6
+ // 文本更新随 SDK 版本发布。
7
+ //
8
+ // 字节契约:本字符串与壹座现行注入逐字节一致(金样 fixture 见
9
+ // test/goldens/discipline.txt,由 paas 现役实现实跑产出)。
10
+ export const MEMORY_DISCIPLINE = "\n\n## 工作记忆(fd-memory)/ Working memory (fd-memory)\n\nMCP 服务器 fd-memory 已挂载(工具名 mcp__fd-memory__*)。 / An MCP server \"fd-memory\" is mounted (tools appear as mcp__fd-memory__*).\n\n- 会话收尾做成了有意义的工作时,调用 memory_handoff_begin 写交接:完成了什么、接口形状、下一步、坑、涉及的文件。\n When you finish meaningful work in a session, call memory_handoff_begin before stopping: what is done, interface shapes, next steps, known pitfalls, files touched.\n- 开场若可能在续作先前工作,先 memory_handoff_list;有给自己的 open handoff 就用 memory_handoff_accept 认领后续作,不要重推背景。\n When you start and may be resuming prior work, call memory_handoff_list first; if an open handoff is addressed to you, claim it with memory_handoff_accept and continue from it instead of re-deriving the background.\n- 当用户明确说「记住这个/别忘了」,或你学到跨会话仍有价值的偏好或事实时,调用 memory_write_page 记一条 note;之后的会话开场会自动带上最近的 note。写之前先 memory_query 查一下是否已有同主题 note——更新就写一条新的更正 note(标明「更正」),蒸馏层会自动接替旧结论,但 note 原文会一直都在,重复写同一条只会稀释开场记忆。\n When the user explicitly asks you to remember something, or you learn a preference or fact worth carrying into later sessions, call memory_write_page; recent notes are surfaced automatically at the start of later sessions. Before writing, memory_query for an existing note on the topic — to update, write a new correction note (marked as such); distillation supersedes the old conclusion automatically, but the raw notes persist, and rewriting the same note only dilutes the opening memory.\n- 读资料库的文档时(library 工具),把与本职相关的要点消化成自己的记忆:调用 memory_write_page 写进自己的分区,正文注明出处(来自哪份文档)。文档是原料、记忆是消化过的经验——只有你理解并转述过的内容才值得写;原样照抄文档不算记忆。\n When reading library documents (the library tools), distill what matters for YOUR role into your own memory via memory_write_page, citing the source document in the note body. Documents are raw material; memory is processed experience — only what you understood and restated is worth writing; verbatim copying is not memory.\n";
@@ -0,0 +1,61 @@
1
+ import { FetchLike, EnvLike } from "./http.js";
2
+ export declare const CAPTURE_DEDUP_MAX = 512;
3
+ /** 封闭五类:五类之外没有第六种口径(映射刻意不可配置)。 */
4
+ export type FleetFactKind = "turn" | "health" | "decision-opened" | "decision-resolved" | "swap";
5
+ export type FactPartition = "own" | "shared";
6
+ export declare const KIND_PARTITION: Readonly<Record<FleetFactKind, FactPartition>>;
7
+ export declare function fleetProjectId(fleetId: unknown): string;
8
+ export declare function memberProjectId(memberId: unknown): string;
9
+ /** 捕获件依赖的写面(fleetMemoryFor 产物或任何同形实现)。 */
10
+ export interface FleetMemoryWriter {
11
+ writeFact(input: {
12
+ fleetId: string;
13
+ memberId: string;
14
+ kind: string;
15
+ partition: FactPartition;
16
+ title: string;
17
+ body: string;
18
+ }): Promise<unknown>;
19
+ }
20
+ export interface FleetMemoryCapture {
21
+ readonly failures: number;
22
+ readonly captured: number;
23
+ capture(fleetId: string, memberId: string, kind: FleetFactKind | (string & {}), title: string, body: string): Promise<boolean | null>;
24
+ }
25
+ export declare function createFleetMemoryCapture({ memory, clock }?: {
26
+ memory?: FleetMemoryWriter;
27
+ clock?: () => number;
28
+ }): FleetMemoryCapture | null;
29
+ export declare function createHealthFlipTracker(): (fleetId: string, memberId: string, status: string) => boolean;
30
+ /** 舰队记忆客户端三面(writeFact / writeSharedPage / readDigest)。 */
31
+ export interface FleetMemory {
32
+ writeFact(input: {
33
+ fleetId: string;
34
+ memberId: string;
35
+ kind: string;
36
+ partition: FactPartition;
37
+ title: string;
38
+ body: string;
39
+ }): Promise<void>;
40
+ writeSharedPage(input: {
41
+ fleetId: string;
42
+ memberId: string;
43
+ title: string;
44
+ body: string;
45
+ metadata?: Record<string, unknown> | null;
46
+ }): Promise<any>;
47
+ readDigest(fleetId: string, memberId: string, q?: string, opts?: {
48
+ k?: number;
49
+ budget?: number;
50
+ scope?: "own+shared" | "shared" | "off";
51
+ }): Promise<{
52
+ sources: string[];
53
+ block: string;
54
+ } | null>;
55
+ }
56
+ export interface FleetDigestSegment {
57
+ source: "own" | "shared" | "legacy";
58
+ block: string;
59
+ }
60
+ export declare const FLEET_SEGMENT_LABELS: Record<FleetDigestSegment["source"], string>;
61
+ export declare function fleetMemoryFor(env: EnvLike, ownerEmail: string, fetchImpl?: FetchLike): Promise<FleetMemory | null>;
package/dist/facts.js ADDED
@@ -0,0 +1,238 @@
1
+ // ── 舰队工作记忆(自 paas/server/fleet-memory.js 下放) ──────────────────────
2
+ //
3
+ // 舰队(FLEET)工作记忆的 cell 侧车道。成员拆分后,事实落 DEPLOYMENT 命名
4
+ // 空间、用每成员的 deployment 令牌:
5
+ // 岗位私有 (turn / health) → 成员自己的分区 d/<pack>/<member>
6
+ // 团队事实 (decision-* / swap) → 该队的共享分区(shared)
7
+ // 每页携带一等 metadata {fleetId, memberId, kind}(openspec: agent-memory-
8
+ // foundation)——标题重新只是人话,永不做机器寻址。遗留的 `f/<fleetId>`
9
+ // owner 项目停止收写;其页保持可读,过渡窗内 digest 仍会合成它们(自然衰减
10
+ // ——不再有新行落进去)。
11
+ //
12
+ // 降级开放是契约(spec: capture 失败 MUST NOT 阻塞回合,digest 缺席即段缺
13
+ // 席):每个失败都是一个计数器,绝不把错误抛进回合。
14
+ //
15
+ // 五类事实(spec: fleet-turn-memory):turn(经 outcome 的报数摘要)、health
16
+ // (ok↔failed 状态迁移翻转)、decision-opened、decision-resolved、swap。
17
+ // 幂等:进程内有界键集对 (fleetId, memberId, kind, ts) 重放去重;跨重启重放
18
+ // 是非目标(fire-and-forget 捕获)。
19
+ import { cellMemoryConfig, personTokenFor, memberTokenFor } from "./tokens.js";
20
+ import { digestTimeoutMs } from "./ladder.js";
21
+ import { fetchWithTimeout } from "./http.js";
22
+ export const CAPTURE_DEDUP_MAX = 512;
23
+ // 封闭的 own/shared 拆分(spec:五类是封闭集——映射刻意不可配置;这里的第
24
+ // 四种意见就是第四种口径)。turn/health 是岗位的私有产出;决定与换防是全员
25
+ // 必须看见的团队事实。
26
+ export const KIND_PARTITION = {
27
+ turn: "own",
28
+ health: "own",
29
+ "decision-opened": "shared",
30
+ "decision-resolved": "shared",
31
+ swap: "shared",
32
+ };
33
+ export function fleetProjectId(fleetId) {
34
+ const out = String(fleetId ?? "").trim().replace(/[/\\]+/g, "-").slice(0, 128);
35
+ return out ? `f/${out}` : "f/unknown";
36
+ }
37
+ // d/ 根内成员自有分区的项目名:fd-memory 把 d/<packId>/<memberId> 解析为根
38
+ // "d-<packId>"、项目 "<memberId>"——线上的 project 就是 memberId(registry
39
+ // metadata.agentId 坐标,与 agent-runner/memory-mount.js 在 runner 载体上跑
40
+ // 的是同一条规则)。
41
+ export function memberProjectId(memberId) {
42
+ const out = String(memberId ?? "").trim().replace(/[/\\]+/g, "-").slice(0, 128);
43
+ return out || "unknown";
44
+ }
45
+ export function createFleetMemoryCapture({ memory, clock = Date.now } = {}) {
46
+ if (!memory)
47
+ return null;
48
+ const seen = new Set();
49
+ const seenKeys = [];
50
+ let failures = 0;
51
+ let captured = 0;
52
+ return {
53
+ get failures() {
54
+ return failures;
55
+ },
56
+ get captured() {
57
+ return captured;
58
+ },
59
+ async capture(fleetId, memberId, kind, title, body) {
60
+ const ts = clock();
61
+ // 幂等键包含「内容」而不只是时间戳:固定时钟(测试、快速连发回合)不
62
+ // 能把两条不同的事实去重掉;真正的重放(同一事实重复投递)仍然会。
63
+ const key = `${fleetId}|${memberId}|${kind}|${ts}|${title}|${body}`;
64
+ if (seen.has(key))
65
+ return null; // 窗内重放 → 幂等
66
+ seen.add(key);
67
+ seenKeys.push(key);
68
+ if (seenKeys.length > CAPTURE_DEDUP_MAX)
69
+ seen.delete(seenKeys.shift());
70
+ const partition = KIND_PARTITION[kind];
71
+ if (!partition) {
72
+ // 未知类型:绝不静默落进错误分区——计为失败,让运维看见第五类漂移。
73
+ failures += 1;
74
+ return false;
75
+ }
76
+ try {
77
+ await memory.writeFact({ fleetId, memberId, kind, partition, title, body });
78
+ captured += 1;
79
+ return true;
80
+ }
81
+ catch {
82
+ failures += 1; // 回合永不知情;计数器就是可观测性
83
+ return false; // false = 失败(null = 重放去重,未尝试)
84
+ }
85
+ },
86
+ };
87
+ }
88
+ // 健康翻转追踪器:记住每成员最近一次落定的状态,只在「翻转」(ok↔failed)
89
+ // 时产出一条观察——留下"谁恢复了/谁开始失败"的记忆叙事,不被逐回合噪音淹没。
90
+ export function createHealthFlipTracker() {
91
+ const last = new Map();
92
+ return (fleetId, memberId, status) => {
93
+ const key = `${fleetId}|${memberId}`;
94
+ const prev = last.get(key);
95
+ last.set(key, status);
96
+ if (prev === undefined || prev === status)
97
+ return false;
98
+ return true;
99
+ };
100
+ }
101
+ // 三段标签:壹座现行字符串,逐字节金样(test/goldens/fleet-digest-block.txt)。
102
+ export const FLEET_SEGMENT_LABELS = {
103
+ own: "本岗位记忆",
104
+ shared: "团队记忆",
105
+ legacy: "历史团队记忆",
106
+ };
107
+ // 返回 { writeFact, writeSharedPage, readDigest } 或 null(配置关 / 无 owner /
108
+ // 铸造失败)——fleetMemoryFor 确立的降级开放姿态。
109
+ export async function fleetMemoryFor(env, ownerEmail, fetchImpl = fetch) {
110
+ const cfg = cellMemoryConfig(env);
111
+ if (!cfg || !ownerEmail)
112
+ return null;
113
+ try {
114
+ // person 令牌留给遗留过渡读(f/ digest);成员令牌按舰队成员懒铸造——
115
+ // 舰队就是 deployment(d/<fleetId>/<memberId>,shared = d/<fleetId>/
116
+ // shared),每支队伍有自己的根与自己的团队事实分区。缓存按 (fleetId,
117
+ // memberId):换防不换(令牌属于岗位),舰队改名 id 不变。
118
+ const token = await personTokenFor(cfg, ownerEmail, fetchImpl);
119
+ const memberTokens = new Map();
120
+ const tokenForMember = async (fleetId, memberId) => {
121
+ const key = `${fleetId}|${memberId}`;
122
+ const cached = memberTokens.get(key);
123
+ if (cached)
124
+ return cached;
125
+ const t = await memberTokenFor(cfg, { packId: fleetId, memberId, ownerEmail }, fetchImpl);
126
+ memberTokens.set(key, t);
127
+ return t;
128
+ };
129
+ const post = async (tok, project, title, body, metadata) => {
130
+ // 出站纪律:共享死线包装(默认 env 界);fetchImpl 保持测试缝。
131
+ const r = await fetchWithTimeout(`${cfg.url}/pages`, {
132
+ method: "POST",
133
+ headers: { authorization: `Bearer ${tok}`, "content-type": "application/json" },
134
+ body: JSON.stringify({ project, title, body, ...(metadata ? { metadata } : {}) }),
135
+ }, { fetchImpl });
136
+ if (!r.ok)
137
+ throw new Error(`pages HTTP ${r.status}`);
138
+ return await r.json();
139
+ };
140
+ const digestOnce = async (tok, project, q, { k = 5, budget = 1200 } = {}) => {
141
+ const u = new URL(`${cfg.url}/digest`);
142
+ u.searchParams.set("project", project);
143
+ if (q)
144
+ u.searchParams.set("q", String(q));
145
+ u.searchParams.set("k", String(k));
146
+ u.searchParams.set("budget", String(budget));
147
+ const controller = new AbortController();
148
+ const timer = setTimeout(() => controller.abort(), digestTimeoutMs(env));
149
+ try {
150
+ const r = await fetchImpl(u, { headers: { authorization: `Bearer ${tok}` }, signal: controller.signal });
151
+ if (!r.ok)
152
+ throw new Error(`digest HTTP ${r.status}`);
153
+ const json = await r.json();
154
+ // fd-memory 的面把渲染文本叫 `digest`;下面的阶梯读 `.block`——在此
155
+ // 归一化,让每一段都说同一种形状。
156
+ return { block: typeof json?.digest === "string" ? json.digest : (json?.block ?? "") };
157
+ }
158
+ finally {
159
+ clearTimeout(timer);
160
+ }
161
+ };
162
+ return {
163
+ // 拆分写:分区来自封闭类型映射;metadata 携带结构化三元组,标题保持
164
+ // 人话。重要:两个分区都走成员的 deployment 令牌——fd-memory 只对
165
+ // deployment 令牌把项目名 "shared" 解析到该队的共享分区
166
+ // (d/<fleetId>/shared),这里用 person 令牌会把团队事实误归到 owner
167
+ // 的 u/ 根。
168
+ async writeFact({ fleetId, memberId, kind, partition, title, body }) {
169
+ const metadata = { fleetId, memberId, kind };
170
+ const tok = await tokenForMember(fleetId, memberId);
171
+ if (partition === "own") {
172
+ await post(tok, memberProjectId(memberId), title, body, metadata);
173
+ }
174
+ else {
175
+ await post(tok, "shared", title, body, metadata);
176
+ }
177
+ },
178
+ // 团队级直接共享写(本来就知道自己是团队级的车道用,如其他载体上的
179
+ // 换防记录员)。走执行成员的 deployment 令牌,理由同上。
180
+ async writeSharedPage({ fleetId, memberId, title, body, metadata }) {
181
+ const tok = await tokenForMember(fleetId, memberId);
182
+ await post(tok, "shared", title, body, metadata ?? null);
183
+ },
184
+ // fleetContext 记忆段——阶梯在一个预算下合成三个来源,按成员的知识
185
+ // 范围门控:成员自有分区、舰队共享分区(两者都走成员的 deployment
186
+ // 令牌——共享项目名只对 deployment 令牌解析到 d/<fleetId>/shared),
187
+ // 以及经 person 令牌的遗留 f/ digest(过渡窗;随停写自然衰减)。每段
188
+ // 以同样的预算纪律服务端渲染;任何一段失败只丢那一段,绝不丢整节。
189
+ // scope "shared" 跳过自有探测;"off" 到不了这个调用(调用方先查)。
190
+ async readDigest(fleetId, memberId, q, { k = 5, budget = 1200, scope = "own+shared" } = {}) {
191
+ const segBudget = Math.max(64, Math.floor(budget / 3));
192
+ let memberTok = null;
193
+ if (scope === "own+shared") {
194
+ try {
195
+ memberTok = await tokenForMember(fleetId, memberId);
196
+ }
197
+ catch {
198
+ // 成员令牌就是 own/shared 两段的身份——没有它两段都丢,只剩遗留
199
+ // f/ digest 能渲染
200
+ }
201
+ }
202
+ const segments = [];
203
+ const probes = [
204
+ { name: "own", project: memberProjectId(memberId), token: memberTok, q: q ?? "" },
205
+ { name: "shared", project: "shared", token: memberTok, q: q ?? "" },
206
+ { name: "legacy", project: fleetProjectId(fleetId), token, q: (q ?? "") || String(memberId ?? "") },
207
+ ];
208
+ for (const probe of probes) {
209
+ if (!probe.token)
210
+ continue;
211
+ try {
212
+ const out = await digestOnce(probe.token, probe.project, probe.q, { k, budget: segBudget });
213
+ if (typeof out?.block === "string" && out.block.trim()) {
214
+ segments.push({ source: probe.name, block: out.block });
215
+ }
216
+ }
217
+ catch {
218
+ // 单段失败是计数器形状的缺席,不是错误
219
+ }
220
+ }
221
+ if (segments.length === 0)
222
+ return null;
223
+ // 渲染器自己的头每段重复——阶梯给每个来源标一次签、拼接正文;空段
224
+ // 消失。
225
+ return {
226
+ sources: segments.map((s) => s.source),
227
+ block: segments
228
+ .map((s) => `【${FLEET_SEGMENT_LABELS[s.source]}】\n${s.block}`)
229
+ .join("\n\n"),
230
+ };
231
+ },
232
+ };
233
+ }
234
+ catch (e) {
235
+ console.warn(`[fleet-memory] unavailable (degrade open): ${e?.message || e}`);
236
+ return null;
237
+ }
238
+ }
package/dist/http.d.ts ADDED
@@ -0,0 +1,33 @@
1
+ export declare const DEFAULT_FETCH_TIMEOUT_MS = 10000;
2
+ /** 环境对象形状(process.env 的结构子集,便于测试注入)。 */
3
+ export type EnvLike = Record<string, string | undefined>;
4
+ /** 响应的最小结构面:SDK 各面只用 ok/status/json。 */
5
+ export interface FetchResponseLike {
6
+ ok: boolean;
7
+ status: number;
8
+ json(): Promise<any>;
9
+ }
10
+ /** 出站请求 init 的最小面。 */
11
+ export interface FetchInitLike {
12
+ method?: string;
13
+ headers?: Record<string, string>;
14
+ body?: string;
15
+ signal?: AbortSignal;
16
+ }
17
+ /**
18
+ * 可注入的 fetch 缝(测试缝):签名比全局 fetch 窄,全局 fetch 天然可赋值。
19
+ * 参照 paas 各面的做法——fetchImpl 永远作为参数保留。
20
+ */
21
+ export type FetchLike = (input: string | URL, init?: FetchInitLike) => Promise<FetchResponseLike>;
22
+ export declare function platformFetchTimeoutMs(env?: EnvLike): number;
23
+ export declare class FetchTimeoutError extends Error {
24
+ constructor(timeoutMs: number, url?: string);
25
+ code: string;
26
+ retryable: boolean;
27
+ timeoutMs: number;
28
+ }
29
+ export declare function fetchWithTimeout(input: string | URL, init?: FetchInitLike, opts?: {
30
+ timeoutMs?: number;
31
+ fetchImpl?: FetchLike;
32
+ env?: EnvLike;
33
+ }): Promise<FetchResponseLike>;
package/dist/http.js ADDED
@@ -0,0 +1,69 @@
1
+ // ── 出站 fetch 超时纪律(自 paas/lib/fetch-with-timeout.js 下放) ────────────
2
+ //
3
+ // SDK 对内核(fd-memory)的每一次出站调用都有界:fetchWithTimeout() 以默认
4
+ // 死线(环境 PLATFORM_FETCH_TIMEOUT_MS,10s)包住 fetch,任何调用点可按次
5
+ // 覆盖。死线到点以结构化 FetchTimeoutError(code "FETCH_TIMEOUT")失败,绝
6
+ // 不悬挂。死线覆盖到「响应头到达」为止;之后的正文流式不在此约束内(内核
7
+ // /digest 等面都是一次性 JSON,不涉及长流)。
8
+ //
9
+ // SDK 纪律:零运行时依赖——本模块只用 Node 内置的全局 fetch / AbortController。
10
+ export const DEFAULT_FETCH_TIMEOUT_MS = 10_000;
11
+ const TIMEOUT_ENV_KEY = "PLATFORM_FETCH_TIMEOUT_MS";
12
+ // 生效死线在调用时读取(而非模块加载时),测试与一次性脚本可经 env 重调。
13
+ export function platformFetchTimeoutMs(env = process.env) {
14
+ const raw = Number(env[TIMEOUT_ENV_KEY]);
15
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_FETCH_TIMEOUT_MS;
16
+ }
17
+ export class FetchTimeoutError extends Error {
18
+ constructor(timeoutMs, url) {
19
+ super(`outbound fetch timed out after ${timeoutMs}ms${url ? `: ${url}` : ""}`);
20
+ this.name = "FetchTimeoutError";
21
+ this.code = "FETCH_TIMEOUT";
22
+ this.retryable = true;
23
+ this.timeoutMs = timeoutMs;
24
+ }
25
+ code;
26
+ retryable;
27
+ timeoutMs;
28
+ }
29
+ // 合成调用方 signal 与死线 signal,让两者都能取消:调用方中止保留自身原因
30
+ // (AbortError),死线到点把 FetchTimeoutError 作为中止原因带上。
31
+ // AbortSignal.any 自 Node 20.3 起可用;监听器兜底覆盖更老运行时。
32
+ function composeSignals(caller, deadline) {
33
+ if (!caller)
34
+ return deadline.signal;
35
+ if (caller.aborted)
36
+ return caller;
37
+ if (typeof AbortSignal.any === "function")
38
+ return AbortSignal.any([caller, deadline.signal]);
39
+ const composed = new AbortController();
40
+ const relay = (from) => () => composed.abort(from.reason);
41
+ caller.addEventListener("abort", relay(caller), { once: true });
42
+ deadline.signal.addEventListener("abort", relay(deadline.signal), { once: true });
43
+ return composed.signal;
44
+ }
45
+ // 共享包装。opts.timeoutMs 按调用点覆盖 env 默认;opts.fetchImpl 穿插模块注入
46
+ // 的 fetch 缝(可测性);opts.env 从非 process 环境对象读默认值。拒绝原样
47
+ // 上抛,唯一例外:死线到点以 FetchTimeoutError 拒绝——中止原因随 signal 走,
48
+ // 尊重 init.signal 的注入式 fetch 自然会把它暴露出来。
49
+ export function fetchWithTimeout(input, init = {}, opts = {}) {
50
+ const impl = opts.fetchImpl ?? fetch;
51
+ const timeoutMs = Number.isFinite(opts.timeoutMs) && opts.timeoutMs > 0
52
+ ? opts.timeoutMs
53
+ : platformFetchTimeoutMs(opts.env);
54
+ // 壹座原样:URL 对象上没有 .url 字段,错误消息里的地址回退为空串。
55
+ const url = typeof input === "string" ? input : (input?.url ?? "");
56
+ const deadline = new AbortController();
57
+ const timer = setTimeout(() => deadline.abort(new FetchTimeoutError(timeoutMs, url)), timeoutMs);
58
+ let promise;
59
+ try {
60
+ promise = Promise.resolve(impl(input, { ...init, signal: composeSignals(init.signal, deadline) }));
61
+ }
62
+ catch (err) {
63
+ // 同步抛出的 impl(坏 mock、非法请求形状)不能泄漏挂起的定时器。
64
+ clearTimeout(timer);
65
+ throw err;
66
+ }
67
+ // 死线只覆盖「等响应头」;promise 落定后定时器即撤,正文自由流动。
68
+ return promise.finally(() => clearTimeout(timer));
69
+ }
@@ -0,0 +1,6 @@
1
+ export * from "./http.js";
2
+ export * from "./discipline.js";
3
+ export * from "./tokens.js";
4
+ export * from "./ladder.js";
5
+ export * from "./facts.js";
6
+ export * from "./observe.js";
package/dist/index.js ADDED
@@ -0,0 +1,20 @@
1
+ // ── @finddatatechnology/yongyi-client · 出口面 ───────────────────────────────
2
+ //
3
+ // 永忆(fd-memory)客户端 SDK:把壹座与 agent-runner 里经过旗舰验证的接入件
4
+ // 下放为独立发包。零运行时依赖——只用 Node 内置 fetch 打内核 HTTP 面
5
+ // (/digest、/pages、/admin/mint、/observe),与服务端之间是网络契约,不是
6
+ // 代码契约。
7
+ //
8
+ // 模块地图(design D6):
9
+ // ladder 注入阶梯 digest→titles→absent(自 cluster-memory.js 移植)
10
+ // discipline 纪律文本双语单源(自 memory-discipline.js 移植)
11
+ // facts 五类事实捕获 own/shared(自 fleet-memory.js 移植)
12
+ // tokens person/deployment 令牌铸造 + 磁盘缓存 0600(自 cell-memory.js 移植)
13
+ // observe /observe 上报客户端(幂等键、失败即丢)
14
+ // http 出站 fetch 死线包装(fetchImpl 测试缝、FetchTimeoutError)
15
+ export * from "./http.js";
16
+ export * from "./discipline.js";
17
+ export * from "./tokens.js";
18
+ export * from "./ladder.js";
19
+ export * from "./facts.js";
20
+ export * from "./observe.js";
@@ -0,0 +1,72 @@
1
+ import { FetchLike, EnvLike } from "./http.js";
2
+ export declare function clusterProjectId(clusterId: unknown): string;
3
+ export declare function digestTimeoutMs(env?: EnvLike): number;
4
+ /** 一条记忆页的最小读取面(/pages GET 的行形状)。 */
5
+ export interface MemoryPage {
6
+ title?: string;
7
+ updated_at?: string;
8
+ [key: string]: unknown;
9
+ }
10
+ /** /digest 响应的服务端形状:渲染文本在 `digest` 字段。 */
11
+ export interface DigestDoc {
12
+ digest?: string;
13
+ }
14
+ export type LadderSource = "digest" | "titles" | "none";
15
+ /** 阶梯依赖的客户端最小面(createClusterMemoryClient 的产物子集)。 */
16
+ export interface LadderMemory {
17
+ readDigest(project: string, q: string, opts?: {
18
+ k?: number;
19
+ budget?: number;
20
+ }): Promise<DigestDoc>;
21
+ readShared(project: string, limit?: number): Promise<MemoryPage[]>;
22
+ }
23
+ /** 集群记忆客户端三面(/digest、/pages 读、/pages 写)。 */
24
+ export interface ClusterMemoryClient extends LadderMemory {
25
+ writeDigest(project: string, title: string, body: string): Promise<any>;
26
+ }
27
+ export interface ClusterClientOptions {
28
+ url: string;
29
+ token: string;
30
+ fetchImpl?: FetchLike;
31
+ /** digest 读死线(毫秒);缺省读 env.CELL_MEMORY_DIGEST_TIMEOUT_MS,默认 9000。 */
32
+ timeoutMs?: number;
33
+ env?: EnvLike;
34
+ }
35
+ export declare function createClusterMemoryClient(opts: ClusterClientOptions): ClusterMemoryClient;
36
+ export declare function clusterMemoryFor(env: EnvLike, ownerEmail: string, fetchImpl?: FetchLike): Promise<ClusterMemoryClient | null>;
37
+ export interface DigestLadderOptions {
38
+ digestOn: boolean;
39
+ k: number;
40
+ budget: number;
41
+ titlesLabel: string;
42
+ warnTag: string;
43
+ warnName: string;
44
+ }
45
+ export interface LadderResult {
46
+ block: string;
47
+ source: LadderSource;
48
+ }
49
+ export declare function digestLadder(cm: LadderMemory | null, project: string, prompt: string, { digestOn, k, budget, titlesLabel, warnTag, warnName }: DigestLadderOptions): Promise<LadderResult>;
50
+ export interface LadderCallOptions {
51
+ /** 读取 CELL_MEMORY_* 键的环境对象;缺省 process.env。 */
52
+ env?: EnvLike;
53
+ /** 显式覆盖 digest 面开关(集群键 env.CELL_MEMORY_DIGEST,聊天键 CELL_MEMORY_CHAT_DIGEST)。 */
54
+ digestOn?: boolean;
55
+ /** digest 召回条数;缺省 env.CELL_MEMORY_DIGEST_K,默认 5。 */
56
+ k?: number;
57
+ /** digest 字符预算;缺省 env.CELL_MEMORY_DIGEST_BUDGET,默认 1500。 */
58
+ budget?: number;
59
+ }
60
+ export declare const CHAT_TITLES_LABEL = "\u3010\u5DE5\u4F5C\u8BB0\u5FC6\u3011\u6B64\u524D\u4F1A\u8BDD\u7684\u6C89\u6DC0\uFF0C\u4F9B\u53C2\u8003\uFF1A";
61
+ export declare const CLUSTER_TITLES_LABEL = "\u3010\u96C6\u7FA4\u5171\u4EAB\u8BB0\u5FC6\u3011\u672C\u96C6\u7FA4\u6B64\u524D\u56DE\u5408\u7684\u6C89\u6DC0\uFF0C\u4F9B\u53C2\u8003\uFF1A";
62
+ export declare function memoryInjectionFor(clusterMemory: LadderMemory | null, clusterId: string, prompt: string, opts?: LadderCallOptions): Promise<{
63
+ prompt: string;
64
+ source: LadderSource;
65
+ }>;
66
+ export declare function chatMemoryInjectionFor(clusterMemory: LadderMemory | null, prompt: string, opts?: LadderCallOptions): Promise<{
67
+ prompt: string;
68
+ source: LadderSource;
69
+ }>;
70
+ export declare function chatMemoryBlockFor(ownerEmail: string | null | undefined, query: string, env?: EnvLike, fetchImpl?: FetchLike): Promise<LadderResult>;
71
+ export declare function chatMemoryClientFor(ownerEmail: string | null | undefined, env?: EnvLike, fetchImpl?: FetchLike): Promise<ClusterMemoryClient | null>;
72
+ export declare function chatPromptWithMemory(ownerEmail: string | null | undefined, prompt: string, env?: EnvLike, fetchImpl?: FetchLike): Promise<string>;
package/dist/ladder.js ADDED
@@ -0,0 +1,217 @@
1
+ // ── 召回注入阶梯(自 paas/server/cluster-memory.js 下放) ────────────────────
2
+ //
3
+ // 集群共享记忆的 cell 侧车道:owner 的 person 令牌经 fd-memory 平台缝 /pages
4
+ // 面读写集群-id 项目的页。降级开放是契约:任何失败返回缺席(空块),集群/
5
+ // 聊天回合照常进行——记忆永不弄破回合。
6
+ //
7
+ // 注入阶梯(add-cluster-digest-injection design D3):digest 面优先(服务端
8
+ // 渲染块,经续作评测验证),失败降级标题列表,再降级缺席。每一级降级恰一条
9
+ // warn;记忆永不失败一个回合。返回块本身(记忆关闭/不可用时为空串),调用方
10
+ // 拼装自己的 prompt 时追加的文本与聊天车道产出的完全一致。
11
+ //
12
+ // 字节同源纪律:标题标签与行组装字符串与壹座现行注入逐字节一致,金样固化在
13
+ // test/goldens/(由 paas 现役实现实跑产出)。
14
+ //
15
+ // 环境参数化:壹座侧从 process.env 读的开关与默认(CELL_MEMORY_DIGEST*)在
16
+ // SDK 里参数化为 options(env 字段仍可传环境对象),默认值不变。
17
+ import { cellMemoryConfig, personTokenFor } from "./tokens.js";
18
+ import { fetchWithTimeout } from "./http.js";
19
+ export function clusterProjectId(clusterId) {
20
+ const out = String(clusterId ?? "").trim().replace(/[/\\]+/g, "-").slice(0, 128);
21
+ return out || "cluster";
22
+ }
23
+ // digest 读超时(fix-digest-adaptive-embed-deadline)。必须高于 fd-memory 的
24
+ // 内部 embed 预算帽,否则会在被叫方仍在收尾时挂断——块整个丢掉而不是收到
25
+ // 降级形态。默认 9000(帽 8000 + 传输余量);设得比帽还低,阶梯会直接跳过
26
+ // digest 级——这正是这条配置要暴露的错误用法。
27
+ export function digestTimeoutMs(env = process.env) {
28
+ const raw = Number(env.CELL_MEMORY_DIGEST_TIMEOUT_MS ?? 9000);
29
+ const n = Number.isFinite(raw) && raw > 0 ? Math.round(raw) : 9000;
30
+ return Math.max(500, Math.min(60_000, n));
31
+ }
32
+ // 从壹座 cell 配置构建的客户端三面(clusterMemoryFor 的 faces 移植)。
33
+ export function createClusterMemoryClient(opts) {
34
+ const { url, token } = opts;
35
+ const fetchImpl = opts.fetchImpl ?? fetch;
36
+ const env = opts.env ?? process.env;
37
+ return {
38
+ async readShared(project, limit = 5) {
39
+ // 出站纪律:共享死线包装(默认 env 界);fetchImpl 保持测试缝。
40
+ const r = await fetchWithTimeout(`${url}/pages?project=${encodeURIComponent(project)}&limit=${limit}`, { headers: { authorization: `Bearer ${token}` } }, { fetchImpl });
41
+ if (!r.ok)
42
+ throw new Error(`pages HTTP ${r.status}`);
43
+ const doc = await r.json();
44
+ return Array.isArray(doc?.pages) ? doc.pages : [];
45
+ },
46
+ async writeDigest(project, title, body) {
47
+ const r = await fetchWithTimeout(`${url}/pages`, {
48
+ method: "POST",
49
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
50
+ body: JSON.stringify({ project, title, body }),
51
+ }, { fetchImpl });
52
+ if (!r.ok)
53
+ throw new Error(`pages HTTP ${r.status}`);
54
+ return await r.json();
55
+ },
56
+ // digest 读面(add-cluster-digest-injection):服务端渲染块——一次机会、
57
+ // 不重试(调用方的阶梯在任何失败时降级标题列表)。
58
+ //
59
+ // 超时(fix-digest-adaptive-embed-deadline):这里曾有平的 5s,低于
60
+ // fd-memory 自身的 embed 预算(8000ms)。嵌入上游慢时,fd-memory 还在
61
+ // 做答案而我们已挂断——块整个丢掉而不是收到降级(关键词)形态。调用方
62
+ // 必须比被叫方的内部预算更有耐心,故默认 9s;快路径毫秒级零成本,可配置。
63
+ async readDigest(project, q, { k = 5, budget = 1500 } = {}) {
64
+ const u = new URL(`${url}/digest`);
65
+ u.searchParams.set("project", project);
66
+ u.searchParams.set("q", String(q ?? ""));
67
+ u.searchParams.set("k", String(k));
68
+ u.searchParams.set("budget", String(budget));
69
+ const controller = new AbortController();
70
+ const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? digestTimeoutMs(env));
71
+ try {
72
+ const r = await fetchImpl(u, { headers: { authorization: `Bearer ${token}` }, signal: controller.signal });
73
+ if (!r.ok)
74
+ throw new Error(`digest HTTP ${r.status}`);
75
+ return await r.json();
76
+ }
77
+ finally {
78
+ clearTimeout(timer);
79
+ }
80
+ },
81
+ };
82
+ }
83
+ // 壹座位形的整体构建(clusterMemoryFor 移植):返回客户端或 null(配置关 /
84
+ // 无 owner / 铸造失败)。person 令牌与记忆挂载铸的是同一枚并缓存磁盘——每
85
+ // owner 一铸,重启稳定。
86
+ export async function clusterMemoryFor(env, ownerEmail, fetchImpl = fetch) {
87
+ const cfg = cellMemoryConfig(env);
88
+ if (!cfg || !ownerEmail)
89
+ return null;
90
+ try {
91
+ const token = await personTokenFor(cfg, ownerEmail, fetchImpl);
92
+ return createClusterMemoryClient({ url: cfg.url, token, fetchImpl, env });
93
+ }
94
+ catch (e) {
95
+ console.warn(`[cluster-memory] unavailable (degrade open): ${e?.message || e}`);
96
+ return null;
97
+ }
98
+ }
99
+ // 阶梯本体:digest 面优先,降级标题列表,再降级缺席。每个降级级恰一条 warn。
100
+ export async function digestLadder(cm, project, prompt, { digestOn, k, budget, titlesLabel, warnTag, warnName }) {
101
+ if (!cm)
102
+ return { block: "", source: "none" };
103
+ if (digestOn) {
104
+ try {
105
+ const d = await cm.readDigest(project, prompt, { k, budget });
106
+ if (d?.digest)
107
+ return { block: d.digest, source: "digest" };
108
+ }
109
+ catch (e) {
110
+ console.warn(`[${warnTag}] digest read failed for ${warnName} (fallback to titles): ${e?.message || e}`);
111
+ }
112
+ }
113
+ try {
114
+ const sharedPages = await cm.readShared(project, 5);
115
+ if (sharedPages.length) {
116
+ const lines = sharedPages.map((p) => `- ${p.title}(${String(p.updated_at ?? "").slice(0, 10)})`).join("\n");
117
+ return { block: `${titlesLabel}\n${lines}`, source: "titles" };
118
+ }
119
+ }
120
+ catch (e) {
121
+ console.warn(`[${warnTag}] shared-memory read failed for ${warnName} (degrade open): ${e?.message || e}`);
122
+ }
123
+ return { block: "", source: "none" };
124
+ }
125
+ const ladderEnv = (env) => ({
126
+ k: Number(env.CELL_MEMORY_DIGEST_K ?? 5) || 5,
127
+ budget: Number(env.CELL_MEMORY_DIGEST_BUDGET ?? 1500) || 1500,
128
+ });
129
+ // 标题标签:壹座现行字符串,逐字节金样(test/goldens/*-titles-block.txt)。
130
+ export const CHAT_TITLES_LABEL = "【工作记忆】此前会话的沉淀,供参考:";
131
+ export const CLUSTER_TITLES_LABEL = "【集群共享记忆】本集群此前回合的沉淀,供参考:";
132
+ // 聊天车道阶梯配置:prompt 包装器与块读取面共用同一份,两面永不漂移
133
+ // (房间车道从这里读块)。项目固定 "default"——person 令牌的 MCP 写入落点
134
+ // (fd-memory namespaceParts),读面即写面。
135
+ const chatLadder = (cm, query, env) => digestLadder(cm, "default", String(query ?? ""), {
136
+ digestOn: true,
137
+ ...ladderEnv(env),
138
+ titlesLabel: CHAT_TITLES_LABEL,
139
+ warnTag: "chat-memory",
140
+ warnName: "default",
141
+ });
142
+ // 集群车道注入:CELL_MEMORY_DIGEST=0 是只跳过 digest 级的回滚杆。签名与行为
143
+ // 不变(env 语义经 options.env 传入)。
144
+ export async function memoryInjectionFor(clusterMemory, clusterId, prompt, opts = {}) {
145
+ const env = opts.env ?? process.env;
146
+ const envDefaults = ladderEnv(env);
147
+ const { block, source } = await digestLadder(clusterMemory, clusterProjectId(clusterId), prompt, {
148
+ digestOn: opts.digestOn ?? env.CELL_MEMORY_DIGEST !== "0",
149
+ k: opts.k ?? envDefaults.k,
150
+ budget: opts.budget ?? envDefaults.budget,
151
+ titlesLabel: CLUSTER_TITLES_LABEL,
152
+ warnTag: "cluster",
153
+ warnName: clusterId,
154
+ });
155
+ return { prompt: block ? `${prompt}\n\n${block}` : prompt, source };
156
+ }
157
+ // 聊天车道(add-chat-memory-injection D2/D3):owner 记忆根上的同一阶梯。
158
+ // CELL_MEMORY_CHAT_DIGEST=0 是聊天车道的总开关(spec:prompt 逐字节一致、
159
+ // 零 fd-memory 请求)——不同于集群杆只跳 digest 级;k/budget 与集群共用。
160
+ export async function chatMemoryInjectionFor(clusterMemory, prompt, opts = {}) {
161
+ const env = opts.env ?? process.env;
162
+ if (opts.digestOn != null ? !opts.digestOn : env.CELL_MEMORY_CHAT_DIGEST === "0") {
163
+ return { prompt, source: "none" };
164
+ }
165
+ const { block, source } = await chatLadder(clusterMemory, prompt, env);
166
+ return { prompt: block ? `${prompt}\n\n${block}` : prompt, source };
167
+ }
168
+ // 聊天车道的块面(add-unified-memory-and-outputs D7):自己拼 prompt 的调用方
169
+ // (智语房间回合)得到同一文本、同一开关,车道与聊天永不分歧。关/杀/不可用
170
+ // 一律 ""——永不抛。
171
+ export async function chatMemoryBlockFor(ownerEmail, query, env = process.env, fetchImpl = fetch) {
172
+ if (!ownerEmail || env.CELL_MEMORY_CHAT_DIGEST === "0")
173
+ return { block: "", source: "none" };
174
+ try {
175
+ const cm = await chatMemoryClientFor(ownerEmail, env, fetchImpl);
176
+ if (!cm)
177
+ return { block: "", source: "none" };
178
+ return await chatLadder(cm, query, env);
179
+ }
180
+ catch (e) {
181
+ console.warn(`[chat-memory] block read failed for ${ownerEmail} (degrade open): ${e?.message || e}`);
182
+ return { block: "", source: "none" };
183
+ }
184
+ }
185
+ // ws 聊天车道的客户端缓存:每 owner email 一个记忆客户端。已解析的客户端按
186
+ // 进程生命周期缓存;配置开着时的 null 结果(瞬时铸造失败)会被丢弃,下一回
187
+ // 合重试而不是黑到重启。
188
+ const chatClients = new Map();
189
+ export async function chatMemoryClientFor(ownerEmail, env = process.env, fetchImpl = fetch) {
190
+ if (!ownerEmail)
191
+ return null;
192
+ let p = chatClients.get(ownerEmail);
193
+ if (!p) {
194
+ p = clusterMemoryFor(env, ownerEmail, fetchImpl);
195
+ chatClients.set(ownerEmail, p);
196
+ }
197
+ const cm = await p;
198
+ if (!cm && cellMemoryConfig(env))
199
+ chatClients.delete(ownerEmail);
200
+ return cm;
201
+ }
202
+ // ws 车道接线(add-chat-memory-injection D1):无身份或任何失败都不动 prompt
203
+ // ——记忆永不失败一次聊天回合。
204
+ export async function chatPromptWithMemory(ownerEmail, prompt, env = process.env, fetchImpl = fetch) {
205
+ if (!ownerEmail)
206
+ return prompt;
207
+ try {
208
+ const cm = await chatMemoryClientFor(ownerEmail, env, fetchImpl);
209
+ if (!cm)
210
+ return prompt;
211
+ return (await chatMemoryInjectionFor(cm, prompt, { env })).prompt;
212
+ }
213
+ catch (e) {
214
+ console.warn(`[chat-memory] injection failed for ${ownerEmail} (degrade open): ${e?.message || e}`);
215
+ return prompt;
216
+ }
217
+ }
@@ -0,0 +1,35 @@
1
+ import { FetchLike, EnvLike } from "./http.js";
2
+ /** /observe 的封闭事件集(内核侧权威;运行时透传,4xx 即裁决)。 */
3
+ export type ObserveEventType = "turn_outcome" | "tool_call" | "health_flip";
4
+ export interface ObserveInput {
5
+ /** 永忆内核基址(尾部斜杠容忍)。 */
6
+ url: string;
7
+ /** person 或 deployment 令牌。 */
8
+ token: string;
9
+ type: ObserveEventType;
10
+ title: string;
11
+ body: string;
12
+ /** 幂等键;缺省自动 crypto.randomUUID()。 */
13
+ idempotencyKey?: string;
14
+ /** 成员/agent 标识(可选)。 */
15
+ subject?: string;
16
+ /** ISO8601;缺省=服务端时钟。 */
17
+ ts?: string;
18
+ /** 来源标注(跨 harness 合流,design D5)。 */
19
+ harness?: string;
20
+ metadata?: Record<string, unknown>;
21
+ /** 有界超时(毫秒);缺省 10s(PLATFORM_FETCH_TIMEOUT_MS 语义)。 */
22
+ timeoutMs?: number;
23
+ fetchImpl?: FetchLike;
24
+ env?: EnvLike;
25
+ }
26
+ export interface ObserveOutcome {
27
+ delivered: boolean;
28
+ status?: number;
29
+ page?: any;
30
+ truncated?: boolean;
31
+ error?: string;
32
+ }
33
+ export declare function newIdempotencyKey(): string;
34
+ export declare function observeOnce(input: ObserveInput): Promise<ObserveOutcome>;
35
+ export declare function observeFireAndForget(input: ObserveInput): void;
@@ -0,0 +1,68 @@
1
+ // ── /observe 客户端(新写,design D6) ───────────────────────────────────────
2
+ //
3
+ // 面向内核 POST /observe(design D3 schema)的客户端:幂等键调用方生成(缺
4
+ // 省自动 crypto.randomUUID()),重复投递由内核按 (namespace, idempotency_key)
5
+ // 幂等。上报纪律与捕获契约对齐:fire-and-forget、失败即丢、无重试、有界超
6
+ // 时——observe 永不阻塞、永不弄破宿主回合。
7
+ //
8
+ // 事件类型是内核侧的封闭集(turn_outcome / tool_call / health_flip):客户端
9
+ // 类型上收窄、运行时透传,4xx {error, code} 即封闭集/护栏的权威裁决。
10
+ import { randomUUID } from "node:crypto";
11
+ import { fetchWithTimeout } from "./http.js";
12
+ export function newIdempotencyKey() {
13
+ return randomUUID();
14
+ }
15
+ function observeBase(input) {
16
+ return `${input.url.replace(/\/+$/, "")}/observe`;
17
+ }
18
+ function observePayload(input) {
19
+ const payload = {
20
+ type: input.type,
21
+ idempotency_key: input.idempotencyKey ?? newIdempotencyKey(),
22
+ title: input.title,
23
+ body: input.body,
24
+ };
25
+ if (input.subject != null)
26
+ payload.subject = input.subject;
27
+ if (input.ts != null)
28
+ payload.ts = input.ts;
29
+ if (input.harness != null)
30
+ payload.harness = input.harness;
31
+ if (input.metadata != null)
32
+ payload.metadata = input.metadata;
33
+ return payload;
34
+ }
35
+ // 单次投递:校验-POST-返回,无排队。任何失败(网络/超时/4xx/5xx)都折算成
36
+ // { delivered: false, ... } 返回,绝不向调用方抛错——要原始结果用本函数,
37
+ // 要「丢了就丢了」用 observeFireAndForget。
38
+ export async function observeOnce(input) {
39
+ try {
40
+ const r = await fetchWithTimeout(observeBase(input), {
41
+ method: "POST",
42
+ headers: { authorization: `Bearer ${input.token}`, "content-type": "application/json" },
43
+ body: JSON.stringify(observePayload(input)),
44
+ }, { fetchImpl: input.fetchImpl, timeoutMs: input.timeoutMs, env: input.env });
45
+ if (!r.ok) {
46
+ let error = `observe HTTP ${r.status}`;
47
+ try {
48
+ const doc = await r.json();
49
+ if (doc?.error)
50
+ error = String(doc.error);
51
+ }
52
+ catch { /* 非法错误体保留 HTTP 状态文本 */ }
53
+ return { delivered: false, status: r.status, error };
54
+ }
55
+ const doc = await r.json();
56
+ return { delivered: true, status: r.status, page: doc?.page, truncated: doc?.truncated === true };
57
+ }
58
+ catch (e) {
59
+ return { delivered: false, error: String(e?.message || e) };
60
+ }
61
+ }
62
+ // fire-and-forget:挂上即走,失败即丢(永不产生 unhandled rejection)。
63
+ // hook 转发器等「绝不能阻塞宿主」的车道用本函数。
64
+ export function observeFireAndForget(input) {
65
+ void observeOnce(input).catch(() => {
66
+ // 双保险:observeOnce 已不抛;这里兜住未来重构引入的意外,保持「失败即丢」。
67
+ });
68
+ }
@@ -0,0 +1,35 @@
1
+ import { MEMORY_DISCIPLINE } from "./discipline.js";
2
+ import { FetchLike, EnvLike } from "./http.js";
3
+ export { MEMORY_DISCIPLINE };
4
+ /** 永忆内核连接三元组(cellMemoryConfig 的产物)。 */
5
+ export interface CellMemoryConfig {
6
+ url: string;
7
+ platformToken: string;
8
+ dataRoot: string;
9
+ }
10
+ export declare function cellMemoryConfig(env?: EnvLike): CellMemoryConfig | null;
11
+ export declare function personTokenFor(cfg: CellMemoryConfig, ownerEmail: string, fetchImpl?: FetchLike): Promise<string>;
12
+ export declare function memoryMcpEntry(url: string, token: string): {
13
+ id: string;
14
+ name: string;
15
+ config: {
16
+ serverName: string;
17
+ failOnStartupError: boolean;
18
+ transport: string;
19
+ url: string;
20
+ headers: {
21
+ Authorization: string;
22
+ };
23
+ };
24
+ };
25
+ export declare function cellMemoryMount(env: EnvLike, ownerEmail: string, fetchImpl?: FetchLike): Promise<{
26
+ mcpEntry: ReturnType<typeof memoryMcpEntry>;
27
+ discipline: string;
28
+ } | null>;
29
+ export declare function memberCachePath(dataRoot: string, packId: string, memberId: string): string;
30
+ export interface MemberTokenInput {
31
+ packId: string;
32
+ memberId: string;
33
+ ownerEmail?: string;
34
+ }
35
+ export declare function memberTokenFor(cfg: CellMemoryConfig, { packId, memberId, ownerEmail }: MemberTokenInput, fetchImpl?: FetchLike): Promise<string>;
package/dist/tokens.js ADDED
@@ -0,0 +1,113 @@
1
+ // ── 永忆挂载客户端:令牌铸造与缓存(自 paas/cell-memory.js 下放) ────────────
2
+ //
3
+ // 平台缝:CELL_MEMORY_ENABLED=1 且平台令牌与永忆 URL 配齐时,person 令牌
4
+ // 按 owner 首次铸造并缓存到数据根磁盘下——每个用户只铸一次,重启稳定。
5
+ //
6
+ // 契约(fail-open 面与 fail-loud 面的分界):铸造/URL 缺失/磁盘等任何失败,
7
+ // 组合层(cellMemoryMount)返回 null、落点与「记忆关闭」基线逐字节一致;
8
+ // 但令牌函数本身(personTokenFor/memberTokenFor)把铸造失败原样抛给调用方
9
+ // 计数处理——绝不静默假装成功。
10
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
11
+ import { join, dirname } from "node:path";
12
+ import { MEMORY_DISCIPLINE } from "./discipline.js";
13
+ import { fetchWithTimeout } from "./http.js";
14
+ export { MEMORY_DISCIPLINE };
15
+ // 开关四件套:ENABLED=1 + URL + 平台令牌 + 数据根,缺一即整体关闭(返回
16
+ // null,上层按「记忆不存在」处理)。
17
+ export function cellMemoryConfig(env = process.env) {
18
+ const enabled = env.CELL_MEMORY_ENABLED === "1";
19
+ const url = String(env.CELL_MEMORY_URL || "").replace(/\/+$/, "");
20
+ const platformToken = String(env.CELL_MEMORY_PLATFORM_TOKEN || "");
21
+ const dataRoot = String(env.CELL_MEMORY_DATA || "");
22
+ return enabled && url && platformToken && dataRoot ? { url, platformToken, dataRoot } : null;
23
+ }
24
+ function cachePath(dataRoot, ownerEmail) {
25
+ const safe = String(ownerEmail || "unknown").replace(/[^a-zA-Z0-9@._-]/g, "_");
26
+ return join(dataRoot, "cell-memory-tokens", `${safe}.json`);
27
+ }
28
+ // 铸造 person(u/)令牌:磁盘缓存优先(namespace 校验后复用),未命中才打
29
+ // /admin/mint;写盘 0600。铸造失败抛错——计数处理归调用方。
30
+ export async function personTokenFor(cfg, ownerEmail, fetchImpl = fetch) {
31
+ const p = cachePath(cfg.dataRoot, ownerEmail);
32
+ try {
33
+ const cached = JSON.parse(readFileSync(p, "utf8"));
34
+ if (cached?.token && cached?.namespace === `u/${ownerEmail}`)
35
+ return cached.token;
36
+ }
37
+ catch { /* 未命中 → 铸造 */ }
38
+ // 出站纪律:令牌铸造走共享死线包装(默认 env 界);fetchImpl 保持测试缝。
39
+ const res = await fetchWithTimeout(`${cfg.url}/admin/mint`, {
40
+ method: "POST",
41
+ headers: { "content-type": "application/json", authorization: `Bearer ${cfg.platformToken}` },
42
+ body: JSON.stringify({ namespace: `u/${ownerEmail}`, label: `cell:${ownerEmail}` }),
43
+ }, { fetchImpl });
44
+ if (!res.ok)
45
+ throw new Error(`person mint HTTP ${res.status}`);
46
+ const { token } = await res.json();
47
+ mkdirSync(dirname(p), { recursive: true });
48
+ writeFileSync(p, JSON.stringify({ token, namespace: `u/${ownerEmail}`, mintedAt: new Date().toISOString() }), { mode: 0o600 });
49
+ return token;
50
+ }
51
+ // dsh-mcp-client 的加载行(http 分支),mcpEntry 形状逐字段保留。
52
+ export function memoryMcpEntry(url, token) {
53
+ return {
54
+ id: "mcp-fd-memory",
55
+ name: "@deepseek-ai/dsh-mcp-client",
56
+ config: {
57
+ serverName: "fd-memory",
58
+ failOnStartupError: false,
59
+ transport: "streamable-http",
60
+ url: `${url}/mcp`,
61
+ headers: { Authorization: `Bearer ${token}` },
62
+ },
63
+ };
64
+ }
65
+ // 组合助手:返回 { mcpEntry, discipline } 或 null(fail-open)。任何失败都
66
+ // 不改变调用方的落点——与「记忆关闭」基线逐字节一致。
67
+ export async function cellMemoryMount(env, ownerEmail, fetchImpl = fetch) {
68
+ const cfg = cellMemoryConfig(env);
69
+ if (!cfg || !ownerEmail)
70
+ return null;
71
+ try {
72
+ const token = await personTokenFor(cfg, ownerEmail, fetchImpl);
73
+ return { mcpEntry: memoryMcpEntry(cfg.url, token), discipline: MEMORY_DISCIPLINE };
74
+ }
75
+ catch (e) {
76
+ console.warn(`[cell-memory] mount skipped (fail-open): ${e?.message || e}`);
77
+ return null;
78
+ }
79
+ }
80
+ // ── Deployment 成员令牌(自 paas/cell-memory.js 下放) ───────────────────────
81
+ //
82
+ // 平台缝为每个成员铸一枚 deployment 令牌(d/<packId>/<memberId>,在 fd-memory
83
+ // 侧绑 owner 作为督导锚点),缓存到 cell 数据根——每成员只铸一次、重启稳定、
84
+ // 0600。令牌属于「岗位」而不属于背后的模型实例:换防保留缓存,新上岗模型
85
+ // 继承旧岗写下的分区。fail-open 同 person 面契约:铸造失败 = 计数 + 把错误
86
+ // 抛给调用方,绝不弄破回合。
87
+ export function memberCachePath(dataRoot, packId, memberId) {
88
+ const safe = (s) => String(s ?? "x").replace(/[^a-zA-Z0-9@._-]/g, "_");
89
+ return join(dataRoot, "cell-memory-tokens", `d-${safe(packId)}-${safe(memberId)}.json`);
90
+ }
91
+ export async function memberTokenFor(cfg, { packId, memberId, ownerEmail }, fetchImpl = fetch) {
92
+ const p = memberCachePath(cfg.dataRoot, packId, memberId);
93
+ const namespace = `d/${packId}/${memberId}`;
94
+ try {
95
+ const cached = JSON.parse(readFileSync(p, "utf8"));
96
+ if (cached?.token && cached?.namespace === namespace)
97
+ return cached.token;
98
+ }
99
+ catch { /* 未命中 → 铸造 */ }
100
+ if (!ownerEmail)
101
+ throw new Error("member mint requires ownerEmail (the oversight anchor)");
102
+ const res = await fetchWithTimeout(`${cfg.url}/admin/mint`, {
103
+ method: "POST",
104
+ headers: { "content-type": "application/json", authorization: `Bearer ${cfg.platformToken}` },
105
+ body: JSON.stringify({ namespace, owner: `u/${ownerEmail}`, label: `member:${packId}:${memberId}` }),
106
+ }, { fetchImpl });
107
+ if (!res.ok)
108
+ throw new Error(`member mint HTTP ${res.status}`);
109
+ const { token } = await res.json();
110
+ mkdirSync(dirname(p), { recursive: true });
111
+ writeFileSync(p, JSON.stringify({ token, namespace, owner: ownerEmail, mintedAt: new Date().toISOString() }), { mode: 0o600 });
112
+ return token;
113
+ }
package/package.json CHANGED
@@ -1,6 +1,43 @@
1
1
  {
2
2
  "name": "@finddatatechnology/yongyi-client",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "description": "永忆(YongYi / fd-memory)客户端 SDK:召回注入阶梯、纪律文本单源、五类事实捕获、令牌铸造缓存、/observe 上报。零运行时依赖,只以 fetch 打内核 HTTP 面。",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "README.md"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsc -p tsconfig.json",
20
+ "test": "tsc -p tsconfig.test.json && node --test \"dist-test/test/*.test.js\""
21
+ },
22
+ "engines": {
23
+ "node": ">=22.5.0"
24
+ },
25
+ "keywords": [
26
+ "agent",
27
+ "memory",
28
+ "llm",
29
+ "mcp",
30
+ "working-memory",
31
+ "yongyi"
32
+ ],
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/FindDataTechnology/fd-memory.git",
36
+ "directory": "sdk"
37
+ },
38
+ "license": "MIT",
39
+ "devDependencies": {
40
+ "typescript": "^5.6.0",
41
+ "@types/node": "^22.0.0"
42
+ }
6
43
  }