Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fce77f43b1 | |||
| 695911d1d8 | |||
| 260f2a3038 | |||
| 4ad04f07f0 | |||
| bfe8bac00c | |||
| 6915b2da74 | |||
| 0f9d69bfda | |||
| 09008a7b55 | |||
| b208c4d2bd | |||
| 3414ae42b1 | |||
| 0daf40d950 | |||
| c7a9d439ca | |||
| f3348f45c0 | |||
| c0d43379b8 | |||
| 938f6d6d37 | |||
| db3235db8a | |||
| 47d0d2accd | |||
| 2bab7eb62d | |||
| 478c33376c | |||
| c6209370d8 | |||
| 2fa9523587 |
@@ -0,0 +1,144 @@
|
||||
import { buildHeadroomRunObservation, resolveHeadroomMode } from './memind-headroom-policy.mjs';
|
||||
import {
|
||||
buildContextBudgetResolvedEvent,
|
||||
planContextBudget,
|
||||
resolveContextBudgetMode,
|
||||
} from './context-budget.mjs';
|
||||
import { resolveMcpCompactMode } from './mcp-result-compactor.mjs';
|
||||
import {
|
||||
buildRecallFusionResolvedEvent,
|
||||
fuseRecallCandidates,
|
||||
resolveRecallFusionMode,
|
||||
} from './recall-fusion.mjs';
|
||||
import { resolveZvecWorkspaceMode } from './memind-zvec-workspace.mjs';
|
||||
|
||||
/** Recommended local shadow profile (observe-only, no behavior change). */
|
||||
export const CONTEXT_RUNTIME_SHADOW_ENV = Object.freeze({
|
||||
MEMIND_HEADROOM_MODE: 'shadow',
|
||||
MEMIND_CONTEXT_BUDGET_MODE: 'shadow',
|
||||
MEMIND_RECALL_FUSION_MODE: 'shadow',
|
||||
MEMIND_MCP_COMPACT_MODE: 'shadow',
|
||||
MEMIND_ZVEC_WORKSPACE_MODE: 'shadow',
|
||||
});
|
||||
|
||||
/** Local active bundle (MCP/ budget / recall / zvec / headroom). Injection stays shadow separately. */
|
||||
export const CONTEXT_RUNTIME_ACTIVE_ENV = Object.freeze({
|
||||
MEMIND_HEADROOM_MODE: 'active',
|
||||
MEMIND_CONTEXT_BUDGET_MODE: 'active',
|
||||
MEMIND_RECALL_FUSION_MODE: 'active',
|
||||
MEMIND_MCP_COMPACT_MODE: 'active',
|
||||
MEMIND_ZVEC_WORKSPACE_MODE: 'active',
|
||||
HEADROOM_OUTPUT_SHAPER: '0',
|
||||
});
|
||||
|
||||
export function describeContextRuntimeProfile(env = process.env) {
|
||||
return {
|
||||
headroom: resolveHeadroomMode(env),
|
||||
contextBudget: resolveContextBudgetMode(env),
|
||||
recallFusion: resolveRecallFusionMode(env),
|
||||
mcpCompact: resolveMcpCompactMode(env),
|
||||
zvecWorkspace: resolveZvecWorkspaceMode(env),
|
||||
};
|
||||
}
|
||||
|
||||
export function isContextRuntimeShadowProfile(env = process.env) {
|
||||
const profile = describeContextRuntimeProfile(env);
|
||||
return Object.values(profile).every((mode) => mode === 'off' || mode === 'shadow');
|
||||
}
|
||||
|
||||
export function isAnyContextRuntimeObservabilityEnabled(env = process.env) {
|
||||
const profile = describeContextRuntimeProfile(env);
|
||||
return Object.values(profile).some((mode) => mode !== 'off');
|
||||
}
|
||||
|
||||
/**
|
||||
* Offline simulation of gateway shadow events (no LLM / no DB).
|
||||
* Mirrors agent-run-gateway appendEvent payloads for local evidence review.
|
||||
*/
|
||||
export function simulateContextRuntimeShadowEvents({
|
||||
skillId = 'web',
|
||||
userMessage = {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: '帮我查一下 Memind context runtime 设计' }],
|
||||
metadata: { displayText: '帮我查一下 Memind context runtime 设计' },
|
||||
},
|
||||
routing = {
|
||||
route: 'agent_orchestration',
|
||||
suggestedSkill: 'web',
|
||||
reason: 'offline-shadow-simulation',
|
||||
},
|
||||
memoryContext = {
|
||||
mode: 'shadow',
|
||||
injectionEnabled: true,
|
||||
memories: [
|
||||
{ label: '偏好', text: '用户偏好:喜欢简洁回答' },
|
||||
{ label: '偏好副本', text: '用户偏好:喜欢简洁回答' },
|
||||
{ label: '项目', text: '正在推进 Context Runtime 融合' },
|
||||
],
|
||||
},
|
||||
env = { ...CONTEXT_RUNTIME_SHADOW_ENV },
|
||||
} = {}) {
|
||||
const events = {};
|
||||
const resolvedSkill = routing?.suggestedSkill ?? routing?.suggested_skill ?? skillId;
|
||||
|
||||
if (resolveHeadroomMode(env) !== 'off') {
|
||||
events.headroom_context_observed = buildHeadroomRunObservation({
|
||||
skillId: resolvedSkill,
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
if (resolveRecallFusionMode(env) !== 'off') {
|
||||
const fusion = fuseRecallCandidates({
|
||||
personalMemories: memoryContext?.memories ?? [],
|
||||
episodicMemories: [],
|
||||
temporalItems: [],
|
||||
query: userMessage.metadata?.displayText ?? 'context runtime',
|
||||
limit: 3,
|
||||
env,
|
||||
});
|
||||
events.recall_fusion_resolved = buildRecallFusionResolvedEvent(fusion);
|
||||
}
|
||||
|
||||
if (resolveContextBudgetMode(env) !== 'off') {
|
||||
const displayText = userMessage.metadata?.displayText
|
||||
?? userMessage.content?.find?.((item) => item?.type === 'text')?.text
|
||||
?? '';
|
||||
const budgetPlan = planContextBudget({
|
||||
userTask: displayText,
|
||||
skillPrompt: `[skill:${resolvedSkill}] 使用 web 搜索补充事实`,
|
||||
memories: memoryContext?.injectionEnabled ? memoryContext.memories : [],
|
||||
temporalText: '最近一周无日程冲突',
|
||||
harnessEntries: [{ title: 'TKMind 用户偏好画像', content: '用户偏好:喜欢简洁回答' }],
|
||||
}, { env });
|
||||
events.context_budget_resolved = buildContextBudgetResolvedEvent(budgetPlan);
|
||||
}
|
||||
|
||||
return {
|
||||
profile: describeContextRuntimeProfile(env),
|
||||
events,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateContextRuntimeShadowEvents(simulation) {
|
||||
const issues = [];
|
||||
const { profile, events } = simulation ?? {};
|
||||
if (!profile || !events) {
|
||||
return { ok: false, issues: ['simulation missing profile or events'] };
|
||||
}
|
||||
|
||||
if (profile.headroom !== 'off' && !events.headroom_context_observed) {
|
||||
issues.push('headroom mode enabled but headroom_context_observed missing');
|
||||
}
|
||||
if (profile.contextBudget !== 'off' && !events.context_budget_resolved) {
|
||||
issues.push('context budget mode enabled but context_budget_resolved missing');
|
||||
}
|
||||
if (profile.recallFusion !== 'off' && !events.recall_fusion_resolved) {
|
||||
issues.push('recall fusion mode enabled but recall_fusion_resolved missing');
|
||||
}
|
||||
if (events.context_budget_resolved?.mode === 'shadow' && !events.context_budget_resolved?.duplicateCount) {
|
||||
issues.push('expected shadow budget simulation to report duplicate fingerprints');
|
||||
}
|
||||
|
||||
return { ok: issues.length === 0, issues };
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
CONTEXT_RUNTIME_SHADOW_ENV,
|
||||
describeContextRuntimeProfile,
|
||||
isContextRuntimeShadowProfile,
|
||||
simulateContextRuntimeShadowEvents,
|
||||
validateContextRuntimeShadowEvents,
|
||||
} from './context-runtime-profile.mjs';
|
||||
|
||||
test('describeContextRuntimeProfile defaults to off', () => {
|
||||
const profile = describeContextRuntimeProfile({});
|
||||
assert.equal(profile.headroom, 'off');
|
||||
assert.equal(profile.contextBudget, 'off');
|
||||
assert.equal(profile.recallFusion, 'off');
|
||||
});
|
||||
|
||||
test('isContextRuntimeShadowProfile accepts all-shadow bundle', () => {
|
||||
assert.equal(isContextRuntimeShadowProfile(CONTEXT_RUNTIME_SHADOW_ENV), true);
|
||||
assert.equal(isContextRuntimeShadowProfile({
|
||||
...CONTEXT_RUNTIME_SHADOW_ENV,
|
||||
MEMIND_HEADROOM_MODE: 'active',
|
||||
}), false);
|
||||
});
|
||||
|
||||
test('simulateContextRuntimeShadowEvents emits gateway-shaped payloads', () => {
|
||||
const simulation = simulateContextRuntimeShadowEvents();
|
||||
assert.ok(simulation.events.headroom_context_observed);
|
||||
assert.ok(simulation.events.context_budget_resolved);
|
||||
assert.ok(simulation.events.recall_fusion_resolved);
|
||||
assert.equal(simulation.events.headroom_context_observed.mode, 'shadow');
|
||||
assert.equal(simulation.events.context_budget_resolved.mode, 'shadow');
|
||||
const validation = validateContextRuntimeShadowEvents(simulation);
|
||||
assert.equal(validation.ok, true, validation.issues?.join('; '));
|
||||
});
|
||||
|
||||
test('simulateContextRuntimeShadowEvents respects off modes', () => {
|
||||
const simulation = simulateContextRuntimeShadowEvents({
|
||||
env: { MEMIND_HEADROOM_MODE: 'off', MEMIND_CONTEXT_BUDGET_MODE: 'off', MEMIND_RECALL_FUSION_MODE: 'off' },
|
||||
});
|
||||
assert.equal(simulation.events.headroom_context_observed, undefined);
|
||||
assert.equal(simulation.events.context_budget_resolved, undefined);
|
||||
assert.equal(simulation.events.recall_fusion_resolved, undefined);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": "goose-v149-message-sanitize-v2",
|
||||
"capturedAt": "2026-09-09T13:35:24.843Z",
|
||||
"capturedAt": "2026-09-10T04:19:57.799Z",
|
||||
"v149": {
|
||||
"root": "/Users/john/Project/tkmind_go-v149",
|
||||
"version": "1.49.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": "goose-v149-thinking-preservation-v1",
|
||||
"capturedAt": "2026-09-09T13:47:20.080Z",
|
||||
"capturedAt": "2026-09-10T04:31:51.798Z",
|
||||
"worktree": "/Users/john/Project/tkmind_go-v149",
|
||||
"tests": [
|
||||
"test_deepseek_thinking_preserved_in_tool_call_message",
|
||||
|
||||
@@ -864,6 +864,51 @@ Memind P0 健康频道:加密健康区、观测 API、baseline 引擎与成熟
|
||||
- 保留本地分支名用于审计追溯。
|
||||
- **不要** merge、cherry-pick 或从该分支继续开发。
|
||||
|
||||
## `feature/context-runtime-shadow-offline`
|
||||
|
||||
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
|
||||
|
||||
审计日期:2026-09-09
|
||||
分支 HEAD:`478c3337`
|
||||
`origin/main` 对应提交:`478c3337`
|
||||
|
||||
### 原始用途
|
||||
|
||||
Context Runtime 离线 shadow 开发:`context-runtime-profile.mjs` 事件模拟、`run-context-runtime-offline.mjs` 零 LLM 验证、本地自测文档。
|
||||
|
||||
### 验证摘要
|
||||
|
||||
- `node scripts/run-context-runtime-offline.mjs`:OK
|
||||
- `node scripts/run-goosed-v149-phase3-offline.mjs`:OK
|
||||
|
||||
### 最终处置
|
||||
|
||||
- 保留本地分支名用于审计追溯。
|
||||
- **不要** merge、cherry-pick 或从该分支继续开发。
|
||||
|
||||
## `feature/goose-v149-real-llm-gate`
|
||||
|
||||
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
|
||||
|
||||
审计日期:2026-09-09
|
||||
分支 HEAD:`2fa95235`
|
||||
`origin/main` 对应提交:`2fa95235`
|
||||
|
||||
### 原始用途
|
||||
|
||||
默认阻断无人值守的 v1.49 真实 LLM smoke(phase2/phase3/all),避免 DashScope 日耗 1 亿+ token;提供 `run-goosed-v149-phase3-offline.mjs` 零 LLM 合并验证。
|
||||
|
||||
### 验证摘要
|
||||
|
||||
- `node --test scripts/goose-v149-real-llm-gate.test.mjs`:4 passed
|
||||
- `node scripts/run-goosed-v149-phase3-offline.mjs`:OK
|
||||
- `node scripts/run-goosed-v149-phase3.mjs`(无 opt-in):exit 2 BLOCKED
|
||||
|
||||
### 最终处置
|
||||
|
||||
- 保留本地分支名用于审计追溯。
|
||||
- **不要** merge、cherry-pick 或从该分支继续开发。
|
||||
|
||||
## `feature/mcp-result-compactor`
|
||||
|
||||
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
|
||||
@@ -911,3 +956,75 @@ Goose v1.49 本地 canary 收口、Context Runtime + Agent Harness 融合 Phase
|
||||
- 保留本地分支名用于审计追溯。
|
||||
- **不要** merge、cherry-pick 或从该分支继续开发。
|
||||
|
||||
## `feature/codebase-memory-mcp-setup`
|
||||
|
||||
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
|
||||
|
||||
审计日期:2026-09-10
|
||||
分支 HEAD:`47d0d2ac`
|
||||
`origin/main` 对应提交:`938f6d6d`
|
||||
|
||||
### 原始用途
|
||||
|
||||
fusion-plan §6.6 本机开发工具:`codebase-memory-mcp` 本地安装验证脚本与文档;索引数据在 `~/.cache/codebase-memory-mcp/`,不进入 103 运行时。
|
||||
|
||||
### 验证摘要
|
||||
|
||||
- `codebase-memory-mcp --version`:0.10.8
|
||||
- `node scripts/check-codebase-memory-mcp-local.mjs`:`CODEBASE_MEMORY_MCP_OK`(23,965 nodes / 89,856 edges)
|
||||
|
||||
### 最终处置
|
||||
|
||||
- 保留本地分支名用于审计追溯。
|
||||
- **不要** merge、cherry-pick 或从该分支继续开发。
|
||||
|
||||
## `feature/dsh-executor-local-setup`
|
||||
|
||||
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
|
||||
|
||||
审计日期:2026-09-10
|
||||
分支 HEAD:(见 merge commit)
|
||||
`origin/main` 对应提交:`f3348f45`
|
||||
|
||||
### 原始用途
|
||||
|
||||
`@deepseek-ai/dsh@0.1.1-rc.2` 本机安装、check 脚本、Tool Gateway headless E2E;Context Runtime shadow Portal E2E(events-only 省 token)。
|
||||
|
||||
### 验证摘要
|
||||
|
||||
- `dsh --version`:0.1.1-rc.2
|
||||
- `node scripts/check-dsh-executor-local.mjs`:`DSH_EXECUTOR_CLI_OK` / dry-run OK
|
||||
- `MEMIND_DSH_RUN_LIVE_SMOKE=1` headless:OK(列 3 文件)
|
||||
- `MEMIND_TOOL_GATEWAY_DSH_ENABLED=1 node scripts/test-dsh-executor-gateway-e2e.mjs`:`DSH_GATEWAY_E2E_OK`
|
||||
- Shadow Portal(`force_deep_reasoning`,run `2ca8be7b…`):`headroom_context_observed` + `context_budget_resolved`,`mode=shadow`
|
||||
|
||||
### 最终处置
|
||||
|
||||
- 保留本地分支名用于审计追溯。
|
||||
- **不要** merge、cherry-pick 或从该分支继续开发。
|
||||
|
||||
## `cursor/v149-merge-prep-gates`
|
||||
|
||||
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
|
||||
|
||||
审计日期:2026-09-10
|
||||
分支 HEAD:`695911d1`
|
||||
`origin/main` 对应提交:`695911d1`
|
||||
|
||||
### 原始用途
|
||||
|
||||
Goose v1.49 合并前准备:修复 `build-goosed-v149-local.sh` 路径、刷新 phase3 baseline evidence、phase3 连续两次 `GOOSE_V149_PHASE3_OK`、更新 closeout 闸门 1–3。
|
||||
|
||||
### 验证摘要
|
||||
|
||||
- `node scripts/run-goosed-v149-phase3-offline.mjs` → `GOOSE_V149_PHASE3_OFFLINE_OK`
|
||||
- `GOOSE_V149_ALLOW_REAL_LLM=1 node scripts/run-goosed-v149-phase3.mjs` → 两次 `GOOSE_V149_PHASE3_OK`(run2/run3 日志)
|
||||
- `node scripts/compare-goose-v149-memory-manifest.mjs` → `GOOSE_V149_MEMORY_DRIFT_OK`
|
||||
- `npm run verify:mindspace-publish-guards:full` → 176 passed
|
||||
- `tkmind_go-v149` → `6648c4bfb` on `tkmind/upgrade/v1.49-tkmind`
|
||||
|
||||
### 最终处置
|
||||
|
||||
- 保留本地分支名用于审计追溯。
|
||||
- **不要** merge、cherry-pick 或从该分支继续开发。
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# codebase-memory-mcp 本地开发
|
||||
|
||||
> MIT · 仅本机 Cursor / code 任务开发工具 · **不进入 MeMind 103 运行时**
|
||||
> 关联:[context-runtime-harness-fusion-plan.md](./context-runtime-harness-fusion-plan.md) §6.6
|
||||
|
||||
## 用途边界
|
||||
|
||||
| 允许 | 禁止 |
|
||||
|------|------|
|
||||
| Cursor 内代码库结构查询、调用链、架构图 | 个人生活流 / h5 用户记忆主检索 |
|
||||
| 未来挂到 `tool-gateway` 的 aider/openhands 路径 | 替代 Memory V2 / Context Runtime Phase 2–3 |
|
||||
| Phase 5 Personal KG **仅借 schema 思想** | 把索引目录提交进 Git |
|
||||
|
||||
## 安装(本机一次)
|
||||
|
||||
```bash
|
||||
npm install -g codebase-memory-mcp
|
||||
codebase-memory-mcp install -y # 写入 ~/.cursor/mcp.json 等,属设计行为
|
||||
```
|
||||
|
||||
安装后 **重启 Cursor**。全局二进制缓存由 npm 包管理;图数据在 CBM 本地 daemon/SQLite,**不在** Memind 仓库根目录。
|
||||
|
||||
**Homebrew 路径**:若用 `brew`/全局 npm 安装,二进制通常在 `/opt/homebrew/bin/codebase-memory-mcp`。`install` 默认写 `~/.local/bin/...`,若该路径不存在会导致 Cursor MCP 连不上——确认 `~/.cursor/mcp.json` 的 `command` 与 `which codebase-memory-mcp` 一致。
|
||||
|
||||
## 首次索引 Memind
|
||||
|
||||
**方式 A — Cursor(推荐)**
|
||||
|
||||
重启后对 agent 说:**「Index this project」**。
|
||||
|
||||
**方式 B — CLI**
|
||||
|
||||
```bash
|
||||
cd /Users/john/Project/Memind
|
||||
codebase-memory-mcp cli --progress index_repository '{"repo_path":"/Users/john/Project/Memind"}'
|
||||
```
|
||||
|
||||
可选:常驻 daemon 减少 CLI 冷启动:
|
||||
|
||||
```bash
|
||||
codebase-memory-mcp daemon start
|
||||
```
|
||||
|
||||
## 离线验证
|
||||
|
||||
```bash
|
||||
codebase-memory-mcp --version
|
||||
node scripts/check-codebase-memory-mcp-local.mjs
|
||||
```
|
||||
|
||||
- 仅验证 CLI + Cursor 配置:`MEMIND_CBM_REQUIRE_INDEX=0 node scripts/check-codebase-memory-mcp-local.mjs`
|
||||
- 完整通过需已索引,输出 **`CODEBASE_MEMORY_MCP_OK`**
|
||||
|
||||
## 常用 CLI 探针
|
||||
|
||||
```bash
|
||||
codebase-memory-mcp cli --json list_projects '{}'
|
||||
codebase-memory-mcp cli --json search_graph '{"name_pattern":".*headroom.*","label":"Function"}'
|
||||
codebase-memory-mcp cli --json get_architecture '{}'
|
||||
```
|
||||
|
||||
## 与 zvec-grep 的分工
|
||||
|
||||
| 工具 | 强项 |
|
||||
|------|------|
|
||||
| **codebase-memory-mcp** | AST 知识图:符号、CALLS、路由、Cypher |
|
||||
| **zvec-grep** | workspace 语义/关键词文件检索(`.zvec-grep/`) |
|
||||
|
||||
两者互补;Context Runtime 本地清单见 [context-runtime-local-dev.md](./context-runtime-local-dev.md)。
|
||||
|
||||
## 卸载 / 更新
|
||||
|
||||
```bash
|
||||
codebase-memory-mcp update
|
||||
codebase-memory-mcp uninstall -y # 仅移除 agent MCP 配置,不删全局 npm 包
|
||||
```
|
||||
@@ -0,0 +1,149 @@
|
||||
# Context Runtime 本地离线开发
|
||||
|
||||
> 不调用 LLM、不跑 phase3 live smoke。用户自测见文末清单。
|
||||
|
||||
## 一键离线验证
|
||||
|
||||
```bash
|
||||
node scripts/run-context-runtime-offline.mjs
|
||||
node scripts/run-goosed-v149-phase3-offline.mjs # 含 v1.49 单测 + manifest drift
|
||||
```
|
||||
|
||||
通过时应分别输出 **`CONTEXT_RUNTIME_OFFLINE_OK`** 与 **`GOOSE_V149_PHASE3_OFFLINE_OK`**。
|
||||
|
||||
## 推荐 shadow 配置(仅观测,不改行为)
|
||||
|
||||
写入 `.env.local`(或临时 export):
|
||||
|
||||
```bash
|
||||
MEMIND_HEADROOM_MODE=shadow
|
||||
MEMIND_CONTEXT_BUDGET_MODE=shadow
|
||||
MEMIND_RECALL_FUSION_MODE=shadow
|
||||
MEMIND_MCP_COMPACT_MODE=shadow
|
||||
MEMIND_ZVEC_WORKSPACE_MODE=shadow
|
||||
HEADROOM_OUTPUT_SHAPER=0
|
||||
```
|
||||
|
||||
Bundle 常量见 `context-runtime-profile.mjs` → `CONTEXT_RUNTIME_SHADOW_ENV`。
|
||||
|
||||
## 本地 active 配置
|
||||
|
||||
五件套可全部 `active`(`CONTEXT_RUNTIME_ACTIVE_ENV`)。**memory injection 仍保持 shadow**(`MEMORY_AGENT_INJECTION_MODE=shadow`,禁止 active injection)。
|
||||
|
||||
```bash
|
||||
node scripts/check-context-runtime-active-local.mjs
|
||||
# → CONTEXT_RUNTIME_ACTIVE_OK
|
||||
```
|
||||
|
||||
| 开关 | active 行为 |
|
||||
|------|-------------|
|
||||
| MCP compact | 压缩大 tool 结果 + `ctx_fetch` |
|
||||
| context budget | 编排注入按预算 dedupe/drop |
|
||||
| recall fusion | 融合 recall 结果生效 |
|
||||
| zvec workspace | code 任务附加 workspace 检索 |
|
||||
| headroom | LLM 经 proxy(不可达 fail-open) |
|
||||
|
||||
## 离线事件形状预览
|
||||
|
||||
不启动 Portal 也可查看 gateway 将 emit 的 shadow 事件:
|
||||
|
||||
```bash
|
||||
node -e "
|
||||
import { simulateContextRuntimeShadowEvents } from './context-runtime-profile.mjs';
|
||||
console.log(JSON.stringify(simulateContextRuntimeShadowEvents(), null, 2));
|
||||
"
|
||||
```
|
||||
|
||||
期望事件类型:
|
||||
|
||||
| event_type | 含义 |
|
||||
|---|---|
|
||||
| `headroom_context_observed` | headroom 路由/eligible 观测 |
|
||||
| `context_budget_resolved` | 注入预算 dedupe/drop 计划 |
|
||||
| `recall_fusion_resolved` | 多路 recall 融合统计 |
|
||||
|
||||
## 组件探针(可选)
|
||||
|
||||
```bash
|
||||
# headroom proxy 已起且 MEMIND_HEADROOM_MODE!=off
|
||||
node scripts/check-headroom-proxy-local.mjs
|
||||
|
||||
# .zvec-grep 已 index
|
||||
node scripts/check-zvec-workspace-local.mjs
|
||||
|
||||
# MCP compactor shadow/active 逻辑
|
||||
node scripts/check-mcp-compactor-local.mjs
|
||||
node scripts/check-mcp-compact-active-local.mjs
|
||||
|
||||
# codebase-memory-mcp CLI + Memind 索引(见 docs/codebase-memory-mcp-local.md)
|
||||
node scripts/check-codebase-memory-mcp-local.mjs
|
||||
|
||||
# dsh CLI + dry-run launch plan(见 docs/dsh-executor-local.md,默认零 LLM)
|
||||
node scripts/check-dsh-executor-local.mjs
|
||||
|
||||
# Context Runtime active 五件套(零 LLM)
|
||||
node scripts/check-context-runtime-active-local.mjs
|
||||
```
|
||||
|
||||
## Shadow Portal E2E(1 轮,省 token 默认只验事件)
|
||||
|
||||
Portal 需带 shadow 五件套(见上文)运行 `pnpm dev`:
|
||||
|
||||
```bash
|
||||
node scripts/run-context-runtime-shadow-portal-e2e.mjs
|
||||
# 默认 CONTEXT_RUNTIME_SHADOW_EVENTS_ONLY=1:拿到 headroom/budget shadow 事件即停,不等 Goose 跑完
|
||||
# 通过 → CONTEXT_RUNTIME_SHADOW_PORTAL_OK
|
||||
```
|
||||
|
||||
`recall_fusion_resolved` 需要 **agent memory resolve 开启**(admin-db `runtimeControl.agentResolveEnabled=true` + `agentInjectionMode=shadow`,**不要**开 `active` injection)。缺失时脚本 WARN 但不阻断。
|
||||
|
||||
本地曾用(仅 dev,不入库):
|
||||
|
||||
```bash
|
||||
# admin-db 写入 shadow resolve(示例 one-shot)
|
||||
node -e "
|
||||
import { createDbPool } from './db.mjs';
|
||||
import { createMemoryV2AdminConfigService } from './memory-v2-admin-config.mjs';
|
||||
const pool = createDbPool();
|
||||
const svc = createMemoryV2AdminConfigService(pool);
|
||||
await svc.updateAdminConfig({
|
||||
runtimeControl: { agentResolveEnabled: true, agentInjectionMode: 'shadow' },
|
||||
chatIntentRouter: { memoryResolveEnabled: true },
|
||||
}, { updatedBy: 'local:context-runtime-shadow' });
|
||||
await pool.end();
|
||||
"
|
||||
```
|
||||
|
||||
## 用户自测清单(有 LLM 配额时)
|
||||
|
||||
1. 确认 **未设置** `GOOSE_V149_ALLOW_REAL_LLM=1` 除非你明确要跑 live smoke
|
||||
2. 应用 shadow env,**重启 Portal**(`pnpm dev`)
|
||||
3. 发起 **一轮**普通 agent 对话(非 page-e2e)
|
||||
4. 查 DB 事件:
|
||||
|
||||
```sql
|
||||
SELECT event_type, data_json, created_at
|
||||
FROM h5_agent_run_events
|
||||
WHERE run_id = '<run_id>'
|
||||
AND event_type IN (
|
||||
'headroom_context_observed',
|
||||
'context_budget_resolved',
|
||||
'recall_fusion_resolved'
|
||||
)
|
||||
ORDER BY created_at;
|
||||
```
|
||||
|
||||
5. 确认 `mode=shadow` 且 **聊天行为与 off 时一致**
|
||||
6. 若要 active 晋升:逐项改 `shadow` → `active`,每次只改一个开关
|
||||
|
||||
## 禁止项
|
||||
|
||||
- Agent **不得**自动设置 `GOOSE_V149_ALLOW_REAL_LLM=1` 或循环跑 phase2/phase3
|
||||
- **预计超过 10k token 的 LLM 调用须用户明确同意**(含 dsh headless / Goose smoke)
|
||||
- **禁止** `headroom wrap` / `headroom learn` / `HEADROOM_OUTPUT_SHAPER=1`
|
||||
- 页面生成 / Page Data 轮次首期 **排除** headroom active
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [context-runtime-harness-fusion-plan.md](./context-runtime-harness-fusion-plan.md)
|
||||
- [goose-v149-real-llm-gate.md](./goose-v149-real-llm-gate.md)
|
||||
@@ -0,0 +1,92 @@
|
||||
# DeepSeek Harness (dsh) 本地开发
|
||||
|
||||
> MIT · developer preview · **并列 code executor,不替换 goosed**
|
||||
> 关联:[context-runtime-harness-fusion-plan.md](./context-runtime-harness-fusion-plan.md) §6.3
|
||||
|
||||
## 用途边界
|
||||
|
||||
| 允许 | 禁止 |
|
||||
|------|------|
|
||||
| `tool-gateway` 白名单 task type 的 headless 代码任务 | 替换 Goose + `tkmind-proxy` 主路径 |
|
||||
| 与 aider / openhands / cursor 并列 executor | 103 默认开启 |
|
||||
| 小范围验证 Cordis / plugin 模型 | 未批准的大流量 smoke |
|
||||
|
||||
## 安装(本机一次)
|
||||
|
||||
Memind launch plan 与 smoke 脚本固定 pin:
|
||||
|
||||
```bash
|
||||
npm install -g @deepseek-ai/dsh@0.1.1-rc.2
|
||||
dsh --version # → 0.1.1-rc.2
|
||||
which dsh # Homebrew 全局 npm 通常在 /opt/homebrew/bin/dsh
|
||||
```
|
||||
|
||||
未装本地二进制时,代码会回退 `npx -y @deepseek-ai/dsh`(首次也会下载包)。
|
||||
|
||||
## 开关(默认全关)
|
||||
|
||||
```bash
|
||||
MEMIND_TOOL_GATEWAY_DSH_ENABLED=0
|
||||
MEMIND_TOOL_GATEWAY_DSH_TASK_TYPES=repo_refactor,multi_file
|
||||
MEMIND_DSH_BIN=dsh
|
||||
# MEMIND_DSH_FORCE_NPX=1 # 强制 npx 路径
|
||||
```
|
||||
|
||||
见 `docs/goose-v149-canary.env.example`。
|
||||
|
||||
## 离线验证(零 LLM)
|
||||
|
||||
```bash
|
||||
node scripts/check-dsh-executor-local.mjs
|
||||
# → DSH_EXECUTOR_CLI_OK
|
||||
|
||||
MEMIND_TOOL_GATEWAY_DSH_ENABLED=1 node scripts/check-dsh-executor-local.mjs
|
||||
# → DSH_EXECUTOR_OK(仅 dry-run launch plan,不 spawn)
|
||||
```
|
||||
|
||||
## Live smoke(耗 token,须批准)
|
||||
|
||||
**默认禁止** Agent 自动跑 live。人工一次性:
|
||||
|
||||
```bash
|
||||
export DEEPSEEK_API_KEY=...
|
||||
MEMIND_TOOL_GATEWAY_DSH_ENABLED=1 \
|
||||
MEMIND_DSH_SMOKE_TASK='List three files in the workspace root and stop.' \
|
||||
MEMIND_DSH_RUN_LIVE_SMOKE=1 \
|
||||
node scripts/check-dsh-executor-local.mjs
|
||||
```
|
||||
|
||||
### Token 成本闸门(2026-09-10)
|
||||
|
||||
- 优先 **dry-run / 单条最小 headless**
|
||||
- **预计超过 10k token 必须先经用户同意**
|
||||
- 禁止循环重跑、禁止 unattended `check-goosed-v149-all` / phase3 聚合
|
||||
|
||||
## Tool Gateway E2E(一次 headless)
|
||||
|
||||
```bash
|
||||
MEMIND_TOOL_GATEWAY_DSH_ENABLED=1 node scripts/test-dsh-executor-gateway-e2e.mjs
|
||||
# → DSH_GATEWAY_E2E_OK
|
||||
```
|
||||
|
||||
路由 `repo_refactor` → `dsh`;会消耗 LLM token,须人工批准。
|
||||
|
||||
## headless 手动探针
|
||||
|
||||
```bash
|
||||
dsh --profile headless "List three files in this directory and stop."
|
||||
```
|
||||
|
||||
## 接入节奏(fusion-plan)
|
||||
|
||||
| 阶段 | 内容 |
|
||||
|------|------|
|
||||
| **现在** | CLI + dry-run + 文档(本页) |
|
||||
| **下一步** | Context Runtime shadow 自测后,**1 次**批准 live smoke |
|
||||
| **之后** | task type 白名单扩展、fallback、memind_adm 开关;103 单独决策 |
|
||||
|
||||
## 相关模块
|
||||
|
||||
- `dsh-agent-launch.mjs` — launch plan
|
||||
- `tool-gateway.mjs` — executor 路由
|
||||
- `llm-providers.mjs` — provider 侧 plan
|
||||
@@ -56,3 +56,10 @@ MEMORY_CANDIDATE_ENABLED=0
|
||||
# MEMIND_MCP_COMPACT_BUDGET_CHARS=4000
|
||||
# MEMIND_MCP_COMPACT_TTL_SECONDS=3600
|
||||
# MEMIND_RUNTIME_REDIS_URL=redis://127.0.0.1:6379/0
|
||||
|
||||
# Context Runtime shadow observe-only bundle (see docs/context-runtime-local-dev.md)
|
||||
# MEMIND_HEADROOM_MODE=shadow
|
||||
# MEMIND_CONTEXT_BUDGET_MODE=shadow
|
||||
# MEMIND_RECALL_FUSION_MODE=shadow
|
||||
# MEMIND_MCP_COMPACT_MODE=shadow
|
||||
# MEMIND_ZVEC_WORKSPACE_MODE=shadow
|
||||
|
||||
@@ -7,9 +7,19 @@
|
||||
|
||||
## 聚合验证入口
|
||||
|
||||
**默认(不消耗 LLM token):**
|
||||
|
||||
```bash
|
||||
bash scripts/run-goosed-v149-local.sh # :18049 另开终端
|
||||
node scripts/run-goosed-v149-phase3.mjs # Phase 2 + parity + drift
|
||||
node scripts/run-goosed-v149-phase3-offline.mjs
|
||||
```
|
||||
|
||||
通过时应输出 **`GOOSE_V149_PHASE3_OFFLINE_OK`**。
|
||||
|
||||
**完整 smoke(须用户明确批准 + DashScope 配额):**
|
||||
|
||||
```bash
|
||||
GOOSE_V149_ALLOW_REAL_LLM=1 bash scripts/run-goosed-v149-local.sh # :18049 另开终端
|
||||
GOOSE_V149_ALLOW_REAL_LLM=1 node scripts/run-goosed-v149-phase3.mjs
|
||||
```
|
||||
|
||||
通过时应输出 **`GOOSE_V149_PHASE3_OK`**。
|
||||
@@ -37,10 +47,10 @@ node scripts/run-goosed-v149-phase3.mjs # Phase 2 + parity + drift
|
||||
|
||||
## 合并前闸门(Memind 仓库)
|
||||
|
||||
1. [ ] `upgrade/v1.49-tkmind` worktree 变更已 review(`tkmind_go-v149` 独立仓库/worktree)
|
||||
2. [ ] Memind 侧脚本/文档/canary 开关 PR 就绪
|
||||
3. [ ] `node scripts/run-goosed-v149-phase3.mjs` 连续通过(2026-09-09:失败项 `phase2`/`page-e2e`/`multiturn-provider` 超时;非 goose 不可用)
|
||||
4. [ ] Memory manifest 无 drift(`compare-goose-v149-memory-manifest.mjs`)
|
||||
1. [x] `upgrade/v1.49-tkmind` worktree 变更已 review(`tkmind_go-v149` · commit `6648c4bfb` pushed to `tkmind/upgrade/v1.49-tkmind`)
|
||||
2. [x] Memind 侧脚本/文档/canary 开关 PR 就绪(`cursor/v149-merge-prep-gates` · `verify:mindspace-publish-guards:full` OK)
|
||||
3. [x] `node scripts/run-goosed-v149-phase3.mjs` 连续通过(2026-09-10:canary=all 下两次 `GOOSE_V149_PHASE3_OK`,日志 `/tmp/v149-phase3-run2.log` 与 `/tmp/v149-phase3-run3.log`)
|
||||
4. [x] Memory manifest 无 drift(`compare-goose-v149-memory-manifest.mjs` · 2026-09-10 `GOOSE_V149_MEMORY_DRIFT_OK`)
|
||||
5. [x] 主 Portal 重启后 canary 联调:`check-goosed-v149-portal-smoke.mjs`(targets 含 `:18049`);canary 块强制 `H5_PUBLIC_BASE_URL=http://127.0.0.1:8081` 避免 5173 未起时链接失效
|
||||
6. [ ] **用户明确批准** 后才:`git push`、合并 `main`、任何 103 构建
|
||||
|
||||
|
||||
@@ -37,8 +37,14 @@ GOOSE_V149_ALLOW_REAL_LLM=1 node scripts/check-goosed-v149-reply-smoke.mjs
|
||||
|
||||
## 推荐替代
|
||||
|
||||
- 合并前优先:`node --test scripts/goose-v149-canary.test.mjs`、`compare-goose-v149-message-sanitize.mjs`、`compare-goose-v149-memory-manifest.mjs`
|
||||
- 必须验证 Portal 集成时:单次、有人值守、先确认 DashScope 配额
|
||||
- **零 LLM 合并前验证**(默认推荐):
|
||||
|
||||
```bash
|
||||
node scripts/run-goosed-v149-phase3-offline.mjs
|
||||
```
|
||||
|
||||
- 单项:`node --test scripts/goose-v149-canary.test.mjs`、`compare-goose-v149-message-sanitize.mjs`、`compare-goose-v149-memory-manifest.mjs`
|
||||
- 必须验证 Portal 集成时:单次、有人值守、先确认 DashScope 配额,且 `GOOSE_V149_ALLOW_REAL_LLM=1`
|
||||
|
||||
## 回滚 Canary
|
||||
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
# Default includes code-mode (required for TKMind). Use GOOSE_V149_BUILD_MODE=minimal to skip v8.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
GOOSED_ROOT="${GOOSED_V149_ROOT:-/Users/john/Project/tkmind_go-v149}"
|
||||
LOG="${GOOSE_V149_BUILD_LOG:-/tmp/goosed-v149-build.log}"
|
||||
MODE="${GOOSE_V149_BUILD_MODE:-codemode}"
|
||||
|
||||
bash "$(dirname "$0")/ensure-goose-v149-vendor.sh"
|
||||
bash "${SCRIPT_DIR}/ensure-goose-v149-vendor.sh"
|
||||
|
||||
cd "${GOOSED_ROOT}"
|
||||
: > "${LOG}"
|
||||
@@ -16,11 +17,11 @@ echo "[goose-v149-build] mode=${MODE} start $(date -u +%Y-%m-%dT%H:%M:%SZ)" | te
|
||||
|
||||
case "${MODE}" in
|
||||
full)
|
||||
bash "$(dirname "$0")/ensure-goose-v149-v8.sh" 2>&1 | tee -a "${LOG}"
|
||||
bash "${SCRIPT_DIR}/ensure-goose-v149-v8.sh" 2>&1 | tee -a "${LOG}"
|
||||
rustup run 1.96.1 cargo build --release --package goose-cli --bin goose 2>&1 | tee -a "${LOG}"
|
||||
;;
|
||||
codemode)
|
||||
bash "$(dirname "$0")/ensure-goose-v149-v8.sh" 2>&1 | tee -a "${LOG}"
|
||||
bash "${SCRIPT_DIR}/ensure-goose-v149-v8.sh" 2>&1 | tee -a "${LOG}"
|
||||
# code-mode without local-inference (smaller/faster than full default).
|
||||
rustup run 1.96.1 cargo build --release --package goose-cli --bin goose \
|
||||
--no-default-features \
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Local codebase-memory-mcp smoke (dev machine only).
|
||||
*
|
||||
* Verifies the global CLI is installed and, when MEMIND_CBM_REQUIRE_INDEX=1
|
||||
* (default), that this repository is indexed.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { basename, resolve } from 'node:path';
|
||||
|
||||
const repoRoot = resolve(process.cwd());
|
||||
const requireIndex = process.env.MEMIND_CBM_REQUIRE_INDEX !== '0';
|
||||
const cliName = process.env.MEMIND_CBM_CLI ?? 'codebase-memory-mcp';
|
||||
|
||||
function runCli(tool, args = {}) {
|
||||
const payload = JSON.stringify(args);
|
||||
const result = spawnSync(cliName, ['cli', '--json', tool, payload], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
});
|
||||
const stdout = (result.stdout ?? '').trim();
|
||||
const stderr = (result.stderr ?? '').trim();
|
||||
if (result.error) {
|
||||
return { ok: false, error: result.error.message, stdout, stderr };
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: stderr.split('\n').pop() || `exit ${result.status}`,
|
||||
stdout,
|
||||
stderr,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(stdout);
|
||||
const structured = parsed.structuredContent ?? parsed;
|
||||
if (structured?.error || parsed.isError) {
|
||||
return { ok: false, error: structured.error ?? parsed.isError, data: structured, stdout, stderr };
|
||||
}
|
||||
return { ok: true, data: structured, stdout, stderr };
|
||||
} catch {
|
||||
return { ok: false, error: 'invalid JSON from cli', stdout, stderr };
|
||||
}
|
||||
}
|
||||
|
||||
const versionResult = spawnSync(cliName, ['--version'], { encoding: 'utf8' });
|
||||
const version = (versionResult.stdout ?? versionResult.stderr ?? '').trim();
|
||||
if (versionResult.status !== 0 || !version) {
|
||||
console.error('CODEBASE_MEMORY_MCP_FAIL: CLI not found');
|
||||
console.error(' install: npm install -g codebase-memory-mcp && codebase-memory-mcp install -y');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('CODEBASE_MEMORY_MCP_PROBE:');
|
||||
console.log(` version=${version}`);
|
||||
console.log(` repo=${repoRoot}`);
|
||||
|
||||
const cursorMcpPath = resolve(homedir(), '.cursor/mcp.json');
|
||||
if (existsSync(cursorMcpPath)) {
|
||||
try {
|
||||
const cursorConfig = JSON.parse(readFileSync(cursorMcpPath, 'utf8'));
|
||||
const servers = cursorConfig.mcpServers ?? cursorConfig;
|
||||
const configured = Boolean(servers?.['codebase-memory-mcp']);
|
||||
console.log(` cursor_mcp_configured=${configured}`);
|
||||
if (!configured) {
|
||||
console.warn('CODEBASE_MEMORY_MCP_WARN: ~/.cursor/mcp.json missing codebase-memory-mcp (run: codebase-memory-mcp install -y)');
|
||||
}
|
||||
} catch {
|
||||
console.warn('CODEBASE_MEMORY_MCP_WARN: could not parse ~/.cursor/mcp.json');
|
||||
}
|
||||
} else {
|
||||
console.warn('CODEBASE_MEMORY_MCP_WARN: ~/.cursor/mcp.json not found');
|
||||
}
|
||||
|
||||
const projects = runCli('list_projects', {});
|
||||
if (!projects.ok) {
|
||||
console.error(`CODEBASE_MEMORY_MCP_FAIL: list_projects — ${projects.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const projectNames = (projects.data.projects ?? []).map((p) => p.name ?? p.project ?? p);
|
||||
console.log(` indexed_projects=${projectNames.length}`);
|
||||
|
||||
const repoName = basename(repoRoot);
|
||||
const matched = projectNames.find((name) => {
|
||||
const normalized = String(name).toLowerCase();
|
||||
return normalized === repoName.toLowerCase() || normalized.includes('memind');
|
||||
});
|
||||
|
||||
if (!matched) {
|
||||
const hint = 'codebase-memory-mcp cli --progress index_repository \'{"repo_path":"' + repoRoot + '"}\'';
|
||||
if (requireIndex) {
|
||||
console.error('CODEBASE_MEMORY_MCP_FAIL: Memind not indexed yet');
|
||||
console.error(` run: ${hint}`);
|
||||
console.error(' or in Cursor after restart: "Index this project"');
|
||||
process.exit(1);
|
||||
}
|
||||
console.warn('CODEBASE_MEMORY_MCP_WARN: Memind not indexed (MEMIND_CBM_REQUIRE_INDEX=0)');
|
||||
console.log('CODEBASE_MEMORY_MCP_CLI_OK');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const status = runCli('index_status', { project: matched });
|
||||
if (!status.ok) {
|
||||
console.error(`CODEBASE_MEMORY_MCP_FAIL: index_status — ${status.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const summary = {
|
||||
project: status.data.project ?? matched,
|
||||
status: status.data.status ?? null,
|
||||
nodes: status.data.nodes ?? null,
|
||||
edges: status.data.edges ?? null,
|
||||
parse_partial_count: status.data.parse_partial?.count ?? status.data.parse_partial?.files?.length ?? 0,
|
||||
};
|
||||
console.log(` project=${summary.project}`);
|
||||
console.log(` index=${JSON.stringify(summary)}`);
|
||||
console.log('CODEBASE_MEMORY_MCP_OK');
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Verify Context Runtime active profile on this machine (no LLM).
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
CONTEXT_RUNTIME_ACTIVE_ENV,
|
||||
describeContextRuntimeProfile,
|
||||
} from '../context-runtime-profile.mjs';
|
||||
import {
|
||||
ensureHeadroomProxyRunning,
|
||||
probeHeadroomProxyReachable,
|
||||
resolveHeadroomMode,
|
||||
} from '../memind-headroom-policy.mjs';
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
for (const [key, value] of Object.entries(CONTEXT_RUNTIME_ACTIVE_ENV)) {
|
||||
process.env[key] = process.env[key] ?? value;
|
||||
}
|
||||
|
||||
const profile = describeContextRuntimeProfile(process.env);
|
||||
console.log('CONTEXT_RUNTIME_ACTIVE_PROBE:');
|
||||
console.log(` profile=${JSON.stringify(profile)}`);
|
||||
|
||||
const issues = [];
|
||||
for (const [key, expected] of Object.entries(CONTEXT_RUNTIME_ACTIVE_ENV)) {
|
||||
if (key === 'HEADROOM_OUTPUT_SHAPER') continue;
|
||||
const actual = profile[
|
||||
key === 'MEMIND_HEADROOM_MODE' ? 'headroom'
|
||||
: key === 'MEMIND_CONTEXT_BUDGET_MODE' ? 'contextBudget'
|
||||
: key === 'MEMIND_RECALL_FUSION_MODE' ? 'recallFusion'
|
||||
: key === 'MEMIND_MCP_COMPACT_MODE' ? 'mcpCompact'
|
||||
: 'zvecWorkspace'
|
||||
];
|
||||
if (actual !== expected) {
|
||||
issues.push(`${key}=${actual} (expected ${expected})`);
|
||||
}
|
||||
}
|
||||
|
||||
function runScript(name) {
|
||||
const result = spawnSync(process.execPath, [path.join(root, 'scripts', name)], {
|
||||
cwd: root,
|
||||
env: process.env,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
return {
|
||||
ok: result.status === 0,
|
||||
stdout: (result.stdout ?? '').trim(),
|
||||
stderr: (result.stderr ?? '').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
for (const script of [
|
||||
'check-mcp-compact-active-local.mjs',
|
||||
'check-mcp-compactor-local.mjs',
|
||||
'check-zvec-workspace-local.mjs',
|
||||
]) {
|
||||
const result = runScript(script);
|
||||
console.log(` ${script}=${result.ok ? 'OK' : 'FAIL'}`);
|
||||
if (!result.ok) {
|
||||
issues.push(`${script} failed`);
|
||||
if (result.stderr) console.error(result.stderr.slice(0, 400));
|
||||
}
|
||||
}
|
||||
|
||||
if (resolveHeadroomMode(process.env) === 'active') {
|
||||
const boot = await ensureHeadroomProxyRunning({ env: process.env });
|
||||
const reachable = boot.reachable || await probeHeadroomProxyReachable({ env: process.env });
|
||||
console.log(` headroom_proxy=${reachable ? 'reachable' : 'unreachable'} started=${boot.started}`);
|
||||
if (!reachable) {
|
||||
issues.push('headroom active but proxy unreachable');
|
||||
}
|
||||
}
|
||||
|
||||
if (issues.length) {
|
||||
console.error(`CONTEXT_RUNTIME_ACTIVE_FAIL: ${issues.join('; ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('CONTEXT_RUNTIME_ACTIVE_OK');
|
||||
@@ -1,11 +1,53 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Local DeepSeek Harness executor dry-run probe.
|
||||
* Local DeepSeek Harness (dsh) smoke — dev machine only, no LLM by default.
|
||||
*
|
||||
* Always verifies the CLI. When MEMIND_TOOL_GATEWAY_DSH_ENABLED=1, also prints
|
||||
* a dry-run launch plan (does not spawn dsh).
|
||||
*
|
||||
* Live headless smoke (costs LLM tokens) is opt-in only:
|
||||
* MEMIND_DSH_RUN_LIVE_SMOKE=1 node scripts/check-dsh-executor-local.mjs
|
||||
* Any run expected to exceed 10k tokens requires explicit user approval.
|
||||
*/
|
||||
import { buildDshExecutorLaunchPlan, dshExecutorEnabled } from '../dsh-agent-launch.mjs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { buildDshExecutorLaunchPlan, dshExecutorEnabled, resolveDshCommand } from '../dsh-agent-launch.mjs';
|
||||
|
||||
const cliName = process.env.MEMIND_DSH_BIN ?? process.env.DSH_BIN ?? 'dsh';
|
||||
const pinnedVersion = process.env.MEMIND_DSH_PINNED_VERSION ?? '0.1.1-rc.2';
|
||||
|
||||
function run(cmd, args, options = {}) {
|
||||
return spawnSync(cmd, args, { encoding: 'utf8', ...options });
|
||||
}
|
||||
|
||||
const versionResult = run(cliName, ['--version']);
|
||||
const version = (versionResult.stdout ?? versionResult.stderr ?? '').trim();
|
||||
if (versionResult.status !== 0 || !version) {
|
||||
console.error('DSH_EXECUTOR_FAIL: CLI not found');
|
||||
console.error(` install: npm install -g @deepseek-ai/dsh@${pinnedVersion}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const resolved = resolveDshCommand(process.env);
|
||||
console.log('DSH_EXECUTOR_PROBE:');
|
||||
console.log(` version=${version}`);
|
||||
console.log(` command=${resolved.command}`);
|
||||
console.log(` via_npx=${resolved.viaNpx}`);
|
||||
|
||||
if (version !== pinnedVersion) {
|
||||
console.warn(`DSH_EXECUTOR_WARN: expected ${pinnedVersion}, got ${version}`);
|
||||
}
|
||||
|
||||
const headlessHelp = run(resolved.command, resolved.viaNpx
|
||||
? ['-y', '@deepseek-ai/dsh', '--profile', 'headless', '--help']
|
||||
: ['--profile', 'headless', '--help']);
|
||||
if (headlessHelp.status !== 0) {
|
||||
console.error('DSH_EXECUTOR_FAIL: headless profile unavailable');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!dshExecutorEnabled(process.env)) {
|
||||
console.log('DSH_EXECUTOR_SKIP: MEMIND_TOOL_GATEWAY_DSH_ENABLED is off');
|
||||
console.log('DSH_EXECUTOR_SKIP: MEMIND_TOOL_GATEWAY_DSH_ENABLED is off (launch plan not checked)');
|
||||
console.log('DSH_EXECUTOR_CLI_OK');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -23,7 +65,7 @@ console.log(JSON.stringify({
|
||||
ok: plan.ok,
|
||||
executor: plan.executor,
|
||||
command: plan.command,
|
||||
argsPreview: plan.args?.slice?.(0, 4),
|
||||
argsPreview: plan.args?.slice?.(0, 6),
|
||||
cwd: plan.cwd,
|
||||
message: plan.message ?? null,
|
||||
}, null, 2));
|
||||
@@ -33,4 +75,40 @@ if (!plan.ok) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('DSH_EXECUTOR_OK: dry-run plan ready (use tool-gateway dry-run to execute)');
|
||||
if (process.env.MEMIND_DSH_RUN_LIVE_SMOKE !== '1') {
|
||||
console.log('DSH_EXECUTOR_OK: dry-run plan ready (no LLM; set MEMIND_DSH_RUN_LIVE_SMOKE=1 to execute once)');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!process.env.DEEPSEEK_API_KEY && !process.env.OPENAI_API_KEY) {
|
||||
console.error('DSH_EXECUTOR_FAIL: live smoke requires DEEPSEEK_API_KEY or OPENAI_API_KEY');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.warn('DSH_EXECUTOR_LIVE: spawning one headless task (LLM tokens will be consumed)');
|
||||
const live = spawnSync(plan.command, plan.args, {
|
||||
cwd: plan.cwd,
|
||||
env: { ...process.env, ...plan.env },
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 1024 * 1024,
|
||||
timeout: positiveTimeout(process.env.MEMIND_DSH_LIVE_TIMEOUT_MS, 120_000),
|
||||
});
|
||||
|
||||
if (live.error) {
|
||||
console.error(`DSH_EXECUTOR_FAIL: ${live.error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (live.status !== 0) {
|
||||
console.error(`DSH_EXECUTOR_FAIL: exit ${live.status}`);
|
||||
if (live.stderr) console.error(live.stderr.slice(0, 2000));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const preview = (live.stdout ?? '').trim().slice(0, 500);
|
||||
console.log(`DSH_EXECUTOR_LIVE_OK: stdout_preview=${JSON.stringify(preview)}`);
|
||||
|
||||
function positiveTimeout(value, fallback) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || n <= 0) return fallback;
|
||||
return Math.floor(n);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Verify MCP compactor active promotion on this machine.
|
||||
* Requires MEMIND_MCP_COMPACT_MODE=active in the environment (e.g. .env.local).
|
||||
*/
|
||||
import {
|
||||
isMcpCompactActive,
|
||||
resolveMcpCompactMode,
|
||||
} from '../mcp-result-compactor.mjs';
|
||||
|
||||
const mode = resolveMcpCompactMode(process.env);
|
||||
console.log('MCP_COMPACT_ACTIVE_PROBE:');
|
||||
console.log(` mode=${mode}`);
|
||||
console.log(` active=${isMcpCompactActive(process.env)}`);
|
||||
|
||||
if (mode !== 'active' || !isMcpCompactActive(process.env)) {
|
||||
console.error('MCP_COMPACT_ACTIVE_FAIL: set MEMIND_MCP_COMPACT_MODE=active and restart Portal/MCP');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('MCP_COMPACT_ACTIVE_OK');
|
||||
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Context Runtime offline gate: unit tests + local probes, no LLM.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
CONTEXT_RUNTIME_SHADOW_ENV,
|
||||
describeContextRuntimeProfile,
|
||||
simulateContextRuntimeShadowEvents,
|
||||
validateContextRuntimeShadowEvents,
|
||||
} from '../context-runtime-profile.mjs';
|
||||
import { prepareGooseV149CheckEnv } from './goose-v149-canary.mjs';
|
||||
import { checkPassed, classifyCheckResult } from './goose-v149-check-result.mjs';
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const checkEnv = prepareGooseV149CheckEnv({ ...process.env }, root);
|
||||
|
||||
const unitChecks = [
|
||||
{
|
||||
name: 'context-runtime-profile',
|
||||
command: [process.execPath, '--test', 'context-runtime-profile.test.mjs'],
|
||||
},
|
||||
{
|
||||
name: 'context-runtime-modules',
|
||||
command: [
|
||||
process.execPath,
|
||||
'--test',
|
||||
'memind-headroom-policy.test.mjs',
|
||||
'context-budget.test.mjs',
|
||||
'recall-fusion.test.mjs',
|
||||
'mcp-result-compactor.test.mjs',
|
||||
'memind-zvec-workspace.test.mjs',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const scriptChecks = [
|
||||
{ name: 'mcp-compactor-local', script: 'check-mcp-compactor-local.mjs' },
|
||||
{ name: 'headroom-proxy-local', script: 'check-headroom-proxy-local.mjs', optional: true },
|
||||
{ name: 'zvec-workspace-local', script: 'check-zvec-workspace-local.mjs', optional: true },
|
||||
];
|
||||
|
||||
function runCommand(command) {
|
||||
const [bin, ...args] = command;
|
||||
const result = spawnSync(bin, args, {
|
||||
cwd: root,
|
||||
env: checkEnv,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
return {
|
||||
ok: checkPassed(result),
|
||||
outcome: classifyCheckResult(result),
|
||||
stdout: result.stdout?.trim() ?? '',
|
||||
stderr: result.stderr?.trim() ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function runScript(script) {
|
||||
const result = spawnSync(process.execPath, [path.join(root, 'scripts', script)], {
|
||||
cwd: root,
|
||||
env: checkEnv,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
return {
|
||||
ok: checkPassed(result),
|
||||
outcome: classifyCheckResult(result),
|
||||
stdout: result.stdout?.trim() ?? '',
|
||||
stderr: result.stderr?.trim() ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function main() {
|
||||
console.log('CONTEXT_RUNTIME_OFFLINE:');
|
||||
console.log(` profile=${JSON.stringify(describeContextRuntimeProfile(checkEnv))}`);
|
||||
console.log(` recommended_shadow=${JSON.stringify(CONTEXT_RUNTIME_SHADOW_ENV)}`);
|
||||
|
||||
const simulation = simulateContextRuntimeShadowEvents({ env: { ...CONTEXT_RUNTIME_SHADOW_ENV } });
|
||||
const validation = validateContextRuntimeShadowEvents(simulation);
|
||||
console.log(` shadow_simulation=${validation.ok ? 'OK' : 'FAIL'}`);
|
||||
if (!validation.ok) {
|
||||
console.error(validation.issues.join('\n'));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(` sample_events=${Object.keys(simulation.events).join(',')}`);
|
||||
|
||||
const failures = [];
|
||||
|
||||
for (const check of unitChecks) {
|
||||
const result = runCommand(check.command);
|
||||
console.log(`[context-runtime-offline] ${result.outcome} ${check.name}`);
|
||||
if (!result.ok) {
|
||||
failures.push(check.name);
|
||||
if (result.stderr) console.error(result.stderr);
|
||||
}
|
||||
}
|
||||
|
||||
for (const check of scriptChecks) {
|
||||
const result = runScript(check.script);
|
||||
const skipped = /SKIP:/.test(result.stdout);
|
||||
if (skipped && check.optional) {
|
||||
console.log(`[context-runtime-offline] SKIP ${check.name}`);
|
||||
continue;
|
||||
}
|
||||
console.log(`[context-runtime-offline] ${result.outcome} ${check.name}`);
|
||||
if (!result.ok && !check.optional) {
|
||||
failures.push(check.name);
|
||||
if (result.stderr) console.error(result.stderr);
|
||||
} else if (!result.ok && check.optional) {
|
||||
console.log(`[context-runtime-offline] WARN ${check.name} (optional probe failed)`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.error(`CONTEXT_RUNTIME_OFFLINE_FAIL: ${failures.join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('CONTEXT_RUNTIME_OFFLINE_OK');
|
||||
console.log(' self-test: docs/context-runtime-local-dev.md');
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,237 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Context Runtime shadow portal E2E: one agent turn, verify shadow events in DB.
|
||||
* Default: poll shadow events only (do not wait for full Goose completion — saves LLM tokens).
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { createDbPool } from '../db.mjs';
|
||||
import { CONTEXT_RUNTIME_SHADOW_ENV } from '../context-runtime-profile.mjs';
|
||||
import {
|
||||
createReporter,
|
||||
loginViaApi,
|
||||
resolvePortalBase,
|
||||
sleep,
|
||||
waitForRunTerminal,
|
||||
} from './scenario-test-lib.mjs';
|
||||
import { waitForAgentRunWorkerIdle } from './goose-v149-worker-idle.mjs';
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
for (const [key, value] of Object.entries(CONTEXT_RUNTIME_SHADOW_ENV)) {
|
||||
process.env[key] = process.env[key] ?? value;
|
||||
}
|
||||
process.env.HEADROOM_OUTPUT_SHAPER = process.env.HEADROOM_OUTPUT_SHAPER ?? '0';
|
||||
|
||||
const baseUrl = resolvePortalBase(Number(process.env.H5_PORT ?? 8081));
|
||||
const timeoutMs = Number(process.env.CONTEXT_RUNTIME_SHADOW_PORTAL_TIMEOUT_MS ?? 180_000);
|
||||
const eventsOnly = process.env.CONTEXT_RUNTIME_SHADOW_EVENTS_ONLY !== '0';
|
||||
const pollMs = Number(process.env.CONTEXT_RUNTIME_SHADOW_POLL_MS ?? 2000);
|
||||
|
||||
async function portalReachable() {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/auth/status`);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRunEvents(pool, runId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT event_type, data_json, created_at
|
||||
FROM h5_agent_run_events
|
||||
WHERE run_id = ?
|
||||
ORDER BY created_at ASC`,
|
||||
[runId],
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
eventType: row.event_type,
|
||||
data: typeof row.data_json === 'string' ? JSON.parse(row.data_json) : row.data_json,
|
||||
createdAt: Number(row.created_at),
|
||||
}));
|
||||
}
|
||||
|
||||
function pickEvents(events, type) {
|
||||
return events.filter((event) => event.eventType === type);
|
||||
}
|
||||
|
||||
function evaluateShadowEvents(events) {
|
||||
const headroomEvents = pickEvents(events, 'headroom_context_observed');
|
||||
const budgetEvents = pickEvents(events, 'context_budget_resolved');
|
||||
const fusionEvents = pickEvents(events, 'recall_fusion_resolved');
|
||||
const issues = [];
|
||||
const warnings = [];
|
||||
|
||||
if (!headroomEvents.length) issues.push('missing headroom_context_observed');
|
||||
if (!budgetEvents.length) issues.push('missing context_budget_resolved');
|
||||
if (!fusionEvents.length) {
|
||||
warnings.push('missing recall_fusion_resolved (memory path may be off/skipped)');
|
||||
}
|
||||
|
||||
for (const [label, rows] of [
|
||||
['headroom', headroomEvents],
|
||||
['budget', budgetEvents],
|
||||
['fusion', fusionEvents],
|
||||
]) {
|
||||
const mode = rows[0]?.data?.mode;
|
||||
if (mode && mode !== 'shadow') {
|
||||
issues.push(`${label} mode=${mode} (expected shadow)`);
|
||||
}
|
||||
}
|
||||
|
||||
return { headroomEvents, budgetEvents, fusionEvents, issues, warnings };
|
||||
}
|
||||
|
||||
async function pollShadowEvents(pool, runId, deadlineMs) {
|
||||
while (Date.now() < deadlineMs) {
|
||||
const events = await fetchRunEvents(pool, runId);
|
||||
const evaluation = evaluateShadowEvents(events);
|
||||
const coreReady = evaluation.headroomEvents.length && evaluation.budgetEvents.length;
|
||||
if (coreReady) {
|
||||
return { events, evaluation, done: true };
|
||||
}
|
||||
await sleep(pollMs);
|
||||
}
|
||||
const events = await fetchRunEvents(pool, runId);
|
||||
return { events, evaluation: evaluateShadowEvents(events), done: false };
|
||||
}
|
||||
|
||||
async function runPortalTurn(pool) {
|
||||
const username = process.env.RELEASE_GATE_SCENARIO_USERNAME ?? 'john';
|
||||
const password =
|
||||
process.env.JOHN_PASSWORD
|
||||
?? process.env.H5_ACCESS_PASSWORD
|
||||
?? process.env.MEMIND_PASSWORD
|
||||
?? '';
|
||||
if (!password) throw new Error('set JOHN_PASSWORD for portal e2e');
|
||||
|
||||
const reporter = createReporter();
|
||||
const auth = await loginViaApi(baseUrl, { username, password }, reporter);
|
||||
await waitForAgentRunWorkerIdle(root, process.env, {
|
||||
logPrefix: '[context-runtime-shadow-e2e]',
|
||||
});
|
||||
|
||||
const startRes = await fetch(`${baseUrl}/api/agent/start`, {
|
||||
method: 'POST',
|
||||
headers: { Cookie: auth.cookie, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const started = await startRes.json().catch(() => ({}));
|
||||
if (!startRes.ok || !started?.id) {
|
||||
throw new Error(`agent/start failed: ${startRes.status}`);
|
||||
}
|
||||
const sessionId = started.id;
|
||||
|
||||
const warmResume = await fetch(`${baseUrl}/api/agent/resume`, {
|
||||
method: 'POST',
|
||||
headers: { Cookie: auth.cookie, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
session_id: sessionId,
|
||||
load_model_and_extensions: true,
|
||||
}),
|
||||
});
|
||||
if (!warmResume.ok) {
|
||||
const warmBody = await warmResume.text().catch(() => '');
|
||||
throw new Error(`pre-run resume failed: ${warmResume.status} ${warmBody.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
const requestId = randomUUID();
|
||||
const runRes = await fetch(`${baseUrl}/api/agent/runs`, {
|
||||
method: 'POST',
|
||||
headers: { Cookie: auth.cookie, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
request_id: requestId,
|
||||
session_id: sessionId,
|
||||
force_deep_reasoning: true,
|
||||
user_message: {
|
||||
id: randomUUID(),
|
||||
role: 'user',
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: process.env.MEMIND_SHADOW_E2E_MESSAGE
|
||||
?? '你还记得我的偏好吗,一句话回答即可',
|
||||
}],
|
||||
metadata: {
|
||||
userVisible: true,
|
||||
agentVisible: true,
|
||||
displayText: process.env.MEMIND_SHADOW_E2E_DISPLAY
|
||||
?? 'context runtime shadow memory ping',
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
const runPayload = await runRes.json().catch(() => ({}));
|
||||
if (!runRes.ok) {
|
||||
throw new Error(`POST /api/agent/runs ${runRes.status}: ${JSON.stringify(runPayload).slice(0, 300)}`);
|
||||
}
|
||||
const runId = runPayload.run?.id ?? runPayload.id;
|
||||
|
||||
if (eventsOnly) {
|
||||
const polled = await pollShadowEvents(pool, runId, Date.now() + Math.min(timeoutMs, 60_000));
|
||||
return {
|
||||
sessionId,
|
||||
runId,
|
||||
terminal: { status: polled.done ? 'shadow_events_ready' : 'shadow_events_timeout' },
|
||||
events: polled.events,
|
||||
evaluation: polled.evaluation,
|
||||
eventsOnly: true,
|
||||
};
|
||||
}
|
||||
|
||||
const terminal = await waitForRunTerminal(baseUrl, auth.cookie, runId, timeoutMs);
|
||||
const events = await fetchRunEvents(pool, runId);
|
||||
return {
|
||||
sessionId,
|
||||
runId,
|
||||
terminal,
|
||||
events,
|
||||
evaluation: evaluateShadowEvents(events),
|
||||
eventsOnly: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!(await portalReachable())) {
|
||||
throw new Error(`Portal not reachable at ${baseUrl} — start pnpm dev first`);
|
||||
}
|
||||
|
||||
const pool = createDbPool();
|
||||
const portal = await runPortalTurn(pool);
|
||||
const { headroomEvents, budgetEvents, fusionEvents, issues, warnings } = portal.evaluation;
|
||||
|
||||
console.log('CONTEXT_RUNTIME_SHADOW_PORTAL_E2E:');
|
||||
console.log(` portal=${baseUrl}`);
|
||||
console.log(` run_id=${portal.runId}`);
|
||||
console.log(` mode=${portal.eventsOnly ? 'events_only' : 'full_terminal'}`);
|
||||
console.log(` terminal=${portal.terminal.status}`);
|
||||
console.log(` headroom_events=${headroomEvents.length}`);
|
||||
console.log(` budget_events=${budgetEvents.length}`);
|
||||
console.log(` fusion_events=${fusionEvents.length}`);
|
||||
if (headroomEvents[0]?.data) {
|
||||
console.log(` headroom_sample=${JSON.stringify(headroomEvents[0].data)}`);
|
||||
}
|
||||
if (budgetEvents[0]?.data) {
|
||||
console.log(` budget_sample=${JSON.stringify(budgetEvents[0].data)}`);
|
||||
}
|
||||
if (fusionEvents[0]?.data) {
|
||||
console.log(` fusion_sample=${JSON.stringify(fusionEvents[0].data)}`);
|
||||
}
|
||||
for (const warning of warnings) {
|
||||
console.warn(`CONTEXT_RUNTIME_SHADOW_PORTAL_WARN: ${warning}`);
|
||||
}
|
||||
|
||||
if (issues.length) {
|
||||
console.error(`CONTEXT_RUNTIME_SHADOW_PORTAL_FAIL: ${issues.join('; ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('CONTEXT_RUNTIME_SHADOW_PORTAL_OK');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err instanceof Error ? err.message : err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Phase 3 offline closeout: unit tests + manifest drift only (no real LLM).
|
||||
* Use when DashScope quota is exhausted or unattended smokes must stay blocked.
|
||||
*
|
||||
* Full portal/page/memory smokes still require:
|
||||
* GOOSE_V149_ALLOW_REAL_LLM=1 node scripts/run-goosed-v149-phase3.mjs
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { prepareGooseV149CheckEnv } from './goose-v149-canary.mjs';
|
||||
import { checkPassed, classifyCheckResult } from './goose-v149-check-result.mjs';
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const checkEnv = prepareGooseV149CheckEnv({ ...process.env }, root);
|
||||
|
||||
const checks = [
|
||||
{
|
||||
name: 'real-llm-gate-unit',
|
||||
command: [process.execPath, '--test', 'scripts/goose-v149-real-llm-gate.test.mjs'],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'deepseek-parity',
|
||||
command: [process.execPath, '--test', 'release-gate/deepseek-production-parity.test.mjs'],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'goose-canary-unit',
|
||||
command: [process.execPath, '--test', 'scripts/goose-v149-canary.test.mjs'],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'memory-manifest-unit',
|
||||
command: [process.execPath, '--test', 'scripts/compare-goose-v149-memory-manifest.test.mjs'],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'memory-drift',
|
||||
script: 'compare-goose-v149-memory-manifest.mjs',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'memory-failopen',
|
||||
script: 'check-goosed-v149-memory-failopen.mjs',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'context-runtime-offline',
|
||||
script: 'run-context-runtime-offline.mjs',
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
|
||||
function runScript(script, extraEnv = {}) {
|
||||
const result = spawnSync(process.execPath, [path.join(root, 'scripts', script)], {
|
||||
cwd: root,
|
||||
env: { ...checkEnv, ...extraEnv },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
return {
|
||||
ok: checkPassed(result),
|
||||
outcome: classifyCheckResult(result),
|
||||
status: result.status,
|
||||
stdout: result.stdout?.trim() ?? '',
|
||||
stderr: result.stderr?.trim() ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function runCommand(command, extraEnv = {}) {
|
||||
const [bin, ...args] = command;
|
||||
const result = spawnSync(bin, args, {
|
||||
cwd: root,
|
||||
env: { ...checkEnv, ...extraEnv },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
return {
|
||||
ok: checkPassed(result),
|
||||
outcome: classifyCheckResult(result),
|
||||
status: result.status,
|
||||
stdout: result.stdout?.trim() ?? '',
|
||||
stderr: result.stderr?.trim() ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function main() {
|
||||
const results = [];
|
||||
|
||||
for (const check of checks) {
|
||||
const item = {
|
||||
...check,
|
||||
...(check.script
|
||||
? runScript(check.script, check.extraEnv ?? {})
|
||||
: runCommand(check.command, check.extraEnv ?? {})),
|
||||
};
|
||||
results.push(item);
|
||||
console.log(`[goose-v149-offline] ${item.outcome} ${check.name}`);
|
||||
if (item.stdout) console.log(item.stdout);
|
||||
if (!item.ok && item.stderr) console.error(item.stderr);
|
||||
}
|
||||
|
||||
const failedRequired = results.filter((item) => item.required && !item.ok);
|
||||
if (failedRequired.length) {
|
||||
console.error(`GOOSE_V149_PHASE3_OFFLINE_FAIL: ${failedRequired.map((item) => item.name).join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('GOOSE_V149_PHASE3_OFFLINE_OK');
|
||||
console.log(' note: portal/page/memory smokes skipped; see docs/goose-v149-real-llm-gate.md');
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Minimal Tool Gateway → dsh executor E2E (one headless task, costs LLM tokens).
|
||||
*
|
||||
* Usage:
|
||||
* MEMIND_TOOL_GATEWAY_DSH_ENABLED=1 \
|
||||
* node scripts/test-dsh-executor-gateway-e2e.mjs
|
||||
*/
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { buildDshExecutorLaunchPlan } from '../dsh-agent-launch.mjs';
|
||||
import { createToolGateway } from '../tool-gateway.mjs';
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const cwd = process.argv[2] ?? root;
|
||||
const instruction = process.env.MEMIND_DSH_GATEWAY_TASK
|
||||
?? 'List three files in the workspace root and stop. Do not modify any files.';
|
||||
|
||||
process.env.MEMIND_TOOL_GATEWAY_ENABLED = process.env.MEMIND_TOOL_GATEWAY_ENABLED ?? '1';
|
||||
process.env.MEMIND_TOOL_GATEWAY_DRY_RUN = '0';
|
||||
process.env.MEMIND_TOOL_GATEWAY_DSH_ENABLED = process.env.MEMIND_TOOL_GATEWAY_DSH_ENABLED ?? '1';
|
||||
process.env.MEMIND_TOOL_GATEWAY_DSH_TASK_TYPES = process.env.MEMIND_TOOL_GATEWAY_DSH_TASK_TYPES ?? 'repo_refactor';
|
||||
|
||||
const gateway = createToolGateway({
|
||||
env: process.env,
|
||||
llmProviderService: {
|
||||
async getExecutorLaunchPlan(executor, options) {
|
||||
if (executor !== 'dsh') {
|
||||
throw new Error(`unexpected executor ${executor}`);
|
||||
}
|
||||
return buildDshExecutorLaunchPlan({
|
||||
cwd: options.cwd,
|
||||
instruction: options.instruction,
|
||||
env: process.env,
|
||||
runtimeEnv: {
|
||||
DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? process.env.OPENAI_API_KEY ?? '',
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const status = gateway.getStatus();
|
||||
console.log('DSH_GATEWAY_E2E_PROBE:');
|
||||
console.log(JSON.stringify({
|
||||
enabled: status.enabled,
|
||||
dryRun: status.dryRun,
|
||||
executors: status.executors,
|
||||
dshEnabled: status.dshEnabled,
|
||||
dshTaskTypes: status.dshTaskTypes,
|
||||
selected: gateway.selectExecutor({ taskType: 'repo_refactor' }),
|
||||
}, null, 2));
|
||||
|
||||
if (!status.dshEnabled) {
|
||||
console.error('DSH_GATEWAY_E2E_FAIL: MEMIND_TOOL_GATEWAY_DSH_ENABLED is off');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = await gateway.executeJob({
|
||||
runId: 'dsh-gateway-e2e',
|
||||
requestId: 'dsh-gateway-req',
|
||||
userId: 'local-user',
|
||||
cwd,
|
||||
taskType: 'repo_refactor',
|
||||
timeoutMs: Number(process.env.MEMIND_DSH_GATEWAY_TIMEOUT_MS ?? 180_000),
|
||||
userMessage: {
|
||||
content: [{ type: 'text', text: instruction }],
|
||||
metadata: {
|
||||
memindRun: {
|
||||
taskType: 'repo_refactor',
|
||||
toolMode: 'code',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const stdoutPreview = String(result.stdout ?? result.displayStdout ?? '').trim().slice(0, 500);
|
||||
console.log('DSH_GATEWAY_E2E_RESULT:');
|
||||
console.log(JSON.stringify({
|
||||
ok: result.ok,
|
||||
executor: result.executor,
|
||||
exitCode: result.exitCode,
|
||||
stdoutPreview,
|
||||
}, null, 2));
|
||||
|
||||
if (!result.ok || result.executor !== 'dsh') {
|
||||
console.error('DSH_GATEWAY_E2E_FAIL');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('DSH_GATEWAY_E2E_OK');
|
||||
@@ -0,0 +1,511 @@
|
||||
import { extractPageTitle } from './wechat/verify/share-preview-repair.mjs';
|
||||
|
||||
const MAX_WECHAT_CONTENT_CHARS = 20000;
|
||||
const MAX_WECHAT_CONTENT_TARGET_CHARS = 19600;
|
||||
const DEFAULT_PORTAL_URL = 'https://m.tkmind.cn';
|
||||
|
||||
const CARD_VARIANTS = {
|
||||
'highlight-box': {
|
||||
wrap: 'background:linear-gradient(135deg,#b71c1c,#d32f2f);border-radius:12px;padding:14px;margin:0 0 10px;color:#fff;',
|
||||
title: 'margin:0 0 6px;font-size:16px;font-weight:700;color:#fff;line-height:1.5;',
|
||||
body: 'margin:0 0 6px;font-size:14px;line-height:1.7;color:#ffcdd2;',
|
||||
source: 'margin:0;font-size:12px;color:#90caf4;',
|
||||
tag: 'display:inline-block;font-size:11px;padding:2px 8px;border-radius:4px;margin:0 0 6px;background:rgba(255,255,255,.18);color:#fff;',
|
||||
},
|
||||
'highlight-box-orange': {
|
||||
wrap: 'background:linear-gradient(135deg,#e65100,#bf360c);border-radius:12px;padding:14px;margin:0 0 10px;color:#fff;',
|
||||
title: 'margin:0 0 6px;font-size:16px;font-weight:700;color:#fff;line-height:1.5;',
|
||||
body: 'margin:0 0 6px;font-size:14px;line-height:1.7;color:#ffe0b2;',
|
||||
source: 'margin:0;font-size:12px;color:#ffcdd2;',
|
||||
tag: 'display:inline-block;font-size:11px;padding:2px 8px;border-radius:4px;margin:0 0 6px;background:rgba(255,255,255,.18);color:#fff;',
|
||||
},
|
||||
'highlight-box-gold': {
|
||||
wrap: 'background:linear-gradient(135deg,#0d47a1,#1565c0);border-radius:12px;padding:14px;margin:0 0 10px;color:#fff;',
|
||||
title: 'margin:0 0 6px;font-size:16px;font-weight:700;color:#fff;line-height:1.5;',
|
||||
body: 'margin:0 0 6px;font-size:14px;line-height:1.7;color:#bbdefb;',
|
||||
source: 'margin:0;font-size:12px;color:#bbdefb;',
|
||||
tag: 'display:inline-block;font-size:11px;padding:2px 8px;border-radius:4px;margin:0 0 6px;background:rgba(255,255,255,.18);color:#fff;',
|
||||
},
|
||||
'highlight-box-teal': {
|
||||
wrap: 'background:linear-gradient(135deg,#004d40,#00695c);border-radius:12px;padding:14px;margin:0 0 10px;color:#fff;',
|
||||
title: 'margin:0 0 6px;font-size:16px;font-weight:700;color:#fff;line-height:1.5;',
|
||||
body: 'margin:0 0 6px;font-size:14px;line-height:1.7;color:#b2dfdb;',
|
||||
source: 'margin:0;font-size:12px;color:#b2dfdb;',
|
||||
tag: 'display:inline-block;font-size:11px;padding:2px 8px;border-radius:4px;margin:0 0 6px;background:rgba(255,255,255,.18);color:#fff;',
|
||||
},
|
||||
default: {
|
||||
wrap: 'background:#fff;border-radius:12px;padding:14px;margin:0 0 10px;',
|
||||
title: 'margin:0 0 4px;font-size:16px;font-weight:600;color:#1a202c;line-height:1.5;',
|
||||
body: 'margin:0 0 6px;font-size:14px;color:#4a5568;line-height:1.7;',
|
||||
source: 'margin:0;font-size:12px;color:#a0aec0;',
|
||||
tag: 'display:inline-block;font-size:11px;padding:2px 8px;border-radius:4px;margin:0 0 6px;background:#ffebee;color:#b71c1c;',
|
||||
},
|
||||
};
|
||||
|
||||
const TAG_CLASS_COLORS = {
|
||||
'tag-red': 'background:#ffcdd2;color:#b71c1c;',
|
||||
'tag-blue': 'background:#bbdefb;color:#0d47a1;',
|
||||
'tag-green': 'background:#c8e6c9;color:#1b5e20;',
|
||||
'tag-purple': 'background:#e1bee7;color:#6a1b9a;',
|
||||
'tag-orange': 'background:#ffe0b2;color:#e65100;',
|
||||
'tag-teal': 'background:#b2dfdb;color:#004d40;',
|
||||
'tag-pink': 'background:#f8bbd0;color:#880e4f;',
|
||||
'tag-gray': 'background:#e0e0e0;color:#424242;',
|
||||
'tag-amber': 'background:#ffecb3;color:#ff6f00;',
|
||||
};
|
||||
|
||||
function extractMetaDescription(html) {
|
||||
const match = String(html ?? '').match(
|
||||
/<meta[^>]+name=["']description["'][^>]+content=["']([^"']*)["']/i,
|
||||
);
|
||||
return match?.[1]?.trim() ?? '';
|
||||
}
|
||||
|
||||
function stripScripts(html) {
|
||||
return String(html ?? '').replace(/<script[\s\S]*?<\/script>/gi, '');
|
||||
}
|
||||
|
||||
function sanitizeInlineHtml(html) {
|
||||
return stripScripts(String(html ?? ''))
|
||||
.replace(/<(?!\/?(a\b|br\b|strong\b|span\b|b\b|em\b|i\b)\b)[^>]+>/gi, '')
|
||||
.replace(/\son\w+="[^"]*"/gi, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function extractInnerHtml(block, pattern) {
|
||||
const match = String(block ?? '').match(pattern);
|
||||
return match?.[1] ? sanitizeInlineHtml(match[1]) : '';
|
||||
}
|
||||
|
||||
function detectCardVariant(cardHtml) {
|
||||
if (/highlight-box-orange/u.test(cardHtml)) return 'highlight-box-orange';
|
||||
if (/highlight-box-gold/u.test(cardHtml)) return 'highlight-box-gold';
|
||||
if (/highlight-box-teal/u.test(cardHtml)) return 'highlight-box-teal';
|
||||
if (/highlight-box/u.test(cardHtml)) return 'highlight-box';
|
||||
return 'default';
|
||||
}
|
||||
|
||||
function renderTagHtml(cardHtml, styles) {
|
||||
const tagMatch = String(cardHtml).match(/<span class="tag([^"]*)"[^>]*(?:style="([^"]*)")?[^>]*>([\s\S]*?)<\/span>/i);
|
||||
if (!tagMatch) return '';
|
||||
const className = tagMatch[1]?.trim() ?? '';
|
||||
const inlineStyle = tagMatch[2]?.trim() ?? '';
|
||||
const label = sanitizeInlineHtml(tagMatch[3]);
|
||||
if (!label) return '';
|
||||
const colorStyle = TAG_CLASS_COLORS[className.replace(/^\s+/, '')] ?? '';
|
||||
const style = inlineStyle || colorStyle || styles.tag;
|
||||
return `<span style="${style}">${label}</span>`;
|
||||
}
|
||||
|
||||
function renderCard(cardHtml) {
|
||||
const variant = detectCardVariant(cardHtml);
|
||||
const styles = CARD_VARIANTS[variant] ?? CARD_VARIANTS.default;
|
||||
const tag = renderTagHtml(cardHtml, styles);
|
||||
const title = extractInnerHtml(cardHtml, /<h3[^>]*>([\s\S]*?)<\/h3>/i);
|
||||
const body = extractInnerHtml(cardHtml, /<p[^>]*>([\s\S]*?)<\/p>/i);
|
||||
const sourceRaw = extractInnerHtml(cardHtml, /<div class="source"[^>]*>([\s\S]*?)<\/div>/i);
|
||||
const source = sourceRaw.replace(/^来源:?/u, '').trim();
|
||||
if (!title) return '';
|
||||
|
||||
const parts = [
|
||||
`<section style="${styles.wrap}">`,
|
||||
tag ? `<p style="margin:0 0 6px;">${tag}</p>` : '',
|
||||
`<p style="${styles.title}"><strong>${title}</strong></p>`,
|
||||
body ? `<p style="${styles.body}">${body}</p>` : '',
|
||||
source ? `<p style="${styles.source}">来源:${source}</p>` : '',
|
||||
'</section>',
|
||||
];
|
||||
return parts.filter(Boolean).join('');
|
||||
}
|
||||
|
||||
function renderSectionTitle(titleHtml) {
|
||||
const text = sanitizeInlineHtml(titleHtml).replace(/<span class="icon"[^>]*>([\s\S]*?)<\/span>/gi, '$1');
|
||||
if (!text) return '';
|
||||
return `<p style="margin:0 0 12px;padding:12px 0 6px;border-bottom:3px solid #0d47a1;font-size:18px;font-weight:700;color:#b71c1c;line-height:1.4;">${text}</p>`;
|
||||
}
|
||||
|
||||
function renderHero(html) {
|
||||
const title = sanitizeInlineHtml(String(html).match(/<h1[^>]*>([\s\S]*?)<\/h1>/i)?.[1] ?? '') || '📰 每日新闻早报';
|
||||
const dateBadge = sanitizeInlineHtml(String(html).match(/<div class="date-badge"[^>]*>([\s\S]*?)<\/div>/i)?.[1] ?? '');
|
||||
const subtitle = sanitizeInlineHtml(String(html).match(/<div class="subtitle"[^>]*>([\s\S]*?)<\/div>/i)?.[1] ?? '');
|
||||
return [
|
||||
'<section style="text-align:center;padding:24px 14px;background:linear-gradient(135deg,#b71c1c,#d32f2f 50%,#0d47a1);color:#fff;margin:0 0 12px;border-radius:12px;">',
|
||||
`<p style="margin:0 0 10px;font-size:24px;font-weight:700;line-height:1.35;">${title}</p>`,
|
||||
dateBadge ? `<p style="margin:0 0 8px;font-size:14px;opacity:.92;">${dateBadge}</p>` : '',
|
||||
subtitle ? `<p style="margin:0;font-size:14px;opacity:.88;line-height:1.7;">${subtitle}</p>` : '',
|
||||
'</section>',
|
||||
].filter(Boolean).join('');
|
||||
}
|
||||
|
||||
function renderReadOriginalTop(publicUrl) {
|
||||
if (!publicUrl) return '';
|
||||
return [
|
||||
'<section style="margin:0 0 16px;text-align:center;">',
|
||||
`<a href="${publicUrl}" style="display:inline-block;padding:11px 22px;background:linear-gradient(135deg,#b71c1c,#d32f2f);color:#fff;font-size:14px;font-weight:600;text-decoration:none;border-radius:999px;letter-spacing:.02em;box-shadow:0 3px 10px rgba(183,28,28,.22);">📖 点击阅读原文</a>`,
|
||||
'<p style="margin:8px 0 0;font-size:12px;color:#a0aec0;line-height:1.6;">查看完整排版、图片与更多栏目</p>',
|
||||
'</section>',
|
||||
].join('');
|
||||
}
|
||||
|
||||
function renderCreatePortalCTA(portalUrl = DEFAULT_PORTAL_URL) {
|
||||
return `<p style="margin:18px 0 0;font-size:14px;color:#718096;text-align:center;line-height:1.7;">✨ 一起创作 → <a href="${portalUrl}" style="color:#e53935;font-weight:600;text-decoration:underline;">点我</a></p>`;
|
||||
}
|
||||
|
||||
function renderFollowMpSection(qrcodeImageUrl = '') {
|
||||
if (qrcodeImageUrl) {
|
||||
return [
|
||||
'<section style="text-align:center;padding:22px 16px 18px;margin:18px 0 8px;background:linear-gradient(180deg,#fff8f8,#fff);border:1px solid #ffcdd2;border-radius:12px;">',
|
||||
'<p style="margin:0 0 6px;font-size:16px;font-weight:700;color:#b71c1c;line-height:1.5;">📣 关注 TKMind 服务号</p>',
|
||||
'<p style="margin:0 0 14px;font-size:13px;color:#666;line-height:1.7;">长按扫描关注,每日早报不错过</p>',
|
||||
`<img src="${qrcodeImageUrl}" alt="TKMind 服务号二维码" style="width:168px;height:168px;display:block;margin:0 auto;border-radius:10px;border:1px solid #eee;" />`,
|
||||
'<p style="margin:12px 0 0;font-size:12px;color:#a0aec0;">长按扫描关注</p>',
|
||||
'</section>',
|
||||
].join('');
|
||||
}
|
||||
return [
|
||||
'<section style="text-align:center;padding:20px 16px;margin:18px 0 8px;background:#fafafa;border:1px dashed #e0e0e0;border-radius:12px;">',
|
||||
'<p style="margin:0 0 6px;font-size:15px;font-weight:600;color:#333;">📣 关注 TKMind 服务号</p>',
|
||||
'<p style="margin:0;font-size:13px;color:#888;line-height:1.7;">推送草稿时将嵌入公众号二维码</p>',
|
||||
'</section>',
|
||||
].join('');
|
||||
}
|
||||
|
||||
function renderWeatherSection(sectionHtml) {
|
||||
const title = extractInnerHtml(sectionHtml, /<div class="section-title"[^>]*>([\s\S]*?)<\/div>/i);
|
||||
const summary = extractInnerHtml(sectionHtml, /<div class="weather-today"[^>]*>([\s\S]*?)<\/div>/i);
|
||||
const cityCards = [...String(sectionHtml).matchAll(/<div class="weather-card[^"]*"[^>]*>[\s\S]*?<span class="w-city">([\s\S]*?)<\/span>[\s\S]*?<span class="w-cond">([\s\S]*?)<\/span>[\s\S]*?<span class="w-temp">([\s\S]*?)<\/span>/gi)]
|
||||
.map((item) => `${sanitizeInlineHtml(item[1])}${sanitizeInlineHtml(item[2])} ${sanitizeInlineHtml(item[3])}`)
|
||||
.slice(0, 12);
|
||||
const footer = extractInnerHtml(sectionHtml, /<p style="font-size:12px;color:#a0aec0;">([\s\S]*?)<\/p>/i);
|
||||
|
||||
const parts = [renderSectionTitle(title)];
|
||||
parts.push('<section style="background:linear-gradient(135deg,#e3f2fd,#bbdefb);border:1px solid #90caf9;border-radius:12px;padding:14px 16px;margin-bottom:12px;">');
|
||||
if (summary) {
|
||||
parts.push(`<p style="margin:0 0 8px;font-size:14px;color:#0d47a1;line-height:1.75;">${summary.replace(/<div[^>]*>/gi, ' ').replace(/<\/div>/gi, ' ')}</p>`);
|
||||
}
|
||||
if (cityCards.length > 0) {
|
||||
parts.push(`<p style="margin:0;font-size:13px;color:#1565c0;line-height:1.8;">${cityCards.join(' · ')}</p>`);
|
||||
}
|
||||
parts.push('</section>');
|
||||
if (footer) {
|
||||
parts.push(`<p style="margin:0 0 16px;font-size:12px;color:#a0aec0;line-height:1.6;">${footer}</p>`);
|
||||
}
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
function splitSections(html) {
|
||||
const body = String(html ?? '').match(/<body>([\s\S]*?)<\/body>/i)?.[1] ?? String(html ?? '');
|
||||
return body
|
||||
.split(/<div class="section"[^>]*>/i)
|
||||
.slice(1)
|
||||
.map((chunk) => {
|
||||
const id = chunk.match(/^[^>]*id="([^"]+)"/i)?.[1] ?? '';
|
||||
const htmlChunk = `<div class="section"${chunk.split(/<div class="section"|<div class="footer"/i)[0]}`;
|
||||
const title = stripInlineText(extractInnerHtml(htmlChunk, /<div class="section-title"[^>]*>([\s\S]*?)<\/div>/i));
|
||||
return { id, title, html: htmlChunk };
|
||||
});
|
||||
}
|
||||
|
||||
function stripInlineText(value) {
|
||||
return sanitizeInlineHtml(String(value ?? ''))
|
||||
.replace(/<span class="icon"[^>]*>([\s\S]*?)<\/span>/gi, '$1')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
const DEFAULT_SECTION_CARD_LIMITS = {
|
||||
headlines: 6,
|
||||
world: 3,
|
||||
domestic: 3,
|
||||
google: 2,
|
||||
finance: 3,
|
||||
tech: 3,
|
||||
sports: 2,
|
||||
technews: 2,
|
||||
hot: 1,
|
||||
};
|
||||
|
||||
const TRIM_PROFILES = [
|
||||
{ skipHistory: true, sectionCardLimits: null, maxBodyChars: null },
|
||||
{
|
||||
skipHistory: true,
|
||||
sectionCardLimits: DEFAULT_SECTION_CARD_LIMITS,
|
||||
maxBodyChars: 120,
|
||||
},
|
||||
{
|
||||
skipHistory: true,
|
||||
sectionCardLimits: { ...DEFAULT_SECTION_CARD_LIMITS, headlines: 5, world: 2, domestic: 2 },
|
||||
maxBodyChars: 90,
|
||||
},
|
||||
{
|
||||
skipHistory: true,
|
||||
sectionCardLimits: { headlines: 4, world: 2, domestic: 2, google: 2, finance: 2, tech: 2, sports: 1, technews: 1, hot: 1 },
|
||||
maxBodyChars: 72,
|
||||
},
|
||||
{
|
||||
skipHistory: true,
|
||||
sectionCardLimits: { headlines: 3, world: 2, domestic: 2, google: 2, finance: 2, tech: 2, sports: 1, technews: 0, hot: 1 },
|
||||
maxBodyChars: 64,
|
||||
},
|
||||
{
|
||||
skipHistory: true,
|
||||
sectionCardLimits: { headlines: 3, world: 2, domestic: 2, google: 1, finance: 2, tech: 2, sports: 1, technews: 0, hot: 0 },
|
||||
maxBodyChars: 56,
|
||||
},
|
||||
];
|
||||
|
||||
function buildFallbackTrimProfiles() {
|
||||
const profiles = [];
|
||||
for (let headlines = 3; headlines >= 2; headlines -= 1) {
|
||||
for (let maxBodyChars = 56; maxBodyChars >= 24; maxBodyChars -= 8) {
|
||||
for (const skipWeather of [false, true]) {
|
||||
profiles.push({
|
||||
skipHistory: true,
|
||||
skipWeather,
|
||||
sectionCardLimits: {
|
||||
headlines,
|
||||
world: 2,
|
||||
domestic: 2,
|
||||
google: 1,
|
||||
finance: 2,
|
||||
tech: 2,
|
||||
sports: 1,
|
||||
technews: 0,
|
||||
hot: 0,
|
||||
},
|
||||
maxBodyChars,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return profiles;
|
||||
}
|
||||
|
||||
function resolveSectionCardLimit(section, sectionCardLimits) {
|
||||
if (!sectionCardLimits) return null;
|
||||
return sectionCardLimits[section.id] ?? 3;
|
||||
}
|
||||
|
||||
function estimateQrcodeUrlForLayout(qrcodeImageUrl = '') {
|
||||
const url = String(qrcodeImageUrl ?? '');
|
||||
if (!url) return '';
|
||||
if (url.length >= 180) return url;
|
||||
return `${url}${'x'.repeat(180 - url.length)}`;
|
||||
}
|
||||
|
||||
function estimateFixedLayoutLength({ publicUrl = '', qrcodeImageUrl = '', portalUrl = DEFAULT_PORTAL_URL } = {}) {
|
||||
const qrcodeForEstimate = estimateQrcodeUrlForLayout(qrcodeImageUrl);
|
||||
return (
|
||||
(publicUrl ? renderReadOriginalTop(publicUrl).length : 0)
|
||||
+ renderFollowMpSection(qrcodeForEstimate).length
|
||||
+ renderCreatePortalCTA(portalUrl).length
|
||||
);
|
||||
}
|
||||
|
||||
function buildWechatInlineContent(html, {
|
||||
publicUrl = '',
|
||||
qrcodeImageUrl = '',
|
||||
portalUrl = DEFAULT_PORTAL_URL,
|
||||
trimProfile = TRIM_PROFILES[0],
|
||||
includeFixedLayout = true,
|
||||
} = {}) {
|
||||
const sections = splitSections(html);
|
||||
const bodyParts = [renderHero(html)];
|
||||
|
||||
for (const section of sections) {
|
||||
if (trimProfile.skipHistory && shouldSkipSection(section, trimProfile)) {
|
||||
continue;
|
||||
}
|
||||
if (resolveSectionCardLimit(section, trimProfile.sectionCardLimits) === 0) {
|
||||
continue;
|
||||
}
|
||||
if (section.id === 'weather') {
|
||||
if (!trimProfile.skipWeather) {
|
||||
bodyParts.push(renderWeatherSection(section.html));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
bodyParts.push(renderGenericSection(section.html, {
|
||||
maxCards: resolveSectionCardLimit(section, trimProfile.sectionCardLimits),
|
||||
maxBodyChars: trimProfile.maxBodyChars,
|
||||
}));
|
||||
}
|
||||
|
||||
bodyParts.push(renderFooter(html));
|
||||
|
||||
const ordered = [...bodyParts];
|
||||
if (includeFixedLayout) {
|
||||
if (publicUrl) ordered.splice(1, 0, renderReadOriginalTop(publicUrl));
|
||||
ordered.push(renderFollowMpSection(qrcodeImageUrl));
|
||||
ordered.push(renderCreatePortalCTA(portalUrl));
|
||||
}
|
||||
|
||||
return {
|
||||
content: ordered.join(''),
|
||||
sectionCount: sections.length,
|
||||
renderedSectionCount: sections.filter((section) => {
|
||||
if (trimProfile.skipHistory && shouldSkipSection(section, trimProfile)) return false;
|
||||
if (resolveSectionCardLimit(section, trimProfile.sectionCardLimits) === 0) return false;
|
||||
return true;
|
||||
}).length,
|
||||
};
|
||||
}
|
||||
|
||||
function truncatePlainText(value, maxChars) {
|
||||
const text = String(value ?? '').replace(/\s+/g, ' ').trim();
|
||||
if (!maxChars || text.length <= maxChars) return text;
|
||||
return `${text.slice(0, Math.max(0, maxChars - 1))}…`;
|
||||
}
|
||||
|
||||
function shouldSkipSection(section, trimProfile) {
|
||||
if (!section) return true;
|
||||
if (['history', 'knowledge'].includes(section.id)) return true;
|
||||
if (trimProfile?.skipSectionIds?.includes(section.id)) return true;
|
||||
return /历史上的今天|近7天新闻早报/u.test(section.title);
|
||||
}
|
||||
|
||||
function renderGenericSection(sectionHtml, { maxCards = null, maxBodyChars = null } = {}) {
|
||||
const title = extractInnerHtml(sectionHtml, /<div class="section-title"[^>]*>([\s\S]*?)<\/div>/i);
|
||||
if (/近7天新闻早报/u.test(stripInlineText(title))) return '';
|
||||
const cards = sectionHtml.split(/<div class="card(?:\s|")/i).slice(1);
|
||||
const parts = [renderSectionTitle(title)];
|
||||
const limit = maxCards == null ? cards.length : Math.min(maxCards, cards.length);
|
||||
for (let index = 0; index < limit; index += 1) {
|
||||
let cardChunk = cards[index];
|
||||
if (maxBodyChars) {
|
||||
cardChunk = cardChunk.replace(/<p[^>]*>([\s\S]*?)<\/p>/i, (match, body) => {
|
||||
const plain = body.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
|
||||
return match.replace(body, truncatePlainText(plain, maxBodyChars));
|
||||
});
|
||||
}
|
||||
const rendered = renderCard(`<div class="card ${cardChunk}`);
|
||||
if (rendered) parts.push(rendered);
|
||||
}
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
function renderFooter(html) {
|
||||
const footer = String(html).match(/<div class="footer"[^>]*>([\s\S]*?)<\/div>/i)?.[1] ?? '';
|
||||
if (!footer) return '';
|
||||
const lines = [...footer.matchAll(/<p[^>]*>([\s\S]*?)<\/p>/gi)]
|
||||
.map((item) => sanitizeInlineHtml(item[1]))
|
||||
.filter(Boolean)
|
||||
.slice(0, 3);
|
||||
if (lines.length === 0) return '';
|
||||
return [
|
||||
'<section style="text-align:center;padding:20px 12px 8px;color:#a0aec0;">',
|
||||
...lines.map((line) => `<p style="margin:0 0 8px;font-size:13px;line-height:1.7;color:#718096;">${line}</p>`),
|
||||
'</section>',
|
||||
].join('');
|
||||
}
|
||||
|
||||
export function convertDailyNewsHtmlToWechatInlineArticle(html, {
|
||||
publicUrl = '',
|
||||
qrcodeImageUrl = '',
|
||||
portalUrl = DEFAULT_PORTAL_URL,
|
||||
} = {}) {
|
||||
const title = (extractPageTitle(html) || '每日新闻早报').slice(0, 32);
|
||||
const digest = (extractMetaDescription(html) || title).slice(0, 120);
|
||||
let built = null;
|
||||
let trimLevel = 0;
|
||||
let usedFallback = false;
|
||||
const allProfiles = [...TRIM_PROFILES, ...buildFallbackTrimProfiles()];
|
||||
|
||||
const fixedLayoutLength = estimateFixedLayoutLength({ publicUrl, qrcodeImageUrl, portalUrl });
|
||||
const trimTargetChars = Math.max(12000, MAX_WECHAT_CONTENT_CHARS - fixedLayoutLength - 800);
|
||||
|
||||
for (const [index, trimProfile] of allProfiles.entries()) {
|
||||
built = buildWechatInlineContent(html, {
|
||||
publicUrl,
|
||||
qrcodeImageUrl,
|
||||
portalUrl,
|
||||
trimProfile,
|
||||
includeFixedLayout: false,
|
||||
});
|
||||
trimLevel = index;
|
||||
usedFallback = index >= TRIM_PROFILES.length;
|
||||
if (built.content.length <= trimTargetChars) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let selectedProfile = allProfiles[trimLevel] ?? allProfiles[allProfiles.length - 1];
|
||||
if (built) {
|
||||
built = buildWechatInlineContent(html, {
|
||||
publicUrl,
|
||||
qrcodeImageUrl,
|
||||
portalUrl,
|
||||
trimProfile: selectedProfile,
|
||||
includeFixedLayout: true,
|
||||
});
|
||||
let tightenGuard = 0;
|
||||
while (built.content.length > MAX_WECHAT_CONTENT_CHARS && tightenGuard < 24) {
|
||||
tightenGuard += 1;
|
||||
const limits = {
|
||||
world: 2,
|
||||
domestic: 2,
|
||||
google: 1,
|
||||
tech: 2,
|
||||
sports: 1,
|
||||
technews: 0,
|
||||
hot: 0,
|
||||
headlines: selectedProfile.sectionCardLimits?.headlines ?? 3,
|
||||
finance: selectedProfile.sectionCardLimits?.finance ?? 2,
|
||||
};
|
||||
const maxBody = selectedProfile.maxBodyChars ?? 64;
|
||||
if (maxBody > 16) {
|
||||
selectedProfile.maxBodyChars = maxBody - 4;
|
||||
} else if (limits.headlines > 2) {
|
||||
limits.headlines -= 1;
|
||||
} else if (limits.finance > 1) {
|
||||
limits.finance -= 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
selectedProfile = {
|
||||
...selectedProfile,
|
||||
skipHistory: true,
|
||||
skipWeather: true,
|
||||
maxBodyChars: selectedProfile.maxBodyChars ?? maxBody,
|
||||
sectionCardLimits: limits,
|
||||
};
|
||||
built = buildWechatInlineContent(html, {
|
||||
publicUrl,
|
||||
qrcodeImageUrl,
|
||||
portalUrl,
|
||||
trimProfile: selectedProfile,
|
||||
includeFixedLayout: true,
|
||||
});
|
||||
usedFallback = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!built || built.content.length > MAX_WECHAT_CONTENT_CHARS) {
|
||||
throw new Error(
|
||||
`微信正文超过 ${MAX_WECHAT_CONTENT_CHARS} 字符(当前 ${built?.content.length ?? 0}),请缩短 HTML 页面后重试`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
digest,
|
||||
content: built.content,
|
||||
contentSourceUrl: publicUrl || undefined,
|
||||
contentMode: 'inline_html',
|
||||
contentLength: built.content.length,
|
||||
sectionCount: built.sectionCount,
|
||||
renderedSectionCount: built.renderedSectionCount,
|
||||
trimLevel,
|
||||
trimmed: trimLevel > 0,
|
||||
usedFallbackTrim: usedFallback,
|
||||
};
|
||||
}
|
||||
|
||||
export const wechatDailyNewsInlineInternals = {
|
||||
renderCard,
|
||||
renderHero,
|
||||
buildWechatInlineContent,
|
||||
TRIM_PROFILES,
|
||||
MAX_WECHAT_CONTENT_CHARS,
|
||||
};
|
||||
@@ -0,0 +1,844 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fetch as undiciFetch } from 'undici';
|
||||
import sharp from 'sharp';
|
||||
import {
|
||||
buildPublicUrl,
|
||||
PUBLISH_ROOT_DIR,
|
||||
PUBLIC_ZONE_DIR,
|
||||
resolvePublicBaseUrl,
|
||||
} from './user-publish.mjs';
|
||||
import { extractPageTitle } from './wechat/verify/share-preview-repair.mjs';
|
||||
import { convertDailyNewsHtmlToWechatInlineArticle } from './wechat-daily-news-inline.mjs';
|
||||
|
||||
const CONFIG_TABLE = 'h5_wechat_admin_config';
|
||||
const CONFIG_KEY = 'news_morning_draft';
|
||||
const RUNS_TABLE = 'h5_wechat_news_draft_runs';
|
||||
const DEFAULT_WECHAT_TOKEN_URL = 'https://api.weixin.qq.com/cgi-bin/stable_token';
|
||||
const DEFAULT_DRAFT_ADD_URL = 'https://api.weixin.qq.com/cgi-bin/draft/add';
|
||||
const DEFAULT_MATERIAL_ADD_URL = 'https://api.weixin.qq.com/cgi-bin/material/add_material';
|
||||
const DEFAULT_UPLOAD_IMG_URL = 'https://api.weixin.qq.com/cgi-bin/media/uploadimg';
|
||||
const MAX_THUMB_BYTES = 64 * 1024;
|
||||
export const NEWS_MORNING_TEMPLATE_VERSION = '2026-09-10';
|
||||
|
||||
/** 0910 新闻早报页面结构说明,供定时任务 taskSpec 与草稿转换共用。 */
|
||||
export const NEWS_MORNING_TEMPLATE_0910_SPEC = [
|
||||
'生成「每日新闻早报」静态 HTML 页面,版式对齐 2026-09-10 生产版(daily-news-MMDD):',
|
||||
'1. Hero 渐变区:主标题「📰 每日新闻早报」、date-badge、subtitle 导语;',
|
||||
'2. quick-links 锚点导航(要闻/天气/国际/国内/财经/科技/体育等);',
|
||||
'3. 多个 section:section-title + card(tag、h3 标题、p 正文、source 来源);',
|
||||
'4. 可选 weather、knowledge、history 等区块;',
|
||||
'5. footer 品牌;',
|
||||
'6. 文件名 daily-news-MMDD.html(如 daily-news-0910.html),并生成同名 .thumbnail.png;',
|
||||
'7. 推送微信草稿时转为内联 style HTML(参考公众号排版),禁止整页长图;正文需控制在 2 万字符内。',
|
||||
].join('\n');
|
||||
|
||||
function normalizeBoolean(value, fallback = false) {
|
||||
if (value == null || value === '') return fallback;
|
||||
if (typeof value === 'boolean') return value;
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
|
||||
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function parseConfigJson(value) {
|
||||
if (!value) return {};
|
||||
if (typeof value === 'object') return value;
|
||||
try {
|
||||
return JSON.parse(String(value));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function clampHour(value, fallback) {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return fallback;
|
||||
return Math.min(23, Math.max(0, Math.floor(num)));
|
||||
}
|
||||
|
||||
function clampMinute(value, fallback) {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return fallback;
|
||||
return Math.min(59, Math.max(0, Math.floor(num)));
|
||||
}
|
||||
|
||||
function slugPatternToRegExp(pattern) {
|
||||
const raw = String(pattern ?? '').trim() || 'daily-news-*';
|
||||
const escaped = raw.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
|
||||
return new RegExp(`^${escaped}\\.html$`, 'i');
|
||||
}
|
||||
|
||||
function formatShanghaiDateParts(date = new Date()) {
|
||||
const formatter = new Intl.DateTimeFormat('zh-CN', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
const parts = formatter.formatToParts(date);
|
||||
const year = parts.find((item) => item.type === 'year')?.value ?? '';
|
||||
const month = parts.find((item) => item.type === 'month')?.value ?? '';
|
||||
const day = parts.find((item) => item.type === 'day')?.value ?? '';
|
||||
return {
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
iso: `${year}-${month}-${day}`,
|
||||
mmdd: `${month}${day}`,
|
||||
compact: `${year}${month}${day}`,
|
||||
};
|
||||
}
|
||||
|
||||
function isDailyNewsFormat(html) {
|
||||
return /daily-news|每日新闻早报/u.test(String(html ?? ''))
|
||||
|| /class="date-badge"/u.test(String(html ?? ''));
|
||||
}
|
||||
|
||||
function extractMetaDescription(html) {
|
||||
const match = String(html ?? '').match(
|
||||
/<meta[^>]+name=["']description["'][^>]+content=["']([^"']*)["']/i,
|
||||
);
|
||||
return match?.[1]?.trim() ?? '';
|
||||
}
|
||||
|
||||
function stripHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function extractCardArticlesFromHotspots(html) {
|
||||
const source = String(html ?? '');
|
||||
const parts = source.split(/<div class="card"[^>]*>/i).slice(1);
|
||||
const cards = parts.map((chunk) => chunk.split(/<div class="(?:trending|note|foot)"/i)[0] ?? chunk);
|
||||
return cards.map((block, index) => {
|
||||
const title = stripHtml(block.match(/<h2[^>]*>([\s\S]*?)<\/h2>/i)?.[1] ?? '');
|
||||
const tag = stripHtml(block.match(/<span class="tag"[^>]*>([\s\S]*?)<\/span>/i)?.[1] ?? '');
|
||||
const keys = [...block.matchAll(/<div class="k"[^>]*>([\s\S]*?)<\/div>/gi)]
|
||||
.map((item) => stripHtml(item[1]))
|
||||
.filter(Boolean);
|
||||
const context = stripHtml(block.match(/<div class="ctx"[^>]*>([\s\S]*?)<\/div>/i)?.[1] ?? '');
|
||||
const sourceLine = stripHtml(block.match(/<div class="src"[^>]*>([\s\S]*?)<\/div>/i)?.[1] ?? '');
|
||||
return { index: index + 1, title, tag, keys, context, source: sourceLine };
|
||||
}).filter((item) => item.title);
|
||||
}
|
||||
|
||||
function extractCardArticlesFromDailyNews(html) {
|
||||
const source = String(html ?? '');
|
||||
const parts = source.split(/<div class="card(?:\s|")/i).slice(1);
|
||||
return parts.map((block, index) => {
|
||||
const title = stripHtml(
|
||||
block.match(/<h3[^>]*>([\s\S]*?)<\/h3>/i)?.[1]
|
||||
?? block.match(/<h2[^>]*>([\s\S]*?)<\/h2>/i)?.[1]
|
||||
?? '',
|
||||
);
|
||||
const tag = stripHtml(block.match(/<span class="tag"[^>]*>([\s\S]*?)<\/span>/i)?.[1] ?? '');
|
||||
const paragraphs = [...block.matchAll(/<p[^>]*>([\s\S]*?)<\/p>/gi)]
|
||||
.map((item) => stripHtml(item[1]))
|
||||
.filter(Boolean);
|
||||
const context = paragraphs[0] ?? '';
|
||||
const sourceLine = stripHtml(block.match(/<div class="source"[^>]*>([\s\S]*?)<\/div>/i)?.[1] ?? '');
|
||||
return { index: index + 1, title, tag, keys: [], context, source: sourceLine };
|
||||
}).filter((item) => item.title);
|
||||
}
|
||||
|
||||
function extractCardArticles(html) {
|
||||
if (isDailyNewsFormat(html)) return extractCardArticlesFromDailyNews(html);
|
||||
return extractCardArticlesFromHotspots(html);
|
||||
}
|
||||
|
||||
function extractDailyNewsSections(html) {
|
||||
return [...String(html ?? '').matchAll(/<div class="section-title"[^>]*>([\s\S]*?)<\/div>/gi)]
|
||||
.map((item) => stripHtml(item[1]))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function extractStats(html) {
|
||||
return [...String(html ?? '').matchAll(/<div class="stat[^"]*"[^>]*><b>([\s\S]*?)<\/b><span>([\s\S]*?)<\/span><\/div>/gi)]
|
||||
.map((item) => ({ value: stripHtml(item[1]), label: stripHtml(item[2]) }))
|
||||
.filter((item) => item.value);
|
||||
}
|
||||
|
||||
function extractTrendingSummary(html) {
|
||||
const block = String(html ?? '').match(/<div class="trending"[^>]*>([\s\S]*?)<\/div>/i)?.[1] ?? '';
|
||||
const chips = [...block.matchAll(/<span class="chip"[^>]*>([\s\S]*?)<\/span>/gi)]
|
||||
.map((item) => stripHtml(item[1]))
|
||||
.filter(Boolean);
|
||||
const paragraph = stripHtml(block.match(/<p[^>]*>([\s\S]*?)<\/p>/i)?.[1] ?? '');
|
||||
return { chips, paragraph };
|
||||
}
|
||||
|
||||
export function resolveDailyNewsPublicBaseUrl(env = process.env) {
|
||||
return resolvePublicBaseUrl({
|
||||
...env,
|
||||
H5_PUBLIC_BASE_URL:
|
||||
env.MEMIND_PAGE_PUBLIC_BASE_URL
|
||||
|| env.H5_PORTAL_PUBLIC_BASE_URL
|
||||
|| 'https://m.tkmind.cn',
|
||||
});
|
||||
}
|
||||
|
||||
export function rewriteDailyNewsHtmlLinks(html, { publicBaseUrl, userId }) {
|
||||
const base = String(publicBaseUrl ?? '').replace(/\/$/, '');
|
||||
const owner = String(userId ?? '').trim();
|
||||
if (!base || !owner) return String(html ?? '');
|
||||
const publicPrefix = `${base}/MindSpace/${encodeURIComponent(owner)}/public/`;
|
||||
return String(html ?? '')
|
||||
.replace(/\shref=(["'])(?!https?:|#|mailto:|tel:)([^"']+)\1/gi, (match, quote, href) => {
|
||||
const target = href.startsWith('/') ? `${base}${href}` : `${publicPrefix}${href.replace(/^\.\//, '')}`;
|
||||
return ` href=${quote}${target}${quote}`;
|
||||
});
|
||||
}
|
||||
|
||||
export function buildDailyNewsWechatHtmlContent(html, { publicUrl = '', publicBaseUrl = '', userId = '' } = {}) {
|
||||
const source = String(html ?? '');
|
||||
const styleBlock = source.match(/<style>([\s\S]*?)<\/style>/i)?.[0] ?? '';
|
||||
const bodyBlock = source.match(/<body>([\s\S]*?)<\/body>/i)?.[0] ?? '';
|
||||
let content = `${styleBlock}${bodyBlock}`;
|
||||
content = rewriteDailyNewsHtmlLinks(content, { publicBaseUrl, userId });
|
||||
if (publicUrl) {
|
||||
content += `<p style="margin:16px 0 0;font-size:13px;color:#718096;text-align:center;">阅读原文:<a href="${publicUrl}">${publicUrl}</a></p>`;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
export function extractDailyNewsArticleMeta(html) {
|
||||
const title = extractPageTitle(html) || '每日新闻早报';
|
||||
const digest = (extractMetaDescription(html) || title).slice(0, 120);
|
||||
return { title: title.slice(0, 64), digest };
|
||||
}
|
||||
|
||||
export function resolveMpFollowQrcodePath(memindLibRoot = process.cwd()) {
|
||||
const candidates = [
|
||||
path.join(memindLibRoot, 'wechat/assets/mp-follow-qrcode.png'),
|
||||
path.join(process.cwd(), 'wechat/assets/mp-follow-qrcode.png'),
|
||||
];
|
||||
return candidates.find((candidate) => fs.existsSync(candidate)) ?? null;
|
||||
}
|
||||
|
||||
export async function uploadWechatArticleContentImage(
|
||||
accessToken,
|
||||
imageBuffer,
|
||||
{ wechatFetch = undiciFetch, uploadUrl = DEFAULT_UPLOAD_IMG_URL, filename = 'content.png' } = {},
|
||||
) {
|
||||
if (!accessToken) throw new Error('缺少微信 access_token');
|
||||
if (!Buffer.isBuffer(imageBuffer) || imageBuffer.length === 0) {
|
||||
throw new Error('正文图片为空');
|
||||
}
|
||||
const form = new FormData();
|
||||
form.append(
|
||||
'media',
|
||||
new Blob([imageBuffer], { type: 'image/png' }),
|
||||
filename,
|
||||
);
|
||||
const endpoint = new URL(uploadUrl);
|
||||
endpoint.searchParams.set('access_token', accessToken);
|
||||
const payload = await readJsonResponse(
|
||||
await wechatFetch(endpoint.toString(), { method: 'POST', body: form }),
|
||||
);
|
||||
if (Number(payload?.errcode ?? 0) !== 0 || !String(payload?.url ?? '').trim()) {
|
||||
throw new Error(payload?.errmsg || '上传微信正文图片失败');
|
||||
}
|
||||
return String(payload.url);
|
||||
}
|
||||
|
||||
export function buildDailyNewsWechatDraftArticle({
|
||||
html,
|
||||
publicUrl,
|
||||
qrcodeImageUrl = '',
|
||||
} = {}) {
|
||||
const article = convertDailyNewsHtmlToWechatInlineArticle(html, { publicUrl, qrcodeImageUrl });
|
||||
return {
|
||||
...article,
|
||||
cardCount: extractCardArticles(html).length,
|
||||
statsCount: extractStats(html).length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildDailyNewsWechatDraftArticleForPush({
|
||||
html,
|
||||
publicUrl,
|
||||
accessToken = null,
|
||||
wechatFetch = undiciFetch,
|
||||
memindLibRoot = process.cwd(),
|
||||
} = {}) {
|
||||
let qrcodeImageUrl = '';
|
||||
if (accessToken) {
|
||||
const qrcodePath = resolveMpFollowQrcodePath(memindLibRoot);
|
||||
if (qrcodePath) {
|
||||
qrcodeImageUrl = await uploadWechatArticleContentImage(
|
||||
accessToken,
|
||||
fs.readFileSync(qrcodePath),
|
||||
{ wechatFetch, filename: 'mp-follow-qrcode.png' },
|
||||
);
|
||||
}
|
||||
}
|
||||
return buildDailyNewsWechatDraftArticle({ html, publicUrl, qrcodeImageUrl });
|
||||
}
|
||||
|
||||
export function convertNewsPageHtmlToWechatArticle(html, { publicUrl = '' } = {}) {
|
||||
const dailyNews = isDailyNewsFormat(html);
|
||||
if (dailyNews) {
|
||||
return buildDailyNewsWechatDraftArticle({ html, publicUrl });
|
||||
}
|
||||
const title = extractPageTitle(html) || '今日新闻热点分析';
|
||||
const digest = (extractMetaDescription(html) || title).slice(0, 120);
|
||||
const heroDate = dailyNews
|
||||
? stripHtml(String(html).match(/<div class="date-badge"[^>]*>([\s\S]*?)<\/div>/i)?.[1] ?? '')
|
||||
: stripHtml(String(html).match(/<span class="date"[^>]*>([\s\S]*?)<\/span>/i)?.[1] ?? '');
|
||||
const heroSub = dailyNews
|
||||
? stripHtml(String(html).match(/<div class="subtitle"[^>]*>([\s\S]*?)<\/div>/i)?.[1] ?? '')
|
||||
: stripHtml(String(html).match(/<p class="sub"[^>]*>([\s\S]*?)<\/p>/i)?.[1] ?? '');
|
||||
const stats = extractStats(html);
|
||||
const cards = extractCardArticles(html).slice(0, dailyNews ? 12 : undefined);
|
||||
const trending = extractTrendingSummary(html);
|
||||
const sectionTitles = dailyNews ? extractDailyNewsSections(html) : [];
|
||||
|
||||
const sections = [];
|
||||
sections.push(`<p style="margin:0 0 12px;font-size:15px;color:#333;">${heroDate || title}</p>`);
|
||||
if (heroSub) {
|
||||
sections.push(`<p style="margin:0 0 16px;font-size:14px;color:#666;line-height:1.8;">${heroSub}</p>`);
|
||||
}
|
||||
if (sectionTitles.length > 0) {
|
||||
sections.push(
|
||||
`<p style="margin:0 0 16px;font-size:13px;color:#888;">栏目:${sectionTitles.slice(0, 8).join(' · ')}</p>`,
|
||||
);
|
||||
}
|
||||
if (stats.length > 0) {
|
||||
const statLine = stats.map((item) => `${item.value} ${item.label}`).join(' · ');
|
||||
sections.push(`<p style="margin:0 0 18px;font-size:14px;color:#444;"><strong>速览:</strong>${statLine}</p>`);
|
||||
}
|
||||
for (const card of cards) {
|
||||
const tagPrefix = card.tag ? `[${card.tag}] ` : '';
|
||||
sections.push(`<section style="margin:0 0 20px;">`);
|
||||
sections.push(`<p style="margin:0 0 8px;font-size:16px;font-weight:700;color:#111;">${card.index}. ${tagPrefix}${card.title}</p>`);
|
||||
if (card.keys.length > 0) {
|
||||
sections.push(`<p style="margin:0 0 8px;font-size:14px;color:#444;line-height:1.8;">${card.keys.join(' | ')}</p>`);
|
||||
}
|
||||
if (card.context) {
|
||||
sections.push(`<p style="margin:0 0 8px;font-size:14px;color:#555;line-height:1.9;">${card.context}</p>`);
|
||||
}
|
||||
if (card.source) {
|
||||
sections.push(`<p style="margin:0;font-size:12px;color:#888;">${card.source}</p>`);
|
||||
}
|
||||
sections.push('</section>');
|
||||
}
|
||||
if (trending.chips.length > 0 || trending.paragraph) {
|
||||
sections.push('<section style="margin:0 0 16px;">');
|
||||
sections.push('<p style="margin:0 0 8px;font-size:15px;font-weight:700;color:#111;">社交平台热榜</p>');
|
||||
if (trending.chips.length > 0) {
|
||||
sections.push(`<p style="margin:0 0 8px;font-size:14px;color:#444;">${trending.chips.join(' · ')}</p>`);
|
||||
}
|
||||
if (trending.paragraph) {
|
||||
sections.push(`<p style="margin:0;font-size:14px;color:#555;line-height:1.8;">${trending.paragraph}</p>`);
|
||||
}
|
||||
sections.push('</section>');
|
||||
}
|
||||
if (publicUrl) {
|
||||
sections.push(
|
||||
`<p style="margin:16px 0 0;font-size:13px;color:#888;">阅读原文:<a href="${publicUrl}">${publicUrl}</a></p>`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
title: title.slice(0, 64),
|
||||
digest,
|
||||
content: sections.join('\n'),
|
||||
contentSourceUrl: publicUrl || undefined,
|
||||
cardCount: cards.length,
|
||||
statsCount: stats.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function findLatestNewsMorningPage({
|
||||
h5Root,
|
||||
userId,
|
||||
slugPattern = 'news-hotspots-*',
|
||||
date = new Date(),
|
||||
} = {}) {
|
||||
if (!h5Root || !userId) throw new Error('缺少 h5Root 或 userId');
|
||||
const publicDir = path.join(h5Root, PUBLISH_ROOT_DIR, String(userId), PUBLIC_ZONE_DIR);
|
||||
if (!fs.existsSync(publicDir)) {
|
||||
return null;
|
||||
}
|
||||
const matcher = slugPatternToRegExp(slugPattern);
|
||||
const { iso, mmdd, compact } = formatShanghaiDateParts(date);
|
||||
const datedCandidates = [];
|
||||
const fallbackCandidates = [];
|
||||
for (const entry of fs.readdirSync(publicDir, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !matcher.test(entry.name)) continue;
|
||||
const fullPath = path.join(publicDir, entry.name);
|
||||
const stat = fs.statSync(fullPath);
|
||||
const item = {
|
||||
slug: entry.name.replace(/\.html$/i, ''),
|
||||
relativePath: `${PUBLIC_ZONE_DIR}/${entry.name}`,
|
||||
localPath: fullPath,
|
||||
modifiedAt: stat.mtimeMs,
|
||||
};
|
||||
const slug = entry.name.replace(/\.html$/i, '');
|
||||
if (
|
||||
slug.endsWith(`-${mmdd}`)
|
||||
|| entry.name.includes(iso)
|
||||
|| entry.name.includes(compact)
|
||||
|| entry.name.includes(iso.replace(/-/g, ''))
|
||||
) {
|
||||
datedCandidates.push(item);
|
||||
} else {
|
||||
fallbackCandidates.push(item);
|
||||
}
|
||||
}
|
||||
const pool = datedCandidates.length > 0 ? datedCandidates : fallbackCandidates;
|
||||
if (pool.length === 0) return null;
|
||||
const slugRank = (slug) => {
|
||||
const match = String(slug).match(/(\d{4})(?!.*\d{4})/) ?? String(slug).match(/(\d{4})/);
|
||||
return match ? Number(match[1]) : 0;
|
||||
};
|
||||
pool.sort((a, b) => {
|
||||
const rankDiff = slugRank(b.slug) - slugRank(a.slug);
|
||||
if (rankDiff !== 0) return rankDiff;
|
||||
return b.modifiedAt - a.modifiedAt;
|
||||
});
|
||||
const selected = pool[0];
|
||||
const thumbPath = `${selected.localPath.replace(/\.html$/i, '')}.thumbnail.png`;
|
||||
return {
|
||||
...selected,
|
||||
thumbPath: fs.existsSync(thumbPath) ? thumbPath : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function readJsonResponse(response) {
|
||||
const text = await response.text().catch(() => '');
|
||||
if (!response.ok) {
|
||||
throw new Error(text || `upstream ${response.status}`);
|
||||
}
|
||||
return text ? JSON.parse(text) : null;
|
||||
}
|
||||
|
||||
async function normalizeThumbBuffer(buffer) {
|
||||
if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
|
||||
throw new Error('封面图为空');
|
||||
}
|
||||
const attempts = [
|
||||
{ width: 900, quality: 82 },
|
||||
{ width: 720, quality: 70 },
|
||||
{ width: 540, quality: 58 },
|
||||
{ width: 420, quality: 48 },
|
||||
];
|
||||
for (const attempt of attempts) {
|
||||
const normalized = await sharp(buffer, { sequentialRead: true })
|
||||
.rotate()
|
||||
.resize({ width: attempt.width, height: attempt.width, fit: 'cover' })
|
||||
.jpeg({ quality: attempt.quality, mozjpeg: true })
|
||||
.toBuffer();
|
||||
if (normalized.length <= MAX_THUMB_BYTES) {
|
||||
return { buffer: normalized, contentType: 'image/jpeg', filename: 'news-thumb.jpg' };
|
||||
}
|
||||
}
|
||||
throw new Error(`封面图压缩后仍超过微信 thumb 限制(${MAX_THUMB_BYTES} bytes)`);
|
||||
}
|
||||
|
||||
export async function uploadWechatPermanentThumb(accessToken, imageBuffer, { wechatFetch = undiciFetch, uploadUrl = DEFAULT_MATERIAL_ADD_URL } = {}) {
|
||||
if (!accessToken) throw new Error('缺少微信 access_token');
|
||||
const normalized = await normalizeThumbBuffer(imageBuffer);
|
||||
const form = new FormData();
|
||||
form.append(
|
||||
'media',
|
||||
new Blob([normalized.buffer], { type: normalized.contentType }),
|
||||
normalized.filename,
|
||||
);
|
||||
const endpoint = new URL(uploadUrl);
|
||||
endpoint.searchParams.set('access_token', accessToken);
|
||||
endpoint.searchParams.set('type', 'thumb');
|
||||
const payload = await readJsonResponse(
|
||||
await wechatFetch(endpoint.toString(), { method: 'POST', body: form }),
|
||||
);
|
||||
if (Number(payload?.errcode ?? 0) !== 0 || !String(payload?.media_id ?? '').trim()) {
|
||||
throw new Error(payload?.errmsg || '上传微信封面素材失败');
|
||||
}
|
||||
return String(payload.media_id);
|
||||
}
|
||||
|
||||
export async function addWechatDraftArticle(accessToken, article, { wechatFetch = undiciFetch, draftAddUrl = DEFAULT_DRAFT_ADD_URL } = {}) {
|
||||
if (!accessToken) throw new Error('缺少微信 access_token');
|
||||
const endpoint = new URL(draftAddUrl);
|
||||
endpoint.searchParams.set('access_token', accessToken);
|
||||
const payload = await readJsonResponse(
|
||||
await wechatFetch(endpoint.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({ articles: [article] }),
|
||||
}),
|
||||
);
|
||||
if (Number(payload?.errcode ?? 0) !== 0 || !String(payload?.media_id ?? '').trim()) {
|
||||
throw new Error(payload?.errmsg || '写入微信草稿箱失败');
|
||||
}
|
||||
return {
|
||||
draftMediaId: String(payload.media_id),
|
||||
raw: payload,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureConfigTable(pool) {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS ${CONFIG_TABLE} (
|
||||
config_key VARCHAR(64) PRIMARY KEY,
|
||||
config_json JSON NOT NULL,
|
||||
updated_by CHAR(36) NULL,
|
||||
updated_at BIGINT NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
}
|
||||
|
||||
async function ensureRunsTable(pool) {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS ${RUNS_TABLE} (
|
||||
id CHAR(36) PRIMARY KEY,
|
||||
status VARCHAR(16) NOT NULL,
|
||||
page_slug VARCHAR(255) NULL,
|
||||
page_url VARCHAR(512) NULL,
|
||||
draft_media_id VARCHAR(128) NULL,
|
||||
error_message TEXT NULL,
|
||||
triggered_by CHAR(36) NULL,
|
||||
created_at BIGINT NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
}
|
||||
|
||||
function defaultsFromEnv(env = process.env) {
|
||||
return {
|
||||
enabled: normalizeBoolean(env.H5_WECHAT_NEWS_MORNING_DRAFT_ENABLED, false),
|
||||
autoPushEnabled: normalizeBoolean(env.H5_WECHAT_NEWS_MORNING_DRAFT_AUTO, false),
|
||||
pushHour: clampHour(env.H5_WECHAT_NEWS_MORNING_DRAFT_HOUR, 6),
|
||||
pushMinute: clampMinute(env.H5_WECHAT_NEWS_MORNING_DRAFT_MINUTE, 0),
|
||||
timezone: String(env.H5_WECHAT_NEWS_MORNING_DRAFT_TZ ?? 'Asia/Shanghai').trim() || 'Asia/Shanghai',
|
||||
sourceUserId: String(env.H5_WECHAT_NEWS_MORNING_DRAFT_USER_ID ?? '').trim() || null,
|
||||
pageSlugPattern: String(env.H5_WECHAT_NEWS_MORNING_DRAFT_SLUG ?? 'daily-news-*').trim() || 'daily-news-*',
|
||||
author: String(env.H5_WECHAT_NEWS_MORNING_DRAFT_AUTHOR ?? 'TKMind').trim() || 'TKMind',
|
||||
templateVersion: NEWS_MORNING_TEMPLATE_VERSION,
|
||||
};
|
||||
}
|
||||
|
||||
export function createWechatNewsMorningDraftService(
|
||||
pool,
|
||||
{
|
||||
mpConfig = null,
|
||||
h5Root = process.cwd(),
|
||||
memindLibRoot = h5Root,
|
||||
env = process.env,
|
||||
wechatFetch = undiciFetch,
|
||||
} = {},
|
||||
) {
|
||||
let ensurePromise = null;
|
||||
let accessTokenCache = { token: '', expiresAt: 0 };
|
||||
|
||||
async function ensureReady() {
|
||||
if (!ensurePromise) {
|
||||
ensurePromise = (async () => {
|
||||
await ensureConfigTable(pool);
|
||||
await ensureRunsTable(pool);
|
||||
})();
|
||||
}
|
||||
await ensurePromise;
|
||||
}
|
||||
|
||||
async function readConfigRow() {
|
||||
await ensureReady();
|
||||
const [rows] = await pool.query(
|
||||
`SELECT config_json, updated_by, updated_at
|
||||
FROM ${CONFIG_TABLE}
|
||||
WHERE config_key = ?
|
||||
LIMIT 1`,
|
||||
[CONFIG_KEY],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
function mergeConfig(row) {
|
||||
const defaults = defaultsFromEnv(env);
|
||||
const stored = parseConfigJson(row?.config_json);
|
||||
return {
|
||||
enabled: normalizeBoolean(stored.enabled, defaults.enabled),
|
||||
autoPushEnabled: normalizeBoolean(stored.autoPushEnabled, defaults.autoPushEnabled),
|
||||
pushHour: clampHour(stored.pushHour, defaults.pushHour),
|
||||
pushMinute: clampMinute(stored.pushMinute, defaults.pushMinute),
|
||||
timezone: String(stored.timezone ?? defaults.timezone).trim() || defaults.timezone,
|
||||
sourceUserId: String(stored.sourceUserId ?? defaults.sourceUserId ?? '').trim() || null,
|
||||
pageSlugPattern: String(stored.pageSlugPattern ?? defaults.pageSlugPattern).trim() || defaults.pageSlugPattern,
|
||||
author: String(stored.author ?? defaults.author).trim().slice(0, 8) || defaults.author,
|
||||
templateVersion: NEWS_MORNING_TEMPLATE_VERSION,
|
||||
};
|
||||
}
|
||||
|
||||
async function getAccessToken() {
|
||||
if (!mpConfig?.enabled) throw new Error('微信服务号未启用');
|
||||
if (accessTokenCache.token && accessTokenCache.expiresAt > Date.now() + 60_000) {
|
||||
return accessTokenCache.token;
|
||||
}
|
||||
const payload = await readJsonResponse(
|
||||
await wechatFetch(mpConfig.tokenUrl || DEFAULT_WECHAT_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
grant_type: 'client_credential',
|
||||
appid: mpConfig.appId,
|
||||
secret: mpConfig.appSecret,
|
||||
force_refresh: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
if (!payload?.access_token) {
|
||||
throw new Error(payload?.errmsg || '获取微信 access_token 失败');
|
||||
}
|
||||
accessTokenCache = {
|
||||
token: payload.access_token,
|
||||
expiresAt: Date.now() + Number(payload.expires_in ?? 7200) * 1000,
|
||||
};
|
||||
return accessTokenCache.token;
|
||||
}
|
||||
|
||||
async function resolvePreview(configOverride = null) {
|
||||
const config = configOverride ?? mergeConfig(await readConfigRow());
|
||||
if (!config.sourceUserId) {
|
||||
throw new Error('请先配置新闻早报来源用户 ID');
|
||||
}
|
||||
const page = findLatestNewsMorningPage({
|
||||
h5Root,
|
||||
userId: config.sourceUserId,
|
||||
slugPattern: config.pageSlugPattern,
|
||||
});
|
||||
if (!page) {
|
||||
throw new Error(`未找到匹配 ${config.pageSlugPattern} 的新闻早报页面`);
|
||||
}
|
||||
const html = fs.readFileSync(page.localPath, 'utf8');
|
||||
const publicBaseUrl = resolveDailyNewsPublicBaseUrl(env);
|
||||
const publicUrl = buildPublicUrl(publicBaseUrl, config.sourceUserId, page.relativePath);
|
||||
const article = isDailyNewsFormat(html)
|
||||
? buildDailyNewsWechatDraftArticle({ html, publicUrl })
|
||||
: convertNewsPageHtmlToWechatArticle(html, { publicUrl });
|
||||
return {
|
||||
config,
|
||||
page: {
|
||||
slug: page.slug,
|
||||
relativePath: page.relativePath,
|
||||
publicUrl,
|
||||
modifiedAt: page.modifiedAt,
|
||||
hasThumb: Boolean(page.thumbPath),
|
||||
},
|
||||
article,
|
||||
template: {
|
||||
version: NEWS_MORNING_TEMPLATE_VERSION,
|
||||
spec: NEWS_MORNING_TEMPLATE_0910_SPEC,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function recordRun({
|
||||
status,
|
||||
pageSlug = null,
|
||||
pageUrl = null,
|
||||
draftMediaId = null,
|
||||
errorMessage = null,
|
||||
triggeredBy = null,
|
||||
}) {
|
||||
await ensureReady();
|
||||
const id = cryptoRandomId();
|
||||
const now = Date.now();
|
||||
await pool.query(
|
||||
`INSERT INTO ${RUNS_TABLE}
|
||||
(id, status, page_slug, page_url, draft_media_id, error_message, triggered_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[id, status, pageSlug, pageUrl, draftMediaId, errorMessage, triggeredBy, now],
|
||||
);
|
||||
return { id, status, pageSlug, pageUrl, draftMediaId, errorMessage, triggeredBy, createdAt: now };
|
||||
}
|
||||
|
||||
return {
|
||||
async getConfig() {
|
||||
const row = await readConfigRow();
|
||||
return {
|
||||
...mergeConfig(row),
|
||||
updatedAt: row?.updated_at ? Number(row.updated_at) : null,
|
||||
updatedBy: row?.updated_by ?? null,
|
||||
};
|
||||
},
|
||||
|
||||
getTemplate() {
|
||||
return {
|
||||
version: NEWS_MORNING_TEMPLATE_VERSION,
|
||||
spec: NEWS_MORNING_TEMPLATE_0910_SPEC,
|
||||
};
|
||||
},
|
||||
|
||||
async updateConfig(payload = {}, { updatedBy = null } = {}) {
|
||||
const current = await this.getConfig();
|
||||
const next = {
|
||||
enabled:
|
||||
payload.enabled === undefined
|
||||
? current.enabled
|
||||
: normalizeBoolean(payload.enabled, current.enabled),
|
||||
autoPushEnabled:
|
||||
payload.autoPushEnabled === undefined
|
||||
? current.autoPushEnabled
|
||||
: normalizeBoolean(payload.autoPushEnabled, current.autoPushEnabled),
|
||||
pushHour:
|
||||
payload.pushHour === undefined ? current.pushHour : clampHour(payload.pushHour, current.pushHour),
|
||||
pushMinute:
|
||||
payload.pushMinute === undefined
|
||||
? current.pushMinute
|
||||
: clampMinute(payload.pushMinute, current.pushMinute),
|
||||
timezone:
|
||||
payload.timezone === undefined
|
||||
? current.timezone
|
||||
: String(payload.timezone ?? current.timezone).trim() || current.timezone,
|
||||
sourceUserId:
|
||||
payload.sourceUserId === undefined
|
||||
? current.sourceUserId
|
||||
: String(payload.sourceUserId ?? '').trim() || null,
|
||||
pageSlugPattern:
|
||||
payload.pageSlugPattern === undefined
|
||||
? current.pageSlugPattern
|
||||
: String(payload.pageSlugPattern ?? current.pageSlugPattern).trim() || current.pageSlugPattern,
|
||||
author:
|
||||
payload.author === undefined
|
||||
? current.author
|
||||
: String(payload.author ?? current.author).trim().slice(0, 8) || current.author,
|
||||
templateVersion: NEWS_MORNING_TEMPLATE_VERSION,
|
||||
};
|
||||
const now = Date.now();
|
||||
await pool.query(
|
||||
`INSERT INTO ${CONFIG_TABLE} (config_key, config_json, updated_by, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
config_json = VALUES(config_json),
|
||||
updated_by = VALUES(updated_by),
|
||||
updated_at = VALUES(updated_at)`,
|
||||
[CONFIG_KEY, JSON.stringify(next), updatedBy, now],
|
||||
);
|
||||
return this.getConfig();
|
||||
},
|
||||
|
||||
async preview() {
|
||||
return resolvePreview();
|
||||
},
|
||||
|
||||
async pushDraft({ triggeredBy = null, dryRun = false } = {}) {
|
||||
const config = await this.getConfig();
|
||||
if (!config.enabled) {
|
||||
throw new Error('新闻早报草稿推送未启用');
|
||||
}
|
||||
let preview = null;
|
||||
preview = await resolvePreview(config);
|
||||
if (dryRun) {
|
||||
return {
|
||||
dryRun: true,
|
||||
preview,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const accessToken = await getAccessToken();
|
||||
let thumbBuffer = null;
|
||||
const page = findLatestNewsMorningPage({
|
||||
h5Root,
|
||||
userId: config.sourceUserId,
|
||||
slugPattern: config.pageSlugPattern,
|
||||
});
|
||||
if (page?.thumbPath) {
|
||||
thumbBuffer = fs.readFileSync(page.thumbPath);
|
||||
} else if (page?.localPath) {
|
||||
thumbBuffer = Buffer.from(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="900" height="900"><rect width="900" height="900" fill="#1a1a2e"/><text x="50%" y="50%" fill="#fff" font-size="48" text-anchor="middle" dominant-baseline="middle">今日新闻</text></svg>',
|
||||
);
|
||||
thumbBuffer = await sharp(thumbBuffer).png().toBuffer();
|
||||
}
|
||||
const thumbMediaId = await uploadWechatPermanentThumb(accessToken, thumbBuffer, { wechatFetch });
|
||||
const html = fs.readFileSync(page.localPath, 'utf8');
|
||||
const article = isDailyNewsFormat(html)
|
||||
? await buildDailyNewsWechatDraftArticleForPush({
|
||||
html,
|
||||
publicUrl: preview.page.publicUrl,
|
||||
accessToken,
|
||||
wechatFetch,
|
||||
memindLibRoot,
|
||||
})
|
||||
: preview.article;
|
||||
const draft = await addWechatDraftArticle(
|
||||
accessToken,
|
||||
{
|
||||
title: article.title,
|
||||
author: config.author,
|
||||
digest: article.digest,
|
||||
content: article.content,
|
||||
content_source_url: article.contentSourceUrl,
|
||||
thumb_media_id: thumbMediaId,
|
||||
need_open_comment: 0,
|
||||
only_fans_can_comment: 0,
|
||||
},
|
||||
{ wechatFetch },
|
||||
);
|
||||
preview = {
|
||||
...preview,
|
||||
article,
|
||||
};
|
||||
const run = await recordRun({
|
||||
status: 'success',
|
||||
pageSlug: preview.page.slug,
|
||||
pageUrl: preview.page.publicUrl,
|
||||
draftMediaId: draft.draftMediaId,
|
||||
triggeredBy,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
draftMediaId: draft.draftMediaId,
|
||||
preview,
|
||||
run,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const run = await recordRun({
|
||||
status: 'failed',
|
||||
pageSlug: preview?.page?.slug ?? null,
|
||||
pageUrl: preview?.page?.publicUrl ?? null,
|
||||
errorMessage: message,
|
||||
triggeredBy,
|
||||
});
|
||||
throw Object.assign(new Error(message), { run });
|
||||
}
|
||||
},
|
||||
|
||||
async listRuns({ limit = 20 } = {}) {
|
||||
await ensureReady();
|
||||
const safeLimit = Math.min(Math.max(Number(limit) || 20, 1), 100);
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, status, page_slug AS pageSlug, page_url AS pageUrl,
|
||||
draft_media_id AS draftMediaId, error_message AS errorMessage,
|
||||
triggered_by AS triggeredBy, created_at AS createdAt
|
||||
FROM ${RUNS_TABLE}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?`,
|
||||
[safeLimit],
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
createdAt: Number(row.createdAt ?? 0),
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function cryptoRandomId() {
|
||||
return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
export const wechatNewsMorningDraftInternals = {
|
||||
normalizeBoolean,
|
||||
defaultsFromEnv,
|
||||
slugPatternToRegExp,
|
||||
convertNewsPageHtmlToWechatArticle,
|
||||
buildDailyNewsWechatHtmlContent,
|
||||
buildDailyNewsWechatDraftArticle,
|
||||
findLatestNewsMorningPage,
|
||||
};
|
||||
@@ -0,0 +1,174 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
convertNewsPageHtmlToWechatArticle,
|
||||
createWechatNewsMorningDraftService,
|
||||
findLatestNewsMorningPage,
|
||||
NEWS_MORNING_TEMPLATE_VERSION,
|
||||
wechatNewsMorningDraftInternals,
|
||||
} from './wechat-news-morning-draft.mjs';
|
||||
import { PUBLIC_ZONE_DIR, PUBLISH_ROOT_DIR } from './user-publish.mjs';
|
||||
|
||||
const SAMPLE_HTML = `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<title>每日新闻早报 · 2026年9月10日</title>
|
||||
<meta name="description" content="2026年9月10日新闻热点摘要">
|
||||
<style>.hero{background:#b71c1c;color:#fff;}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="hero">
|
||||
<h1>📰 每日新闻早报</h1>
|
||||
<div class="date-badge">2026年9月10日 · 星期三</div>
|
||||
<div class="subtitle">综合多家权威媒体梳理全网核心热点。</div>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div class="stat s1"><b>4 大</b><span>核心热点</span></div>
|
||||
</div>
|
||||
<div class="section" id="headlines">
|
||||
<div class="section-title">📰 今日要闻</div>
|
||||
<div class="card">
|
||||
<span class="tag tag-red">要闻</span>
|
||||
<h3>示例热点</h3>
|
||||
<p>这是示例正文。</p>
|
||||
<div class="source">来源:示例媒体</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="trending">
|
||||
<h3>热榜</h3>
|
||||
<div class="chips"><span class="chip"><span class="emoji">🔥</span>话题 A</span></div>
|
||||
<p>今日热榜整体偏科技。</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
function createPool(seedRow = null) {
|
||||
const state = { row: seedRow, runs: [] };
|
||||
return {
|
||||
async query(sql, params) {
|
||||
if (sql.includes('CREATE TABLE')) return [[], []];
|
||||
if (sql.includes('SELECT config_json')) return [state.row ? [state.row] : [], []];
|
||||
if (sql.includes('INSERT INTO h5_wechat_admin_config')) {
|
||||
state.row = {
|
||||
config_json: params[1],
|
||||
updated_by: params[2],
|
||||
updated_at: params[3],
|
||||
};
|
||||
return [[], []];
|
||||
}
|
||||
if (sql.includes('INSERT INTO h5_wechat_news_draft_runs')) {
|
||||
state.runs.unshift({
|
||||
id: params[0],
|
||||
status: params[1],
|
||||
pageSlug: params[2],
|
||||
pageUrl: params[3],
|
||||
draftMediaId: params[4],
|
||||
errorMessage: params[5],
|
||||
triggeredBy: params[6],
|
||||
createdAt: params[7],
|
||||
});
|
||||
return [[], []];
|
||||
}
|
||||
if (sql.includes('FROM h5_wechat_news_draft_runs')) {
|
||||
return [state.runs, []];
|
||||
}
|
||||
throw new Error(`Unexpected query: ${sql}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('convertNewsPageHtmlToWechatArticle converts daily-news to inline html', () => {
|
||||
const article = convertNewsPageHtmlToWechatArticle(SAMPLE_HTML, {
|
||||
publicUrl: 'https://m.tkmind.cn/MindSpace/demo/public/daily-news-0910.html',
|
||||
});
|
||||
assert.match(article.title, /2026年9月10日/u);
|
||||
assert.equal(article.contentMode, 'inline_html');
|
||||
assert.match(article.content, /每日新闻早报/u);
|
||||
assert.match(article.content, /示例热点/u);
|
||||
assert.match(article.content, /点击阅读原文/u);
|
||||
assert.match(article.content, /一起创作/u);
|
||||
assert.match(article.content, /点我/u);
|
||||
assert.doesNotMatch(article.content, /近7天新闻早报/u);
|
||||
});
|
||||
|
||||
test('buildDailyNewsWechatHtmlContent preserves style and body markup', () => {
|
||||
const content = wechatNewsMorningDraftInternals.buildDailyNewsWechatHtmlContent(SAMPLE_HTML, {
|
||||
publicUrl: 'https://m.tkmind.cn/MindSpace/demo/public/daily-news-0910.html',
|
||||
publicBaseUrl: 'https://m.tkmind.cn',
|
||||
userId: 'demo-user',
|
||||
});
|
||||
assert.match(content, /<style>/u);
|
||||
assert.match(content, /每日新闻早报/u);
|
||||
assert.match(content, /class="hero"/u);
|
||||
assert.match(content, /示例热点/u);
|
||||
});
|
||||
|
||||
test('findLatestNewsMorningPage prefers dated slug for today', async () => {
|
||||
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'news-draft-'));
|
||||
const userId = 'user-1';
|
||||
const publicDir = path.join(root, PUBLISH_ROOT_DIR, userId, PUBLIC_ZONE_DIR);
|
||||
await fs.promises.mkdir(publicDir, { recursive: true });
|
||||
const oldPath = path.join(publicDir, 'news-hotspots-2026-08-28.html');
|
||||
const todayPath = path.join(publicDir, 'news-hotspots-2026-09-10.html');
|
||||
await fs.promises.writeFile(oldPath, SAMPLE_HTML);
|
||||
await fs.promises.writeFile(todayPath, SAMPLE_HTML);
|
||||
await fs.promises.utimes(oldPath, new Date('2026-09-09'), new Date('2026-09-09'));
|
||||
await fs.promises.utimes(todayPath, new Date('2026-09-10'), new Date('2026-09-10'));
|
||||
|
||||
const page = findLatestNewsMorningPage({
|
||||
h5Root: root,
|
||||
userId,
|
||||
slugPattern: 'news-hotspots-*',
|
||||
date: new Date('2026-09-10T01:00:00+08:00'),
|
||||
});
|
||||
assert.equal(page.slug, 'news-hotspots-2026-09-10');
|
||||
});
|
||||
|
||||
test('news morning draft service persists config and records failed push', async () => {
|
||||
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'news-draft-service-'));
|
||||
const userId = 'user-2';
|
||||
const publicDir = path.join(root, PUBLISH_ROOT_DIR, userId, PUBLIC_ZONE_DIR);
|
||||
await fs.promises.mkdir(publicDir, { recursive: true });
|
||||
await fs.promises.writeFile(path.join(publicDir, 'daily-news-0910.html'), SAMPLE_HTML);
|
||||
|
||||
const service = createWechatNewsMorningDraftService(createPool(), {
|
||||
mpConfig: { enabled: false },
|
||||
h5Root: root,
|
||||
env: {},
|
||||
});
|
||||
|
||||
const updated = await service.updateConfig(
|
||||
{
|
||||
enabled: true,
|
||||
sourceUserId: userId,
|
||||
pushHour: 6,
|
||||
pushMinute: 15,
|
||||
author: 'TKMind',
|
||||
},
|
||||
{ updatedBy: 'admin-1' },
|
||||
);
|
||||
assert.equal(updated.enabled, true);
|
||||
assert.equal(updated.sourceUserId, userId);
|
||||
assert.equal(updated.templateVersion, NEWS_MORNING_TEMPLATE_VERSION);
|
||||
|
||||
const preview = await service.preview();
|
||||
assert.equal(preview.page.slug, 'daily-news-0910');
|
||||
assert.match(preview.template.spec, /0910|2026-09-10/u);
|
||||
|
||||
await assert.rejects(
|
||||
() => service.pushDraft({ triggeredBy: 'admin-1' }),
|
||||
/微信服务号未启用/u,
|
||||
);
|
||||
const runs = await service.listRuns();
|
||||
assert.equal(runs[0].status, 'failed');
|
||||
});
|
||||
|
||||
test('internals normalize booleans consistently', () => {
|
||||
assert.equal(wechatNewsMorningDraftInternals.normalizeBoolean('1', false), true);
|
||||
assert.equal(wechatNewsMorningDraftInternals.normalizeBoolean('off', true), false);
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
Reference in New Issue
Block a user