M2: 基座能力 —— memory / toolbox / channel / message

- memory   任务记忆四性质(只追加·信封盖戳·治理先行·归侧)、活动记忆四层与条目三件套、
           任务日志两级投影(结构化零 LLM / 叙述性小结不入事实库)、度量八指标只读投影
- toolbox  注册两审(静态审 + 试运行审)、效应等级决定运行时待遇、契约演进只增不破坏、
           白名单与纯工具私有作用域、ToolInvoker(两缝之二,live/record/replay)
- channel  通道实例配置、入站归一(解析 → 系统写 → 数据事件)、出站发送与关联记录登记
- message  通知 / 跨任务征答(就地作答 · 驳回重答只增不改)/ 死信 / 无主兜底

app:message 接上 event 的 MessageFallback;派发器按目标 ID 取 advisory lock 串行消费 +
消费幂等表;/api/ingest 模拟邮件通道(mock 只在外界)

已验证:无主 RFQ 邮件入站 → 归属解析 no-match → 事件落库 → 兜底落 OP 岗位的消息要素
门禁五道全绿;150 个单元测试 + 1 个 PG 集成测试

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
JCAI Main Agent
2026-07-27 01:16:52 +08:00
co-authored by Claude Opus 5
parent 14d4fa046a
commit f0444f1417
73 changed files with 7488 additions and 31 deletions
+12
View File
@@ -0,0 +1,12 @@
{
"messageId": "m-1001",
"from": "buyer@acme.com",
"to": [
"rfq@we.com"
],
"cc": [],
"subject": "询价 RFQ - 车规 Analog",
"body": "请报价:LM358DR 5000pcs, 交期 8 周",
"receivedAt": "2026-07-27T02:00:00.000Z",
"attachments": []
}
+48
View File
@@ -0,0 +1,48 @@
/**
* module: channel —— 对外类型(编码规范 §10.1)。
*/
import type { InboundMail, OwnershipResolution, TaskId } from '@contracts';
import type { ChannelInstanceConfig, ExtractRule, LedgerMapping, NormalizedWrite } from './internal/domain/payload-mapping.ts';
export type { ChannelInstanceConfig, ExtractRule, InboundMail, LedgerMapping, NormalizedWrite, OwnershipResolution };
export declare class ChannelNotFoundError extends Error {
readonly channelId: string;
constructor(channelId: string);
}
export declare class ChannelDisabledError extends Error {
readonly channelId: string;
constructor(channelId: string);
}
export type InboundOutcome = {
readonly eventId: string;
readonly resolution: OwnershipResolution;
readonly writes: readonly NormalizedWrite[];
readonly listenersHit: number;
readonly triggersHit: number;
readonly fellBackToMessage: boolean;
};
export type OutboundMail = {
readonly taskId: TaskId;
readonly to: readonly string[];
readonly cc?: readonly string[];
readonly subject: string;
readonly body: string;
readonly threadId?: string;
readonly attachments?: ReadonlyArray<{
name: string;
ref: string;
}>;
};
export type ChannelService = {
list(): Promise<ChannelInstanceConfig[]>;
upsert(config: ChannelInstanceConfig): Promise<ChannelInstanceConfig>;
/**
* 入站:归属解析 → 归一为**系统写** → 数据事件。
* 「环外回音经归属解析归一为数据写入后,以数据事件抛出 —— 活动认数据,不认通道。」
*/
ingestMail(channelId: string, mail: InboundMail): Promise<InboundOutcome>;
/** 出站:触达世界的动作;发送即登记出站关联记录(归属解析①③级的对表来源) */
sendMail(channelId: string, mail: OutboundMail): Promise<{
messageId: string;
threadId: string;
}>;
};
+11
View File
@@ -0,0 +1,11 @@
/**
* module: channel —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
*/
export type { ChannelInstanceConfig, ChannelService, ExtractRule, InboundMail, InboundOutcome, LedgerMapping, NormalizedWrite, OutboundMail, } from './contract.ts';
export { ChannelDisabledError, ChannelNotFoundError } from './contract.ts';
export type { AttachmentArchive, ChannelRepository, Clock, EventGateway, IdGenerator, LedgerSystemWriter, MailTransport, } from './port.ts';
export type { ChannelServiceDeps } from './internal/usecase/channel-service.ts';
export { createChannelService } from './internal/usecase/channel-service.ts';
export { extractFields, normalize } from './internal/domain/payload-mapping.ts';
export { createChannelRepository, createFileAttachmentArchive, createSimulatedMailTransport, } from './internal/adapter/channel-store.ts';
export { channelTable, outboundMailTable } from './internal/adapter/channel-schema.ts';
+94
View File
@@ -0,0 +1,94 @@
/**
* module: channel —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
*/
import type { EnvelopeDraft, OwnershipResolution, PositionId, TaskId } from '@contracts';
import type { ChannelInstanceConfig } from './internal/domain/payload-mapping.ts';
export type Clock = {
now(): Date;
};
export type IdGenerator = {
next(): string;
};
export type ChannelRepository = {
list(): Promise<ChannelInstanceConfig[]>;
get(channelId: string): Promise<ChannelInstanceConfig | undefined>;
upsert(config: ChannelInstanceConfig): Promise<ChannelInstanceConfig>;
};
/** 事件入口 —— app 注入 `event`(L1)的出口 */
export type EventGateway = {
ingestInbound(input: {
facts: {
threadId?: string;
from: string;
to: readonly string[];
subject: string;
body: string;
receivedAt: Date;
};
rules: {
patterns: ReadonlyArray<{
key: string;
regex: string;
source: 'subject' | 'body' | 'both';
}>;
addressWindowMs: number;
fallbackPosition?: PositionId | undefined;
};
draft: EnvelopeDraft;
}): Promise<{
eventId: string;
resolution: OwnershipResolution;
plan: {
taskTargets: readonly string[];
triggerTargets: readonly string[];
messageFallback: boolean;
};
}>;
recordOutbound(ref: {
taskId: TaskId;
threadId?: string;
address: string;
sentAt: Date;
}): Promise<void>;
};
/**
* 台账系统写 —— app 注入 `ledger`(L1)的 `systemWrite`。
* 入站归一是「登记世界已发生的事实」,走**系统写**:不过放行门,写正式版并即时抛数据事件(OS 09)。
*/
export type LedgerSystemWriter = {
systemWrite(input: {
address: {
domain: string;
object: string;
};
key: Record<string, string | number>;
values: Record<string, unknown>;
cause: {
kind: 'inbound';
ref: string;
};
taskId?: TaskId;
}): Promise<void>;
};
/** 邮件收发 —— 外界。MVP 允许模拟通道 / 录制回放替身(README MVP 完成判据) */
export type MailTransport = {
send(input: {
readonly from: string;
readonly to: readonly string[];
readonly cc: readonly string[];
readonly subject: string;
readonly body: string;
readonly threadId?: string;
readonly attachments: ReadonlyArray<{
name: string;
ref: string;
}>;
}): Promise<{
messageId: string;
threadId: string;
}>;
};
/** 原件存档 —— 附件对象的存放;返回存档地址(信封的原件指针) */
export type AttachmentArchive = {
put(name: string, content: string): Promise<string>;
};
+51
View File
@@ -0,0 +1,51 @@
/**
* module: memory —— 对外类型(编码规范 §10.1)。
*/
import type { AttributionSide, TaskId } from '@contracts';
import type { ActivityMemoryEntry, CandidateIncrement, Evidence, MemoryLayer } from './internal/domain/activity-memory.ts';
import type { EntryKind, GovernanceMark, MemoryEntry, VersionStamp } from './internal/domain/task-memory.ts';
import type { LogLine, Metrics, MetricsInput } from './internal/domain/task-log.ts';
export type { ActivityMemoryEntry, AttributionSide, CandidateIncrement, EntryKind, Evidence, GovernanceMark, LogLine, MemoryEntry, MemoryLayer, Metrics, MetricsInput, VersionStamp, };
export { NoEvidenceError } from './internal/domain/activity-memory.ts';
/** 追加一条任务记忆 —— 归侧由 kind 自动推导,调用方不得指定(零埋点) */
export type AppendEntry = {
readonly taskId: TaskId;
readonly kind: EntryKind;
readonly title: string;
readonly detail?: Record<string, unknown>;
readonly stamp: VersionStamp;
readonly governance?: Partial<GovernanceMark>;
readonly linksTo?: string;
readonly eventRef?: string;
};
export type DistillInput = {
readonly taskId: TaskId;
readonly activityId: string;
readonly trigger: 'terminal' | 'milestone';
readonly milestone?: string;
readonly prompt: string;
};
export type MemoryService = {
append(entry: AppendEntry): Promise<MemoryEntry>;
history(taskId: TaskId): Promise<MemoryEntry[]>;
/** 活动侧唯一可配置项:窗口大小 —— 每次 LLM 调用加载多少轮 */
window(taskId: TaskId, size: number): Promise<MemoryEntry[]>;
seal(taskId: TaskId): Promise<void>;
taskLog(taskId: TaskId, options?: {
canSeeMasked?: boolean;
}): Promise<LogLine[]>;
/** 叙述性小结(LLM,可选)—— 叙述是视图不是事实,不入事实库 */
narrate(taskId: TaskId): Promise<string | undefined>;
loadActivityMemory(activityId: string, options?: {
layers?: readonly MemoryLayer[];
limit?: number;
}): Promise<ActivityMemoryEntry[]>;
/** **不设直写旁路**:唯一写入口是蒸馏钩子 */
distill(input: DistillInput): Promise<ActivityMemoryEntry[]>;
/** 人可修剪 —— 治理台 · 记忆管理 */
prune(entryId: string): Promise<void>;
setIncrementState(entryId: string, state: CandidateIncrement['state']): Promise<void>;
metrics(input: Omit<MetricsInput, 'entries'> & {
taskIds: readonly TaskId[];
}): Promise<Metrics>;
};
+13
View File
@@ -0,0 +1,13 @@
/**
* module: memory —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
*/
export type { ActivityMemoryEntry, AppendEntry, AttributionSide, CandidateIncrement, DistillInput, EntryKind, Evidence, GovernanceMark, LogLine, MemoryEntry, MemoryLayer, MemoryService, Metrics, MetricsInput, VersionStamp, } from './contract.ts';
export { NoEvidenceError } from './contract.ts';
export type { ActivityMemoryStore, Clock, IdGenerator, SystemModel, TaskMemoryStore } from './port.ts';
export type { MemoryServiceDeps } from './internal/usecase/memory-service.ts';
export { createMemoryService } from './internal/usecase/memory-service.ts';
export { assertThreePart, isBinding, MEMORY_LAYER_LABEL, MEMORY_LAYER_TARGET, MEMORY_LAYERS, selectForLoading, } from './internal/domain/activity-memory.ts';
export { assertAppendOnly, deriveSide, distillable, ENTRY_KINDS, redactForReader, windowOf, } from './internal/domain/task-memory.ts';
export { computeMetrics, splitBySide, structuredLog } from './internal/domain/task-log.ts';
export { createActivityMemoryStore, createTaskMemoryStore } from './internal/adapter/memory-store.ts';
export { activityMemoryTable, taskMemorySealTable, taskMemoryTable } from './internal/adapter/memory-schema.ts';
+42
View File
@@ -0,0 +1,42 @@
/**
* module: memory —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
*/
import type { TaskId } from '@contracts';
import type { ActivityMemoryEntry, MemoryLayer } from './internal/domain/activity-memory.ts';
import type { MemoryEntry } from './internal/domain/task-memory.ts';
export type Clock = {
now(): Date;
};
export type IdGenerator = {
next(): string;
};
export type TaskMemoryStore = {
nextSeq(taskId: TaskId): Promise<number>;
append(entry: MemoryEntry): Promise<void>;
list(taskId: TaskId): Promise<MemoryEntry[]>;
tail(taskId: TaskId, size: number): Promise<MemoryEntry[]>;
seal(taskId: TaskId, at: Date): Promise<void>;
isSealed(taskId: TaskId): Promise<boolean>;
};
export type ActivityMemoryStore = {
list(activityId: string, layers: readonly MemoryLayer[] | undefined, limit: number): Promise<ActivityMemoryEntry[]>;
insertMany(entries: readonly ActivityMemoryEntry[]): Promise<void>;
prune(entryId: string, at: Date): Promise<void>;
setIncrementState(entryId: string, state: string): Promise<void>;
};
/**
* 模型调用 —— app 注入 `llm`(L1)的 ModelGateway。
* 蒸馏钩子与叙述性小结用**系统用途**绑定实例:计量入系统账、不占活动预算(OS 02)。
*/
export type SystemModel = {
generate(input: {
readonly model: string;
readonly prompt: string;
readonly outputSchema?: Record<string, unknown>;
}): Promise<{
text: string;
object?: unknown;
}>;
/** 系统用途模型名;未配置即 undefined —— 此时叙述与蒸馏静默跳过,不报错 */
systemModelName(): Promise<string | undefined>;
};
+41
View File
@@ -0,0 +1,41 @@
/**
* module: message —— 对外类型(编码规范 §10.1)。
*/
import type { Envelope, StaffId } from '@contracts';
import type { MessageKind, SystemMessage } from './internal/domain/message-kind.ts';
export type { Envelope, MessageKind, SystemMessage };
export declare class MessageNotAnswerableError extends Error {
readonly messageId: string;
constructor(messageId: string);
}
export type SendNotice = {
readonly staffId: StaffId;
readonly title: string;
readonly body: string;
readonly eventRef?: string;
};
export type SendElicitation = {
readonly staffId: StaffId;
readonly title: string;
readonly body: string;
readonly cardId: string;
readonly sourceTaskId: string;
readonly payload: Record<string, unknown>;
};
export type MessageService = {
inbox(staffId: StaffId, options?: {
unreadOnly?: boolean;
limit?: number;
}): Promise<SystemMessage[]>;
markRead(messageId: string): Promise<void>;
notify(input: SendNotice): Promise<SystemMessage>;
/** 跨人卡以系统消息送达对方,就地作答 —— 任务要素永远只装「我负责的任务」(OS 2.1) */
sendElicitation(input: SendElicitation): Promise<SystemMessage>;
answer(messageId: string, staffId: StaffId, answer: Record<string, unknown>): Promise<SystemMessage>;
/** 驳回重答 = 新发一条系统消息(新卡实例),原卡闭合 —— 只增不改 */
rejectAndReask(messageId: string, reason: string): Promise<SystemMessage>;
/** 死信:重试超限 → 系统通知送达相关人的消息要素与治理台 */
deadLetter(envelope: Envelope, error: string): Promise<void>;
/** 无主兜底:相关人 = 通道实例配置的默认认领岗位 */
unowned(envelope: Envelope, fallbackPosition: string | undefined): Promise<void>;
};
+11
View File
@@ -0,0 +1,11 @@
/**
* module: message —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
*/
export type { MessageKind, MessageService, SendElicitation, SendNotice, SystemMessage } from './contract.ts';
export { MessageNotAnswerableError } from './contract.ts';
export type { Clock, IdGenerator, MessageRepository, PositionResolver } from './port.ts';
export type { MessageServiceDeps } from './internal/usecase/message-service.ts';
export { createMessageService } from './internal/usecase/message-service.ts';
export { canAnswer, MESSAGE_KIND_LABEL, MESSAGE_KINDS, pendingAnswerCount, unreadCount, } from './internal/domain/message-kind.ts';
export { createMessageRepository } from './internal/adapter/message-store.ts';
export { messageTable } from './internal/adapter/message-schema.ts';
+29
View File
@@ -0,0 +1,29 @@
/**
* module: message —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
*/
import type { StaffId } from '@contracts';
import type { SystemMessage } from './internal/domain/message-kind.ts';
export type Clock = {
now(): Date;
};
export type IdGenerator = {
next(): string;
};
export type MessageRepository = {
list(staffId: StaffId, unreadOnly: boolean, limit: number): Promise<SystemMessage[]>;
get(messageId: string): Promise<SystemMessage | undefined>;
insert(message: SystemMessage): Promise<SystemMessage>;
markRead(messageId: string, at: Date): Promise<void>;
markAnswered(messageId: string, at: Date, answer: Record<string, unknown>): Promise<void>;
supersede(messageId: string, byMessageId: string, reason: string): Promise<void>;
};
/** 岗位解析 —— app 注入 `org`(L1):无主兜底要把消息落到默认认领岗位上的人 */
export type PositionResolver = {
staffOfPosition(positionId: string): Promise<Array<{
id: StaffId;
}>>;
/** 治理台收件人(任务管理岗)—— 死信同时送治理台 */
governanceStaff(): Promise<Array<{
id: StaffId;
}>>;
};
+109
View File
@@ -0,0 +1,109 @@
/**
* module: toolbox —— 对外类型(编码规范 §10.1)。
*
* 两条缝之二(§7.3):`ToolInvoker` 支持 record | replay | live ——
* **任何工具执行不经 ToolInvoker,回放审就对它失明**(§12.5#10)。
*/
import type { ActionIntent, TaskId } from '@contracts';
import type { AuditFinding } from './internal/domain/registration-audit.ts';
import type { CallDisposition, EffectLevel, EvolutionViolation, Preauthorization, ToolContract } from './internal/domain/tool-contract.ts';
export type { ActionIntent, AuditFinding, CallDisposition, EffectLevel, EvolutionViolation, Preauthorization, ToolContract };
export declare const INVOKER_MODES: readonly ["live", "record", "replay"];
export type InvokerMode = (typeof INVOKER_MODES)[number];
export declare class ToolNotFoundError extends Error {
readonly tool: string;
constructor(tool: string);
}
export declare class ToolNotAllowedError extends Error {
readonly tool: string;
readonly activityId: string;
constructor(tool: string, activityId: string);
}
export declare class ToolDisabledError extends Error {
readonly tool: string;
constructor(tool: string);
}
export declare class ToolReplayMissError extends Error {
readonly key: string;
constructor(key: string);
}
export declare class RegistrationRejectedError extends Error {
readonly findings: readonly AuditFinding[];
constructor(findings: readonly AuditFinding[]);
}
export type ToolCall = {
readonly tool: string;
readonly operation?: string;
readonly input: Record<string, unknown>;
readonly activityId: string;
readonly taskId?: TaskId;
readonly stepNo?: number;
};
export type ToolResult = {
readonly ok: boolean;
readonly output?: Record<string, unknown>;
readonly error?: {
readonly code: string;
readonly retryable: boolean;
readonly message: string;
};
readonly latencyMs: number;
readonly mode: InvokerMode;
};
/** 调用留痕:入参快照、出参、时延与错误自动留痕入任务记忆(OS 7.3) */
export type InvocationTrace = {
readonly tool: string;
readonly input: Record<string, unknown>;
readonly result: ToolResult;
readonly at: string;
};
/**
* 工具调用器 —— 活动基座六件里的「工具(调用契约)」。
* **读类**过程中自由调用;**写类**登记为动作意向,放行后落地(除契约显式预授权者)。
*/
export type ToolInvoker = {
readonly mode: InvokerMode;
/** 判定运行时待遇 —— 调用方据此决定是即调还是登记意向 */
disposition(call: ToolCall, preauthorized: readonly Preauthorization[]): Promise<CallDisposition>;
invoke(call: ToolCall): Promise<ToolResult>;
/** 落地预检第四项:工具契约含 validate 能力时调之(ADR-005) */
validate(call: ToolCall): Promise<ToolResult | undefined>;
withMode(mode: InvokerMode): ToolInvoker;
};
export type RegisterToolInput = {
readonly contract: ToolContract;
/** 契约测试集:样例入参 → 期望出参形状;试运行审的依据 */
readonly samples: ReadonlyArray<{
input: Record<string, unknown>;
expectShape: Record<string, unknown>;
}>;
/** 连接配置的值 —— 只存治理台,加密落库(ADR-009) */
readonly connectionValues?: Readonly<Record<string, string>>;
};
export type RegistrationVerdict = {
readonly accepted: boolean;
readonly staticFindings: readonly AuditFinding[];
readonly evolution: readonly EvolutionViolation[];
readonly trialRun: {
readonly ran: number;
readonly passed: number;
readonly failures: readonly string[];
};
};
export type ToolboxService = {
/** 注册两审:静态审读清单,试运行审验代码 */
register(input: RegisterToolInput): Promise<RegistrationVerdict>;
list(): Promise<ToolContract[]>;
get(name: string): Promise<ToolContract | undefined>;
/** 有活动声明引用的工具不得删除;可停用 —— 停用后调用返回结构化错误 */
setEnabled(name: string, enabled: boolean): Promise<void>;
bindConnection(name: string, values: Readonly<Record<string, string>>): Promise<void>;
/** 白名单:活动只经调用契约用工具,授权在白名单与契约,不在活动代码 */
setAllowlist(activityId: string, tools: readonly string[]): Promise<void>;
allowlistOf(activityId: string): Promise<string[]>;
/** 安装静态审:活动声明的工具契约齐备?装不齐不上架 */
assertContractsAvailable(names: readonly string[]): Promise<{
ok: boolean;
missing: string[];
}>;
};
+15
View File
@@ -0,0 +1,15 @@
/**
* module: toolbox —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
*/
export type { ActionIntent, AuditFinding, CallDisposition, EffectLevel, EvolutionViolation, InvocationTrace, InvokerMode, Preauthorization, RegisterToolInput, RegistrationVerdict, ToolCall, ToolContract, ToolInvoker, ToolResult, ToolboxService, } from './contract.ts';
export { INVOKER_MODES, RegistrationRejectedError, ToolDisabledError, ToolNotAllowedError, ToolNotFoundError, ToolReplayMissError, } from './contract.ts';
export type { Clock, IdGenerator, SchemaValidator, SecretBox, ToolRegistry, ToolReplayStore, ToolRuntime, } from './port.ts';
export type { ToolInvokerDeps } from './internal/usecase/tool-invoker.ts';
export { createToolInvoker, toolReplayKey } from './internal/usecase/tool-invoker.ts';
export type { ToolboxServiceDeps } from './internal/usecase/toolbox-service.ts';
export { createToolboxService } from './internal/usecase/toolbox-service.ts';
export { checkEvolution, dispositionOf, EFFECT_LEVEL_LABEL, EFFECT_LEVELS } from './internal/domain/tool-contract.ts';
export { auditPassed, staticAudit } from './internal/domain/registration-audit.ts';
export type { ToolHandler } from './internal/adapter/toolbox-store.ts';
export { createSchemaValidator, createToolRegistry, createToolReplayStore, createToolRuntime, isAllowed, } from './internal/adapter/toolbox-store.ts';
export { replayToolTable, toolAllowlistTable, toolTable } from './internal/adapter/toolbox-schema.ts';
+68
View File
@@ -0,0 +1,68 @@
/**
* module: toolbox —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
*/
import type { ToolContract } from './internal/domain/tool-contract.ts';
import type { ToolCall, ToolResult } from './contract.ts';
export type Clock = {
now(): Date;
};
export type IdGenerator = {
next(): string;
};
/** 凭证密封 —— 与 llm 同一实现,由 app 注入(ADR-009) */
export type SecretBox = {
seal(plaintext: string): string;
open(cipher: string): string;
};
export type ToolRegistry = {
list(): Promise<ToolContract[]>;
get(name: string): Promise<ToolContract | undefined>;
isEnabled(name: string): Promise<boolean>;
upsert(contract: ToolContract, connectionCipher: Record<string, string> | null): Promise<void>;
setEnabled(name: string, enabled: boolean): Promise<void>;
connectionCipher(name: string): Promise<Record<string, string> | undefined>;
setAllowlist(activityId: string, tools: readonly string[]): Promise<void>;
allowlistOf(activityId: string): Promise<string[]>;
/** 有活动声明引用即不得删除 */
referencedBy(name: string): Promise<string[]>;
};
/** 工具录制回放存储 —— 与 llm 的 ReplayStore 同构,另存一表(§7.3) */
export type ToolReplayStore = {
put(record: {
key: string;
call: ToolCall;
result: ToolResult;
recordedAt: Date;
}): Promise<void>;
get(key: string): Promise<{
call: ToolCall;
result: ToolResult;
} | undefined>;
};
/**
* 工具执行 —— 实现对系统:server 侧执行代码,运行时受限,
* **只可访问注册申报的连接目标,无其他环境能力**(OS 7.3)。
*/
export type ToolRuntime = {
execute(input: {
readonly contract: ToolContract;
readonly connection: Readonly<Record<string, string>>;
readonly call: ToolCall;
readonly idempotencyKey?: string;
}): Promise<{
ok: boolean;
output?: Record<string, unknown>;
error?: {
code: string;
retryable: boolean;
message: string;
};
}>;
};
/** JSON Schema 校验 —— ajv 在 adapter,usecase 只见本端口(ADR-003) */
export type SchemaValidator = {
validate(schema: Record<string, unknown>, value: unknown): {
ok: boolean;
errors: string[];
};
};
+3
View File
@@ -17,9 +17,12 @@
"@ai-sdk/openai-compatible": "^3.0.14",
"@hono/node-server": "^2.0.12",
"ai": "^7.0.37",
"ajv": "^8.20.0",
"ajv-formats": "^3.0.1",
"drizzle-orm": "^0.45.2",
"hono": "^4.12.32",
"pg": "^8.22.0",
"pg-boss": "^12.26.3",
"zod": "^4.4.3"
},
"devDependencies": {
+8
View File
@@ -1,10 +1,16 @@
/**
* 环境配置 —— 只在 app/ 层读环境变量;模块内一律经 port 注入(§5.3)。
*/
import { join } from 'node:path';
export type AppConfig = {
readonly nodeEnv: 'development' | 'test' | 'production';
readonly port: number;
readonly databaseUrl: string;
/** 原件存档根目录 —— 信封的原件指针指向这里 */
readonly attachmentRoot: string;
/** 派发器轮询间隔(毫秒) */
readonly dispatchIntervalMs: number;
};
const DEFAULT_DATABASE_URL = 'postgres://jcai:jcai@localhost:54329/jcai';
@@ -15,5 +21,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
nodeEnv: nodeEnv === 'production' || nodeEnv === 'test' ? nodeEnv : 'development',
port: Number(env['PORT'] ?? 8787),
databaseUrl: env['DATABASE_URL'] ?? DEFAULT_DATABASE_URL,
attachmentRoot: env['JCAI_ATTACHMENT_ROOT'] ?? join(process.cwd(), '.data', 'attachments'),
dispatchIntervalMs: Number(env['JCAI_DISPATCH_INTERVAL_MS'] ?? 2000),
};
}
+123 -28
View File
@@ -9,7 +9,7 @@ import { Pool } from 'pg';
import { drizzle } from 'drizzle-orm/node-postgres';
import type { Db } from '@shared/db.ts';
import type { EventService, MessageFallback, TriggerMatcher } from '@modules/event';
import type { EventService, TriggerMatcher } from '@modules/event';
import { createEventService, createEventStore } from '@modules/event';
import type { LedgerService } from '@modules/ledger';
import { createDraftStore, createLedgerService, createLedgerStore, DRAFT_DDL } from '@modules/ledger';
@@ -28,6 +28,26 @@ import {
createProviderInvoker,
createReplayStore,
} from '@modules/llm';
import type { MemoryService } from '@modules/memory';
import { createActivityMemoryStore, createMemoryService, createTaskMemoryStore } from '@modules/memory';
import type { ToolboxService, ToolHandler, ToolInvoker } from '@modules/toolbox';
import {
createSchemaValidator,
createToolInvoker,
createToolRegistry,
createToolReplayStore,
createToolRuntime,
createToolboxService,
} from '@modules/toolbox';
import type { MessageService } from '@modules/message';
import { createMessageRepository, createMessageService } from '@modules/message';
import type { ChannelService } from '@modules/channel';
import {
createChannelRepository,
createChannelService,
createFileAttachmentArchive,
createSimulatedMailTransport,
} from '@modules/channel';
import type { AppConfig } from './config.ts';
import { createIdGenerator, systemClock } from './runtime.ts';
@@ -42,12 +62,17 @@ export type Container = {
readonly event: EventService;
readonly llm: LlmService;
readonly modelGateway: ModelGateway;
readonly memory: MemoryService;
readonly toolbox: ToolboxService;
readonly toolInvoker: ToolInvoker;
readonly message: MessageService;
readonly channel: ChannelService;
close(): Promise<void>;
};
/**
* M1 的占位实现 —— 在途工作探针要问的是 task(L6)与 human-tool(L3),二者随 M3/M4 落地。
* 占位返回空集,停用检查因此在 M1 一律放行;M4 起接真实用例。
* 在途工作探针 —— 停用前置检查要问的是 task(L6)与 human-tool(L3),二者随 M3/M4 落地。
* 占位返回空集;M4 起接真实用例(docs/debt.md · M1-1)。
*/
const emptyInFlightProbe: InFlightWorkProbe = {
openTasksOf: async () => [],
@@ -55,26 +80,15 @@ const emptyInFlightProbe: InFlightWorkProbe = {
openApprovalsOf: async () => [],
};
/** M1 的占位实现 —— 触发器(L7)随 M5 落地,当下恒不命中 */
/** 触发器判定 —— trigger(L7)随 M5 落地,当下恒不命中(docs/debt.md · M1-1) */
const noTriggerMatcher: TriggerMatcher = { match: async () => [] };
/** M1 的占位实现 —— 消息(L2)随 M2 落地;在此之前无主事件只落日志,不静默丢弃 */
function createConsoleMessageFallback(): MessageFallback {
return {
async deadLetter(envelope, error) {
process.stderr.write(`[死信] event=${envelope.id} type=${envelope.type} error=${error}\n`);
},
async unowned(envelope, fallbackPosition) {
process.stderr.write(
`[无主兜底] event=${envelope.id} type=${envelope.type} 认领岗位=${fallbackPosition ?? '未配置'}\n`,
);
},
};
}
/** S-FEEL 求值 —— @sfeel 随 M3 落地;M1 的条件监听一律按「无条件」处理 */
/** S-FEEL 求值 —— @sfeel 随 M3 落地;在此之前监听只用类型 + 关联键过滤 */
const passthroughEvaluator = (): boolean => true;
/** 第一方工具实现 —— 由 app 注册进 ToolRuntime;活动只见调用契约 */
export const firstPartyToolHandlers = new Map<string, ToolHandler>();
export async function createContainer(config: AppConfig): Promise<Container> {
const pool = new Pool({ connectionString: config.databaseUrl, max: 10 });
const db = drizzle(pool);
@@ -84,13 +98,44 @@ export async function createContainer(config: AppConfig): Promise<Container> {
// 草稿层的建表:ledger schema 的唯一写者是 ledger adapter,DDL 在其出口给出
await pool.query(DRAFT_DDL);
const org = createOrgService({
positions: createPositionRepository(db),
staff: createStaffRepository(db),
grants: createGrantRepository(db),
inFlight: emptyInFlightProbe,
ids,
});
// message 是 event 的 `MessageFallback` 实现面 —— event(L1)不反向依赖它,由此处注入
const message = createMessageService({
repo: createMessageRepository(db),
org: {
staffOfPosition: async (positionId) => {
const { staff } = await org.listStaff();
return staff.filter((s) => s.positionId === positionId).map((s) => ({ id: s.id }));
},
governanceStaff: async () => {
const { staff } = await org.listStaff();
const governing = await Promise.all(
staff.map(async (s) => ({
s,
ok: await org.authorize(s.id, { kind: 'governance', function: 'task' }, 'govern'),
})),
);
return governing.filter((g) => g.ok).map((g) => ({ id: g.s.id }));
},
},
clock: systemClock,
ids,
});
const event = createEventService({
store: createEventStore(db, ids),
clock: systemClock,
ids,
evaluate: passthroughEvaluator,
triggers: noTriggerMatcher,
messages: createConsoleMessageFallback(),
messages: message,
});
const ledger = createLedgerService({
@@ -101,14 +146,6 @@ export async function createContainer(config: AppConfig): Promise<Container> {
ids,
});
const org = createOrgService({
positions: createPositionRepository(db),
staff: createStaffRepository(db),
grants: createGrantRepository(db),
inFlight: emptyInFlightProbe,
ids,
});
const instances = createLlmInstanceRepository(db);
const provider = createProviderInvoker();
const llm = createLlmService({ instances, provider, secrets, clock: systemClock, ids });
@@ -121,6 +158,59 @@ export async function createContainer(config: AppConfig): Promise<Container> {
mode: 'live',
});
const memory = createMemoryService({
tasks: createTaskMemoryStore(db),
activities: createActivityMemoryStore(db),
model: {
// 系统用途模型:叙述性小结 / 汇报生成 / 蒸馏钩子 —— 计量入系统账,不占活动预算
generate: async (input) => {
const result = await modelGateway.generate({
model: input.model,
prompt: input.prompt,
...(input.outputSchema === undefined ? {} : { outputSchema: input.outputSchema }),
label: 'system-use',
});
return { text: result.text, ...(result.object === undefined ? {} : { object: result.object }) };
},
systemModelName: async () => (await llm.systemUseBinding())?.name,
},
clock: systemClock,
ids,
});
const toolRegistry = createToolRegistry(db);
const toolRuntime = createToolRuntime(firstPartyToolHandlers);
const schemas = createSchemaValidator();
const toolbox = createToolboxService({
registry: toolRegistry,
runtime: toolRuntime,
schemas,
secrets,
clock: systemClock,
});
const toolInvoker = createToolInvoker({
registry: toolRegistry,
runtime: toolRuntime,
replay: createToolReplayStore(db),
schemas,
secrets,
clock: systemClock,
mode: 'live',
});
const channel = createChannelService({
channels: createChannelRepository(db),
events: {
ingestInbound: (input) => event.ingestInbound(input),
recordOutbound: (ref) => event.recordOutbound(ref),
},
ledger: { systemWrite: (input) => ledger.systemWrite(input) },
mail: createSimulatedMailTransport(db, ids),
archive: createFileAttachmentArchive(config.attachmentRoot),
clock: systemClock,
ids,
});
return {
config,
pool,
@@ -130,6 +220,11 @@ export async function createContainer(config: AppConfig): Promise<Container> {
event,
llm,
modelGateway,
memory,
toolbox,
toolInvoker,
message,
channel,
async close() {
await pool.end();
},
+6
View File
@@ -6,18 +6,24 @@ import { serve } from '@hono/node-server';
import { loadConfig } from './config.ts';
import { createContainer } from './container.ts';
import { createServer } from './server.ts';
import { startDispatcher } from './workers/dispatcher.ts';
export async function boot(): Promise<void> {
const config = loadConfig();
const container = await createContainer(config);
await container.org.ensureSeeded();
// 派发器:投递记录即队列,按目标 ID 取 advisory lock 串行消费(ADR-008)
// 消费者随 M4(任务)/ M5(触发器)注册;此刻空表即「只标记送达」
const dispatcher = startDispatcher({ container, consumers: new Map() }, config.dispatchIntervalMs);
const app = createServer(container);
serve({ fetch: app.fetch, port: config.port }, (info) => {
process.stdout.write(`JCAI api · http://localhost:${info.port} · env=${config.nodeEnv}\n`);
});
const shutdown = async (): Promise<void> => {
dispatcher.stop();
await container.close();
process.exit(0);
};
+42
View File
@@ -0,0 +1,42 @@
/**
* 入站路由 —— 模拟邮件通道的注入口(编码规范 §3;README MVP 完成判据)。
*
* mock 只允许出现在**外界**:这条路由代替真实 IMAP 拉件,
* 之后的归属解析、事件流、三级消费、系统写全部是真机制。
*/
import type { Hono } from 'hono';
import type { ApiError } from '@contracts';
import { ingestMailRoute } from '@contracts';
import { ChannelDisabledError, ChannelNotFoundError } from '@modules/channel';
import type { Container } from '../container.ts';
import type { AppEnv } from '../http-env.ts';
export function registerIngestRoutes(app: Hono<AppEnv>, container: Container): void {
app.post(ingestMailRoute.path, async (c) => {
const parsed = ingestMailRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
const body: ApiError = { code: 'validation-failed', message: '入站邮件不合 schema', detail: parsed.error.issues };
return c.json(body, 400);
}
try {
const outcome = await container.channel.ingestMail(c.req.param('channelId'), parsed.data);
return c.json({
eventId: outcome.eventId,
resolution: outcome.resolution,
listenersHit: outcome.listenersHit,
triggersHit: outcome.triggersHit,
fellBackToMessage: outcome.fellBackToMessage,
});
} catch (error) {
if (error instanceof ChannelNotFoundError) {
const body: ApiError = { code: 'not-found', message: error.message };
return c.json(body, 404);
}
if (error instanceof ChannelDisabledError) {
const body: ApiError = { code: 'precondition-failed', message: error.message };
return c.json(body, 412);
}
throw error;
}
});
}
+2
View File
@@ -8,6 +8,7 @@ import { DEFAULT_LOGIN_ACCOUNT } from '@modules/org';
import type { Container } from './container.ts';
import type { AppEnv } from './http-env.ts';
import { registerGovernanceRoutes } from './routes/governance.ts';
import { registerIngestRoutes } from './routes/ingest.ts';
import { registerWorkbenchRoutes } from './routes/workbench.ts';
export type { AppEnv };
@@ -39,6 +40,7 @@ export function createServer(container: Container): Hono<AppEnv> {
registerWorkbenchRoutes(app, container);
registerGovernanceRoutes(app, container);
registerIngestRoutes(app, container);
return app;
}
+87
View File
@@ -0,0 +1,87 @@
/**
* 投递派发器 —— ADR-008 #2。
*
* 按 `state='pending' and next_attempt_at <= now()` 取投递记录,
* 对**目标 ID 取 advisory lock** 串行执行 —— 这正是「同任务按序」的实现(§7.1)。
* 消费幂等由 `sys.event_consumption(event_id, consumer)` 唯一约束承担。
*
* 消费者(任务 / 触发器)随 M4 / M5 落地;在此之前派发器把投递标记为已送达并留痕,
* 保证投递链路本身可测、不积压(docs/debt.md · M1-3)。
*/
import { sql } from 'drizzle-orm';
import type { Delivery } from '@modules/event';
import { claimPendingDeliveries } from '@modules/event';
import type { Container } from '../container.ts';
/** 消费者:按目标类型分派 */
export type Consumer = (delivery: Delivery, container: Container) => Promise<void>;
export type DispatcherDeps = {
readonly container: Container;
readonly consumers: ReadonlyMap<Delivery['target']['kind'], Consumer>;
readonly batchSize?: number;
};
/** 目标 ID → advisory lock 键(稳定散列,同一目标恒同一把锁) */
function lockKey(target: Delivery['target']): number {
const text = `${target.kind}:${target.id}`;
let hash = 5381;
for (let i = 0; i < text.length; i += 1) hash = ((hash << 5) + hash + text.charCodeAt(i)) | 0;
return hash;
}
export async function dispatchOnce(deps: DispatcherDeps): Promise<number> {
const { container } = deps;
const batch = await claimPendingDeliveries(container.db, deps.batchSize ?? 20);
let handled = 0;
for (const delivery of batch) {
const key = lockKey(delivery.target);
// 同任务按序:取到锁才处理,取不到说明同目标的另一条正在处理,留待下一轮
const locked = await container.db.execute<{ ok: boolean }>(
sql`select pg_try_advisory_lock(${key}) as ok`,
);
if (locked.rows[0]?.ok !== true) continue;
try {
const consumer = deps.consumers.get(delivery.target.kind);
if (consumer !== undefined) {
const claimed = await container.event.claimConsumption(
delivery.eventId,
`${delivery.target.kind}:${delivery.target.id}`,
);
// 至少一次 + 幂等:已消费过的重复投递直接标记完成,不再执行副作用
if (claimed) await consumer(delivery, container);
}
await container.db.execute(
sql`update sys.event_delivery set state = 'delivered' where id = ${delivery.id}`,
);
handled += 1;
} catch (error) {
await container.event.reportFailure(delivery.id, error instanceof Error ? error.message : String(error));
} finally {
await container.db.execute(sql`select pg_advisory_unlock(${key})`);
}
}
return handled;
}
export function startDispatcher(deps: DispatcherDeps, intervalMs: number): { stop(): void } {
let stopped = false;
const tick = async (): Promise<void> => {
if (stopped) return;
try {
await dispatchOnce(deps);
} catch (error) {
process.stderr.write(`[dispatcher] ${error instanceof Error ? error.message : String(error)}\n`);
}
};
const timer = setInterval(() => void tick(), intervalMs);
timer.unref();
return {
stop() {
stopped = true;
clearInterval(timer);
},
};
}
+35
View File
@@ -0,0 +1,35 @@
# module: channel
职责: 通道实例:配置(归属键提取规则 / 载荷映射 / 默认认领岗位)、入站归一
(解析 → 系统写 → 数据事件)、出站发送与出站关联记录登记
不负责: 归属解析与三级消费判定(见 event,经 `EventGateway` 端口)、
台账写入的实现(见 ledger,经 `LedgerSystemWriter` 端口)、消息兜底的呈现(见 message)
公开出口: index.ts
- 类型:`ChannelService` `ChannelInstanceConfig` `ExtractRule` `LedgerMapping`
`NormalizedWrite` `InboundOutcome` `OutboundMail`
- 错误:`ChannelNotFoundError` `ChannelDisabledError`
- 端口类型:`ChannelRepository` `EventGateway` `LedgerSystemWriter` `MailTransport`
`AttachmentArchive` `Clock` `IdGenerator`
- 用例工厂:`createChannelService(deps: ChannelServiceDeps): ChannelService`
- 纯函数:`extractFields` `normalize`
- adapter:`createChannelRepository` `createSimulatedMailTransport` `createFileAttachmentArchive`
- Drizzle 表:`channelTable` `outboundMailTable`
层级: L2(编码规范 §4)
依赖: @contracts;@shared/db;port:`EventGateway`(app 注入 event)、
`LedgerSystemWriter`(app 注入 ledger)、`MailTransport`(模拟通道)
被依赖: release(出站发送)、app(治理台 · 系统管理 / ingest 路由)
不变量:
- **活动认数据,不认通道**:入站一律归一为域.对象.字段的写入,通道对活动的前提不可见
- 入站归一走 **系统写**(登记世界已发生的事实,免门、即时抛数据事件),
绝不走草稿写、也不绕过 `ledger` 出口(§12.5#9)
- 归属解析**不在本模块**:通道只交事实,判定归 event —— 匹配次序与降级口径一处实现
- **无主不写台账**:仅有主入站才归一为系统写;无主入站只入事件流并走消息兜底
- 无主键的映射结果丢弃(无从定位),由调用方按提取失败处置
- 出站即登记关联记录:线程 ID 与往来地址是归属解析①③级的对表来源
- 邮件通道是**外界**:MVP 用模拟通道(出站落库不真发,入站经 ingest 注入);
系统内部机制一律真跑,不得 mock
影响半径: index/contract/port 未变 → 对外零影响
@@ -0,0 +1,81 @@
/** 入站归一的载荷映射 —— domain 测试零 mock(OS 6.2) */
import { describe, expect, it } from 'vitest';
import { extractFields, normalize } from '../internal/domain/payload-mapping.ts';
import type { ExtractRule, LedgerMapping } from '../internal/domain/payload-mapping.ts';
const fields = {
subject: '回复:报价 Q202601',
body: 'PN: LM358DR,我方接受单价 1.85 元',
from: 'buyer@acme.com',
to: ['bd@we.com'],
};
const rules: ExtractRule[] = [
{ key: 'quoteNo', regex: 'Q\\d{6}', source: 'subject' },
{ key: 'pn', regex: 'PN:\\s*([A-Z0-9]+)', source: 'body' },
{ key: 'sender', regex: '.+', source: 'from' },
];
describe('extractFields', () => {
it('按来源分别提取,捕获组优先', () => {
expect(extractFields(fields, rules)).toEqual({
quoteNo: 'Q202601',
pn: 'LM358DR',
sender: 'buyer@acme.com',
});
});
it('不命中即不产出该键', () => {
expect(extractFields({ ...fields, subject: '你好' }, [rules[0]!])).toEqual({});
});
it('both 同时看主题与正文', () => {
expect(
extractFields({ ...fields, subject: '你好' }, [{ key: 'pn', regex: 'LM\\d+DR', source: 'both' }]),
).toEqual({ pn: 'LM358DR' });
});
});
describe('normalize —— 提取字段 → 域.对象.字段', () => {
const mapping: LedgerMapping[] = [
{ from: 'quoteNo', domain: 'quote', object: 'reply', field: 'quote_no', primaryKey: true },
{ from: 'pn', domain: 'quote', object: 'reply', field: 'pn', primaryKey: true },
{ from: 'sender', domain: 'quote', object: 'reply', field: 'sender' },
{ from: '', domain: 'quote', object: 'reply', field: 'channel', constant: 'mail' },
];
it('同一对象的字段合并成一次写入,主键进 key', () => {
const writes = normalize(extractFields(fields, rules), mapping);
expect(writes).toEqual([
{
domain: 'quote',
object: 'reply',
key: { quote_no: 'Q202601', pn: 'LM358DR' },
values: { sender: 'buyer@acme.com', channel: 'mail' },
},
]);
});
it('常量映射不依赖提取结果', () => {
const writes = normalize({}, [{ from: 'x', domain: 'd', object: 'o', field: 'k', primaryKey: true, constant: 'C' }]);
expect(writes[0]?.key).toEqual({ k: 'C' });
});
it('无主键的写入丢弃 —— 无从定位', () => {
expect(normalize({ sender: 'a@b.com' }, [{ from: 'sender', domain: 'd', object: 'o', field: 'f' }])).toEqual([]);
});
it('缺提取值的映射跳过,不写 undefined', () => {
const writes = normalize({ quoteNo: 'Q1' }, mapping);
expect(writes[0]?.values).toEqual({ channel: 'mail' });
expect(writes[0]?.key).toEqual({ quote_no: 'Q1' });
});
it('多对象各成一次写入', () => {
const writes = normalize({ a: '1', b: '2' }, [
{ from: 'a', domain: 'd1', object: 'o1', field: 'k', primaryKey: true },
{ from: 'b', domain: 'd2', object: 'o2', field: 'k', primaryKey: true },
]);
expect(writes).toHaveLength(2);
});
});
+57
View File
@@ -0,0 +1,57 @@
/**
* module: channel —— 对外类型(编码规范 §10.1)。
*/
import type { InboundMail, OwnershipResolution, TaskId } from '@contracts';
import type {
ChannelInstanceConfig,
ExtractRule,
LedgerMapping,
NormalizedWrite,
} from './internal/domain/payload-mapping.ts';
export type { ChannelInstanceConfig, ExtractRule, InboundMail, LedgerMapping, NormalizedWrite, OwnershipResolution };
export class ChannelNotFoundError extends Error {
constructor(readonly channelId: string) {
super(`通道实例不存在:${channelId}`);
this.name = 'ChannelNotFoundError';
}
}
export class ChannelDisabledError extends Error {
constructor(readonly channelId: string) {
super(`通道实例已停用:${channelId}`);
this.name = 'ChannelDisabledError';
}
}
export type InboundOutcome = {
readonly eventId: string;
readonly resolution: OwnershipResolution;
readonly writes: readonly NormalizedWrite[];
readonly listenersHit: number;
readonly triggersHit: number;
readonly fellBackToMessage: boolean;
};
export type OutboundMail = {
readonly taskId: TaskId;
readonly to: readonly string[];
readonly cc?: readonly string[];
readonly subject: string;
readonly body: string;
readonly threadId?: string;
readonly attachments?: ReadonlyArray<{ name: string; ref: string }>;
};
export type ChannelService = {
list(): Promise<ChannelInstanceConfig[]>;
upsert(config: ChannelInstanceConfig): Promise<ChannelInstanceConfig>;
/**
* 入站:归属解析 → 归一为**系统写** → 数据事件。
* 「环外回音经归属解析归一为数据写入后,以数据事件抛出 —— 活动认数据,不认通道。」
*/
ingestMail(channelId: string, mail: InboundMail): Promise<InboundOutcome>;
/** 出站:触达世界的动作;发送即登记出站关联记录(归属解析①③级的对表来源) */
sendMail(channelId: string, mail: OutboundMail): Promise<{ messageId: string; threadId: string }>;
};
+36
View File
@@ -0,0 +1,36 @@
/**
* module: channel —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
*/
export type {
ChannelInstanceConfig,
ChannelService,
ExtractRule,
InboundMail,
InboundOutcome,
LedgerMapping,
NormalizedWrite,
OutboundMail,
} from './contract.ts';
export { ChannelDisabledError, ChannelNotFoundError } from './contract.ts';
export type {
AttachmentArchive,
ChannelRepository,
Clock,
EventGateway,
IdGenerator,
LedgerSystemWriter,
MailTransport,
} from './port.ts';
export type { ChannelServiceDeps } from './internal/usecase/channel-service.ts';
export { createChannelService } from './internal/usecase/channel-service.ts';
export { extractFields, normalize } from './internal/domain/payload-mapping.ts';
export {
createChannelRepository,
createFileAttachmentArchive,
createSimulatedMailTransport,
} from './internal/adapter/channel-store.ts';
export { channelTable, outboundMailTable } from './internal/adapter/channel-schema.ts';
@@ -0,0 +1,35 @@
/**
* channel 的 sys schema 表(编码规范 §7 / ADR-002)。
* 表定义单独成文件:drizzle.config.ts 以 `*-schema.ts` glob 收集。
*/
import { boolean, integer, jsonb, text, timestamp } from 'drizzle-orm/pg-core';
import { sys } from '@shared/db.ts';
export const channelTable = sys.table('channel_instance', {
id: text('id').primaryKey(),
name: text('name').notNull(),
kind: text('kind').notNull(),
address: text('address').notNull(),
enabled: boolean('enabled').notNull().default(true),
/** 归属键提取规则(声明式,OS 6.2) */
extract: jsonb('extract').notNull().default([]),
/** 载荷映射:提取字段 → 域.对象.字段,与边映射同一形式 */
mapping: jsonb('mapping').notNull().default([]),
/** 无主入站的默认认领岗位 */
fallbackPosition: text('fallback_position'),
addressWindowMs: integer('address_window_ms').notNull().default(604_800_000),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
/** 出站存档:模拟通道把「已发出的信」落库,供演示与回放查看 */
export const outboundMailTable = sys.table('outbound_mail', {
id: text('id').primaryKey(),
threadId: text('thread_id').notNull(),
fromAddr: text('from_addr').notNull(),
toAddrs: jsonb('to_addrs').notNull(),
ccAddrs: jsonb('cc_addrs').notNull().default([]),
subject: text('subject').notNull(),
body: text('body').notNull(),
attachments: jsonb('attachments').notNull().default([]),
sentAt: timestamp('sent_at', { withTimezone: true }).notNull().defaultNow(),
});
@@ -0,0 +1,96 @@
/**
* channel 存储与传输 adapter —— Drizzle 只准出现在 adapter(编码规范 §5.3)。
*/
import { eq } from 'drizzle-orm';
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import type { Db } from '@shared/db.ts';
import { positionIdSchema } from '@contracts';
import type { AttachmentArchive, ChannelRepository, MailTransport } from '../../port.ts';
import type { ChannelInstanceConfig, ExtractRule, LedgerMapping } from '../domain/payload-mapping.ts';
import { channelTable, outboundMailTable } from './channel-schema.ts';
type Row = typeof channelTable.$inferSelect;
function toConfig(row: Row): ChannelInstanceConfig {
return {
id: row.id,
name: row.name,
kind: row.kind as ChannelInstanceConfig['kind'],
address: row.address,
enabled: row.enabled,
extract: row.extract as ExtractRule[],
mapping: row.mapping as LedgerMapping[],
fallbackPosition: row.fallbackPosition === null ? undefined : positionIdSchema.parse(row.fallbackPosition),
addressWindowMs: row.addressWindowMs,
};
}
export function createChannelRepository(db: Db): ChannelRepository {
return {
async list() {
const rows = await db.select().from(channelTable).orderBy(channelTable.name);
return rows.map(toConfig);
},
async get(channelId) {
const rows = await db.select().from(channelTable).where(eq(channelTable.id, channelId)).limit(1);
return rows[0] === undefined ? undefined : toConfig(rows[0]);
},
async upsert(config) {
const values = {
id: config.id,
name: config.name,
kind: config.kind,
address: config.address,
enabled: config.enabled,
extract: config.extract,
mapping: config.mapping,
fallbackPosition: config.fallbackPosition ?? null,
addressWindowMs: config.addressWindowMs,
};
const [row] = await db
.insert(channelTable)
.values(values)
.onConflictDoUpdate({ target: channelTable.id, set: values })
.returning();
if (row === undefined) throw new Error('通道实例写入未返回行');
return toConfig(row);
},
};
}
/**
* 模拟邮件通道 —— **mock 只允许出现在外界**(README MVP 完成判据):
* 出站信落库不真发,入站经 /api/ingest 注入。系统内部机制一律真跑。
*/
export function createSimulatedMailTransport(db: Db, ids: { next(): string }): MailTransport {
return {
async send(input) {
const messageId = ids.next();
const threadId = input.threadId ?? messageId;
await db.insert(outboundMailTable).values({
id: messageId,
threadId,
fromAddr: input.from,
toAddrs: [...input.to],
ccAddrs: [...input.cc],
subject: input.subject,
body: input.body,
attachments: [...input.attachments],
});
return { messageId, threadId };
},
};
}
/** 原件存档 —— 落本地目录,返回存档地址作为信封的原件指针 */
export function createFileAttachmentArchive(root: string): AttachmentArchive {
return {
async put(name, content) {
const path = join(root, name);
await mkdir(dirname(path), { recursive: true });
await writeFile(path, content, 'utf8');
return `file://${path}`;
},
};
}
@@ -0,0 +1,100 @@
/**
* 通道入站的载荷映射 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 OS 6.2)。
*
* 「有主入站归一为**数据写入**:写入目标由通道实例配置的载荷映射给定
* (提取字段 → 域.对象.字段,**与边映射同一形式**),原件存档为附件对象,以数据事件抛出。」
*
* 活动认数据,不认通道 —— 通道对活动的前提不可见。
*/
import type { PositionId } from '@contracts';
export type ExtractRule = {
readonly key: string;
readonly regex: string;
readonly source: 'subject' | 'body' | 'both' | 'from' | 'to';
};
/** 提取字段 → 域.对象.字段 */
export type LedgerMapping = {
readonly from: string;
readonly domain: string;
readonly object: string;
readonly field: string;
/** 该字段是否参与主键 */
readonly primaryKey?: boolean;
/** 常量:from 留空时用它 */
readonly constant?: string;
};
export type ChannelInstanceConfig = {
readonly id: string;
readonly name: string;
readonly kind: 'mail' | 'im';
readonly address: string;
readonly enabled: boolean;
/** 归属键提取规则(声明式) */
readonly extract: readonly ExtractRule[];
/** 载荷映射 */
readonly mapping: readonly LedgerMapping[];
/** 无主入站的默认认领岗位 */
readonly fallbackPosition?: PositionId | undefined;
readonly addressWindowMs: number;
};
export type InboundFields = {
readonly subject: string;
readonly body: string;
readonly from: string;
readonly to: readonly string[];
};
export function extractFields(fields: InboundFields, rules: readonly ExtractRule[]): Record<string, string> {
const out: Record<string, string> = {};
for (const rule of rules) {
const haystack =
rule.source === 'subject'
? fields.subject
: rule.source === 'body'
? fields.body
: rule.source === 'from'
? fields.from
: rule.source === 'to'
? fields.to.join(',')
: `${fields.subject}\n${fields.body}`;
const match = new RegExp(rule.regex).exec(haystack);
const captured = match?.[1] ?? match?.[0];
if (captured !== undefined) out[rule.key] = captured;
}
return out;
}
export type NormalizedWrite = {
readonly domain: string;
readonly object: string;
readonly key: Record<string, string>;
readonly values: Record<string, string>;
};
/**
* 归一:提取结果 + 映射 → 一组「域.对象」的写入。
* 同一对象的多个字段合并成一次写入;主键字段进 key,其余进 values。
*/
export function normalize(
extracted: Readonly<Record<string, string>>,
mapping: readonly LedgerMapping[],
): NormalizedWrite[] {
const byObject = new Map<string, { domain: string; object: string; key: Record<string, string>; values: Record<string, string> }>();
for (const rule of mapping) {
const value = rule.constant ?? extracted[rule.from];
if (value === undefined) continue;
const objectKey = `${rule.domain}.${rule.object}`;
const bucket = byObject.get(objectKey) ?? { domain: rule.domain, object: rule.object, key: {}, values: {} };
if (rule.primaryKey === true) bucket.key[rule.field] = value;
else bucket.values[rule.field] = value;
byObject.set(objectKey, bucket);
}
// 无主键的写入无从定位,丢弃并由调用方按「提取失败」处置
return [...byObject.values()].filter((w) => Object.keys(w.key).length > 0);
}
@@ -0,0 +1,135 @@
/**
* channel 用例 —— 入站归一与出站发送(编码规范 §5.3;语义出处 OS 6.2 / 07)。
*/
import type { InboundMail, TaskId } from '@contracts';
import type { ChannelService, InboundOutcome, OutboundMail } from '../../contract.ts';
import { ChannelDisabledError, ChannelNotFoundError } from '../../contract.ts';
import type {
AttachmentArchive,
ChannelRepository,
Clock,
EventGateway,
IdGenerator,
LedgerSystemWriter,
MailTransport,
} from '../../port.ts';
import { extractFields, normalize } from '../domain/payload-mapping.ts';
export type ChannelServiceDeps = {
readonly channels: ChannelRepository;
readonly events: EventGateway;
readonly ledger: LedgerSystemWriter;
readonly mail: MailTransport;
readonly archive: AttachmentArchive;
readonly clock: Clock;
readonly ids: IdGenerator;
};
export function createChannelService(deps: ChannelServiceDeps): ChannelService {
async function require(channelId: string) {
const config = await deps.channels.get(channelId);
if (config === undefined) throw new ChannelNotFoundError(channelId);
if (!config.enabled) throw new ChannelDisabledError(channelId);
return config;
}
return {
async list() {
return deps.channels.list();
},
async upsert(config) {
return deps.channels.upsert(config);
},
async ingestMail(channelId, mail: InboundMail): Promise<InboundOutcome> {
const config = await require(channelId);
const receivedAt = new Date(mail.receivedAt);
// 原件存档为附件对象 —— 信封带原件指针,不带内容本身
const originRef = await deps.archive.put(
`inbound/${mail.messageId}.eml`,
JSON.stringify({ ...mail }, null, 2),
);
const extracted = extractFields(
{ subject: mail.subject, body: mail.body, from: mail.from, to: mail.to },
config.extract,
);
// 归属解析 + 三级消费判定归 event(L1);channel 只把通道事实交上去
const result = await deps.events.ingestInbound({
facts: {
...(mail.threadId === undefined ? {} : { threadId: mail.threadId }),
from: mail.from,
to: mail.to,
subject: mail.subject,
body: mail.body,
receivedAt,
},
rules: {
patterns: config.extract
.filter((r) => r.source === 'subject' || r.source === 'body' || r.source === 'both')
.map((r) => ({ key: r.key, regex: r.regex, source: r.source as 'subject' | 'body' | 'both' })),
addressWindowMs: config.addressWindowMs,
fallbackPosition: config.fallbackPosition,
},
draft: {
type: 'data',
correlation: { cause: { kind: 'inbound', ref: mail.messageId }, keys: extracted },
payload: { data: { ...extracted, subject: mail.subject, from: mail.from }, originRef },
occurredAt: receivedAt.toISOString(),
source: { kind: 'channel', detail: config.name },
},
});
// 有主入站归一为**系统写**:登记世界已发生的事实,免门、即时抛数据事件
const writes = normalize(extracted, config.mapping);
if (result.resolution.outcome === 'owned') {
for (const write of writes) {
await deps.ledger.systemWrite({
address: { domain: write.domain, object: write.object },
key: write.key,
values: write.values,
cause: { kind: 'inbound', ref: mail.messageId },
taskId: result.resolution.taskId,
});
}
}
return {
eventId: result.eventId,
resolution: result.resolution,
writes,
listenersHit: result.plan.taskTargets.length,
triggersHit: result.plan.triggerTargets.length,
fellBackToMessage: result.plan.messageFallback,
};
},
async sendMail(channelId, mail: OutboundMail) {
const config = await require(channelId);
const sent = await deps.mail.send({
from: config.address,
to: mail.to,
cc: mail.cc ?? [],
subject: mail.subject,
body: mail.body,
...(mail.threadId === undefined ? {} : { threadId: mail.threadId }),
attachments: mail.attachments ?? [],
});
// 出站即登记关联记录 —— 归属解析①线程与③地址+时间窗的对表来源
const sentAt = deps.clock.now();
for (const address of mail.to) {
await deps.events.recordOutbound({
taskId: mail.taskId as TaskId,
threadId: sent.threadId,
address,
sentAt,
});
}
return sent;
},
};
}
@@ -0,0 +1,7 @@
{
"name": "@modules/channel",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": { ".": "./index.ts" }
}
+71
View File
@@ -0,0 +1,71 @@
/**
* module: channel —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
*/
import type { EnvelopeDraft, OwnershipResolution, PositionId, TaskId } from '@contracts';
import type { ChannelInstanceConfig } from './internal/domain/payload-mapping.ts';
export type Clock = { now(): Date };
export type IdGenerator = { next(): string };
export type ChannelRepository = {
list(): Promise<ChannelInstanceConfig[]>;
get(channelId: string): Promise<ChannelInstanceConfig | undefined>;
upsert(config: ChannelInstanceConfig): Promise<ChannelInstanceConfig>;
};
/** 事件入口 —— app 注入 `event`(L1)的出口 */
export type EventGateway = {
ingestInbound(input: {
facts: {
threadId?: string;
from: string;
to: readonly string[];
subject: string;
body: string;
receivedAt: Date;
};
rules: {
patterns: ReadonlyArray<{ key: string; regex: string; source: 'subject' | 'body' | 'both' }>;
addressWindowMs: number;
fallbackPosition?: PositionId | undefined;
};
draft: EnvelopeDraft;
}): Promise<{
eventId: string;
resolution: OwnershipResolution;
plan: { taskTargets: readonly string[]; triggerTargets: readonly string[]; messageFallback: boolean };
}>;
recordOutbound(ref: { taskId: TaskId; threadId?: string; address: string; sentAt: Date }): Promise<void>;
};
/**
* 台账系统写 —— app 注入 `ledger`(L1)的 `systemWrite`。
* 入站归一是「登记世界已发生的事实」,走**系统写**:不过放行门,写正式版并即时抛数据事件(OS 09)。
*/
export type LedgerSystemWriter = {
systemWrite(input: {
address: { domain: string; object: string };
key: Record<string, string | number>;
values: Record<string, unknown>;
cause: { kind: 'inbound'; ref: string };
taskId?: TaskId;
}): Promise<void>;
};
/** 邮件收发 —— 外界。MVP 允许模拟通道 / 录制回放替身(README MVP 完成判据) */
export type MailTransport = {
send(input: {
readonly from: string;
readonly to: readonly string[];
readonly cc: readonly string[];
readonly subject: string;
readonly body: string;
readonly threadId?: string;
readonly attachments: ReadonlyArray<{ name: string; ref: string }>;
}): Promise<{ messageId: string; threadId: string }>;
};
/** 原件存档 —— 附件对象的存放;返回存档地址(信封的原件指针) */
export type AttachmentArchive = {
put(name: string, content: string): Promise<string>;
};
@@ -16,7 +16,7 @@ export type OwnershipKeyRules = {
/** 地址 + 时间窗的窗口长度(毫秒) */
readonly addressWindowMs: number;
/** 无主入站的默认认领岗位 */
readonly fallbackPosition?: PositionId;
readonly fallbackPosition?: PositionId | undefined;
};
export type InboundFacts = {
+35
View File
@@ -0,0 +1,35 @@
# module: memory
职责: 任务记忆(会话历史 + 窗口配置)、活动记忆四层存取、任务日志两级投影、
蒸馏钩子执行、度量八指标的只读投影
不负责: 事件投递(见 event)、模型实例与凭证(见 llm,经 `SystemModel` 端口)、
候选增量的回放与放行(见 activity-registry 的进化管线)
公开出口: index.ts
- 类型:`MemoryService` `MemoryEntry` `AppendEntry` `VersionStamp` `GovernanceMark`
`ActivityMemoryEntry` `MemoryLayer` `Evidence` `CandidateIncrement` `LogLine` `Metrics`
- 错误:`NoEvidenceError`
- 端口类型:`TaskMemoryStore` `ActivityMemoryStore` `SystemModel` `Clock` `IdGenerator`
- 用例工厂:`createMemoryService(deps: MemoryServiceDeps): MemoryService`
- 纯函数:`deriveSide` `windowOf` `distillable` `redactForReader` `assertAppendOnly`
`structuredLog` `splitBySide` `computeMetrics` `assertThreePart` `selectForLoading` `isBinding`
- adapter:`createTaskMemoryStore` `createActivityMemoryStore`
- Drizzle 表:`taskMemoryTable` `taskMemorySealTable` `activityMemoryTable`
层级: L2(编码规范 §4)
依赖: @contracts;@shared/db;port:`SystemModel`(app 注入 llm 的 ModelGateway + 系统用途实例)
被依赖: interaction, human-tool, release, activity-registry, loop-engine, flow-engine,
skill-engine, task, report, app(治理台 · 记忆管理)
不变量:
- **只追加**:序号严格递增;封卷后不得追加
- **归侧自动推导**:`deriveSide(kind)` 是纯函数,调用方不得指定 —— 零埋点,统计即查询
- **信封盖戳**:每条带 `VersionStamp`(契约 / 业务模型 / 技能 / 模型版本 + 定位)
- **治理先行**:脱敏 / 可学习标记在留痕时刻打上;`distillable` 只放行 learnable 且未脱敏的条目
- **投影不得比源暴露更多**:`taskLog` 按读者权限走 `redactForReader`
- **写入唯蒸馏**:活动记忆没有直写方法 —— `distill` 是唯一写入口,人只能 `prune`
- **无证据链不得入库**:`assertThreePart` 对蒸馏产物同样生效
- **记忆不载权力**:`isBinding` 仅在候选增量 `released` 时为真;语义引擎不读活动记忆
- 叙述性小结是**视图不是事实**:`narrate` 不落库,系统用途模型未配置时返回 undefined
影响半径: index/contract/port 未变 → 对外零影响
@@ -0,0 +1,189 @@
/** 任务记忆四条性质与日志投影 —— domain 测试零 mock(OS 8.1 / AM 9.1) */
import { describe, expect, it } from 'vitest';
import {
assertAppendOnly,
deriveSide,
distillable,
ENTRY_KINDS,
redactForReader,
windowOf,
} from '../internal/domain/task-memory.ts';
import type { EntryKind, MemoryEntry } from '../internal/domain/task-memory.ts';
import { computeMetrics, splitBySide, structuredLog } from '../internal/domain/task-log.ts';
import { assertThreePart, isBinding, NoEvidenceError, selectForLoading } from '../internal/domain/activity-memory.ts';
import type { ActivityMemoryEntry } from '../internal/domain/activity-memory.ts';
const entry = (kind: EntryKind, over: Partial<MemoryEntry> = {}): MemoryEntry => ({
id: `e-${kind}-${over.seq ?? 1}`,
taskId: 't1',
seq: over.seq ?? 1,
at: '2026-07-27T10:00:00.000Z',
kind,
side: deriveSide(kind),
title: `${kind} 标题`,
stamp: { contractVersion: '1', businessModelVersion: '1', skillVersions: [] },
governance: { masked: false, learnable: true },
...over,
});
describe('deriveSide —— 归侧自动推导,零埋点', () => {
it('每一种条目都有确定的归侧', () => {
for (const kind of ENTRY_KINDS) expect(deriveSide(kind)).toBeTruthy();
});
it('机器 / 人 / 人裁 / 世界等待各归其位', () => {
expect(deriveSide('action')).toBe('machine');
expect(deriveSide('card-reply')).toBe('human');
expect(deriveSide('human-input')).toBe('human');
expect(deriveSide('release-verdict')).toBe('human-verdict');
expect(deriveSide('terminal')).toBe('human-verdict');
expect(deriveSide('card-sent')).toBe('world-wait');
});
});
describe('assertAppendOnly —— 只追加', () => {
it('序号递增放行', () => {
expect(() => assertAppendOnly(3, 4)).not.toThrow();
});
it('回退或重复即拒', () => {
expect(() => assertAppendOnly(4, 4)).toThrow(/只追加/);
expect(() => assertAppendOnly(4, 3)).toThrow(/只追加/);
});
});
describe('windowOf —— 活动侧唯一可配置项', () => {
const entries = [1, 2, 3, 4, 5].map((n) => entry('observation', { seq: n }));
it('取最后 N 条', () => {
expect(windowOf(entries, 2).map((e) => e.seq)).toEqual([4, 5]);
});
it('窗口大于总数即全量', () => {
expect(windowOf(entries, 99)).toHaveLength(5);
});
it('窗口为零即空 —— 但历史仍完整在库', () => {
expect(windowOf(entries, 0)).toEqual([]);
});
});
describe('治理先行', () => {
it('不可学习或已脱敏的条目不进蒸馏队列', () => {
const rows = [
entry('action', { seq: 1 }),
entry('action', { seq: 2, governance: { masked: false, learnable: false } }),
entry('action', { seq: 3, governance: { masked: true, learnable: true } }),
];
expect(distillable(rows).map((e) => e.seq)).toEqual([1]);
});
it('投影不得比源暴露更多', () => {
const masked = entry('result', { governance: { masked: true, learnable: true }, detail: { secret: 'x' } });
expect(redactForReader(masked, false).title).toBe('【已脱敏】');
expect(redactForReader(masked, false).detail).toEqual({});
expect(redactForReader(masked, true).title).toBe(masked.title);
});
});
describe('structuredLog —— 零 LLM,可重算', () => {
const entries = [
entry('thought', { seq: 1 }),
entry('action', { seq: 2 }),
entry('card-sent', { seq: 3 }),
entry('card-reply', { seq: 4 }),
entry('release-verdict', { seq: 5 }),
];
it('只取骨架条目,思考不入日志', () => {
const lines = structuredLog(entries);
expect(lines).toHaveLength(4);
expect(lines.some((l) => l.title.startsWith('思考'))).toBe(false);
});
it('两次生成完全一致 —— 口径与源恒一致', () => {
expect(structuredLog(entries)).toEqual(structuredLog(entries));
});
it('归侧使日志天然分栏', () => {
const split = splitBySide(structuredLog(entries));
expect(split.machine).toHaveLength(1);
expect(split['world-wait']).toHaveLength(1);
expect(split.human).toHaveLength(1);
expect(split['human-verdict']).toHaveLength(1);
});
});
describe('computeMetrics —— 度量八指标', () => {
it('自主率:裁决与等待不进分母', () => {
const entries = [
entry('action', { seq: 1 }),
entry('result', { seq: 2 }),
entry('card-reply', { seq: 3 }),
entry('release-verdict', { seq: 4 }),
entry('card-sent', { seq: 5 }),
];
const m = computeMetrics({
entries,
submissions: 2,
corrections: 1,
taskCount: 1,
deterministicJudgements: 3,
totalJudgements: 4,
});
expect(m.autonomy).toBeCloseTo(2 / 3);
expect(m.correction).toBe(0.5);
expect(m.effectiveAutonomy).toBeCloseTo((2 / 3) * 0.5);
expect(m.interruption).toBe(1);
expect(m.dmnCoverage).toBe(0.75);
});
it('零样本不除零', () => {
const m = computeMetrics({
entries: [],
submissions: 0,
corrections: 0,
taskCount: 0,
deterministicJudgements: 0,
totalJudgements: 0,
});
expect(m).toEqual({ autonomy: 0, correction: 0, effectiveAutonomy: 0, interruption: 0, dmnCoverage: 0 });
});
});
describe('活动记忆 —— 三件套与不载权力', () => {
const base: ActivityMemoryEntry = {
id: 'a1',
activityId: 'act-1',
layer: 'verdict',
statement: '低于底价 20% 一律先问 PM',
evidence: [{ taskId: 't1', entryIds: ['e-1'] }],
createdAt: '2026-07-27T10:00:00.000Z',
};
it('无证据链不得入库', () => {
expect(() => assertThreePart({ statement: base.statement, evidence: [] })).toThrow(NoEvidenceError);
expect(() => assertThreePart({ statement: base.statement, evidence: [{ taskId: 't1', entryIds: [] }] })).toThrow(
NoEvidenceError,
);
});
it('空陈述不得入库', () => {
expect(() => assertThreePart({ statement: ' ', evidence: base.evidence })).toThrow(NoEvidenceError);
});
it('三件套齐备放行', () => {
expect(() => assertThreePart(base)).not.toThrow();
});
it('记忆不载权力:只有已放行的候选增量才有约束力', () => {
expect(isBinding(base)).toBe(false);
expect(isBinding({ ...base, increment: { target: 'dmn', diff: '+row', state: 'candidate' } })).toBe(false);
expect(isBinding({ ...base, increment: { target: 'dmn', diff: '+row', state: 'released' } })).toBe(true);
});
it('装载按层选摘,已修剪的不入上下文', () => {
const rows: ActivityMemoryEntry[] = [
base,
{ ...base, id: 'a2', layer: 'fact' },
{ ...base, id: 'a3', layer: 'fact', prunedAt: '2026-07-27T11:00:00.000Z' },
];
expect(selectForLoading(rows, { layers: ['fact'], limit: 10 }).map((e) => e.id)).toEqual(['a2']);
expect(selectForLoading(rows, { limit: 10 })).toHaveLength(2);
});
});
+76
View File
@@ -0,0 +1,76 @@
/**
* module: memory —— 对外类型(编码规范 §10.1)。
*/
import type { AttributionSide, TaskId } from '@contracts';
import type {
ActivityMemoryEntry,
CandidateIncrement,
Evidence,
MemoryLayer,
} from './internal/domain/activity-memory.ts';
import type { EntryKind, GovernanceMark, MemoryEntry, VersionStamp } from './internal/domain/task-memory.ts';
import type { LogLine, Metrics, MetricsInput } from './internal/domain/task-log.ts';
export type {
ActivityMemoryEntry,
AttributionSide,
CandidateIncrement,
EntryKind,
Evidence,
GovernanceMark,
LogLine,
MemoryEntry,
MemoryLayer,
Metrics,
MetricsInput,
VersionStamp,
};
export { NoEvidenceError } from './internal/domain/activity-memory.ts';
/** 追加一条任务记忆 —— 归侧由 kind 自动推导,调用方不得指定(零埋点) */
export type AppendEntry = {
readonly taskId: TaskId;
readonly kind: EntryKind;
readonly title: string;
readonly detail?: Record<string, unknown>;
readonly stamp: VersionStamp;
readonly governance?: Partial<GovernanceMark>;
readonly linksTo?: string;
readonly eventRef?: string;
};
export type DistillInput = {
readonly taskId: TaskId;
readonly activityId: string;
readonly trigger: 'terminal' | 'milestone';
readonly milestone?: string;
readonly prompt: string;
};
export type MemoryService = {
/* 任务记忆:框架自动记录、只追加 */
append(entry: AppendEntry): Promise<MemoryEntry>;
history(taskId: TaskId): Promise<MemoryEntry[]>;
/** 活动侧唯一可配置项:窗口大小 —— 每次 LLM 调用加载多少轮 */
window(taskId: TaskId, size: number): Promise<MemoryEntry[]>;
seal(taskId: TaskId): Promise<void>;
/* 任务日志:任务记忆的两级投影 */
taskLog(taskId: TaskId, options?: { canSeeMasked?: boolean }): Promise<LogLine[]>;
/** 叙述性小结(LLM,可选)—— 叙述是视图不是事实,不入事实库 */
narrate(taskId: TaskId): Promise<string | undefined>;
/* 活动记忆:四层存取;写入唯蒸馏 */
loadActivityMemory(
activityId: string,
options?: { layers?: readonly MemoryLayer[]; limit?: number },
): Promise<ActivityMemoryEntry[]>;
/** **不设直写旁路**:唯一写入口是蒸馏钩子 */
distill(input: DistillInput): Promise<ActivityMemoryEntry[]>;
/** 人可修剪 —— 治理台 · 记忆管理 */
prune(entryId: string): Promise<void>;
setIncrementState(entryId: string, state: CandidateIncrement['state']): Promise<void>;
metrics(input: Omit<MetricsInput, 'entries'> & { taskIds: readonly TaskId[] }): Promise<Metrics>;
};
+47
View File
@@ -0,0 +1,47 @@
/**
* module: memory —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
*/
export type {
ActivityMemoryEntry,
AppendEntry,
AttributionSide,
CandidateIncrement,
DistillInput,
EntryKind,
Evidence,
GovernanceMark,
LogLine,
MemoryEntry,
MemoryLayer,
MemoryService,
Metrics,
MetricsInput,
VersionStamp,
} from './contract.ts';
export { NoEvidenceError } from './contract.ts';
export type { ActivityMemoryStore, Clock, IdGenerator, SystemModel, TaskMemoryStore } from './port.ts';
export type { MemoryServiceDeps } from './internal/usecase/memory-service.ts';
export { createMemoryService } from './internal/usecase/memory-service.ts';
export {
assertThreePart,
isBinding,
MEMORY_LAYER_LABEL,
MEMORY_LAYER_TARGET,
MEMORY_LAYERS,
selectForLoading,
} from './internal/domain/activity-memory.ts';
export {
assertAppendOnly,
deriveSide,
distillable,
ENTRY_KINDS,
redactForReader,
windowOf,
} from './internal/domain/task-memory.ts';
export { computeMetrics, splitBySide, structuredLog } from './internal/domain/task-log.ts';
export { createActivityMemoryStore, createTaskMemoryStore } from './internal/adapter/memory-store.ts';
export { activityMemoryTable, taskMemorySealTable, taskMemoryTable } from './internal/adapter/memory-schema.ts';
@@ -0,0 +1,50 @@
/**
* memory 的 sys schema 表(编码规范 §7 / ADR-002)。
*
* 注:@mastra/memory 的线程/消息(mastra schema)由 Mastra 单写,投影一律经本模块出口(§6.2 规则 3);
* 这里存的是**任务记忆的权威条目**(只追加、盖戳、归侧)与活动记忆四层。
*/
import { boolean, index, integer, jsonb, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { sys } from '@shared/db.ts';
export const taskMemoryTable = sys.table(
'task_memory',
{
id: text('id').primaryKey(),
taskId: text('task_id').notNull(),
seq: integer('seq').notNull(),
at: timestamp('at', { withTimezone: true }).notNull(),
kind: text('kind').notNull(),
side: text('side').notNull(),
title: text('title').notNull(),
detail: jsonb('detail'),
stamp: jsonb('stamp').notNull(),
governance: jsonb('governance').notNull(),
linksTo: text('links_to'),
eventRef: text('event_ref'),
},
(t) => [uniqueIndex('task_memory_seq_key').on(t.taskId, t.seq), index('task_memory_task_idx').on(t.taskId, t.at)],
);
/** 封卷:终态后归档,不再追加 */
export const taskMemorySealTable = sys.table('task_memory_seal', {
taskId: text('task_id').primaryKey(),
sealedAt: timestamp('sealed_at', { withTimezone: true }).notNull(),
});
export const activityMemoryTable = sys.table(
'activity_memory',
{
id: text('id').primaryKey(),
activityId: text('activity_id').notNull(),
layer: text('layer').notNull(),
statement: text('statement').notNull(),
/** 证据链 —— 空即不得入库(AM 9.2) */
evidence: jsonb('evidence').notNull(),
increment: jsonb('increment'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
prunedAt: timestamp('pruned_at', { withTimezone: true }),
active: boolean('active').notNull().default(true),
},
(t) => [index('activity_memory_layer_idx').on(t.activityId, t.layer, t.createdAt)],
);
@@ -0,0 +1,148 @@
/**
* memory 存储 adapter —— Drizzle 只准出现在 adapter(编码规范 §5.3)。
*/
import { and, asc, desc, eq, inArray, isNull, sql } from 'drizzle-orm';
import type { TaskId } from '@contracts';
import type { Db } from '@shared/db.ts';
import type { ActivityMemoryStore, TaskMemoryStore } from '../../port.ts';
import type { ActivityMemoryEntry, MemoryLayer } from '../domain/activity-memory.ts';
import type { EntryKind, GovernanceMark, MemoryEntry, VersionStamp } from '../domain/task-memory.ts';
import { activityMemoryTable, taskMemorySealTable, taskMemoryTable } from './memory-schema.ts';
type TaskRow = typeof taskMemoryTable.$inferSelect;
type ActivityRow = typeof activityMemoryTable.$inferSelect;
function toEntry(row: TaskRow): MemoryEntry {
return {
id: row.id,
taskId: row.taskId,
seq: row.seq,
at: row.at.toISOString(),
kind: row.kind as EntryKind,
side: row.side as MemoryEntry['side'],
title: row.title,
...(row.detail === null ? {} : { detail: row.detail as Record<string, unknown> }),
stamp: row.stamp as VersionStamp,
governance: row.governance as GovernanceMark,
...(row.linksTo === null ? {} : { linksTo: row.linksTo }),
...(row.eventRef === null ? {} : { eventRef: row.eventRef }),
};
}
function toActivityEntry(row: ActivityRow): ActivityMemoryEntry {
return {
id: row.id,
activityId: row.activityId,
layer: row.layer as MemoryLayer,
statement: row.statement,
evidence: row.evidence as ActivityMemoryEntry['evidence'],
...(row.increment === null
? {}
: { increment: row.increment as NonNullable<ActivityMemoryEntry['increment']> }),
createdAt: row.createdAt.toISOString(),
...(row.prunedAt === null ? {} : { prunedAt: row.prunedAt.toISOString() }),
};
}
export function createTaskMemoryStore(db: Db): TaskMemoryStore {
return {
async nextSeq(taskId: TaskId) {
const rows = await db
.select({ max: sql<number | null>`max(${taskMemoryTable.seq})` })
.from(taskMemoryTable)
.where(eq(taskMemoryTable.taskId, taskId));
return (rows[0]?.max ?? 0) + 1;
},
async append(entry) {
await db.insert(taskMemoryTable).values({
id: entry.id,
taskId: entry.taskId,
seq: entry.seq,
at: new Date(entry.at),
kind: entry.kind,
side: entry.side,
title: entry.title,
detail: entry.detail ?? null,
stamp: entry.stamp,
governance: entry.governance,
linksTo: entry.linksTo ?? null,
eventRef: entry.eventRef ?? null,
});
},
async list(taskId: TaskId) {
const rows = await db
.select()
.from(taskMemoryTable)
.where(eq(taskMemoryTable.taskId, taskId))
.orderBy(asc(taskMemoryTable.seq));
return rows.map(toEntry);
},
async tail(taskId: TaskId, size: number) {
const rows = await db
.select()
.from(taskMemoryTable)
.where(eq(taskMemoryTable.taskId, taskId))
.orderBy(desc(taskMemoryTable.seq))
.limit(Math.max(size, 0));
return rows.map(toEntry).reverse();
},
async seal(taskId: TaskId, at: Date) {
await db
.insert(taskMemorySealTable)
.values({ taskId, sealedAt: at })
.onConflictDoNothing();
},
async isSealed(taskId: TaskId) {
const rows = await db
.select()
.from(taskMemorySealTable)
.where(eq(taskMemorySealTable.taskId, taskId))
.limit(1);
return rows.length > 0;
},
};
}
export function createActivityMemoryStore(db: Db): ActivityMemoryStore {
return {
async list(activityId, layers, limit) {
const conditions = [eq(activityMemoryTable.activityId, activityId), isNull(activityMemoryTable.prunedAt)];
if (layers !== undefined && layers.length > 0) {
conditions.push(inArray(activityMemoryTable.layer, [...layers]));
}
const rows = await db
.select()
.from(activityMemoryTable)
.where(and(...conditions))
.orderBy(desc(activityMemoryTable.createdAt))
.limit(limit);
return rows.map(toActivityEntry);
},
async insertMany(entries) {
if (entries.length === 0) return;
await db.insert(activityMemoryTable).values(
entries.map((e) => ({
id: e.id,
activityId: e.activityId,
layer: e.layer,
statement: e.statement,
evidence: e.evidence,
increment: e.increment ?? null,
createdAt: new Date(e.createdAt),
})),
);
},
async prune(entryId, at) {
await db
.update(activityMemoryTable)
.set({ prunedAt: at, active: false })
.where(eq(activityMemoryTable.id, entryId));
},
async setIncrementState(entryId, state) {
await db
.update(activityMemoryTable)
.set({ increment: sql`jsonb_set(coalesce(${activityMemoryTable.increment}, '{}'::jsonb), '{state}', to_jsonb(${state}::text))` })
.where(eq(activityMemoryTable.id, entryId));
},
};
}
@@ -0,0 +1,94 @@
/**
* 活动记忆四层 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 OS 8.2,AM 9.2)。
*
* 一类一本,跨全部任务实例累积;结构不是自由笔记 —— 每类条目对准业务模型的一个作用面。
* 三条铁律:
* 条目三件套(必须)—— 归纳陈述 + 证据链 + 候选增量(可选);**无证据链的条目不得入库**
* 写入 = 蒸馏(必须)—— 只经蒸馏钩子从任务记忆归纳;人可修剪;**不设直写旁路**
* 记忆不载权力(必须)—— 只有参考力,强制力只经候选增量 + 回放 + 人放行进入业务模型
*/
export const MEMORY_LAYERS = ['fact', 'pattern', 'verdict', 'exception'] as const;
export type MemoryLayer = (typeof MEMORY_LAYERS)[number];
export const MEMORY_LAYER_LABEL: Readonly<Record<MemoryLayer, string>> = {
fact: '事实层',
pattern: '模式层',
verdict: '裁决层',
exception: '例外层',
};
/** 每层优化谁 —— 记忆天然是模型优化的原料堆(AM 9.2) */
export const MEMORY_LAYER_TARGET: Readonly<Record<MemoryLayer, string>> = {
fact: '校准时限 · ifPart 阈值 · DMN 输入项 · 重试参数',
pattern: '候选结构增量:裁量项转常项 · 新前提 · 阶段重组 · 步序修订',
verdict: '候选新前提条件 · 收紧完成判据 · DMN 新规则行',
exception: '规则适用边界 · 前提负向条件 · 技能适用描述修正',
};
/** 证据链:指回任务记忆的具体片段,**可回放** */
export type Evidence = {
readonly taskId: string;
readonly entryIds: readonly string[];
readonly note?: string;
};
export const INCREMENT_STATES = ['candidate', 'replaying', 'released', 'rejected'] as const;
export type IncrementState = (typeof INCREMENT_STATES)[number];
/** 候选增量:落到业务模型 / 技能的具体 diff */
export type CandidateIncrement = {
readonly target: 'cmmn' | 'dmn' | 'flow-steps' | 'skill';
readonly diff: string;
readonly state: IncrementState;
};
export type ActivityMemoryEntry = {
readonly id: string;
readonly activityId: string;
readonly layer: MemoryLayer;
/** 归纳陈述 */
readonly statement: string;
/** 证据链 —— 空即不得入库 */
readonly evidence: readonly Evidence[];
readonly increment?: CandidateIncrement;
readonly createdAt: string;
readonly prunedAt?: string;
};
export class NoEvidenceError extends Error {
constructor(readonly statement: string) {
super(`活动记忆条目无证据链,不得入库:${statement}`);
this.name = 'NoEvidenceError';
}
}
/** 条目三件套的机检 —— 写入前必过 */
export function assertThreePart(entry: Pick<ActivityMemoryEntry, 'statement' | 'evidence'>): void {
if (entry.statement.trim() === '') throw new NoEvidenceError('(空陈述)');
const hasEvidence = entry.evidence.some((e) => e.entryIds.length > 0);
if (!hasEvidence) throw new NoEvidenceError(entry.statement);
}
/**
* 装载:按定位选摘相关条目入 Agent 上下文(渐进披露)。
* **语义引擎不读活动记忆** —— 引擎零业务,记忆是 Agent 侧的输入(AM 9.2)。
*/
export function selectForLoading(
entries: readonly ActivityMemoryEntry[],
options: { readonly layers?: readonly MemoryLayer[]; readonly limit: number },
): ActivityMemoryEntry[] {
const layers = options.layers;
return entries
.filter((e) => e.prunedAt === undefined)
.filter((e) => layers === undefined || layers.includes(e.layer))
.slice(0, options.limit);
}
/**
* 记忆不载权力:条目对行为只有**参考力**。
* 强制力的唯一路径是候选增量经回放与人放行进入业务模型本身 —— 本函数是那条路径的判据。
*/
export function isBinding(entry: ActivityMemoryEntry): boolean {
return entry.increment?.state === 'released';
}
@@ -0,0 +1,123 @@
/**
* 任务日志:任务记忆的两级投影 —— 纯函数,零 IO(编码规范 §5.3;语义出处 OS 8.1)。
*
* 「做事与留痕是同一个动作,不存在补写日志」——
* 结构化日志(零 LLM)以引擎事件流为骨架,按事件类型筛选、模板措辞;
* **任何时刻可重算,口径与源恒一致**;归侧标签使其天然分栏。
* 叙述性小结(LLM,可选)是视图不是事实,不入事实库 —— 由 usecase 经系统用途模型生成。
*/
import type { AttributionSide } from '@contracts';
import { ATTRIBUTION_SIDE_LABEL } from '@contracts';
import type { EntryKind, MemoryEntry } from './task-memory.ts';
export type LogLine = {
readonly id: string;
readonly at: string;
readonly side: AttributionSide;
readonly sideLabel: string;
readonly title: string;
readonly summary: string | undefined;
readonly eventRef: string | undefined;
readonly position: string | undefined;
};
/** 骨架:只有这些种类进结构化日志(OS 8.1 列举) */
const SKELETON: ReadonlySet<EntryKind> = new Set<EntryKind>([
'engine-transition',
'action',
'result',
'card-sent',
'card-reply',
'ledger-write',
'action-intent',
'release-verdict',
'terminal',
'human-input',
]);
const TEMPLATE: Readonly<Record<EntryKind, (e: MemoryEntry) => string>> = {
observation: (e) => `观察:${e.title}`,
thought: (e) => `思考:${e.title}`,
action: (e) => `调用:${e.title}`,
result: (e) => `返回:${e.title}`,
'engine-transition': (e) => `迁移:${e.title}`,
evaluation: (e) => `求值:${e.title}`,
'card-sent': (e) => `发出:${e.title}`,
'card-reply': (e) => `回传:${e.title}`,
'ledger-write': (e) => `写入:${e.title}`,
'action-intent': (e) => `动作意向:${e.title}`,
'release-verdict': (e) => `放行裁决:${e.title}`,
terminal: (e) => `终态:${e.title}`,
'human-input': (e) => `留言:${e.title}`,
};
/** 结构化日志:零 LLM,可重算,每条自带谁 · 何时 · 依据 · 留痕指针 */
export function structuredLog(entries: readonly MemoryEntry[]): LogLine[] {
return entries
.filter((e) => SKELETON.has(e.kind))
.map((e) => ({
id: e.id,
at: e.at,
side: e.side,
sideLabel: ATTRIBUTION_SIDE_LABEL[e.side],
title: TEMPLATE[e.kind](e),
summary: typeof e.detail?.['summary'] === 'string' ? e.detail['summary'] : undefined,
eventRef: e.eventRef,
position: e.stamp.position,
}));
}
/** 归侧标签使日志天然分栏:机器做的 / 人做的 / 人裁的 / 在等谁 */
export function splitBySide(lines: readonly LogLine[]): Record<AttributionSide, LogLine[]> {
const out: Record<AttributionSide, LogLine[]> = {
machine: [],
human: [],
'human-verdict': [],
'world-wait': [],
};
for (const line of lines) out[line.side].push(line);
return out;
}
/* ── 度量八指标(OS 8.3):只读投影,零埋点,统计即查询 ─────────────── */
export type Metrics = {
/** 自主率 = 机器产出 ÷(机器产出 + 人产出);裁决与等待不进分母 */
readonly autonomy: number;
/** 纠正率 = 被纠正批次 ÷ 提交批次 */
readonly correction: number;
/** 有效自主率 = 自主率 ×(1 − 纠正率) */
readonly effectiveAutonomy: number;
/** 打扰率 = 每单任务调用人类工具的次数 */
readonly interruption: number;
/** DMN 覆盖率 = 确定性求值占判断次数的比例 */
readonly dmnCoverage: number;
};
export type MetricsInput = {
readonly entries: readonly MemoryEntry[];
/** 提交批次数:每次放行提交 = 一个产出批次 */
readonly submissions: number;
/** 被纠正批次:驳回重做 / 驳回迭代 / 叫停 / 裁决推翻 */
readonly corrections: number;
readonly taskCount: number;
/** 判断次数中由 DMN / 机检确定性求值的次数 */
readonly deterministicJudgements: number;
readonly totalJudgements: number;
};
export function computeMetrics(input: MetricsInput): Metrics {
const machine = input.entries.filter((e) => e.side === 'machine').length;
const human = input.entries.filter((e) => e.side === 'human').length;
const denominator = machine + human;
const autonomy = denominator === 0 ? 0 : machine / denominator;
const correction = input.submissions === 0 ? 0 : input.corrections / input.submissions;
const cards = input.entries.filter((e) => e.kind === 'card-sent').length;
return {
autonomy,
correction,
effectiveAutonomy: autonomy * (1 - correction),
interruption: input.taskCount === 0 ? 0 : cards / input.taskCount,
dmnCoverage: input.totalJudgements === 0 ? 0 : input.deterministicJudgements / input.totalJudgements,
};
}
@@ -0,0 +1,114 @@
/**
* 任务记忆的纯逻辑 —— 零 IO(编码规范 §5.3;语义出处 OS 8.1,AM 9.1)。
*
* 任务记忆 = 会话历史:消息历史 + 引擎事件流,同轴合流、按时间全序。四条性质(必须):
* 只追加 · 信封盖戳 · 治理先行 · 归侧
*/
import type { AttributionSide } from '@contracts';
/** 条目种类 —— 消息历史与引擎事件流两股同轴 */
export const ENTRY_KINDS = [
'observation',
'thought',
'action',
'result',
'engine-transition',
'evaluation',
'card-sent',
'card-reply',
'ledger-write',
'action-intent',
'release-verdict',
'terminal',
'human-input',
] as const;
export type EntryKind = (typeof ENTRY_KINDS)[number];
/** 版本信封:行为可归因、历史可回放的前提(AM 9.1) */
export type VersionStamp = {
readonly contractVersion: string;
readonly businessModelVersion: string;
readonly skillVersions: readonly string[];
readonly modelName?: string;
/** 定位:loop = 阶段 · 目标项;flow = 步序位置 */
readonly position?: string;
};
/** 治理标记:留痕时刻打上,不合规的记录不得进入蒸馏队列 */
export type GovernanceMark = {
readonly masked: boolean;
readonly learnable: boolean;
readonly retainUntil?: string;
};
export type MemoryEntry = {
readonly id: string;
readonly taskId: string;
readonly seq: number;
readonly at: string;
readonly kind: EntryKind;
readonly side: AttributionSide;
readonly title: string;
readonly detail?: Record<string, unknown>;
readonly stamp: VersionStamp;
readonly governance: GovernanceMark;
/** 迟到信号回链原步 —— 不改写因果,只以后续条目累加 */
readonly linksTo?: string;
/** 留痕指针:事件存储位置 */
readonly eventRef?: string;
};
/**
* 归侧:每条按**动作类型自动**打四侧标签 —— 度量的口径来源,零埋点(OS 8.1)。
* 这是纯推导,不是可配置项:同一种动作恒定同一侧。
*/
export function deriveSide(kind: EntryKind): AttributionSide {
switch (kind) {
case 'observation':
case 'thought':
case 'action':
case 'result':
case 'engine-transition':
case 'evaluation':
case 'ledger-write':
case 'action-intent':
return 'machine';
case 'card-reply':
case 'human-input':
return 'human';
case 'release-verdict':
case 'terminal':
return 'human-verdict';
case 'card-sent':
return 'world-wait';
}
}
/** 只追加:序号严格递增,同一任务内不得回退或重复 */
export function assertAppendOnly(previousSeq: number, nextSeq: number): void {
if (nextSeq <= previousSeq) {
throw new Error(`任务记忆只追加:序号必须递增(${previousSeq} → ${nextSeq})`);
}
}
/**
* 窗口:活动侧**唯一**可配置项 —— 每次 LLM 调用加载多少轮会话历史。
* 窗口之外的历史不进上下文,但完整在库(续跑、审计、回放永远对全量记录进行)。
*/
export function windowOf(entries: readonly MemoryEntry[], window: number): MemoryEntry[] {
if (window <= 0) return [];
return entries.slice(-window);
}
/** 治理约束:不合规的记录不得进入蒸馏队列(OS 8.1) */
export function distillable(entries: readonly MemoryEntry[]): MemoryEntry[] {
return entries.filter((e) => e.governance.learnable && !e.governance.masked);
}
/**
* 投影不得比源暴露更多 —— 日志继承任务记忆的治理标记,按读者权限裁剪生成(OS 8.1)。
*/
export function redactForReader(entry: MemoryEntry, canSeeMasked: boolean): MemoryEntry {
if (!entry.governance.masked || canSeeMasked) return entry;
return { ...entry, title: '【已脱敏】', ...(entry.detail === undefined ? {} : { detail: {} }) };
}
@@ -0,0 +1,192 @@
/**
* memory 用例 —— 编排 domain,依赖 port.ts(编码规范 §5.3)。
*/
import type { TaskId } from '@contracts';
import type { AppendEntry, DistillInput, MemoryService } from '../../contract.ts';
import type { ActivityMemoryStore, Clock, IdGenerator, SystemModel, TaskMemoryStore } from '../../port.ts';
import type { ActivityMemoryEntry, MemoryLayer } from '../domain/activity-memory.ts';
import { assertThreePart, MEMORY_LAYERS, selectForLoading } from '../domain/activity-memory.ts';
import { computeMetrics, structuredLog } from '../domain/task-log.ts';
import type { MemoryEntry } from '../domain/task-memory.ts';
import { assertAppendOnly, deriveSide, distillable, redactForReader, windowOf } from '../domain/task-memory.ts';
export type MemoryServiceDeps = {
readonly tasks: TaskMemoryStore;
readonly activities: ActivityMemoryStore;
readonly model: SystemModel;
readonly clock: Clock;
readonly ids: IdGenerator;
};
/** 蒸馏产物的形状 —— 系统用途模型按此 schema 出参 */
const DISTILL_SCHEMA = {
type: 'object',
required: ['entries'],
properties: {
entries: {
type: 'array',
items: {
type: 'object',
required: ['layer', 'statement', 'entryIds'],
properties: {
layer: { enum: [...MEMORY_LAYERS] },
statement: { type: 'string' },
entryIds: { type: 'array', items: { type: 'string' } },
},
},
},
},
} as const;
type DistillDraft = { layer: string; statement: string; entryIds: string[] };
export function createMemoryService(deps: MemoryServiceDeps): MemoryService {
return {
async append(input: AppendEntry): Promise<MemoryEntry> {
if (await deps.tasks.isSealed(input.taskId)) {
throw new Error(`任务记忆已封卷,不得追加:${input.taskId}`);
}
const seq = await deps.tasks.nextSeq(input.taskId);
assertAppendOnly(seq - 1, seq);
const entry: MemoryEntry = {
id: deps.ids.next(),
taskId: input.taskId,
seq,
at: deps.clock.now().toISOString(),
kind: input.kind,
// 归侧自动推导 —— 调用方不得指定(零埋点,OS 8.1)
side: deriveSide(input.kind),
title: input.title,
...(input.detail === undefined ? {} : { detail: input.detail }),
stamp: input.stamp,
governance: {
masked: input.governance?.masked ?? false,
learnable: input.governance?.learnable ?? true,
...(input.governance?.retainUntil === undefined ? {} : { retainUntil: input.governance.retainUntil }),
},
...(input.linksTo === undefined ? {} : { linksTo: input.linksTo }),
...(input.eventRef === undefined ? {} : { eventRef: input.eventRef }),
};
await deps.tasks.append(entry);
return entry;
},
async history(taskId) {
return deps.tasks.list(taskId);
},
async window(taskId, size) {
// 窗口之外的历史不进上下文,但完整在库
return windowOf(await deps.tasks.tail(taskId, size), size);
},
async seal(taskId) {
await deps.tasks.seal(taskId, deps.clock.now());
},
async taskLog(taskId, options) {
const entries = await deps.tasks.list(taskId);
const visible = entries.map((e) => redactForReader(e, options?.canSeeMasked ?? false));
return structuredLog(visible);
},
async narrate(taskId) {
const model = await deps.model.systemModelName();
if (model === undefined) return undefined;
const lines = await this.taskLog(taskId);
if (lines.length === 0) return undefined;
const { text } = await deps.model.generate({
model,
prompt:
'以下是一单任务的结构化日志。用不超过 200 字做阶段小结,只陈述日志里有的事实,不要推测。\n\n' +
lines.map((l) => `${l.at} [${l.sideLabel}] ${l.title}`).join('\n'),
});
return text;
},
async loadActivityMemory(activityId, options) {
const entries = await deps.activities.list(activityId, options?.layers, options?.limit ?? 50);
return selectForLoading(entries, {
...(options?.layers === undefined ? {} : { layers: options.layers }),
limit: options?.limit ?? 50,
});
},
/** 写入唯蒸馏:从任务记忆归纳,条目必挂证据链 */
async distill(input: DistillInput): Promise<ActivityMemoryEntry[]> {
const model = await deps.model.systemModelName();
if (model === undefined) return [];
const source = distillable(await deps.tasks.list(input.taskId));
if (source.length === 0) return [];
const { object } = await deps.model.generate({
model,
prompt:
`${input.prompt}\n\n` +
'从以下任务记忆里归纳活动记忆条目。每条必须给出 layer(fact/pattern/verdict/exception)、' +
'statement(一般化陈述)与 entryIds(引用的条目 ID,不得为空)。只输出 JSON。\n\n' +
source.map((e) => `${e.id} [${e.kind}] ${e.title}`).join('\n'),
outputSchema: DISTILL_SCHEMA,
});
const drafts = extractDrafts(object);
const known = new Set(source.map((e) => e.id));
const now = deps.clock.now().toISOString();
const entries: ActivityMemoryEntry[] = [];
for (const draft of drafts) {
const entryIds = draft.entryIds.filter((id) => known.has(id));
const candidate = {
id: deps.ids.next(),
activityId: input.activityId,
layer: normalizeLayer(draft.layer),
statement: draft.statement,
evidence: [{ taskId: input.taskId, entryIds }],
createdAt: now,
} satisfies ActivityMemoryEntry;
// 无证据链的条目不得入库 —— 蒸馏产物一样过这道门
try {
assertThreePart(candidate);
} catch {
continue;
}
entries.push(candidate);
}
if (entries.length > 0) await deps.activities.insertMany(entries);
return entries;
},
async prune(entryId) {
await deps.activities.prune(entryId, deps.clock.now());
},
async setIncrementState(entryId, state) {
await deps.activities.setIncrementState(entryId, state);
},
async metrics(input) {
const all: MemoryEntry[] = [];
for (const taskId of input.taskIds) all.push(...(await deps.tasks.list(taskId as TaskId)));
return computeMetrics({ ...input, entries: all });
},
};
}
function extractDrafts(object: unknown): DistillDraft[] {
if (object === null || typeof object !== 'object') return [];
const entries = (object as { entries?: unknown }).entries;
if (!Array.isArray(entries)) return [];
return entries.flatMap((raw): DistillDraft[] => {
if (raw === null || typeof raw !== 'object') return [];
const { layer, statement, entryIds } = raw as Record<string, unknown>;
if (typeof layer !== 'string' || typeof statement !== 'string') return [];
const ids = Array.isArray(entryIds) ? entryIds.filter((x): x is string => typeof x === 'string') : [];
return [{ layer, statement, entryIds: ids }];
});
}
function normalizeLayer(value: string): MemoryLayer {
return (MEMORY_LAYERS as readonly string[]).includes(value) ? (value as MemoryLayer) : 'fact';
}
+7
View File
@@ -0,0 +1,7 @@
{
"name": "@modules/memory",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": { ".": "./index.ts" }
}
+39
View File
@@ -0,0 +1,39 @@
/**
* module: memory —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
*/
import type { TaskId } from '@contracts';
import type { ActivityMemoryEntry, MemoryLayer } from './internal/domain/activity-memory.ts';
import type { MemoryEntry } from './internal/domain/task-memory.ts';
export type Clock = { now(): Date };
export type IdGenerator = { next(): string };
export type TaskMemoryStore = {
nextSeq(taskId: TaskId): Promise<number>;
append(entry: MemoryEntry): Promise<void>;
list(taskId: TaskId): Promise<MemoryEntry[]>;
tail(taskId: TaskId, size: number): Promise<MemoryEntry[]>;
seal(taskId: TaskId, at: Date): Promise<void>;
isSealed(taskId: TaskId): Promise<boolean>;
};
export type ActivityMemoryStore = {
list(activityId: string, layers: readonly MemoryLayer[] | undefined, limit: number): Promise<ActivityMemoryEntry[]>;
insertMany(entries: readonly ActivityMemoryEntry[]): Promise<void>;
prune(entryId: string, at: Date): Promise<void>;
setIncrementState(entryId: string, state: string): Promise<void>;
};
/**
* 模型调用 —— app 注入 `llm`(L1)的 ModelGateway。
* 蒸馏钩子与叙述性小结用**系统用途**绑定实例:计量入系统账、不占活动预算(OS 02)。
*/
export type SystemModel = {
generate(input: {
readonly model: string;
readonly prompt: string;
readonly outputSchema?: Record<string, unknown>;
}): Promise<{ text: string; object?: unknown }>;
/** 系统用途模型名;未配置即 undefined —— 此时叙述与蒸馏静默跳过,不报错 */
systemModelName(): Promise<string | undefined>;
};
+28
View File
@@ -0,0 +1,28 @@
# module: message
职责: 系统消息:通知、跨任务征答收件箱(就地作答 / 驳回重答)、死信与无主兜底的人侧呈现
不负责: 卡的内容与回传校验(见 human-tool)、事件投递与三级消费判定(见 event)、
任务与审批(它们是**责任**要素,消息只是信息)
公开出口: index.ts
- 类型:`MessageService` `SystemMessage` `MessageKind` `SendNotice` `SendElicitation`
- 错误:`MessageNotAnswerableError`
- 端口类型:`MessageRepository` `PositionResolver` `Clock` `IdGenerator`
- 用例工厂:`createMessageService(deps: MessageServiceDeps): MessageService`
- 纯函数:`canAnswer` `unreadCount` `pendingAnswerCount`
- adapter:`createMessageRepository`;Drizzle 表:`messageTable`
层级: L2(编码规范 §4)
依赖: @contracts;@shared/db;port:`PositionResolver`(app 注入 org)
被依赖: event(经 `MessageFallback` 端口,由 app 注入 —— event 是 L1,不反向依赖本模块)、
human-tool(跨人卡的到达面)、app(工作台 · 消息)
不变量:
- **消息不产生责任**:它是信息要素,与任务 / 审批(责任要素)分明
- **跨人卡是一次性解答请求,不是责任转移**:作答权限只及卡面与决策包,不开放任务对话流
- **只增不改**:驳回重答 = 新发一条系统消息(新卡实例,附驳回理由),原卡闭合;
已答或已闭合的卡不得再答(`canAnswer` 是唯一判据)
- 死信同时送**相关人的消息要素与治理台 · 任务管理**
- 无主兜底的相关人 = 通道实例配置的**默认认领岗位**;未配置则落治理台,绝不静默丢弃
影响半径: index/contract/port 未变 → 对外零影响
@@ -0,0 +1,62 @@
/** 跨任务征答的作答判据 —— domain 测试零 mock(OS 2.1) */
import { describe, expect, it } from 'vitest';
import { canAnswer, pendingAnswerCount, unreadCount } from '../internal/domain/message-kind.ts';
import type { SystemMessage } from '../internal/domain/message-kind.ts';
const msg = (over: Partial<SystemMessage> = {}): SystemMessage => ({
id: 'm1',
kind: 'elicitation',
staffId: 's1',
title: '请补全交期',
body: '缺客户可接受交期',
at: '2026-07-27T10:00:00.000Z',
card: { cardId: 'c1', sourceTaskId: 't1', payload: {} },
...over,
});
describe('canAnswer —— 跨人卡是一次性解答请求', () => {
it('本人未答的征答可答', () => {
expect(canAnswer(msg(), 's1')).toBe(true);
});
it('别人的卡不可答', () => {
expect(canAnswer(msg(), 's2')).toBe(false);
});
it('通知不可答 —— 消息不产生责任', () => {
expect(canAnswer(msg({ kind: 'notice' }), 's1')).toBe(false);
});
it('已答的卡不可再答 —— 只增不改', () => {
expect(canAnswer(msg({ card: { cardId: 'c1', sourceTaskId: 't1', payload: {}, answeredAt: 'x' } }), 's1')).toBe(
false,
);
});
it('被驳回重答闭合的原卡不可再答', () => {
expect(canAnswer(msg({ card: { cardId: 'c1', sourceTaskId: 't1', payload: {}, supersededBy: 'm2' } }), 's1')).toBe(
false,
);
});
it('无卡体的征答不可答', () => {
const { card: _card, ...rest } = msg();
expect(canAnswer(rest as SystemMessage, 's1')).toBe(false);
});
});
describe('计数', () => {
it('未读计数', () => {
expect(unreadCount([msg(), msg({ id: 'm2', readAt: 'x' })])).toBe(1);
});
it('待答计数只算未答未闭合的征答', () => {
expect(
pendingAnswerCount([
msg(),
msg({ id: 'm2', card: { cardId: 'c2', sourceTaskId: 't1', payload: {}, answeredAt: 'x' } }),
msg({ id: 'm3', kind: 'notice' }),
]),
).toBe(1);
});
});
+47
View File
@@ -0,0 +1,47 @@
/**
* module: message —— 对外类型(编码规范 §10.1)。
*/
import type { Envelope, StaffId } from '@contracts';
import type { MessageKind, SystemMessage } from './internal/domain/message-kind.ts';
export type { Envelope, MessageKind, SystemMessage };
export class MessageNotAnswerableError extends Error {
constructor(readonly messageId: string) {
super(`该消息不可作答:${messageId}(已答、已闭合,或不是跨任务征答)`);
this.name = 'MessageNotAnswerableError';
}
}
export type SendNotice = {
readonly staffId: StaffId;
readonly title: string;
readonly body: string;
readonly eventRef?: string;
};
export type SendElicitation = {
readonly staffId: StaffId;
readonly title: string;
readonly body: string;
readonly cardId: string;
readonly sourceTaskId: string;
readonly payload: Record<string, unknown>;
};
export type MessageService = {
inbox(staffId: StaffId, options?: { unreadOnly?: boolean; limit?: number }): Promise<SystemMessage[]>;
markRead(messageId: string): Promise<void>;
notify(input: SendNotice): Promise<SystemMessage>;
/** 跨人卡以系统消息送达对方,就地作答 —— 任务要素永远只装「我负责的任务」(OS 2.1) */
sendElicitation(input: SendElicitation): Promise<SystemMessage>;
answer(messageId: string, staffId: StaffId, answer: Record<string, unknown>): Promise<SystemMessage>;
/** 驳回重答 = 新发一条系统消息(新卡实例),原卡闭合 —— 只增不改 */
rejectAndReask(messageId: string, reason: string): Promise<SystemMessage>;
/* ── event 的 `MessageFallback` 端口的实现面(OS 06) ─────────────── */
/** 死信:重试超限 → 系统通知送达相关人的消息要素与治理台 */
deadLetter(envelope: Envelope, error: string): Promise<void>;
/** 无主兜底:相关人 = 通道实例配置的默认认领岗位 */
unowned(envelope: Envelope, fallbackPosition: string | undefined): Promise<void>;
};
+21
View File
@@ -0,0 +1,21 @@
/**
* module: message —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
*/
export type { MessageKind, MessageService, SendElicitation, SendNotice, SystemMessage } from './contract.ts';
export { MessageNotAnswerableError } from './contract.ts';
export type { Clock, IdGenerator, MessageRepository, PositionResolver } from './port.ts';
export type { MessageServiceDeps } from './internal/usecase/message-service.ts';
export { createMessageService } from './internal/usecase/message-service.ts';
export {
canAnswer,
MESSAGE_KIND_LABEL,
MESSAGE_KINDS,
pendingAnswerCount,
unreadCount,
} from './internal/domain/message-kind.ts';
export { createMessageRepository } from './internal/adapter/message-store.ts';
export { messageTable } from './internal/adapter/message-schema.ts';
@@ -0,0 +1,24 @@
/**
* message 的 sys schema 表(编码规范 §7 / ADR-002)。
*/
import { index, jsonb, text, timestamp } from 'drizzle-orm/pg-core';
import { sys } from '@shared/db.ts';
export const messageTable = sys.table(
'system_message',
{
id: text('id').primaryKey(),
kind: text('kind').notNull(),
staffId: text('staff_id').notNull(),
positionId: text('position_id'),
title: text('title').notNull(),
body: text('body').notNull(),
at: timestamp('at', { withTimezone: true }).notNull().defaultNow(),
readAt: timestamp('read_at', { withTimezone: true }),
/** 跨任务征答的卡体;驳回重答以 supersededBy 闭合原卡 —— 只增不改 */
card: jsonb('card'),
answer: jsonb('answer'),
eventRef: text('event_ref'),
},
(t) => [index('message_staff_idx').on(t.staffId, t.at), index('message_unread_idx').on(t.staffId, t.readAt)],
);
@@ -0,0 +1,92 @@
/**
* message 存储 adapter —— Drizzle 只准出现在 adapter(编码规范 §5.3)。
*/
import { and, desc, eq, isNull } from 'drizzle-orm';
import type { StaffId } from '@contracts';
import type { Db } from '@shared/db.ts';
import type { MessageRepository } from '../../port.ts';
import type { MessageKind, SystemMessage } from '../domain/message-kind.ts';
import { messageTable } from './message-schema.ts';
type Row = typeof messageTable.$inferSelect;
function toMessage(row: Row): SystemMessage {
return {
id: row.id,
kind: row.kind as MessageKind,
staffId: row.staffId,
...(row.positionId === null ? {} : { positionId: row.positionId }),
title: row.title,
body: row.body,
at: row.at.toISOString(),
...(row.readAt === null ? {} : { readAt: row.readAt.toISOString() }),
...(row.card === null ? {} : { card: row.card as NonNullable<SystemMessage['card']> }),
...(row.eventRef === null ? {} : { eventRef: row.eventRef }),
};
}
export function createMessageRepository(db: Db): MessageRepository {
return {
async list(staffId: StaffId, unreadOnly, limit) {
const conditions = [eq(messageTable.staffId, staffId)];
if (unreadOnly) conditions.push(isNull(messageTable.readAt));
const rows = await db
.select()
.from(messageTable)
.where(and(...conditions))
.orderBy(desc(messageTable.at))
.limit(limit);
return rows.map(toMessage);
},
async get(messageId) {
const rows = await db.select().from(messageTable).where(eq(messageTable.id, messageId)).limit(1);
return rows[0] === undefined ? undefined : toMessage(rows[0]);
},
async insert(message) {
const [row] = await db
.insert(messageTable)
.values({
id: message.id,
kind: message.kind,
staffId: message.staffId,
positionId: message.positionId ?? null,
title: message.title,
body: message.body,
at: new Date(message.at),
card: message.card ?? null,
eventRef: message.eventRef ?? null,
})
.returning();
if (row === undefined) throw new Error('消息写入未返回行');
return toMessage(row);
},
async markRead(messageId, at) {
await db.update(messageTable).set({ readAt: at }).where(eq(messageTable.id, messageId));
},
async markAnswered(messageId, at, answer) {
const rows = await db.select().from(messageTable).where(eq(messageTable.id, messageId)).limit(1);
const row = rows[0];
if (row === undefined) return;
const card = (row.card ?? {}) as Record<string, unknown>;
await db
.update(messageTable)
.set({ card: { ...card, answeredAt: at.toISOString() }, answer, readAt: row.readAt ?? at })
.where(eq(messageTable.id, messageId));
},
async supersede(messageId, byMessageId, reason) {
const rows = await db.select().from(messageTable).where(eq(messageTable.id, messageId)).limit(1);
const row = rows[0];
if (row === undefined) return;
const card = (row.card ?? {}) as Record<string, unknown>;
await db
.update(messageTable)
.set({ card: { ...card, supersededBy: byMessageId, rejectedReason: reason } })
.where(eq(messageTable.id, messageId));
},
};
}
@@ -0,0 +1,65 @@
/**
* 系统消息的种类与投影 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 OS 2.1 / 06)。
*
* 「消息是**信息**,不产生责任」—— 与任务、审批(责任要素)分明。
* 三类内容:通知(承载触发器的邮件和消息、公告、提醒与告警)+ 跨任务征答(他人任务发来的卡)
* + 死信与回执的人侧呈现。
*/
export const MESSAGE_KINDS = ['notice', 'elicitation', 'dead-letter', 'unowned', 'receipt'] as const;
export type MessageKind = (typeof MESSAGE_KINDS)[number];
export const MESSAGE_KIND_LABEL: Readonly<Record<MessageKind, string>> = {
notice: '通知',
elicitation: '跨任务征答',
'dead-letter': '死信',
unowned: '无主入站',
receipt: '回执',
};
export type SystemMessage = {
readonly id: string;
readonly kind: MessageKind;
/** 收件人:员工;跨任务卡按岗位解析出人后落到具体员工 */
readonly staffId: string;
/** 岗位收件(无主兜底按默认认领岗位投递) */
readonly positionId?: string;
readonly title: string;
readonly body: string;
readonly at: string;
readonly readAt?: string;
/** 跨任务征答:卡本体与来源任务 —— 就地作答,不开放任务对话流 */
readonly card?: {
readonly cardId: string;
readonly sourceTaskId: string;
readonly payload: Record<string, unknown>;
readonly answeredAt?: string;
/** 驳回重答:新卡实例,附驳回理由与原答引用;原卡闭合 —— 只增不改 */
readonly supersededBy?: string;
readonly rejectedReason?: string;
};
readonly eventRef?: string;
};
/**
* 跨人卡是**一次性的解答请求**,不是责任转移 ——
* 作答权限只及卡面与决策包,不开放任务对话流(OS 2.1)。
*/
export function canAnswer(message: SystemMessage, staffId: string): boolean {
if (message.kind !== 'elicitation') return false;
if (message.staffId !== staffId) return false;
if (message.card === undefined) return false;
// 已作答或已被驳回重答闭合的卡不得再答 —— 每次请求与回答都是独立消息
return message.card.answeredAt === undefined && message.card.supersededBy === undefined;
}
/** 未读计数:消息不产生责任,只做信息提示 */
export function unreadCount(messages: readonly SystemMessage[]): number {
return messages.filter((m) => m.readAt === undefined).length;
}
/** 待答计数:跨任务征答里还没作答的 */
export function pendingAnswerCount(messages: readonly SystemMessage[]): number {
return messages.filter((m) => m.kind === 'elicitation' && m.card?.answeredAt === undefined && m.card?.supersededBy === undefined)
.length;
}
@@ -0,0 +1,125 @@
/**
* message 用例 —— 编排 domain,依赖 port.ts(编码规范 §5.3)。
*/
import type { StaffId } from '@contracts';
import type { MessageService, SendElicitation, SendNotice } from '../../contract.ts';
import { MessageNotAnswerableError } from '../../contract.ts';
import type { Clock, IdGenerator, MessageRepository, PositionResolver } from '../../port.ts';
import type { MessageKind, SystemMessage } from '../domain/message-kind.ts';
import { canAnswer } from '../domain/message-kind.ts';
export type MessageServiceDeps = {
readonly repo: MessageRepository;
readonly org: PositionResolver;
readonly clock: Clock;
readonly ids: IdGenerator;
};
export function createMessageService(deps: MessageServiceDeps): MessageService {
function build(
kind: MessageKind,
staffId: StaffId,
title: string,
body: string,
extra: Partial<SystemMessage> = {},
): SystemMessage {
return {
id: deps.ids.next(),
kind,
staffId,
title,
body,
at: deps.clock.now().toISOString(),
...extra,
};
}
async function fanOut(kind: MessageKind, staff: ReadonlyArray<{ id: StaffId }>, title: string, body: string, eventRef?: string) {
for (const person of staff) {
await deps.repo.insert(build(kind, person.id, title, body, eventRef === undefined ? {} : { eventRef }));
}
}
return {
async inbox(staffId, options) {
return deps.repo.list(staffId, options?.unreadOnly ?? false, options?.limit ?? 100);
},
async markRead(messageId) {
await deps.repo.markRead(messageId, deps.clock.now());
},
async notify(input: SendNotice) {
return deps.repo.insert(
build('notice', input.staffId, input.title, input.body, input.eventRef === undefined ? {} : { eventRef: input.eventRef }),
);
},
async sendElicitation(input: SendElicitation) {
return deps.repo.insert(
build('elicitation', input.staffId, input.title, input.body, {
card: { cardId: input.cardId, sourceTaskId: input.sourceTaskId, payload: input.payload },
}),
);
},
async answer(messageId, staffId, answer) {
const message = await deps.repo.get(messageId);
if (message === undefined || !canAnswer(message, staffId)) {
throw new MessageNotAnswerableError(messageId);
}
await deps.repo.markAnswered(messageId, deps.clock.now(), answer);
const updated = await deps.repo.get(messageId);
if (updated === undefined) throw new MessageNotAnswerableError(messageId);
return updated;
},
/** 驳回重答:新卡实例(附驳回理由与原答引用),原卡闭合 —— 每次请求与回答都是独立消息 */
async rejectAndReask(messageId, reason) {
const original = await deps.repo.get(messageId);
if (original === undefined || original.card === undefined) {
throw new MessageNotAnswerableError(messageId);
}
const reask = build('elicitation', original.staffId as StaffId, original.title, original.body, {
card: {
cardId: deps.ids.next(),
sourceTaskId: original.card.sourceTaskId,
payload: original.card.payload,
rejectedReason: reason,
},
});
const created = await deps.repo.insert(reask);
await deps.repo.supersede(messageId, created.id, reason);
return created;
},
/** 死信:送相关人的消息要素**与**治理台 · 任务管理(OS 6.3) */
async deadLetter(envelope, error) {
const governance = await deps.org.governanceStaff();
await fanOut(
'dead-letter',
governance,
`事件投递失败入死信:${envelope.type}`,
`事件 ${envelope.id} 重试超限。原因:${error}`,
envelope.trace.storeRef,
);
},
/** 无主兜底:相关人 = 通道实例配置的默认认领岗位;未配置则落治理台 */
async unowned(envelope, fallbackPosition) {
const recipients =
fallbackPosition === undefined
? await deps.org.governanceStaff()
: await deps.org.staffOfPosition(fallbackPosition);
const targets = recipients.length > 0 ? recipients : await deps.org.governanceStaff();
const subject = typeof envelope.payload.data['subject'] === 'string' ? envelope.payload.data['subject'] : '(无主题)';
await fanOut(
'unowned',
targets,
`无主入站待认领:${subject}`,
`事件 ${envelope.id} 未命中任何任务监听与触发器。原件:${envelope.payload.originRef ?? '无'}`,
envelope.trace.storeRef,
);
},
};
}
@@ -0,0 +1,7 @@
{
"name": "@modules/message",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": { ".": "./index.ts" }
}
+24
View File
@@ -0,0 +1,24 @@
/**
* module: message —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
*/
import type { StaffId } from '@contracts';
import type { SystemMessage } from './internal/domain/message-kind.ts';
export type Clock = { now(): Date };
export type IdGenerator = { next(): string };
export type MessageRepository = {
list(staffId: StaffId, unreadOnly: boolean, limit: number): Promise<SystemMessage[]>;
get(messageId: string): Promise<SystemMessage | undefined>;
insert(message: SystemMessage): Promise<SystemMessage>;
markRead(messageId: string, at: Date): Promise<void>;
markAnswered(messageId: string, at: Date, answer: Record<string, unknown>): Promise<void>;
supersede(messageId: string, byMessageId: string, reason: string): Promise<void>;
};
/** 岗位解析 —— app 注入 `org`(L1):无主兜底要把消息落到默认认领岗位上的人 */
export type PositionResolver = {
staffOfPosition(positionId: string): Promise<Array<{ id: StaffId }>>;
/** 治理台收件人(任务管理岗)—— 死信同时送治理台 */
governanceStaff(): Promise<Array<{ id: StaffId }>>;
};
+36
View File
@@ -0,0 +1,36 @@
# module: toolbox
职责: 功能工具注册与两审、调用契约、效应等级与运行时待遇、连接配置声明与凭证绑定、
白名单授权、调用留痕、纯工具的活动私有作用域、`ToolInvoker`(**两条缝之二**)
不负责: 动作意向的存放与放行落地(见 ledger 的 `ActionIntent` 与 release)、
人类工具与 LLM 工具(前者见 human-tool,后者是本模块的一类工具契约)
公开出口: index.ts
- 类型:`ToolboxService` `ToolInvoker` `ToolContract` `ToolCall` `ToolResult` `CallDisposition`
`EffectLevel` `RegisterToolInput` `RegistrationVerdict` `AuditFinding` `Preauthorization`
- 错误:`ToolNotFoundError` `ToolNotAllowedError` `ToolDisabledError` `ToolReplayMissError`
`RegistrationRejectedError`
- 端口类型:`ToolRegistry` `ToolRuntime` `ToolReplayStore` `SchemaValidator` `SecretBox` `Clock`
- 用例工厂:`createToolboxService` `createToolInvoker`
- 纯函数:`dispositionOf` `checkEvolution` `staticAudit` `auditPassed` `toolReplayKey`
- adapter:`createToolRegistry` `createToolReplayStore` `createSchemaValidator` `createToolRuntime`
- Drizzle 表:`toolTable` `toolAllowlistTable` `replayToolTable`
层级: L2(编码规范 §4)
依赖: @contracts;@shared/db;port:`SecretBox`(app 注入,与 llm 同一实现)、`ToolRuntime`
被依赖: channel, release, activity-registry, skill-engine, loop-engine, flow-engine, task,
app(治理台 · 工具管理)
不变量:
- **一切工具执行经 `ToolInvoker`** —— 绕过它,回放审就对它失明(§12.5#10)
- `replay` 模式**绝不回退实调**:缺录即 `ToolReplayMissError`
- **白名单在结构上拦截**:功能工具按活动白名单,纯工具按 `ownerActivityId` 私有作用域;
不在名单即 `ToolNotAllowedError`,活动代码无从绕开
- **效应等级决定待遇**:读类即调;写类登记为动作意向;唯一例外是契约**显式逐项列举**的预授权
- **契约演进只增不破坏**:出参只增字段、入参新增必为可选、效应等级不得变更;
破坏性变更 = 新工具名
- **注册两审**:静态审有 error 即不上架;试运行审要求样例集非空且全过
- **凭证隔离**:连接值加密落库,运行时只把**申报过的连接键**交给处理器,不透传其它环境值
- 停用后调用返回结构化错误(`ToolDisabledError`),不静默成功
影响半径: index/contract/port 未变 → 对外零影响
@@ -0,0 +1,139 @@
/** 调用契约、效应等级与注册静态审 —— domain 测试零 mock(OS 7.3) */
import { describe, expect, it } from 'vitest';
import { checkEvolution, dispositionOf } from '../internal/domain/tool-contract.ts';
import type { ToolContract } from '../internal/domain/tool-contract.ts';
import { auditPassed, staticAudit } from '../internal/domain/registration-audit.ts';
const contract = (over: Partial<ToolContract> = {}): ToolContract => ({
name: 'crm.customer-read',
version: '1.0.0',
description: '读取客户资料库中的客户档案,用于取客户等级与付款条款',
input: { type: 'object', properties: { customerCode: { type: 'string' } }, required: ['customerCode'] },
output: { type: 'object', properties: { grade: { type: 'string' }, terms: { type: 'string' } } },
effect: 'read',
errors: [{ code: 'not-found', retryable: false, message: '客户不存在' }],
idempotent: true,
timeoutMs: 15_000,
retries: 2,
connectionKeys: ['endpoint', 'apiKey'],
...over,
});
describe('dispositionOf —— 效应等级决定运行时待遇', () => {
it('读类:过程中自由调用', () => {
expect(dispositionOf(contract(), 'read', [])).toEqual({ kind: 'invoke-now', reason: 'read' });
});
it('写类:登记为动作意向,集中在放行时落地', () => {
expect(dispositionOf(contract({ effect: 'write' }), 'send', [])).toEqual({ kind: 'register-intent' });
});
it('写类 + 显式预授权:过程中即时执行', () => {
const disposition = dispositionOf(contract({ effect: 'write', name: 'mail.send' }), 'nudge', [
{ tool: 'mail.send', operation: 'nudge' },
]);
expect(disposition).toEqual({ kind: 'invoke-now', reason: 'preauthorized' });
});
it('预授权必须逐项列举:工具对了动作不对,仍是意向', () => {
expect(
dispositionOf(contract({ effect: 'write', name: 'mail.send' }), 'quote', [
{ tool: 'mail.send', operation: 'nudge' },
]),
).toEqual({ kind: 'register-intent' });
});
});
describe('checkEvolution —— 契约演进只增不破坏', () => {
it('出参只增字段:合法', () => {
const next = contract({
output: { type: 'object', properties: { grade: {}, terms: {}, risk: {} } },
});
expect(checkEvolution(contract(), next)).toEqual([]);
});
it('出参删字段:拦下', () => {
const next = contract({ output: { type: 'object', properties: { grade: {} } } });
expect(checkEvolution(contract(), next).map((v) => v.field)).toEqual(['output.terms']);
});
it('入参新增必填:拦下', () => {
const next = contract({
input: {
type: 'object',
properties: { customerCode: {}, region: {} },
required: ['customerCode', 'region'],
},
});
expect(checkEvolution(contract(), next).map((v) => v.field)).toEqual(['input.region']);
});
it('入参新增可选:合法', () => {
const next = contract({
input: { type: 'object', properties: { customerCode: {}, region: {} }, required: ['customerCode'] },
});
expect(checkEvolution(contract(), next)).toEqual([]);
});
it('入参删字段:拦下', () => {
const next = contract({ input: { type: 'object', properties: {}, required: [] } });
expect(checkEvolution(contract(), next).map((v) => v.field)).toEqual(['input.customerCode']);
});
it('效应等级变更:拦下', () => {
expect(checkEvolution(contract(), contract({ effect: 'write', idempotent: true })).map((v) => v.field)).toContain(
'effect',
);
});
it('改名 = 破坏性变更,必须换新工具名', () => {
const v = checkEvolution(contract(), contract({ name: 'crm.customer-read-v2' }));
expect(v).toHaveLength(1);
expect(v[0]?.field).toBe('name');
});
});
describe('staticAudit —— 注册两审之静态审', () => {
it('规范契约通过', () => {
expect(auditPassed(staticAudit(contract()))).toBe(true);
});
it('工具名须是小写点分标识', () => {
expect(staticAudit(contract({ name: 'CRM_Read' })).map((f) => f.code)).toContain('name-invalid');
});
it('描述过短即拦', () => {
expect(staticAudit(contract({ description: '读' })).map((f) => f.code)).toContain('description-missing');
});
it('入参不是 object schema 即拦', () => {
expect(staticAudit(contract({ input: { type: 'string' } })).map((f) => f.code)).toContain('schema-invalid');
});
it('写类工具必须声明幂等性 —— 落地要附幂等键', () => {
const findings = staticAudit(contract({ effect: 'write', idempotent: false }));
expect(findings.map((f) => f.code)).toContain('idempotency-missing');
expect(auditPassed(findings)).toBe(false);
});
it('超时须在合理区间', () => {
expect(staticAudit(contract({ timeoutMs: 0 })).map((f) => f.code)).toContain('timeout-invalid');
expect(staticAudit(contract({ timeoutMs: 999_999 })).map((f) => f.code)).toContain('timeout-invalid');
});
it('纯工具:无连接配置、无外效应、绑活动作用域', () => {
const pure = contract({ pure: true, connectionKeys: [], effect: 'read', ownerActivityId: 'act-1' });
expect(auditPassed(staticAudit(pure))).toBe(true);
expect(staticAudit({ ...pure, connectionKeys: ['x'] }).map((f) => f.code)).toContain('pure-tool-has-connection');
expect(staticAudit({ ...pure, effect: 'write', idempotent: true }).map((f) => f.code)).toContain('effect-invalid');
const unscoped = staticAudit({ ...pure, ownerActivityId: undefined });
expect(unscoped.map((f) => f.code)).toContain('pure-tool-not-scoped');
});
it('功能工具没申报连接目标只给告警,不阻塞', () => {
const findings = staticAudit(contract({ connectionKeys: [] }));
expect(findings.map((f) => f.code)).toContain('connection-keys-missing');
expect(auditPassed(findings)).toBe(true);
});
});
+129
View File
@@ -0,0 +1,129 @@
/**
* module: toolbox —— 对外类型(编码规范 §10.1)。
*
* 两条缝之二(§7.3):`ToolInvoker` 支持 record | replay | live ——
* **任何工具执行不经 ToolInvoker,回放审就对它失明**(§12.5#10)。
*/
import type { ActionIntent, TaskId } from '@contracts';
import type {
AuditFinding,
} from './internal/domain/registration-audit.ts';
import type {
CallDisposition,
EffectLevel,
EvolutionViolation,
Preauthorization,
ToolContract,
} from './internal/domain/tool-contract.ts';
export type { ActionIntent, AuditFinding, CallDisposition, EffectLevel, EvolutionViolation, Preauthorization, ToolContract };
export const INVOKER_MODES = ['live', 'record', 'replay'] as const;
export type InvokerMode = (typeof INVOKER_MODES)[number];
export class ToolNotFoundError extends Error {
constructor(readonly tool: string) {
super(`工具未注册:${tool}`);
this.name = 'ToolNotFoundError';
}
}
export class ToolNotAllowedError extends Error {
constructor(
readonly tool: string,
readonly activityId: string,
) {
super(`工具不在本活动白名单:${tool}(活动 ${activityId})—— 越权在结构上不可能`);
this.name = 'ToolNotAllowedError';
}
}
export class ToolDisabledError extends Error {
constructor(readonly tool: string) {
super(`工具已停用:${tool} —— 活动按失败路径处理`);
this.name = 'ToolDisabledError';
}
}
export class ToolReplayMissError extends Error {
constructor(readonly key: string) {
super(`工具回放缺录:${key} —— 回放模式下不得回退为实调`);
this.name = 'ToolReplayMissError';
}
}
export class RegistrationRejectedError extends Error {
constructor(readonly findings: readonly AuditFinding[]) {
super(`工具注册未过审:${findings.map((f) => f.message).join('; ')}`);
this.name = 'RegistrationRejectedError';
}
}
export type ToolCall = {
readonly tool: string;
readonly operation?: string;
readonly input: Record<string, unknown>;
readonly activityId: string;
readonly taskId?: TaskId;
readonly stepNo?: number;
};
export type ToolResult = {
readonly ok: boolean;
readonly output?: Record<string, unknown>;
readonly error?: { readonly code: string; readonly retryable: boolean; readonly message: string };
readonly latencyMs: number;
readonly mode: InvokerMode;
};
/** 调用留痕:入参快照、出参、时延与错误自动留痕入任务记忆(OS 7.3) */
export type InvocationTrace = {
readonly tool: string;
readonly input: Record<string, unknown>;
readonly result: ToolResult;
readonly at: string;
};
/**
* 工具调用器 —— 活动基座六件里的「工具(调用契约)」。
* **读类**过程中自由调用;**写类**登记为动作意向,放行后落地(除契约显式预授权者)。
*/
export type ToolInvoker = {
readonly mode: InvokerMode;
/** 判定运行时待遇 —— 调用方据此决定是即调还是登记意向 */
disposition(call: ToolCall, preauthorized: readonly Preauthorization[]): Promise<CallDisposition>;
invoke(call: ToolCall): Promise<ToolResult>;
/** 落地预检第四项:工具契约含 validate 能力时调之(ADR-005) */
validate(call: ToolCall): Promise<ToolResult | undefined>;
withMode(mode: InvokerMode): ToolInvoker;
};
export type RegisterToolInput = {
readonly contract: ToolContract;
/** 契约测试集:样例入参 → 期望出参形状;试运行审的依据 */
readonly samples: ReadonlyArray<{ input: Record<string, unknown>; expectShape: Record<string, unknown> }>;
/** 连接配置的值 —— 只存治理台,加密落库(ADR-009) */
readonly connectionValues?: Readonly<Record<string, string>>;
};
export type RegistrationVerdict = {
readonly accepted: boolean;
readonly staticFindings: readonly AuditFinding[];
readonly evolution: readonly EvolutionViolation[];
readonly trialRun: { readonly ran: number; readonly passed: number; readonly failures: readonly string[] };
};
export type ToolboxService = {
/** 注册两审:静态审读清单,试运行审验代码 */
register(input: RegisterToolInput): Promise<RegistrationVerdict>;
list(): Promise<ToolContract[]>;
get(name: string): Promise<ToolContract | undefined>;
/** 有活动声明引用的工具不得删除;可停用 —— 停用后调用返回结构化错误 */
setEnabled(name: string, enabled: boolean): Promise<void>;
bindConnection(name: string, values: Readonly<Record<string, string>>): Promise<void>;
/** 白名单:活动只经调用契约用工具,授权在白名单与契约,不在活动代码 */
setAllowlist(activityId: string, tools: readonly string[]): Promise<void>;
allowlistOf(activityId: string): Promise<string[]>;
/** 安装静态审:活动声明的工具契约齐备?装不齐不上架 */
assertContractsAvailable(names: readonly string[]): Promise<{ ok: boolean; missing: string[] }>;
};
+56
View File
@@ -0,0 +1,56 @@
/**
* module: toolbox —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
*/
export type {
ActionIntent,
AuditFinding,
CallDisposition,
EffectLevel,
EvolutionViolation,
InvocationTrace,
InvokerMode,
Preauthorization,
RegisterToolInput,
RegistrationVerdict,
ToolCall,
ToolContract,
ToolInvoker,
ToolResult,
ToolboxService,
} from './contract.ts';
export {
INVOKER_MODES,
RegistrationRejectedError,
ToolDisabledError,
ToolNotAllowedError,
ToolNotFoundError,
ToolReplayMissError,
} from './contract.ts';
export type {
Clock,
IdGenerator,
SchemaValidator,
SecretBox,
ToolRegistry,
ToolReplayStore,
ToolRuntime,
} from './port.ts';
export type { ToolInvokerDeps } from './internal/usecase/tool-invoker.ts';
export { createToolInvoker, toolReplayKey } from './internal/usecase/tool-invoker.ts';
export type { ToolboxServiceDeps } from './internal/usecase/toolbox-service.ts';
export { createToolboxService } from './internal/usecase/toolbox-service.ts';
export { checkEvolution, dispositionOf, EFFECT_LEVEL_LABEL, EFFECT_LEVELS } from './internal/domain/tool-contract.ts';
export { auditPassed, staticAudit } from './internal/domain/registration-audit.ts';
export type { ToolHandler } from './internal/adapter/toolbox-store.ts';
export {
createSchemaValidator,
createToolRegistry,
createToolReplayStore,
createToolRuntime,
isAllowed,
} from './internal/adapter/toolbox-store.ts';
export { replayToolTable, toolAllowlistTable, toolTable } from './internal/adapter/toolbox-schema.ts';
@@ -0,0 +1,48 @@
/**
* toolbox 的 sys schema 表(编码规范 §7 / ADR-002 / ADR-009)。
*/
import { boolean, index, integer, jsonb, primaryKey, text, timestamp } from 'drizzle-orm/pg-core';
import { sys } from '@shared/db.ts';
export const toolTable = sys.table(
'tool',
{
name: text('name').primaryKey(),
version: text('version').notNull(),
description: text('description').notNull(),
input: jsonb('input').notNull(),
output: jsonb('output').notNull(),
effect: text('effect').notNull(),
errors: jsonb('errors').notNull().default([]),
idempotent: boolean('idempotent').notNull().default(false),
timeoutMs: integer('timeout_ms').notNull().default(30_000),
retries: integer('retries').notNull().default(1),
connectionKeys: jsonb('connection_keys').notNull().default([]),
capabilities: jsonb('capabilities'),
pure: boolean('pure').notNull().default(false),
ownerActivityId: text('owner_activity_id'),
/** 凭证隔离:值加密落库,不入包、不入活动、不入留痕明文(ADR-009) */
connectionCipher: jsonb('connection_cipher'),
enabled: boolean('enabled').notNull().default(true),
registeredAt: timestamp('registered_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [index('tool_effect_idx').on(t.effect), index('tool_owner_idx').on(t.ownerActivityId)],
);
/** 白名单:授权在白名单与契约,不在活动代码(OS 04) */
export const toolAllowlistTable = sys.table(
'tool_allowlist',
{
activityId: text('activity_id').notNull(),
tool: text('tool').notNull(),
},
(t) => [primaryKey({ columns: [t.activityId, t.tool] })],
);
/** 工具录制回放 —— 与 llm 的 replay_model 同构(§7.3 两缝) */
export const replayToolTable = sys.table('replay_tool', {
key: text('key').primaryKey(),
call: jsonb('call').notNull(),
result: jsonb('result').notNull(),
recordedAt: timestamp('recorded_at', { withTimezone: true }).notNull().defaultNow(),
});
@@ -0,0 +1,213 @@
/**
* toolbox 存储与运行时 adapter —— Drizzle / ajv 只准出现在 adapter(编码规范 §5.3 / §11.4)。
*/
import { Ajv } from 'ajv';
import addFormats from 'ajv-formats';
import { and, eq } from 'drizzle-orm';
import type { Db } from '@shared/db.ts';
import type { SchemaValidator, ToolRegistry, ToolReplayStore, ToolRuntime } from '../../port.ts';
import type { ToolCall, ToolResult } from '../../contract.ts';
import type { EffectLevel, ToolContract } from '../domain/tool-contract.ts';
import { replayToolTable, toolAllowlistTable, toolTable } from './toolbox-schema.ts';
type Row = typeof toolTable.$inferSelect;
function toContract(row: Row): ToolContract {
return {
name: row.name,
version: row.version,
description: row.description,
input: row.input as Record<string, unknown>,
output: row.output as Record<string, unknown>,
effect: row.effect as EffectLevel,
errors: row.errors as ToolContract['errors'],
idempotent: row.idempotent,
timeoutMs: row.timeoutMs,
retries: row.retries,
connectionKeys: row.connectionKeys as string[],
...(row.capabilities === null ? {} : { capabilities: row.capabilities as ToolContract['capabilities'] }),
pure: row.pure,
...(row.ownerActivityId === null ? {} : { ownerActivityId: row.ownerActivityId }),
};
}
export function createToolRegistry(db: Db): ToolRegistry {
return {
async list() {
const rows = await db.select().from(toolTable).orderBy(toolTable.name);
return rows.map(toContract);
},
async get(name) {
const rows = await db.select().from(toolTable).where(eq(toolTable.name, name)).limit(1);
return rows[0] === undefined ? undefined : toContract(rows[0]);
},
async isEnabled(name) {
const rows = await db.select({ enabled: toolTable.enabled }).from(toolTable).where(eq(toolTable.name, name)).limit(1);
return rows[0]?.enabled ?? false;
},
async upsert(contract, connectionCipher) {
const values = {
name: contract.name,
version: contract.version,
description: contract.description,
input: contract.input,
output: contract.output,
effect: contract.effect,
errors: contract.errors,
idempotent: contract.idempotent,
timeoutMs: contract.timeoutMs,
retries: contract.retries,
connectionKeys: contract.connectionKeys,
capabilities: contract.capabilities ?? null,
pure: contract.pure ?? false,
ownerActivityId: contract.ownerActivityId ?? null,
connectionCipher,
};
await db
.insert(toolTable)
.values(values)
.onConflictDoUpdate({
target: toolTable.name,
set: {
version: values.version,
description: values.description,
input: values.input,
output: values.output,
effect: values.effect,
errors: values.errors,
idempotent: values.idempotent,
timeoutMs: values.timeoutMs,
retries: values.retries,
connectionKeys: values.connectionKeys,
capabilities: values.capabilities,
pure: values.pure,
ownerActivityId: values.ownerActivityId,
// 未传凭证即保持原值(只写不读)
...(connectionCipher === null ? {} : { connectionCipher }),
},
});
},
async setEnabled(name, enabled) {
await db.update(toolTable).set({ enabled }).where(eq(toolTable.name, name));
},
async connectionCipher(name) {
const rows = await db.select({ c: toolTable.connectionCipher }).from(toolTable).where(eq(toolTable.name, name)).limit(1);
const value = rows[0]?.c;
return value === null || value === undefined ? undefined : (value as Record<string, string>);
},
async setAllowlist(activityId, tools) {
await db.transaction(async (tx) => {
await tx.delete(toolAllowlistTable).where(eq(toolAllowlistTable.activityId, activityId));
if (tools.length === 0) return;
await tx
.insert(toolAllowlistTable)
.values(tools.map((tool) => ({ activityId, tool })))
.onConflictDoNothing();
});
},
async allowlistOf(activityId) {
const rows = await db
.select({ tool: toolAllowlistTable.tool })
.from(toolAllowlistTable)
.where(eq(toolAllowlistTable.activityId, activityId));
return rows.map((r) => r.tool);
},
async referencedBy(name) {
const rows = await db
.select({ activityId: toolAllowlistTable.activityId })
.from(toolAllowlistTable)
.where(eq(toolAllowlistTable.tool, name));
return rows.map((r) => r.activityId);
},
};
}
export function createToolReplayStore(db: Db): ToolReplayStore {
return {
async put(record) {
await db
.insert(replayToolTable)
.values({ key: record.key, call: record.call, result: record.result, recordedAt: record.recordedAt })
.onConflictDoUpdate({
target: replayToolTable.key,
set: { call: record.call, result: record.result, recordedAt: record.recordedAt },
});
},
async get(key) {
const rows = await db.select().from(replayToolTable).where(eq(replayToolTable.key, key)).limit(1);
const row = rows[0];
if (row === undefined) return undefined;
return { call: row.call as ToolCall, result: row.result as ToolResult };
},
};
}
/** ADR-003:声明层 = JSON Schema 2020-12 子集,运行时校验用 ajv */
export function createSchemaValidator(): SchemaValidator {
const ajv = new Ajv({ allErrors: true, strict: false });
addFormats(ajv);
return {
validate(schema, value) {
const validate = ajv.compile(schema);
const ok = validate(value) === true;
return {
ok,
errors: ok ? [] : (validate.errors ?? []).map((e) => `${e.instancePath || '/'} ${e.message ?? ''}`.trim()),
};
},
};
}
/**
* 工具执行运行时 —— 实现对系统。
* 处理器由 app 注册(第一方功能工具与纯工具);运行时**只把申报的连接目标交给处理器**,
* 不提供任何其它环境能力(OS 7.3)。
*/
export type ToolHandler = (input: {
readonly input: Record<string, unknown>;
readonly connection: Readonly<Record<string, string>>;
readonly operation: string | undefined;
readonly idempotencyKey: string | undefined;
}) => Promise<{ ok: boolean; output?: Record<string, unknown>; error?: { code: string; retryable: boolean; message: string } }>;
export function createToolRuntime(handlers: ReadonlyMap<string, ToolHandler>): ToolRuntime {
return {
async execute({ contract, connection, call, idempotencyKey }) {
const handler = handlers.get(contract.name);
if (handler === undefined) {
return { ok: false, error: { code: 'tool-unimplemented', retryable: false, message: `工具无实现:${contract.name}` } };
}
// 只把申报的连接键交出去,多余的环境值一律不透传
const scoped: Record<string, string> = {};
for (const key of contract.connectionKeys) {
const value = connection[key];
if (value !== undefined) scoped[key] = value;
}
const timeout = new Promise<{ ok: false; error: { code: string; retryable: boolean; message: string } }>((resolve) =>
setTimeout(
() => resolve({ ok: false, error: { code: 'timeout', retryable: true, message: `超时 ${contract.timeoutMs}ms` } }),
contract.timeoutMs,
),
);
return Promise.race([
handler({
input: call.input,
connection: scoped,
operation: call.operation,
idempotencyKey,
}),
timeout,
]);
},
};
}
/** 供 app 使用的辅助:把 `and`/`eq` 的组合暴露给白名单查询(保持 adapter 内聚) */
export async function isAllowed(db: Db, activityId: string, tool: string): Promise<boolean> {
const rows = await db
.select({ tool: toolAllowlistTable.tool })
.from(toolAllowlistTable)
.where(and(eq(toolAllowlistTable.activityId, activityId), eq(toolAllowlistTable.tool, tool)))
.limit(1);
return rows.length > 0;
}
@@ -0,0 +1,83 @@
/**
* 注册两审的静态审 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 OS 7.3)。
*
* 静态审:签名、无动态执行、调用契约 schema 校验、效应等级核验、连接配置声明齐备。
* 试运行审(以 mock 绑定跑契约测试集)在 usecase —— 它要跑代码,不是纯判定。
*/
import type { ToolContract } from './tool-contract.ts';
export type AuditFinding = {
readonly code:
| 'name-invalid'
| 'description-missing'
| 'schema-invalid'
| 'effect-invalid'
| 'connection-keys-missing'
| 'timeout-invalid'
| 'idempotency-missing'
| 'pure-tool-has-connection'
| 'pure-tool-not-scoped';
readonly severity: 'error' | 'warn';
readonly message: string;
};
const NAME = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)*$/;
export function staticAudit(contract: ToolContract): AuditFinding[] {
const out: AuditFinding[] = [];
if (!NAME.test(contract.name)) {
out.push({ code: 'name-invalid', severity: 'error', message: '工具名须是小写点分标识,如 crm.customer-read' });
}
if (contract.description.trim().length < 8) {
out.push({
code: 'description-missing',
severity: 'error',
message: '描述是面向 AI 的适用说明(何时用、语义、边界),不得为空或过短',
});
}
if (!isObjectSchema(contract.input) || !isObjectSchema(contract.output)) {
out.push({ code: 'schema-invalid', severity: 'error', message: '入参 / 出参必须是 object 型 JSON Schema' });
}
if (contract.timeoutMs <= 0 || contract.timeoutMs > 300_000) {
out.push({ code: 'timeout-invalid', severity: 'error', message: '超时须在 (0, 300000] 毫秒之间' });
}
if (contract.effect === 'write' && !contract.idempotent) {
// 写类动作执行时附幂等键 —— 契约必须声明幂等性(OS 7.3 调用运行时)
out.push({
code: 'idempotency-missing',
severity: 'error',
message: '写类工具必须声明幂等性:放行落地时要附幂等键',
});
}
if (contract.pure === true) {
// 纯工具的简化规格:无连接配置、无外效应、活动私有作用域
if (contract.connectionKeys.length > 0) {
out.push({ code: 'pure-tool-has-connection', severity: 'error', message: '纯工具不得有连接配置' });
}
if (contract.effect !== 'read') {
out.push({ code: 'effect-invalid', severity: 'error', message: '纯工具无外效应,效应等级只能是读' });
}
if (contract.ownerActivityId === undefined) {
out.push({ code: 'pure-tool-not-scoped', severity: 'error', message: '纯工具须绑定所属活动(私有作用域)' });
}
} else if (contract.connectionKeys.length === 0) {
// 功能工具触达外界,必须申报连接目标 —— 运行时只可访问注册申报的连接目标
out.push({
code: 'connection-keys-missing',
severity: 'warn',
message: '功能工具未申报连接配置键:运行时将无任何可访问目标',
});
}
return out;
}
export function auditPassed(findings: readonly AuditFinding[]): boolean {
return !findings.some((f) => f.severity === 'error');
}
function isObjectSchema(schema: Record<string, unknown>): boolean {
return schema['type'] === 'object' && typeof schema['properties'] === 'object' && schema['properties'] !== null;
}
@@ -0,0 +1,107 @@
/**
* 调用契约与效应等级 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 OS 7.3)。
*
* 工具承载**权力**,约束严于活动包,三面分离:契约对活动,实现对系统,凭证对治理台。
*/
/** 效应等级决定运行时待遇 —— 读类自由调,写类登记为动作意向 */
export const EFFECT_LEVELS = ['read', 'write'] as const;
export type EffectLevel = (typeof EFFECT_LEVELS)[number];
export const EFFECT_LEVEL_LABEL: Readonly<Record<EffectLevel, string>> = { read: '读', write: '写' };
export type ToolContract = {
/** 全局唯一逻辑名 */
readonly name: string;
readonly version: string;
/** 面向 AI 的适用说明:何时用、语义、边界 */
readonly description: string;
readonly input: Record<string, unknown>;
readonly output: Record<string, unknown>;
readonly effect: EffectLevel;
/** 结构化错误码 + 可重试标志 */
readonly errors: ReadonlyArray<{ code: string; retryable: boolean; message: string }>;
readonly idempotent: boolean;
readonly timeoutMs: number;
readonly retries: number;
/** 连接配置声明:只声明键,值不入包(OS 7.3 凭证隔离) */
readonly connectionKeys: readonly string[];
/** ADR-005:`capabilities.validate` 存在即在落地预检时调 */
readonly capabilities?: { readonly validate?: boolean | undefined } | undefined;
/** 纯工具:无外效应,随活动包自带,作用域是活动私有 */
readonly pure?: boolean | undefined;
readonly ownerActivityId?: string | undefined;
};
/* ── 运行时待遇(OS 7.3) ────────────────────────────────────────────── */
export type CallDisposition =
/** 读类:过程中可自由调用(白名单内),读取即快照留痕 */
| { readonly kind: 'invoke-now'; readonly reason: 'read' }
/** 契约显式预授权的写类动作可在过程中即时执行 —— 预授权必须逐项列举 */
| { readonly kind: 'invoke-now'; readonly reason: 'preauthorized' }
/** 写类:登记为动作意向(入参冻结,存入草稿态),放行后由系统执行落地 */
| { readonly kind: 'register-intent' };
export type Preauthorization = { readonly tool: string; readonly operation: string };
export function dispositionOf(
contract: ToolContract,
operation: string,
preauthorized: readonly Preauthorization[],
): CallDisposition {
if (contract.effect === 'read') return { kind: 'invoke-now', reason: 'read' };
const listed = preauthorized.some((p) => p.tool === contract.name && p.operation === operation);
return listed ? { kind: 'invoke-now', reason: 'preauthorized' } : { kind: 'register-intent' };
}
/* ── 契约演进只增不破坏(OS 7.3 必须) ─────────────────────────────── */
export type EvolutionViolation = { readonly field: string; readonly message: string };
/**
* 出参**只增字段**;入参新增字段**必为可选**;破坏性变更 = 新工具名。
* 这里只检查 JSON Schema 的 properties / required 两面 —— 声明层是 JSON Schema 子集(ADR-003)。
*/
export function checkEvolution(previous: ToolContract, next: ToolContract): EvolutionViolation[] {
const out: EvolutionViolation[] = [];
if (previous.name !== next.name) {
out.push({ field: 'name', message: '破坏性变更必须换新工具名,不得原地改名' });
return out;
}
if (previous.effect !== next.effect) {
out.push({ field: 'effect', message: `效应等级不得变更(${previous.effect} → ${next.effect})` });
}
const prevOut = propertyNames(previous.output);
for (const field of prevOut) {
if (!propertyNames(next.output).includes(field)) {
out.push({ field: `output.${field}`, message: '出参只增字段,不得删除' });
}
}
const prevRequired = requiredNames(previous.input);
const nextRequired = requiredNames(next.input);
for (const field of nextRequired) {
if (!prevRequired.includes(field)) {
out.push({ field: `input.${field}`, message: '入参新增字段必为可选,不得直接必填' });
}
}
for (const field of propertyNames(previous.input)) {
if (!propertyNames(next.input).includes(field)) {
out.push({ field: `input.${field}`, message: '入参不得删除已有字段' });
}
}
return out;
}
function propertyNames(schema: Record<string, unknown>): string[] {
const props = schema['properties'];
if (props === null || typeof props !== 'object') return [];
return Object.keys(props as Record<string, unknown>);
}
function requiredNames(schema: Record<string, unknown>): string[] {
const req = schema['required'];
return Array.isArray(req) ? req.filter((x): x is string => typeof x === 'string') : [];
}
@@ -0,0 +1,137 @@
/**
* ToolInvoker —— 两条缝之二(编码规范 §7.3)。
*
* 一切调用经调用契约:入参快照、出参、时延与错误自动留痕;
* 白名单在此把关 —— **越权在结构上不可能**(OS 04 基座即笼子)。
*/
import type { CallDisposition, InvokerMode, ToolCall, ToolInvoker, ToolResult } from '../../contract.ts';
import {
ToolDisabledError,
ToolNotAllowedError,
ToolNotFoundError,
ToolReplayMissError,
} from '../../contract.ts';
import type {
Clock,
SchemaValidator,
SecretBox,
ToolRegistry,
ToolReplayStore,
ToolRuntime,
} from '../../port.ts';
import { dispositionOf } from '../domain/tool-contract.ts';
export type ToolInvokerDeps = {
readonly registry: ToolRegistry;
readonly runtime: ToolRuntime;
readonly replay: ToolReplayStore;
readonly schemas: SchemaValidator;
readonly secrets: SecretBox;
readonly clock: Clock;
readonly mode: InvokerMode;
};
/** 录制回放定位:任务 × 步序号;缺定位退到工具名 + 入参指纹 */
export function toolReplayKey(call: ToolCall): string {
if (call.taskId !== undefined && call.stepNo !== undefined) return `task:${call.taskId}#${call.stepNo}:${call.tool}`;
return `fp:${call.tool}:${hash(JSON.stringify(sortKeys(call.input)))}`;
}
export function createToolInvoker(deps: ToolInvokerDeps): ToolInvoker {
async function loadContract(call: ToolCall) {
const contract = await deps.registry.get(call.tool);
if (contract === undefined) throw new ToolNotFoundError(call.tool);
// 白名单:纯工具是活动私有作用域,功能工具按活动白名单
if (contract.pure === true) {
if (contract.ownerActivityId !== call.activityId) throw new ToolNotAllowedError(call.tool, call.activityId);
} else {
const allowlist = await deps.registry.allowlistOf(call.activityId);
if (!allowlist.includes(call.tool)) throw new ToolNotAllowedError(call.tool, call.activityId);
}
return contract;
}
async function run(call: ToolCall, idempotencyKey?: string): Promise<ToolResult> {
const key = toolReplayKey(call);
if (deps.mode === 'replay') {
const record = await deps.replay.get(key);
if (record === undefined) throw new ToolReplayMissError(key);
return { ...record.result, mode: 'replay' };
}
const contract = await loadContract(call);
if (!(await deps.registry.isEnabled(call.tool))) throw new ToolDisabledError(call.tool);
const inputCheck = deps.schemas.validate(contract.input, call.input);
if (!inputCheck.ok) {
return {
ok: false,
error: { code: 'input-invalid', retryable: false, message: inputCheck.errors.join('; ') },
latencyMs: 0,
mode: deps.mode,
};
}
const cipher = (await deps.registry.connectionCipher(call.tool)) ?? {};
const connection: Record<string, string> = {};
for (const [k, v] of Object.entries(cipher)) connection[k] = deps.secrets.open(v);
const started = deps.clock.now().getTime();
const raw = await deps.runtime.execute({
contract,
connection,
call,
...(idempotencyKey === undefined ? {} : { idempotencyKey }),
});
const latencyMs = deps.clock.now().getTime() - started;
const result: ToolResult = {
ok: raw.ok,
...(raw.output === undefined ? {} : { output: raw.output }),
...(raw.error === undefined ? {} : { error: raw.error }),
latencyMs,
mode: deps.mode,
};
if (deps.mode === 'record') {
await deps.replay.put({ key, call, result, recordedAt: deps.clock.now() });
}
return result;
}
const invoker: ToolInvoker = {
mode: deps.mode,
async disposition(call, preauthorized): Promise<CallDisposition> {
const contract = await loadContract(call);
return dispositionOf(contract, call.operation ?? call.tool, preauthorized);
},
invoke: (call) => run(call),
async validate(call) {
const contract = await loadContract(call);
if (contract.capabilities?.validate !== true) return undefined;
return run({ ...call, operation: 'validate' });
},
withMode(mode) {
return createToolInvoker({ ...deps, mode });
},
};
return invoker;
}
function sortKeys(value: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const key of Object.keys(value).sort()) out[key] = value[key];
return out;
}
function hash(text: string): string {
let h = 5381;
for (let i = 0; i < text.length; i += 1) h = ((h << 5) + h + text.charCodeAt(i)) >>> 0;
return h.toString(36);
}
@@ -0,0 +1,115 @@
/**
* toolbox 用例 —— 注册两审、白名单、凭证绑定(编码规范 §5.3;语义出处 OS 7.1 / 7.3)。
*/
import type { RegisterToolInput, RegistrationVerdict, ToolboxService } from '../../contract.ts';
import { RegistrationRejectedError } from '../../contract.ts';
import type { Clock, SchemaValidator, SecretBox, ToolRegistry, ToolRuntime } from '../../port.ts';
import { auditPassed, staticAudit } from '../domain/registration-audit.ts';
import { checkEvolution } from '../domain/tool-contract.ts';
export type ToolboxServiceDeps = {
readonly registry: ToolRegistry;
readonly runtime: ToolRuntime;
readonly schemas: SchemaValidator;
readonly secrets: SecretBox;
readonly clock: Clock;
};
export function createToolboxService(deps: ToolboxServiceDeps): ToolboxService {
return {
/** 注册两审:静态审(读清单)+ 试运行审(以 mock 绑定跑契约测试集),全过方可上架 */
async register(input: RegisterToolInput): Promise<RegistrationVerdict> {
const staticFindings = staticAudit(input.contract);
const previous = await deps.registry.get(input.contract.name);
const evolution = previous === undefined ? [] : checkEvolution(previous, input.contract);
const failures: string[] = [];
let passed = 0;
if (auditPassed(staticFindings) && evolution.length === 0) {
for (const [index, sample] of input.samples.entries()) {
const inputCheck = deps.schemas.validate(input.contract.input, sample.input);
if (!inputCheck.ok) {
failures.push(`样例 ${index + 1} 入参不合契约:${inputCheck.errors.join('; ')}`);
continue;
}
const run = await deps.runtime.execute({
contract: input.contract,
connection: {},
call: { tool: input.contract.name, input: sample.input, activityId: '__trial__' },
});
if (!run.ok) {
failures.push(`样例 ${index + 1} 执行失败:${run.error?.message ?? '未知'}`);
continue;
}
const shapeCheck = deps.schemas.validate(sample.expectShape, run.output ?? {});
if (!shapeCheck.ok) {
failures.push(`样例 ${index + 1} 出参形状不符:${shapeCheck.errors.join('; ')}`);
continue;
}
passed += 1;
}
}
const accepted =
auditPassed(staticFindings) && evolution.length === 0 && failures.length === 0 && input.samples.length > 0;
if (accepted) {
const cipher =
input.connectionValues === undefined
? null
: Object.fromEntries(Object.entries(input.connectionValues).map(([k, v]) => [k, deps.secrets.seal(v)]));
await deps.registry.upsert(input.contract, cipher);
}
return {
accepted,
staticFindings,
evolution,
trialRun: { ran: input.samples.length, passed, failures },
};
},
async list() {
return deps.registry.list();
},
async get(name) {
return deps.registry.get(name);
},
async setEnabled(name, enabled) {
if (!enabled) {
// 有活动声明引用的工具不得删除;停用是合法动作,调用返回结构化错误
await deps.registry.referencedBy(name);
}
await deps.registry.setEnabled(name, enabled);
},
async bindConnection(name, values) {
const contract = await deps.registry.get(name);
if (contract === undefined) {
throw new RegistrationRejectedError([
{ code: 'name-invalid', severity: 'error', message: `工具未注册:${name}` },
]);
}
const cipher = Object.fromEntries(Object.entries(values).map(([k, v]) => [k, deps.secrets.seal(v)]));
await deps.registry.upsert(contract, cipher);
},
async setAllowlist(activityId, tools) {
await deps.registry.setAllowlist(activityId, tools);
},
async allowlistOf(activityId) {
return deps.registry.allowlistOf(activityId);
},
async assertContractsAvailable(names) {
const all = await deps.registry.list();
const known = new Set(all.map((c) => c.name));
const missing = [...new Set(names)].filter((n) => !known.has(n));
return { ok: missing.length === 0, missing };
},
};
}
@@ -0,0 +1,7 @@
{
"name": "@modules/toolbox",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": { ".": "./index.ts" }
}
+49
View File
@@ -0,0 +1,49 @@
/**
* module: toolbox —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
*/
import type { ToolContract } from './internal/domain/tool-contract.ts';
import type { ToolCall, ToolResult } from './contract.ts';
export type Clock = { now(): Date };
export type IdGenerator = { next(): string };
/** 凭证密封 —— 与 llm 同一实现,由 app 注入(ADR-009) */
export type SecretBox = { seal(plaintext: string): string; open(cipher: string): string };
export type ToolRegistry = {
list(): Promise<ToolContract[]>;
get(name: string): Promise<ToolContract | undefined>;
isEnabled(name: string): Promise<boolean>;
upsert(contract: ToolContract, connectionCipher: Record<string, string> | null): Promise<void>;
setEnabled(name: string, enabled: boolean): Promise<void>;
connectionCipher(name: string): Promise<Record<string, string> | undefined>;
setAllowlist(activityId: string, tools: readonly string[]): Promise<void>;
allowlistOf(activityId: string): Promise<string[]>;
/** 有活动声明引用即不得删除 */
referencedBy(name: string): Promise<string[]>;
};
/** 工具录制回放存储 —— 与 llm 的 ReplayStore 同构,另存一表(§7.3) */
export type ToolReplayStore = {
put(record: { key: string; call: ToolCall; result: ToolResult; recordedAt: Date }): Promise<void>;
get(key: string): Promise<{ call: ToolCall; result: ToolResult } | undefined>;
};
/**
* 工具执行 —— 实现对系统:server 侧执行代码,运行时受限,
* **只可访问注册申报的连接目标,无其他环境能力**(OS 7.3)。
*/
export type ToolRuntime = {
execute(input: {
readonly contract: ToolContract;
readonly connection: Readonly<Record<string, string>>;
readonly call: ToolCall;
readonly idempotencyKey?: string;
}): Promise<{ ok: boolean; output?: Record<string, unknown>; error?: { code: string; retryable: boolean; message: string } }>;
};
/** JSON Schema 校验 —— ajv 在 adapter,usecase 只见本端口(ADR-003) */
export type SchemaValidator = {
validate(schema: Record<string, unknown>, value: unknown): { ok: boolean; errors: string[] };
};
@@ -0,0 +1,70 @@
CREATE TABLE "sys"."activity_memory" (
"id" text PRIMARY KEY NOT NULL,
"activity_id" text NOT NULL,
"layer" text NOT NULL,
"statement" text NOT NULL,
"evidence" jsonb NOT NULL,
"increment" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"pruned_at" timestamp with time zone,
"active" boolean DEFAULT true NOT NULL
);
--> statement-breakpoint
CREATE TABLE "sys"."task_memory_seal" (
"task_id" text PRIMARY KEY NOT NULL,
"sealed_at" timestamp with time zone NOT NULL
);
--> statement-breakpoint
CREATE TABLE "sys"."task_memory" (
"id" text PRIMARY KEY NOT NULL,
"task_id" text NOT NULL,
"seq" integer NOT NULL,
"at" timestamp with time zone NOT NULL,
"kind" text NOT NULL,
"side" text NOT NULL,
"title" text NOT NULL,
"detail" jsonb,
"stamp" jsonb NOT NULL,
"governance" jsonb NOT NULL,
"links_to" text,
"event_ref" text
);
--> statement-breakpoint
CREATE TABLE "sys"."replay_tool" (
"key" text PRIMARY KEY NOT NULL,
"call" jsonb NOT NULL,
"result" jsonb NOT NULL,
"recorded_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "sys"."tool_allowlist" (
"activity_id" text NOT NULL,
"tool" text NOT NULL,
CONSTRAINT "tool_allowlist_activity_id_tool_pk" PRIMARY KEY("activity_id","tool")
);
--> statement-breakpoint
CREATE TABLE "sys"."tool" (
"name" text PRIMARY KEY NOT NULL,
"version" text NOT NULL,
"description" text NOT NULL,
"input" jsonb NOT NULL,
"output" jsonb NOT NULL,
"effect" text NOT NULL,
"errors" jsonb DEFAULT '[]'::jsonb NOT NULL,
"idempotent" boolean DEFAULT false NOT NULL,
"timeout_ms" integer DEFAULT 30000 NOT NULL,
"retries" integer DEFAULT 1 NOT NULL,
"connection_keys" jsonb DEFAULT '[]'::jsonb NOT NULL,
"capabilities" jsonb,
"pure" boolean DEFAULT false NOT NULL,
"owner_activity_id" text,
"connection_cipher" jsonb,
"enabled" boolean DEFAULT true NOT NULL,
"registered_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE INDEX "activity_memory_layer_idx" ON "sys"."activity_memory" USING btree ("activity_id","layer","created_at");--> statement-breakpoint
CREATE UNIQUE INDEX "task_memory_seq_key" ON "sys"."task_memory" USING btree ("task_id","seq");--> statement-breakpoint
CREATE INDEX "task_memory_task_idx" ON "sys"."task_memory" USING btree ("task_id","at");--> statement-breakpoint
CREATE INDEX "tool_effect_idx" ON "sys"."tool" USING btree ("effect");--> statement-breakpoint
CREATE INDEX "tool_owner_idx" ON "sys"."tool" USING btree ("owner_activity_id");
+41
View File
@@ -0,0 +1,41 @@
CREATE TABLE "sys"."channel_instance" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"kind" text NOT NULL,
"address" text NOT NULL,
"enabled" boolean DEFAULT true NOT NULL,
"extract" jsonb DEFAULT '[]'::jsonb NOT NULL,
"mapping" jsonb DEFAULT '[]'::jsonb NOT NULL,
"fallback_position" text,
"address_window_ms" integer DEFAULT 604800000 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "sys"."outbound_mail" (
"id" text PRIMARY KEY NOT NULL,
"thread_id" text NOT NULL,
"from_addr" text NOT NULL,
"to_addrs" jsonb NOT NULL,
"cc_addrs" jsonb DEFAULT '[]'::jsonb NOT NULL,
"subject" text NOT NULL,
"body" text NOT NULL,
"attachments" jsonb DEFAULT '[]'::jsonb NOT NULL,
"sent_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "sys"."system_message" (
"id" text PRIMARY KEY NOT NULL,
"kind" text NOT NULL,
"staff_id" text NOT NULL,
"position_id" text,
"title" text NOT NULL,
"body" text NOT NULL,
"at" timestamp with time zone DEFAULT now() NOT NULL,
"read_at" timestamp with time zone,
"card" jsonb,
"answer" jsonb,
"event_ref" text
);
--> statement-breakpoint
CREATE INDEX "message_staff_idx" ON "sys"."system_message" USING btree ("staff_id","at");--> statement-breakpoint
CREATE INDEX "message_unread_idx" ON "sys"."system_message" USING btree ("staff_id","read_at");
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14
View File
@@ -8,6 +8,20 @@
"when": 1785084862267,
"tag": "0000_luxuriant_gorilla_man",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1785086083016,
"tag": "0001_nostalgic_cobalt_man",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1785086166573,
"tag": "0002_superb_talisman",
"breakpoints": true
}
]
}
+62
View File
@@ -0,0 +1,62 @@
# 实施进度(主 Agent 维护)
> 里程碑序见 编码规范 §15。每个里程碑先立骨架(index / contract / port / CONTRACT.md + 空测试)
> 再填实现;门禁从 M0 起全绿,红了先修门禁再前进。
## 状态总览
| 里程碑 | 内容 | 状态 |
|---|---|---|
| M0 | 工作区骨架 + 五道门禁 + 环境自举 | ✅ 完成 |
| M1 | `@contracts` 首版、`event`、`ledger`、`org`、`llm` | ✅ 完成 |
| M2 | `memory`、`toolbox`、`channel`、`message` | ✅ 完成 |
| M3 | `interaction`、`human-tool`、`release`、`activity-registry`、`@sfeel` | ⬜ 未开始 |
| M4 | `skill-engine`、`loop-engine`、`flow-engine`、`task` | ⬜ 未开始 |
| M5 | `process`、`trigger`、`@activity-sdk` + 首个样例活动 | ⬜ 未开始 |
| M6 | 前端:工作台五要素 → 治理台九功能;`minute`、`report` | ⬜ 未开始 |
## 常用命令
```bash
pnpm gates # gen:tiers → typecheck → lint → depcruise → api:check → test(五道门禁)
pnpm db:up # docker compose 起 PG16(四 schema 由 db/init 建)
pnpm db:generate # drizzle-kit 生成 sys schema 迁移
pnpm db:migrate # 应用迁移
pnpm db:seed # 播种五岗位五员工(登录者 = 冯兵)
pnpm dev:api # tsup watch + node dist/main.js(:8787)
pnpm dev:web # vite(:5173,/api 反代到 8787)
```
契约变更后:`pnpm api:check --update && git add api-report`(§12.4)。
## 已完成的落点
**M0** — pnpm workspaces / tsconfig 双工程 / eslint flat / dependency-cruiser
(基线八条 + JCAI 增补,tier-order 由 `tools/module-tiers.json` 生成)/ 契约快照只留契约面 /
docker-compose PG16 + 四 schema。
**M1** — `@contracts` 九文件首版;四个 L1 模块各带 index / contract / port / CONTRACT.md /
domain 零 mock 测试;app 层 Hono 服务、鉴权中间件、DI 装配、drizzle 迁移、AES-256-GCM 凭证密封。
已验证:种子播种、岗位授权(BD 被拒 / PM 放行)、LLM 四元绑定密钥不外泄、
ledger 对真实 PG 的 schema 自生长与写三分。
**M2** — 四个 L2 模块:`memory`(任务记忆四性质 / 活动记忆四层 / 任务日志两级投影 / 度量)、
`toolbox`(注册两审 / 效应等级 / 白名单 / `ToolInvoker` 第二条缝)、
`channel`(入站归一 → 系统写 → 数据事件;出站与关联记录)、`message`(通知 / 跨任务征答 / 死信 / 无主兜底)。
app 侧接上派发器(advisory lock 串行 + 消费幂等)与 `/api/ingest` 模拟通道。
已验证:一封无主 RFQ 邮件入站 → 归属解析 no-match → 事件落库 → 兜底落到 OP 岗位李元初的消息要素。
## 待接的占位(详见 docs/debt.md)
- `container.ts` 两个占位端口:`InFlightWorkProbe`(M4)、`TriggerMatcher`(M5)
- 条件求值 `passthroughEvaluator`(M3 接 `@sfeel`)
- 派发器的消费者表为空(M4 任务 / M5 触发器)
- pg-boss 已装未用(M5 定时作业)
## 已建 ADR
编码规范 §14 已占 001–006,新 ADR 自 **007** 续(README 写「从 005 续」是笔误):
- ADR-007 工具链与门禁的落地形态
- ADR-008 投递队列:`sys.event_delivery` 是队列本体,pg-boss 只管定时
- ADR-009 凭证加密:AES-256-GCM + `JCAI_SECRET_KEY`
+4 -2
View File
@@ -7,6 +7,8 @@
|---|---|---|---|---|
| M0-1 | `pnpm db:seed` 只打印种子计划,未真正写库 | 种子数据(五岗位 / 五员工)已在 `apps/api/src/app/seed.ts` 冻结为常量并定义 `OrgSeeder` 端口;写入实现依赖 `org` 模块的 Drizzle schema,属 M1 | M1 | 已清理(M1) |
| M0-2 | `packages/{sfeel,activity-sdk,ui-kit}` 仅有版本常量 | M0 只立骨架,内容按里程碑序填(§15);`@contracts` 已于 M1 落首版 | M3–M5 | 进行中 |
| M1-1 | `container.ts` 里三个占位端口:`InFlightWorkProbe`(空集)、`TriggerMatcher`(恒不命中)、`MessageFallback`(落 stderr) | 三者的真身分别在 task(L6,M4)、trigger(L7,M5)、message(L2,M2);M1 只有 L1,端口先立、实现随层级到位 | M2 / M4 / M5 | 进行中 |
| M1-1 | `container.ts` 里两个占位端口:`InFlightWorkProbe`(空集)、`TriggerMatcher`(恒不命中) | 二者的真身在 task(L6,M4)与 trigger(L7,M5);`MessageFallback` 已于 M2 接真身 | M4 / M5 | 进行中 |
| M1-2 | 条件求值 `passthroughEvaluator` 恒真 | `@sfeel` 属 M3;M1 的监听只用类型 + 关联键过滤,不用内容条件 | M3 | 进行中 |
| M1-3 | 投递记录写入即止,尚无派发 worker | 队列本体已按 ADR-008 落 `sys.event_delivery`;派发 worker 与 pg-boss 定时随 M2 | M2 | 进行中 |
| M1-3 | ~~投递记录写入即止,尚无派发 worker~~ | 派发器已于 M2 落地(advisory lock 串行 + 消费幂等);消费者(任务 / 触发器)随 M4 / M5 注册 | M2 | 已清理 |
| M2-1 | 派发器的消费者表为空 —— 投递只标记已送达 | 任务消费者属 task(M4),触发器消费者属 trigger(M5);投递链路本身已可测 | M4 / M5 | 进行中 |
| M2-2 | pg-boss 已装未用 | 定时作业(监听时限扫描、周期触发器)随 M5 落地;当下派发器用 setInterval | M5 | 进行中 |
+85
View File
@@ -59,6 +59,12 @@ importers:
ai:
specifier: ^7.0.37
version: 7.0.37(zod@4.4.3)
ajv:
specifier: ^8.20.0
version: 8.20.0
ajv-formats:
specifier: ^3.0.1
version: 3.0.1(ajv@8.20.0)
drizzle-orm:
specifier: ^0.45.2
version: 0.45.2(@types/pg@8.20.0)(pg@8.22.0)
@@ -68,6 +74,9 @@ importers:
pg:
specifier: ^8.22.0
version: 8.22.0
pg-boss:
specifier: ^12.26.3
version: 12.26.3
zod:
specifier: ^4.4.3
version: 4.4.3
@@ -76,14 +85,22 @@ importers:
specifier: ^8.20.0
version: 8.20.0
apps/api/src/modules/channel: {}
apps/api/src/modules/event: {}
apps/api/src/modules/ledger: {}
apps/api/src/modules/llm: {}
apps/api/src/modules/memory: {}
apps/api/src/modules/message: {}
apps/api/src/modules/org: {}
apps/api/src/modules/toolbox: {}
apps/web:
dependencies:
react:
@@ -1311,6 +1328,14 @@ packages:
peerDependencies:
zod: ^3.25.76 || ^4.1.8
ajv-formats@3.0.1:
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
peerDependencies:
ajv: ^8.0.0
peerDependenciesMeta:
ajv:
optional: true
ajv@6.15.0:
resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
@@ -1550,6 +1575,10 @@ packages:
resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==}
engines: {node: '>= 14'}
cron-parser@5.6.2:
resolution: {integrity: sha512-yJ/G1LVir6CnlkLI40CsampPLKl1SprGGlUagWtGJxewytRVYezv5xVyzzbT+Pvzx+VIRvZVF7/a0eEMTxdnTA==}
engines: {node: '>=18'}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -2073,6 +2102,10 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
luxon@3.7.2:
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
engines: {node: '>=12'}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -2138,6 +2171,10 @@ packages:
resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==}
engines: {node: '>=18'}
non-error@0.1.0:
resolution: {integrity: sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==}
engines: {node: '>=20'}
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
@@ -2190,6 +2227,11 @@ packages:
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
engines: {node: '>= 14.16'}
pg-boss@12.26.3:
resolution: {integrity: sha512-slpR0zeMXzX3c+gAI6035XzJfNfUYndCdvxudJojB2S1LZEMxT31xBBEVm7shEcJQ35Vj2ik+w2Hf7lmLuvrZQ==}
engines: {node: '>=22.12.0'}
hasBin: true
pg-cloudflare@1.4.0:
resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==}
@@ -2405,6 +2447,10 @@ packages:
engines: {node: '>=10'}
hasBin: true
serialize-error@13.0.1:
resolution: {integrity: sha512-bBZaRwLH9PN5HbLCjPId4dP5bNGEtumcErgOX952IsvOhVPrm3/AeK1y0UHA/QaPG701eg0yEnOKsCOC6X/kaA==}
engines: {node: '>=20'}
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@@ -2510,6 +2556,10 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
tagged-tag@1.0.0:
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
engines: {node: '>=20'}
tapable@2.3.3:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'}
@@ -2624,6 +2674,10 @@ packages:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
type-fest@5.8.0:
resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==}
engines: {node: '>=20'}
typescript-eslint@8.65.0:
resolution: {integrity: sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -3738,6 +3792,10 @@ snapshots:
'@ai-sdk/provider-utils': 5.0.12(zod@4.4.3)
zod: 4.4.3
ajv-formats@3.0.1(ajv@8.20.0):
optionalDependencies:
ajv: 8.20.0
ajv@6.15.0:
dependencies:
fast-deep-equal: 3.1.3
@@ -3968,6 +4026,10 @@ snapshots:
crc-32: 1.2.2
readable-stream: 4.7.0
cron-parser@5.6.2:
dependencies:
luxon: 3.7.2
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -4472,6 +4534,8 @@ snapshots:
dependencies:
yallist: 3.1.1
luxon@3.7.2: {}
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -4530,6 +4594,8 @@ snapshots:
node-releases@2.0.51: {}
non-error@0.1.0: {}
normalize-path@3.0.0: {}
object-assign@4.1.1: {}
@@ -4576,6 +4642,14 @@ snapshots:
pathval@2.0.1: {}
pg-boss@12.26.3:
dependencies:
cron-parser: 5.6.2
pg: 8.22.0
serialize-error: 13.0.1
transitivePeerDependencies:
- pg-native
pg-cloudflare@1.4.0:
optional: true
@@ -4803,6 +4877,11 @@ snapshots:
semver@7.8.5: {}
serialize-error@13.0.1:
dependencies:
non-error: 0.1.0
type-fest: 5.8.0
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
@@ -4910,6 +4989,8 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
tagged-tag@1.0.0: {}
tapable@2.3.3: {}
tar-fs@2.1.5:
@@ -5074,6 +5155,10 @@ snapshots:
dependencies:
prelude-ls: 1.2.1
type-fest@5.8.0:
dependencies:
tagged-tag: 1.0.0
typescript-eslint@8.65.0(eslint@9.39.5)(typescript@5.9.3):
dependencies:
'@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5)(typescript@5.9.3)