feat(goose): add local v1.49 canary routing, smoke gates, and migration docs

Wire Portal and TKMind proxy to loopback Goose v1.49 via canary env blocks,
with verification scripts, Phase 2/3 evidence baselines, and rollback runbooks
so local upgrade stays isolated from stable 1.41 and production.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-09-08 21:10:20 +08:00
parent d2d463b1db
commit 716ef407fe
68 changed files with 48162 additions and 2 deletions
+179
View File
@@ -0,0 +1,179 @@
# TKMind Goose v1.49 迁移决策表
## 目标与边界
本次升级的目标是“升级运行时基线 + 选择性吸收 upstream”,不是把 TKMind 变成 vanilla Goose。
- Layer 0:PG Session、多实例 affinity、9 实例拓扑——保留。
- Layer 1:goosed 运行时、安全修复、agent loop——跟随 upstream v1.49。
- Layer 2:通用能力的 platform extensions——优先改为薄封装,逐项退役重复实现。
- Layer 3:`tkmind_compat`、harness、sandbox-fs MCP——保留,确保 Portal/H5 契约与业务语义不变。
- Layer 4:Portal、微信、计费、MindSpace、Page Data——保留,属于产品层。
迁移完成的判定不是“upstream 代码已合入”,而是:新旧运行时在受保护业务路径上行为等价,且每个可退役定制都有 canary 证据与可执行回滚。
## Memory 无缝迁移硬闸门
Memory 不能作为普通 extension 随运行时升级一起替换。这里必须分别保护四类状态,不能只比较“能否召回”这一项:
| 状态层 | 当前事实/责任 | v1.49 原则 |
|---|---|---|
| Goose Session | PG 共享 session、`agent_session_id`、SSE/Finish 状态 | 只适配 runtime API;不得让 upstream 本地 SQLite 成为隐式第二份真相。 |
| 业务记忆 | MySQL `h5_conversation_messages`、`h5_user_memory_items`、`h5_episodic_memory_items` | 保持原表、原 ID、原 user/session/evidence 关联与原写入语义;upstream 不得接管 owner。 |
| V2 候选/生命周期 | `h5_memory_v2_candidates` 及 promotion/expire/forget | 继续由 Portal policy 控制;`off` 必须零写入,所有操作按 user scope 严格隔离。 |
| 语义索引/项目记忆 | `memory_embeddings`、可选向量后端、harness bootstrap/remember | 只能是可重建派生数据或受控项目记忆;必须保留来源 ID、embedding 版本和回填水位,不能反向覆盖业务记忆。 |
以下条件未全部满足时,Memory owner 必须保持现有 `conversation-memory` / Portal 链路,v1.49 只能旁路 shadow 观察:
- [ ] 同一 `userId` 能在旧运行时、新运行时、Portal 和所有 Memory adapter 中稳定映射;不能用 display name、邮箱或临时 session ID 代替。
- [ ] `agent_session_id`、`source_session_id`、`evidence_message_id`、memory `id`、`memory_hash` 的映射已导出并逐项对账;不能因 session ID 重建造成重复记忆或丢 evidence。
- [ ] 迁移期间只有一个业务记忆写入 owner。禁止旧链路和 upstream 同时写入同一事实,避免重复提取、hash 冲突和不可逆覆盖。
- [ ] 新 runtime 先双读/对照旧结果,再考虑切换 resolve;`write`、`compact`、promotion、expire、forget 仍走旧 owner,直到对账稳定。
- [ ] 已建立按用户的 count、ID 集合、hash、状态、更新时间、来源 session 和 evidence 外键对账;总数相同不能替代逐用户逐 ID 对账。
- [ ] 已覆盖增量追平:固定 cutover watermark,回填期间捕获 watermark 之后的新写入;不能依赖从 `updated_at=0` 开始的有限批次。
- [ ] 向量索引记录 embedding provider/model/version/dimensions、文本规范化版本、来源 memory ID 和写入时间;维度或模型变化必须新建 namespace/table 或完整重建,禁止混索引。
- [ ] 向量不可用、索引落后、embedding 失败、upstream 超时或结果不一致时,必须 fail-open 回到 legacy exact/recent recall;不得阻塞聊天或删除原记忆。
- [ ] `remember-recent` 使用原始用户消息(优先 `h5_agent_runs.user_message_json`),不得把注入的 `[Memory Context]` 再提取成新记忆;空数组必须表示“无需保存”。
- [ ] 回滚只切回 resolve/write/compact owner 与 feature flag,不删除候选表、向量表或原始记忆;回滚后仍能从原始表重建派生索引。
### Memory 数据不对接上的常见盲点
1. **只迁 goosed session,不迁业务记忆关联**:session 恢复成功不代表 `userId → source_session_id → evidence_message_id` 仍然成立。
2. **把向量库当主库**:向量结果是派生索引,不能替代 MySQL 事实、状态和 forget/expire 语义。
3. **双写但没有幂等键**:必须以稳定的业务 memory ID/hash + user namespace 幂等 upsert;不能用每次 embedding 的随机 ID。
4. **只比总数不比内容**:同数但 user 串线、status 丢失、evidence 外键断裂或更新时间截断,都会表现为“数据在但接不上”。
5. **忽略提取输入污染**:新 runtime 注入的 Memory Context 可能被再次保存,造成旧记忆复制和权重膨胀。
6. **把 schema/DDL 当启动副作用**:本地可以显式建空表并验证;生产 schema、备份、回填和 owner 切换必须是独立审批步骤,本次升级禁止触碰 103。
## 本地优先与生产隔离规则
本次迁移只能在本地 loopback、隔离数据库和临时 workspace 开始。未获得新的明确授权前,禁止:
- 连接 103、105 或任何生产数据库、生产 goosed、生产 MindSpace 服务;
- 执行生产发布、runtime/artifact 生成、远端迁移、远端 backfill、远端 schema DDL 或生产重启;
- 使用生产 `DATABASE_URL`、`GOOSE_SESSION_DB_URL`、Memory provider key 或生产数据做测试;
- 将 upstream Memory backend 接入 Portal 主路径,或修改 production profile 的 Memory 开关。
本地测试必须使用显式 loopback 地址和隔离数据库名,例如 `127.0.0.1` 的 MySQL/PG;测试启动前应打印并检查目标 host、database、runtime profile,命中 `103`、`105`、公网数据库或 production profile 时直接失败。优先使用脱敏 fixture 或本地数据副本,测试结束删除临时用户/会话/索引,但保留可复核的对账报告。
## 决策规则
| 判断问题 | Replace:upstream 替代 | Keep:保留定制 | Thin wrapper:薄封装 upstream |
|---|---|---|---|
| 是否 TKMind 独有? | 否 | 是 | 部分独有 |
| upstream 是否明显更成熟? | 是 | 否或不对等 | 是,但缺 TKMind 钩子 |
| 替换后 H5/微信契约是否不变? | 是 | 不适用 | 需要 adapter 层 |
| 是否绑定 MindSpace、计费或权限? | 否 | 是 | 部分绑定 |
只有“非独有 + upstream 更强 + 契约可不变”同时成立时,才直接 Replace。任何涉及用户空间、发布、计费、权限、微信 ACK 或 PG Session 的能力,默认先 Keep;若能复用 upstream 内核,采用 Thin wrapper。
## 定制项决策表
状态含义:`Candidate` 表示建议方向,尚未凭 canary 取得退役资格;`Keep` 表示本次不以 upstream 替代;`Wrapper` 表示保留入口与契约,内部逐步换内核。
| 定制项 | 决策 | v1.49 迁移动作 | 退役/切换门槛 | 回滚点 |
|---|---|---|---|---|
| `platform_extensions/web.rs`(DuckDuckGo + fetch) | Wrapper → Replace | 保留现有 extension 名与 H5 调用入口;内部优先转发 upstream `web-search` / `browser-use` skill。 | 搜索、抓取、超时、fallback、权限与结果格式回归通过;无 TKMind 专属域规则回归。 | 切回旧 extension 实现。 |
| `session-reply-wait` 与 Portal 侧 DeepSeek workaround | Wrapper / Keep(分阶段) | 先在 v1.49 保留等待、Finish、重试与游标适配;对 upstream reasoning 修复做独立 canary。 | 工具调用后的 `reasoning_content`、SSE Finish、断线续播、重复消息与超时回归通过。 | 保留 `session-reply-wait` 与旧兼容链路。 |
| DeepSeek no-think 代理(`:18036`) | Wrapper → Pass-through | 先保留独立代理与端口;验证 v1.49 原生兼容后,再将代理改为 pass-through,最后才评估停用。 | 生产同构工具轮次通过,候选失效时可自动回落稳定版本;不得仅凭版本号拆除。 | 恢复旧 `:18036` no-think 处理。 |
| `billing-token-state` 粗估价 | Candidate Replace / Simplify | 优先读取单条消息 usage/cost 与 cache token;`accumulated_cost` 稳定时,Portal 仅保留缺失字段时的保守估算。 | Finish 稳定带完整累计成本;账单、margin、补偿与幂等回归通过;不能出现超扣。 | 恢复 Portal 估算逻辑。 |
| 部分 message sanitize 补丁 | Candidate Replace | 对比 v1.49 provider message 处理与本地补丁 diff;只删除已被 upstream 覆盖且契约等价的分支。 | DeepSeek、多模态隔离、工具轮次、历史消息显示回归通过。 | 恢复对应 sanitize 分支。 |
| PG Session 多实例共享 | Keep | 移植到 v1.49 session/runtime 接口;保持 PG、9 实例 affinity 与共享 session 语义。 | 多实例并发、重启恢复、session 一致性与故障切换通过。 | 保留旧 PG adapter / 配置。 |
| `tkmind_compat`(`/sessions` 等 REST adapter) | Keep | 仅适配 upstream v1.49 内部 API;对外 REST/H5 契约不变。 | Portal、H5、微信客户端不需要改请求格式;旧客户端回归通过。 | 切回旧 goosed adapter。 |
| `tkmind_memory`(harness bootstrap/remember) | Keep / Wrapper | 保留项目记忆与 capability 门控;若 upstream 有 projects/memory 原语,只在内部复用,不改变 TKMind 入口。 | bootstrap、remember、权限边界与项目级记忆回归通过。 | 使用现有 harness 实现。 |
| sandbox-fs MCP(MindSpace、Page Data、`generate_image`) | Keep | 按 v1.49 extension/MCP 生命周期接口移植;保持路径隔离、页面落盘、缩略图 fallback 与图片策略。 | 发布/改页、Page Data、图片生成、私有路径隔离、Transport closed 重试回归通过。 | 保留现有 sandbox-fs 子进程与 Portal fallback。 |
| aider / openhands platform extensions | Keep | 保持 `coding_router`、微信与 agent-run 策略;只适配运行时加载接口。 | coding 路由、模型名映射、权限与失败回退不变。 | 保留旧 extension。 |
| `custom_tkmind_relay_deepseek` | Keep | 保留计费、margin、Admin 模型中心绑定;若 upstream provider 足够,只替换内部传输实现。 | provider/key/model 目录、计费归属、margin 与 canary 策略回归通过。 | 切回 custom relay。 |
| `capabilities.mjs` + extension policy | Keep / Wrapper | 保留用户权限、canary、微信策略;可将通用 extension 解析委托给 upstream。 | capability 不越权,`sandbox-fs` 与 `read_image` 等关键策略不回归。 | 使用现有 policy builder。 |
## 明确不替换的产品契约
以下能力不能因为 upstream 1.49 提供了相似原语就直接删除:
- MindSpace 页面发布、`edit_file` 落盘、Finish 合并与 Page Data 交付。
- `sandbox-fs` 的 workspace 路径隔离、图片生成、缩略图 fallback 与 MCP 失败兜底。
- 微信 5 秒 ACK、消息路由、页面链接验证与定时任务投递。
- 计费、usage/cost、margin、provider/model 权限与 canary 策略。
- PG Session 共享、9 实例 affinity、Portal/H5 `/sessions` 等兼容 REST。
- SSE 断线续播、Portal replay ID 与 Goose `Last-Event-ID` 的映射。
- 私有 MindSpace 页 noindex、confirmed public 才可收录的 SEO/GEO 策略。
## 分阶段迁移清单
### Phase 0:冻结基线
- [x] 记录当前 1.41/现生产候选的 commit、runtime artifact、配置与端口。(见 [docs/goose-v149-local-phase0-manifest.md](docs/goose-v149-local-phase0-manifest.md))
- [x] 拉取 upstream `v1.49.0`;创建 `upgrade/v1.49-tkmind` worktree(`/Users/john/Project/tkmind_go-v149`)。
- [x] 本地隔离 PG 库 `goose_sessions_v149_dev`;启动脚本 `scripts/run-goosed-v149-local.sh`(端口 `18049`)。
- [x] 建立旧版与 v1.49 的 canary 开关,以及一键回落路径(`scripts/switch-goose-v149-canary.sh` + [docs/goose-v149-canary-runbook.md](docs/goose-v149-canary-runbook.md))。
- [x] 保存关键业务场景的输入、SSE 事件、Finish 状态、usage/cost 与产物校验结果(`node scripts/capture-goose-v149-baseline.mjs` + `node scripts/check-goosed-v149-all.mjs`)。
- [x] 导出本地基线 Memory manifest:每用户 memory ID/hash/status/source session/evidence、候选状态、向量 namespace/version(`node scripts/export-goose-v149-memory-manifest.mjs`)。
- [x] 记录 schema 版本、索引版本、回填 watermark、时区和字符规范化规则(见 manifest `schemaVersion` / `cutoverWatermarkMs` / `textNormalization` 字段)。
### Phase 1:运行时升级
- [x] 将 upstream v1.49 安全修复、agent loop 与 provider 变更移植到 Layer 1(基线已在 v1.49.0 tag)。
- [x] 适配 `tkmind_compat`、PG Session、harness 的加载/生命周期接口(P0 路由 + PG 双后端)。
- [x] 适配 sandbox-fs 的加载/生命周期接口(`extension_overrides` + `/agent/tools` 验证)。
- [x] 自定义 provider 路由(`/config/custom-providers`、`/config/upsert`)+ `custom_tkmind_relay_deepseek` 本地 reply Finish smoke。
- [x] aider / openhands platform extensions + `deploy/coding_router.sh`(`check-goosed-v149-executors.mjs`)。
- [x] v1.49 初始配置强制 `MEMORY_BACKEND=legacy`、`MEMORY_VECTOR_ENABLED=0`、lifecycle/promotion/injection 为 off/shadow(`run-goosed-v149-local.sh` + `check-goosed-v149-memory-policy.mjs`)。
- [x] 先验证旧 owner 在 v1.49 上完成“写入 → compact → 新 session resolve”的本地闭环(`check-goosed-v149-memory-loop.mjs`)。
- [x] 保持 Layer 3–4 对外接口不变(Portal canary 联调:`check-goosed-v149-canary-portal.mjs` ✅;主 Portal canary + `check-goosed-v149-portal-smoke.mjs` ✅;`POST /config/read` 已移植并纳入 smoke)。
### Phase 2:逐项 canary
- [x] web/search:TKMind `platform/web` 已移植至 v1.49(`check-goosed-v149-web-smoke.mjs`);upstream `web-search` skill 对照仍保留。
- [x] DeepSeek:工具调用 Finish(`check-goosed-v149-deepseek-tools.mjs`);reasoning 保留单测(`check-goosed-v149-thinking-preservation.mjs` ✅);`:18036` no-think 代理与 Portal replay 仍保留。
- [x] upstream web-search:`load_skill` 路径 smoke(`check-goosed-v149-skills-smoke.mjs`);TKMind `platform/web` 仍为主 H5 契约。
- [x] cost:`usage`、cache token、`accumulated_cost`、估算回退与账单金额对照(`check-goosed-v149-cost-smoke.mjs` → `docs/baselines/goose-v149-cost-evidence-*.json`)。
- [x] message sanitize:provider 层单测 v1.41≈v1.49(`compare-goose-v149-message-sanitize.mjs` ✅);Portal 多模态/历史展示路径仍保留对照。
- [x] Memory 对账:逐用户逐 ID/hash/status/source/evidence 对照(`compare-goose-v149-memory-manifest.mjs` ✅)。
- [x] Memory 故障演练:legacy owner 超时 fail-open 不中断(`check-goosed-v149-memory-failopen.mjs`);向量→legacy 自动回退待 pgvector canary 时再验。
- [x] 每项记录“通过 / 保留 / 回滚”,不得一次性批量删除(台账:[docs/goose-v149-phase2-evidence.md](docs/goose-v149-phase2-evidence.md))。
**Phase 2 聚合入口:** `node scripts/run-goosed-v149-phase2.mjs`
**Phase 3 收口入口:** `node scripts/run-goosed-v149-phase3.mjs` · 清单:[docs/goose-v149-phase3-closeout.md](docs/goose-v149-phase3-closeout.md)
### Phase 3:退役与收口
- [ ] 只删除已取得 canary 证据的重复逻辑(当前:**无删除项**;见 Phase 3 清单)。
- [x] 保留薄封装入口、feature flag 与回滚说明(canary runbook + `switch-goose-v149-canary.sh`)。
- [x] 更新 runtime 文档、证据链接与 Phase 3 合并闸门(`docs/goose-v149-phase3-closeout.md`、`docs/baselines/README.md`)。
- [x] 本地连续验证 + Memory manifest 无 drift(`run-goosed-v149-phase3.mjs`);**不授权** 103 操作。
- [ ] 合并前运行影响域 verify;完成测试后再讨论 commit、合并或发布(须用户明确批准)。
## 必须通过的回归守卫
涉及受保护路径时,至少执行仓库约定的:
```bash
npm run verify:mindspace-publish-guards
npm run verify:mindspace-publish-guards:full
npm run verify:mindspace-page-sync-guards
npm run verify:h5-session-patches
npm run verify:seo-geo
npm run verify:seo-discovery
npm run verify:page-data
```
DeepSeek 兼容链路还必须覆盖 `release-gate/deepseek-production-parity.test.mjs` 及对应生产影响域场景。任何守卫失败时,结论只能是“保留定制并修复适配”,不能以“upstream 已支持”为理由继续退役。
Memory 本地最低验证集:
```bash
npm run check:memory-v2-contracts
npm run check:memory-v2-config
npm run check:memory-v2-session
npm run check:memory-v2-stack
node --test conversation-memory.test.mjs conversation-repair.test.mjs \
memory-v2-personal-store.test.mjs memory-v2-lifecycle.test.mjs \
memory-v2-pgvector-backfill.test.mjs memory-v2-runtime.test.mjs
```
以上命令只验证本地代码/fixture;若要使用真实本地 MySQL/PG,必须先确认连接字符串是 loopback 隔离库,并把 count、ID/hash、关联和 watermark 对账结果作为附件保存。任何测试失败、数据 drift、owner 不明确或无法证明回滚时,Memory 迁移保持 blocked,不得进入生产。
## 一句话结论
跟随升级,但不做 upstream 全量替换:通用能力(搜索、浏览器、hooks、cost 追踪、DeepSeek 兼容)按证据逐步 Replace 或 Wrapper;MindSpace、Page Data、微信、计费、权限、PG 多实例与 H5/Portal 契约必须 Keep。
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
# Goose v1.49 Phase 0 本地基线
本目录存放 loopback 升级对照用的 JSON 基线,**不含生产密钥**。
| 文件 | 生成命令 |
|---|---|
| `goose-v149-memory-latest.json` | `node scripts/export-goose-v149-memory-manifest.mjs --output docs/baselines/goose-v149-memory-latest.json` |
| `goose-v149-runtime-baseline-*.json` | `node scripts/capture-goose-v149-baseline.mjs` |
| `goose-v149-cost-evidence-*.json` | `node scripts/check-goosed-v149-cost-smoke.mjs` |
| `goose-v149-deepseek-tools-evidence-*.json` | `node scripts/check-goosed-v149-deepseek-tools.mjs` |
| `goose-v149-thinking-preservation-latest.json` | `node scripts/check-goosed-v149-thinking-preservation.mjs` |
| `goose-v149-message-sanitize-latest.json` | `node scripts/compare-goose-v149-message-sanitize.mjs` |
Phase 3 收口:[goose-v149-phase3-closeout.md](../goose-v149-phase3-closeout.md) · 聚合:`node scripts/run-goosed-v149-phase3.mjs`
@@ -0,0 +1,22 @@
{
"schemaVersion": "goose-v149-cost-evidence-v1",
"capturedAt": "2026-09-08T10:06:59.003Z",
"sessionId": "20260908_30",
"requestId": "b0ee6f3f-cedc-470c-84cd-925b83c3c131",
"finishReason": "stop",
"tokenState": {
"inputTokens": 8339,
"outputTokens": 2,
"totalTokens": 8341,
"accumulatedInputTokens": 8339,
"accumulatedOutputTokens": 2,
"accumulatedTotalTokens": 8341,
"accumulatedCost": null
},
"billingState": {
"accumulatedInputTokens": 8339,
"accumulatedOutputTokens": 2,
"accumulatedCost": 0.0022537300000000002
},
"enrichedCost": 0.0022537300000000002
}
@@ -0,0 +1,22 @@
{
"schemaVersion": "goose-v149-cost-evidence-v1",
"capturedAt": "2026-09-08T10:07:14.546Z",
"sessionId": "20260908_37",
"requestId": "4c558b69-64e3-4909-9e33-1cb598a00cc2",
"finishReason": "stop",
"tokenState": {
"inputTokens": 8339,
"outputTokens": 2,
"totalTokens": 8341,
"accumulatedInputTokens": 8339,
"accumulatedOutputTokens": 2,
"accumulatedTotalTokens": 8341,
"accumulatedCost": null
},
"billingState": {
"accumulatedInputTokens": 8339,
"accumulatedOutputTokens": 2,
"accumulatedCost": 0.0022537300000000002
},
"enrichedCost": 0.0022537300000000002
}
@@ -0,0 +1,22 @@
{
"schemaVersion": "goose-v149-cost-evidence-v1",
"capturedAt": "2026-09-08T10:30:13.352Z",
"sessionId": "20260908_50",
"requestId": "752da410-df43-44c6-9958-2c23f3d948ae",
"finishReason": "stop",
"tokenState": {
"inputTokens": 8339,
"outputTokens": 2,
"totalTokens": 8341,
"accumulatedInputTokens": 8339,
"accumulatedOutputTokens": 2,
"accumulatedTotalTokens": 8341,
"accumulatedCost": null
},
"billingState": {
"accumulatedInputTokens": 8339,
"accumulatedOutputTokens": 2,
"accumulatedCost": 0.0022537300000000002
},
"enrichedCost": 0.0022537300000000002
}
@@ -0,0 +1,22 @@
{
"schemaVersion": "goose-v149-cost-evidence-v1",
"capturedAt": "2026-09-08T11:12:36.359Z",
"sessionId": "20260908_66",
"requestId": "ba50d32c-5024-4b77-8372-cf6ea1c5eaf0",
"finishReason": "stop",
"tokenState": {
"inputTokens": 8339,
"outputTokens": 2,
"totalTokens": 8341,
"accumulatedInputTokens": 8339,
"accumulatedOutputTokens": 2,
"accumulatedTotalTokens": 8341,
"accumulatedCost": null
},
"billingState": {
"accumulatedInputTokens": 8339,
"accumulatedOutputTokens": 2,
"accumulatedCost": 0.0022537300000000002
},
"enrichedCost": 0.0022537300000000002
}
@@ -0,0 +1,22 @@
{
"schemaVersion": "goose-v149-cost-evidence-v1",
"capturedAt": "2026-09-08T11:19:29.723Z",
"sessionId": "20260908_79",
"requestId": "388a1d9e-6edb-413a-bd49-9d602e3132d0",
"finishReason": "stop",
"tokenState": {
"inputTokens": 8586,
"outputTokens": 2,
"totalTokens": 8588,
"accumulatedInputTokens": 8586,
"accumulatedOutputTokens": 2,
"accumulatedTotalTokens": 8588,
"accumulatedCost": null
},
"billingState": {
"accumulatedInputTokens": 8586,
"accumulatedOutputTokens": 2,
"accumulatedCost": 0.00232042
},
"enrichedCost": 0.00232042
}
@@ -0,0 +1,22 @@
{
"schemaVersion": "goose-v149-cost-evidence-v1",
"capturedAt": "2026-09-08T12:07:15.664Z",
"sessionId": "20260908_94",
"requestId": "f72ff3fd-413f-4c80-b671-1d4f15d1ecf1",
"finishReason": "stop",
"tokenState": {
"inputTokens": 8586,
"outputTokens": 2,
"totalTokens": 8588,
"accumulatedInputTokens": 8586,
"accumulatedOutputTokens": 2,
"accumulatedTotalTokens": 8588,
"accumulatedCost": null
},
"billingState": {
"accumulatedInputTokens": 8586,
"accumulatedOutputTokens": 2,
"accumulatedCost": 0.00232042
},
"enrichedCost": 0.00232042
}
@@ -0,0 +1,22 @@
{
"schemaVersion": "goose-v149-cost-evidence-v1",
"capturedAt": "2026-09-08T12:23:16.640Z",
"sessionId": "20260908_109",
"requestId": "a9365f33-add9-45f3-8512-6a5554e85194",
"finishReason": "stop",
"tokenState": {
"inputTokens": 8586,
"outputTokens": 2,
"totalTokens": 8588,
"accumulatedInputTokens": 8586,
"accumulatedOutputTokens": 2,
"accumulatedTotalTokens": 8588,
"accumulatedCost": null
},
"billingState": {
"accumulatedInputTokens": 8586,
"accumulatedOutputTokens": 2,
"accumulatedCost": 0.00232042
},
"enrichedCost": 0.00232042
}
@@ -0,0 +1,22 @@
{
"schemaVersion": "goose-v149-cost-evidence-v1",
"capturedAt": "2026-09-08T12:32:49.137Z",
"sessionId": "20260908_124",
"requestId": "b31d65ea-e68a-4a7b-a47b-89b3d365afa2",
"finishReason": "stop",
"tokenState": {
"inputTokens": 8586,
"outputTokens": 2,
"totalTokens": 8588,
"accumulatedInputTokens": 8586,
"accumulatedOutputTokens": 2,
"accumulatedTotalTokens": 8588,
"accumulatedCost": null
},
"billingState": {
"accumulatedInputTokens": 8586,
"accumulatedOutputTokens": 2,
"accumulatedCost": 0.00232042
},
"enrichedCost": 0.00232042
}
@@ -0,0 +1,22 @@
{
"schemaVersion": "goose-v149-cost-evidence-v1",
"capturedAt": "2026-09-08T13:06:15.865Z",
"sessionId": "20260908_141",
"requestId": "d85df70e-9d1d-45d1-9408-96dbff9d5c96",
"finishReason": "stop",
"tokenState": {
"inputTokens": 8586,
"outputTokens": 2,
"totalTokens": 8588,
"accumulatedInputTokens": 8586,
"accumulatedOutputTokens": 2,
"accumulatedTotalTokens": 8588,
"accumulatedCost": null
},
"billingState": {
"accumulatedInputTokens": 8586,
"accumulatedOutputTokens": 2,
"accumulatedCost": 0.00232042
},
"enrichedCost": 0.00232042
}
@@ -0,0 +1,18 @@
{
"schemaVersion": "goose-v149-deepseek-tools-v1",
"capturedAt": "2026-09-08T12:07:03.092Z",
"sessionId": "20260908_84",
"requestId": "24118cbb-ebe9-4f0f-9b8c-59a71cbbad28",
"providerId": "custom_tkmind_relay_deepseek",
"model": "deepseek-chat",
"toolNames": [
"todo__todo_write"
],
"finishReason": "stop",
"eventTypes": [
"Message",
"Message",
"Message",
"Finish"
]
}
@@ -0,0 +1,18 @@
{
"schemaVersion": "goose-v149-deepseek-tools-v1",
"capturedAt": "2026-09-08T12:07:06.888Z",
"sessionId": "20260908_89",
"requestId": "cbdfc7d5-48ee-4fe9-acf1-dd08bbba27b2",
"providerId": "custom_tkmind_relay_deepseek",
"model": "deepseek-chat",
"toolNames": [
"todo__todo_write"
],
"finishReason": "stop",
"eventTypes": [
"Message",
"Message",
"Message",
"Finish"
]
}
@@ -0,0 +1,18 @@
{
"schemaVersion": "goose-v149-deepseek-tools-v1",
"capturedAt": "2026-09-08T12:07:18.952Z",
"sessionId": "20260908_96",
"requestId": "23c5fcf6-e15a-4880-b0d1-c9fd0073c419",
"providerId": "custom_tkmind_relay_deepseek",
"model": "deepseek-chat",
"toolNames": [
"todo__todo_write"
],
"finishReason": "stop",
"eventTypes": [
"Message",
"Message",
"Message",
"Finish"
]
}
@@ -0,0 +1,18 @@
{
"schemaVersion": "goose-v149-deepseek-tools-v1",
"capturedAt": "2026-09-08T12:23:05.066Z",
"sessionId": "20260908_104",
"requestId": "c5f423b5-9dc0-474e-898c-a472a935386e",
"providerId": "custom_tkmind_relay_deepseek",
"model": "deepseek-chat",
"toolNames": [
"todo__todo_write"
],
"finishReason": "stop",
"eventTypes": [
"Message",
"Message",
"Message",
"Finish"
]
}
@@ -0,0 +1,18 @@
{
"schemaVersion": "goose-v149-deepseek-tools-v1",
"capturedAt": "2026-09-08T12:23:19.758Z",
"sessionId": "20260908_112",
"requestId": "0f40ec62-b124-4288-9518-040c4550e329",
"providerId": "custom_tkmind_relay_deepseek",
"model": "deepseek-chat",
"toolNames": [
"todo__todo_write"
],
"finishReason": "stop",
"eventTypes": [
"Message",
"Message",
"Message",
"Finish"
]
}
@@ -0,0 +1,18 @@
{
"schemaVersion": "goose-v149-deepseek-tools-v1",
"capturedAt": "2026-09-08T12:32:37.309Z",
"sessionId": "20260908_119",
"requestId": "3e3c39f4-6165-4e40-9b0a-d4785c969851",
"providerId": "custom_tkmind_relay_deepseek",
"model": "deepseek-chat",
"toolNames": [
"todo__todo_write"
],
"finishReason": "stop",
"eventTypes": [
"Message",
"Message",
"Message",
"Finish"
]
}
@@ -0,0 +1,18 @@
{
"schemaVersion": "goose-v149-deepseek-tools-v1",
"capturedAt": "2026-09-08T12:32:51.717Z",
"sessionId": "20260908_127",
"requestId": "8c1f27ac-e8c1-47c8-8835-9ae27d217adb",
"providerId": "custom_tkmind_relay_deepseek",
"model": "deepseek-chat",
"toolNames": [
"todo__todo_write"
],
"finishReason": "stop",
"eventTypes": [
"Message",
"Message",
"Message",
"Finish"
]
}
@@ -0,0 +1,18 @@
{
"schemaVersion": "goose-v149-deepseek-tools-v1",
"capturedAt": "2026-09-08T13:04:37.865Z",
"sessionId": "20260908_135",
"requestId": "c1360188-7f59-41e3-ba43-689fa8f7be1d",
"providerId": "custom_tkmind_relay_deepseek",
"model": "deepseek-chat",
"toolNames": [
"todo__todo_write"
],
"finishReason": "stop",
"eventTypes": [
"Message",
"Message",
"Message",
"Finish"
]
}
@@ -0,0 +1,18 @@
{
"schemaVersion": "goose-v149-deepseek-tools-v1",
"capturedAt": "2026-09-08T13:06:18.709Z",
"sessionId": "20260908_144",
"requestId": "328356c3-25b6-4d15-bd18-206dd7c803aa",
"providerId": "custom_tkmind_relay_deepseek",
"model": "deepseek-chat",
"toolNames": [
"todo__todo_write"
],
"finishReason": "stop",
"eventTypes": [
"Message",
"Message",
"Message",
"Finish"
]
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,14 @@
{
"schemaVersion": "goose-v149-message-sanitize-v1",
"capturedAt": "2026-09-08T13:06:26.641Z",
"v149": {
"passed": 11,
"root": "/Users/john/Project/tkmind_go-v149"
},
"v141": {
"passed": 11,
"ok": true,
"root": "/Users/john/Project/tkmind_go-v141-prod-prep"
},
"equivalent": true
}
@@ -0,0 +1,283 @@
{
"schemaVersion": "goose-v149-baseline-v1",
"capturedAt": "2026-09-08T10:02:06.825Z",
"target": "https://127.0.0.1:18049",
"status": {
"status": "ok"
},
"sessionsSummary": {
"count": 20,
"sample": [
{
"id": "20260908_20",
"working_dir": "/Users/john/Project/Memind",
"name": "pong",
"user_set_name": false,
"session_type": "user",
"created_at": "2026-09-08T10:02:00.279689Z",
"updated_at": "2026-09-08T10:02:02.146310Z",
"extension_data": {
"enabled_extensions.v0": {
"extensions": [
{
"type": "platform",
"name": "tom",
"description": "Inject custom context into every turn via GOOSE_MOIM_MESSAGE_TEXT and GOOSE_MOIM_MESSAGE_FILE environment variables",
"display_name": "Top Of Mind",
"bundled": true
},
{
"type": "platform",
"name": "skills",
"description": "Discover and provide skill instructions from filesystem and builtins",
"display_name": "Skills",
"bundled": true
},
{
"type": "platform",
"name": "aider",
"description": "Delegate multi-file coding tasks to Aider instead of developer write/edit",
"display_name": "Aider",
"bundled": true
},
{
"type": "platform",
"name": "Extension Manager",
"description": "Enable extension management tools for discovering, enabling, and disabling extensions",
"display_name": "Extension Manager",
"bundled": true
},
{
"type": "platform",
"name": "developer",
"description": "Write and edit files, and execute shell commands",
"display_name": "Developer",
"bundled": true
},
{
"type": "platform",
"name": "summon",
"description": "Load knowledge and delegate tasks to subagents",
"display_name": "Summon",
"bundled": true
},
{
"type": "platform",
"name": "openhands",
"description": "Delegate multi-file coding tasks to OpenHands instead of developer write/edit",
"display_name": "OpenHands",
"bundled": true
},
{
"type": "platform",
"name": "apps",
"description": "Create and manage custom Goose apps through chat. Apps are HTML/CSS/JavaScript and run in sandboxed windows.",
"display_name": "Apps",
"bundled": true
},
{
"type": "platform",
"name": "analyze",
"description": "Analyze code structure with tree-sitter: directory overviews, file details, symbol call graphs",
"display_name": "Analyze",
"bundled": true
},
{
"type": "platform",
"name": "todo",
"description": "Enable a todo list for goose so it can keep track of what it is doing",
"display_name": "Todo",
"bundled": true
}
]
}
},
"usage": {
"input_tokens": 8339,
"output_tokens": 2,
"total_tokens": 8341,
"cache_read_input_tokens": 0,
"cache_write_input_tokens": null
},
"accumulated_usage": {
"input_tokens": 8339,
"output_tokens": 2,
"total_tokens": 8341,
"cache_read_input_tokens": 0,
"cache_write_input_tokens": 0
},
"accumulated_cost": null,
"schedule_id": null,
"recipe": null,
"user_recipe_values": null,
"conversation": null,
"message_count": 3,
"last_message_at": "2026-09-08T10:02:01Z",
"provider_name": "custom_tkmind_relay_deepseek",
"model_config": {
"model_name": "deepseek-chat",
"temperature": null,
"max_tokens": null,
"toolshim": false,
"toolshim_model": null
},
"goose_mode": "auto",
"archived_at": null,
"project_id": null,
"parent_session_id": null,
"last_message_snippet": null
},
{
"id": "20260908_19",
"working_dir": "/Users/john/Project/Memind",
"name": "New Chat",
"user_set_name": false,
"session_type": "user",
"created_at": "2026-09-08T10:02:00.057531Z",
"updated_at": "2026-09-08T10:02:00.064842Z",
"extension_data": {
"enabled_extensions.v0": {
"extensions": [
{
"type": "platform",
"name": "openhands",
"description": "OpenHands coding delegate",
"display_name": null,
"bundled": null,
"available_tools": [
"code"
]
},
{
"type": "platform",
"name": "aider",
"description": "Aider coding delegate",
"display_name": null,
"bundled": null,
"available_tools": [
"code"
]
}
]
}
},
"usage": {
"input_tokens": null,
"output_tokens": null,
"total_tokens": null,
"cache_read_input_tokens": null,
"cache_write_input_tokens": null
},
"accumulated_usage": {
"input_tokens": null,
"output_tokens": null,
"total_tokens": null,
"cache_read_input_tokens": null,
"cache_write_input_tokens": null
},
"accumulated_cost": null,
"schedule_id": null,
"recipe": null,
"user_recipe_values": null,
"conversation": null,
"message_count": 0,
"last_message_at": null,
"provider_name": null,
"model_config": null,
"goose_mode": "auto",
"archived_at": null,
"project_id": null,
"parent_session_id": null,
"last_message_snippet": null
},
{
"id": "20260908_18",
"working_dir": "/Users/john/Project/Memind",
"name": "New Chat",
"user_set_name": false,
"session_type": "user",
"created_at": "2026-09-08T10:01:59.691171Z",
"updated_at": "2026-09-08T10:01:59.885622Z",
"extension_data": {
"enabled_extensions.v0": {
"extensions": [
{
"type": "stdio",
"name": "sandbox-fs",
"description": "MindSpace sandbox filesystem MCP (local v1.49 smoke)",
"cmd": "/opt/homebrew/Cellar/node/26.0.0/bin/node",
"args": [
"/Users/john/Project/Memind/mindspace-sandbox-mcp.mjs",
"/var/folders/wv/y43yzlmn7gd1bcsq1fcwx3b00000gn/T/goose-v149-sandbox-3ADPMS"
],
"envs": {
"ALLOWED_TOOLS": "write_file,read_file,edit_file,create_dir,list_dir",
"SANDBOX_ROOT": "/var/folders/wv/y43yzlmn7gd1bcsq1fcwx3b00000gn/T/goose-v149-sandbox-3ADPMS"
},
"env_keys": [],
"timeout": null,
"cwd": null,
"bundled": null,
"available_tools": [
"write_file",
"read_file",
"edit_file",
"create_dir",
"list_dir"
]
},
{
"type": "platform",
"name": "skills",
"description": "skills",
"display_name": null,
"bundled": null
}
]
}
},
"usage": {
"input_tokens": null,
"output_tokens": null,
"total_tokens": null,
"cache_read_input_tokens": null,
"cache_write_input_tokens": null
},
"accumulated_usage": {
"input_tokens": null,
"output_tokens": null,
"total_tokens": null,
"cache_read_input_tokens": null,
"cache_write_input_tokens": null
},
"accumulated_cost": null,
"schedule_id": null,
"recipe": null,
"user_recipe_values": null,
"conversation": null,
"message_count": 0,
"last_message_at": null,
"provider_name": null,
"model_config": null,
"goose_mode": "auto",
"archived_at": null,
"project_id": null,
"parent_session_id": null,
"last_message_snippet": null
}
]
},
"reply": {
"requestId": "a53113c2-8462-4b6d-94f6-0303f6eebea5",
"outcome": "error",
"events": [
{
"type": "Error",
"error": "Provider not set",
"chat_request_id": "a53113c2-8462-4b6d-94f6-0303f6eebea5",
"request_id": "a53113c2-8462-4b6d-94f6-0303f6eebea5"
}
],
"sessionId": "20260908_21"
}
}
@@ -0,0 +1,12 @@
{
"schemaVersion": "goose-v149-thinking-preservation-v1",
"capturedAt": "2026-09-08T13:06:25.818Z",
"worktree": "/Users/john/Project/tkmind_go-v149",
"tests": [
"test_deepseek_thinking_preserved_in_tool_call_message",
"test_kimi_thinking_preserved_in_tool_call_message",
"test_reasoning_preserved_when_combined_with_tool_call",
"test_multi_tool_response_preserves_reasoning_and_message_id_correlation"
],
"passed": 4
}
+109
View File
@@ -0,0 +1,109 @@
# Goose v1.49 本地 Canary 运行手册
> 范围:仅本机 loopback(`:18049` vs `:18006`),**禁止**连接 103/105 或修改生产 launchd。
> 决策表:[TKMIND_V1_49_MIGRATION.md](../TKMIND_V1_49_MIGRATION.md)
## 架构
```text
Portal (pnpm dev :8081)
│
├─ TKMIND_GOOSE_CANARY=off → TKMIND_API_TARGET=https://127.0.0.1:18006 (stable 1.41)
├─ TKMIND_GOOSE_CANARY=all → TKMIND_API_TARGET=https://127.0.0.1:18049 (v1.49 候选)
└─ TKMIND_GOOSE_CANARY=users → 双 target;canary 用户路由到 v1.49(需 USER_IDS)
```
Memory 在 canary 期间保持 legacy owner(MySQL `h5_user_memory_items`),upstream goosed **不得**写入业务记忆。
## 前置条件
```bash
bash scripts/build-goosed-v149-local.sh
bash scripts/ensure-goose-v149-local-db.sh
bash scripts/run-goosed-v149-local.sh # 另开终端,监听 :18049
curl -sk https://127.0.0.1:18049/status # → ok
```
## 一键开启 Canary(全量切到 v1.49)
```bash
bash scripts/switch-goose-v149-canary.sh on all
pnpm dev # 重启 Portal 以加载 .env.local
node scripts/check-goosed-v149-canary-portal.mjs # 隔离 :8083 验证(不干扰主 Portal)
```
> 已写入 `.env.local` canary 块。主 Portal `:8081` 须重启后才路由到 `:18049`。
> v1.49 已移植 `platform/web`(`web_search` / `fetch_url`);验证:`node scripts/check-goosed-v149-web-smoke.mjs`。
脚本会在 `.env.local` 写入 `# GOOSE_V149_CANARY_BEGIN … END` 块,设置:
- `TKMIND_GOOSE_CANARY=all`
- `TKMIND_API_TARGET=https://127.0.0.1:18049`
- Memory 安全开关(legacy / vector off)
- `H5_PUBLIC_BASE_URL=http://127.0.0.1:8081`(避免 Vite `:5173` 未起时页面链接失效)
## 按用户 Canary(双 target)
```bash
export TKMIND_GOOSE_CANARY_USER_IDS="<your-user-uuid>"
bash scripts/switch-goose-v149-canary.sh on users
pnpm dev
```
Portal 会注册 `[18006, 18049]` 双 target;canary 用户的新 session 应 pin 到 v1.49(见 `scripts/goose-v149-canary.mjs` + `release-gate/canary-routing.mjs`)。
## 一键回滚(回落 stable)
```bash
bash scripts/switch-goose-v149-canary.sh rollback
pnpm dev
```
回滚仅移除 canary 块,不删 v1.49 进程或 PG 库;stable `:18006` 保持可用。
## 验证
```bash
# Phase 2 聚合(推荐日常)
node scripts/run-goosed-v149-phase2.mjs
# Phase 3 收口(合并前)
node scripts/run-goosed-v149-phase3.mjs
# 单项 / 全量 smoke
node scripts/check-goosed-v149-all.mjs
node scripts/check-goosed-v149-memory-loop.mjs
node scripts/check-goosed-v149-portal-smoke.mjs # 主 Portal 重启后
node scripts/check-goosed-v149-canary-portal.mjs # 隔离 :8083
```
## Phase 0 基线采集
```bash
# Runtime 样本(status / sessions / 可选 SSE reply)
node scripts/capture-goose-v149-baseline.mjs
# Memory manifest(每用户 ID/hash/evidence/source session)
node scripts/export-goose-v149-memory-manifest.mjs --output docs/baselines/goose-v149-memory-latest.json
```
输出目录:`docs/baselines/`(git 可跟踪的本地基线 JSON)。
## 环境变量参考
见 [goose-v149-canary.env.example](./goose-v149-canary.env.example)。
| 变量 | 说明 |
|---|---|
| `TKMIND_GOOSE_CANARY` | `off` / `all` / `users` |
| `TKMIND_API_TARGET_STABLE` | 默认 `https://127.0.0.1:18006` |
| `TKMIND_API_TARGET_V149` | 默认 `https://127.0.0.1:18049` |
| `TKMIND_GOOSE_CANARY_USER_IDS` | 逗号分隔 user UUID(users 模式) |
| `GOOSE_V149_CANARY_ENV_FILE` | 默认 `.env.local` |
## 禁止项
- 不得把 `TKMIND_API_TARGET` 指向 103/105 公网或生产端口
- 不得在本阶段修改 103 launchd、105 nginx
- 不得启用 upstream Memory backend 写入 MySQL 业务表
- 生产发布仍须走 `main` + `check-release-ready.sh` + Gate report
+25
View File
@@ -0,0 +1,25 @@
# Goose v1.49 local canary example.
# Copy into .env.local via: bash scripts/switch-goose-v149-canary.sh on all
# Rollback: bash scripts/switch-goose-v149-canary.sh rollback
# --- Full canary (all Portal traffic → v1.49) ---
# TKMIND_GOOSE_CANARY=all
# TKMIND_API_TARGET=https://127.0.0.1:18049
# TKMIND_API_TARGET_STABLE=https://127.0.0.1:18006
# TKMIND_API_TARGET_V149=https://127.0.0.1:18049
# --- Per-user canary ---
# TKMIND_GOOSE_CANARY=users
# TKMIND_API_TARGET=https://127.0.0.1:18006
# TKMIND_API_TARGETS=https://127.0.0.1:18006,https://127.0.0.1:18049
# TKMIND_GOOSE_CANARY_USER_IDS=replace-with-user-uuid
# Memory safety (Phase 0–1)
MEMORY_BACKEND=legacy
MEMORY_VECTOR_ENABLED=0
MEMORY_AGENT_INJECTION_MODE=off
MEMORY_LIFECYCLE_ENABLED=0
MEMORY_CANDIDATE_ENABLED=0
# Goose v1.49 local session DB (isolated from production memind_sessions)
# GOOSE_SESSION_DB_URL=postgresql://john@127.0.0.1:5432/goose_sessions_v149_dev
+77
View File
@@ -0,0 +1,77 @@
# Goose v1.49 本地升级 Phase 0 基线
> 范围:仅本机 loopback,禁止连接 103/105 或生产库。
> 决策表:[TKMIND_V1_49_MIGRATION.md](../TKMIND_V1_49_MIGRATION.md)
## Stable 基线(103 生产,只读对照)
| 项 | 值 |
|---|---|
| goosed 版本 | `goose-server 1.41.0` |
| 二进制 | `tkmind_go-native/releases/goosed-20260729-v141-m1` |
| 源码分支 | `upgrade/goose-1.47`(TKMind 补丁) |
| 端口 | `18006`–`18014`(native launchd × 9) |
| Session PG | `memind_sessions` @ `127.0.0.1:5432`(103) |
| 记录时间 | 2026-09-08 |
## v1.49 候选(本地隔离)
| 项 | 值 |
|---|---|
| worktree | `/Users/john/Project/tkmind_go-v149` |
| 分支 | `upgrade/v1.49-tkmind` |
| upstream tag | `v1.49.0` |
| 端口 | `18049` |
| 启动命令 | `goose serve --tls`(**v1.49 已移除 `goosed`/`goose-server`**,改为 ACP HTTP/WS) |
| Session PG | `goose_sessions_v149_dev` @ `127.0.0.1:5432` |
| XDG 隔离 | `/tmp/goose-v149-runtime/{config,data,state,cache}` |
| Portal 目标 | `TKMIND_API_TARGET_V149=https://127.0.0.1:18049`(独立 H5 进程时使用) |
## Memory 本地策略(Phase 1 初始)
```bash
MEMORY_BACKEND=legacy
MEMORY_VECTOR_ENABLED=0
MEMORY_AGENT_INJECTION_MODE=off
MEMORY_LIFECYCLE_ENABLED=0
MEMORY_CANDIDATE_ENABLED=0
```
upstream / v1.49 goosed 不得写入 MySQL 业务记忆;只走现有 Portal `conversation-memory` owner。
## Phase 0 检查项
- [x] 记录 stable 1.41 基线
- [x] 拉取 upstream `v1.49.0` tag
- [x] 创建 `upgrade/v1.49-tkmind` worktree
- [x] 创建隔离 PG 库 `goose_sessions_v149_dev`
- [x] 编译 v1.49 goosed 并通过 `GET /status`(`goose 1.49.0` @ `:18049` → `ok`)
- [x] 移植 legacy REST(`/agent/start`、`/sessions`、`/sessions/{id}/events|reply|cancel` 等,见 `tkmind_compat`)
- [x] 导出 Memory manifest(`node scripts/export-goose-v149-memory-manifest.mjs` → `docs/baselines/goose-v149-memory-latest.json`)
- [x] 保存 runtime 基线样本(`node scripts/capture-goose-v149-baseline.mjs` → `docs/baselines/`)
- [x] Canary 开关与一键回滚(`scripts/switch-goose-v149-canary.sh`,见 [goose-v149-canary-runbook.md](./goose-v149-canary-runbook.md))
## Phase 1 验证
```bash
node scripts/run-goosed-v149-phase2.mjs # Phase 2 聚合(cost + drift + phase1)
node scripts/check-goosed-v149-cost-smoke.mjs # Finish token_state / billing
node scripts/compare-goose-v149-memory-manifest.mjs
bash scripts/switch-goose-v149-canary.sh on all # 全量 canary 后重启 pnpm dev
```
```bash
bash scripts/ensure-goose-v149-vendor.sh # 离线 vendor(GitHub 不可用时)
bash scripts/build-goosed-v149-local.sh # 编译 goose CLI(首次较久)
bash scripts/ensure-goose-v149-local-db.sh
bash scripts/run-goosed-v149-local.sh # goose serve @ 18049
curl -sk https://127.0.0.1:18049/status
```
> v1.49 已通过 `crates/goose/src/acp/tkmind/` 重移植 legacy REST;聚合验证:`node scripts/check-goosed-v149-all.mjs`。
## 禁止项
- 不得使用生产 `DATABASE_URL`、`GOOSE_SESSION_DB_URL`(含 RDS / 103 / 105 地址)
- 不得修改 103 launchd、105 nginx、Portal `:8081`
- 不得在本阶段删除 1.41 定制或 `:18036` DeepSeek 代理
+87
View File
@@ -0,0 +1,87 @@
# Goose v1.49 本地编译所需下载链接
> TKMind 需要 **code-mode**(含 v8)。本机 GitHub SSL 不稳定时,可手动下载后放入 cargo 缓存。
## 默认编译(含 code-mode,推荐)
```bash
bash scripts/build-goosed-v149-local.sh
# 等价 GOOSE_V149_BUILD_MODE=codemode
```
会先执行 `scripts/ensure-goose-v149-v8.sh` 确保 v8 缓存就绪。
## 方案 A:仅跳过 local-inference 的 code-mode 编译
```bash
cd /Users/john/Project/tkmind_go-v149
bash ../Memind/scripts/ensure-goose-v149-v8.sh
rustup run 1.96.1 cargo build --release --package goose-cli --bin goose \
--no-default-features --features rustls-tls,aws-providers,system-keyring,code-mode,telemetry
```
## 方案 B:完整 default features(含 v8 + local-inference)
```bash
GOOSE_V149_BUILD_MODE=full bash scripts/build-goosed-v149-local.sh
```
### rusty_v8 静态库(code-mode 必需)
| 项 | 值 |
|---|---|
| 文件 | `librusty_v8_release_aarch64-apple-darwin.a.gz` |
| 直链 | https://github.com/denoland/rusty_v8/releases/download/v145.0.0/librusty_v8_release_aarch64-apple-darwin.a.gz |
| Release 页 | https://github.com/denoland/rusty_v8/releases/tag/v145.0.0 |
**重要**:cargo/rusty_v8 要求缓存路径是**单个 gzip 文件**,不是目录:
```text
~/.cargo/.rusty_v8/https___github_com_denoland_rusty_v8_releases_download_v145_0_0_librusty_v8_release_aarch64_apple_darwin_a_gz
```
(该路径本身就是文件,不要再套一层子目录。)
手动下载命令:
```bash
bash scripts/ensure-goose-v149-v8.sh
```
或:
```bash
CACHE_FILE="$HOME/.cargo/.rusty_v8/https___github_com_denoland_rusty_v8_releases_download_v145_0_0_librusty_v8_release_aarch64_apple_darwin_a_gz"
mkdir -p "$(dirname "$CACHE_FILE")"
curl -fL --retry 5 -o "$CACHE_FILE" \
"https://github.com/denoland/rusty_v8/releases/download/v145.0.0/librusty_v8_release_aarch64-apple-darwin.a.gz"
```
## 方案 C:minimal(无 code-mode,仅调试 serve 路由)
```bash
GOOSE_V149_BUILD_MODE=minimal bash scripts/build-goosed-v149-local.sh
```
### 2. Git 依赖(已用 file:// 本地 hermit 缓存,一般不需再下)
| 依赖 | 本地路径 |
|---|---|
| agent-client-protocol rust-sdk | `tkmind_go-v141-prod-prep/.hermit/rust/git/checkouts/rust-sdk-.../c97a520` |
| cudaforge | `tkmind_go-v141-prod-prep/.hermit/rust/git/checkouts/cudaforge-.../e7c1967` |
已在 `tkmind_go-v149/Cargo.toml` 的 `[patch.crates-io]` 指向 `file://`。
### 3. upstream Goose 源码
| 项 | 链接 |
|---|---|
| Tag v1.49.0 | https://github.com/aaif-goose/goose/releases/tag/v1.49.0 |
## 验证
```bash
bash scripts/build-goosed-v149-local.sh # 或方案 A 命令
bash scripts/run-goosed-v149-local.sh
curl -sk https://127.0.0.1:18049/status
```
+45
View File
@@ -0,0 +1,45 @@
# Goose v1.49 Phase 2 证据台账
> 本地 loopback only。每项记录 **通过 / 保留 / 回滚**,禁止批量删除定制。
## 执行入口
```bash
node scripts/run-goosed-v149-phase2.mjs
node scripts/check-goosed-v149-cost-smoke.mjs # 需 DEEPSEEK_API_KEY
node scripts/compare-goose-v149-memory-manifest.mjs # 对比 baseline drift
node scripts/check-goosed-v149-deepseek-tools.mjs # 需 DEEPSEEK_API_KEY
node scripts/compare-goose-v149-message-sanitize.mjs # provider sanitize 单测对照
node scripts/check-goosed-v149-thinking-preservation.mjs
node scripts/check-goosed-v149-skills-smoke.mjs
```
## 证据项
| 项 | 脚本/产物 | 状态 | 备注 |
|---|---|---|---|
| Phase 1 聚合 smoke | `check-goosed-v149-all.mjs` | 通过 | 2026-09-08 |
| Finish token_state + cost | `check-goosed-v149-cost-smoke.mjs` | 通过 | in=8339 out=2 cost≈0.00225 USD |
| Memory manifest 无 drift | `compare-goose-v149-memory-manifest.mjs` | 通过 | digest=69b48723… |
| Memory fail-open (legacy) | `check-goosed-v149-memory-failopen.mjs` | 通过 | 超时 degrade 不抛错 |
| Portal canary 联调 | `check-goosed-v149-canary-portal.mjs` | 通过 | 隔离 Portal :8083 → v149 :18049 |
| Session reconcile 路由 | `check-goosed-v149-session-reconcile.mjs` | 通过 | GET /sessions/{id}, add/remove/restart |
| platform/web 扩展 | `check-goosed-v149-web-smoke.mjs` | 通过 | web_search + fetch_url 工具挂载 |
| DeepSeek 工具轮次 | `check-goosed-v149-deepseek-tools.mjs` | 通过/可选 | 需 DEEPSEEK_API_KEY;todo_write Finish |
| DeepSeek reasoning 保留 | `check-goosed-v149-thinking-preservation.mjs` | 通过 | agent 集成测试 4 项 |
| upstream web-search 路径 | `check-goosed-v149-skills-smoke.mjs` | 通过 | skills/load_skill(与 platform/web 并存) |
| message sanitize 对照 | `compare-goose-v149-message-sanitize.mjs` | 通过 | goose-providers sanitize 单测 v1.41≈v1.49 |
| DeepSeek no-think 代理 `:18036` | — | 保留 | 生产 parity;Portal session-reply-wait 仍启用 |
## 回滚
```bash
bash scripts/switch-goose-v149-canary.sh rollback
pnpm dev
```
## 判定规则
- **通过**:脚本 OK + 证据 JSON 已写入 `docs/baselines/`
- **保留**:行为不等价或缺证据,维持 1.41 / 定制实现
- **回滚**:canary 一切换即异常,执行 rollback 脚本
+62
View File
@@ -0,0 +1,62 @@
# Goose v1.49 Phase 3 收口清单
> **本阶段不授权 103/105 操作。** 仅整理证据、回滚路径与合并前闸门。
> 决策表:[TKMIND_V1_49_MIGRATION.md](../TKMIND_V1_49_MIGRATION.md) · Phase 2 台账:[goose-v149-phase2-evidence.md](./goose-v149-phase2-evidence.md)
## 聚合验证入口
```bash
bash scripts/run-goosed-v149-local.sh # :18049 另开终端
node scripts/run-goosed-v149-phase3.mjs # Phase 2 + parity + drift
```
通过时应输出 **`GOOSE_V149_PHASE3_OK`**。
## 已取得证据(可进入合并讨论,不可直接删代码)
| 能力 | 证据 | Phase 3 结论 |
|---|---|---|
| tkmind_compat / PG Session | `check-goosed-v149-all.mjs` | **Keep** — 已适配 v1.49 |
| sandbox-fs / harness | harness + sandbox-fs smoke | **Keep** |
| platform/web | `check-goosed-v149-web-smoke.mjs` | **Keep** — 已移植;upstream skill 并存 |
| skills / web-search | `check-goosed-v149-skills-smoke.mjs` | **Wrapper** — 不替换 platform/web |
| DeepSeek Finish + 工具轮次 | provider + deepseek-tools smoke | **Keep** relay + v1.49 runtime |
| reasoning 保留 | `check-goosed-v149-thinking-preservation.mjs` | **保留** `:18036` 直至生产 parity |
| message sanitize | `compare-goose-v149-message-sanitize.mjs` | provider 层等价;Portal 展示仍 Keep |
| Memory legacy owner | memory-loop + drift + failopen | **Keep** — 禁止 upstream 写 MySQL |
| 计费 token_state | cost-smoke JSON | **Wrapper** — 观察 v1.49 原生 cost |
## 明确禁止在本阶段删除
- `session-reply-wait.mjs`、H5 SSE 游标映射、Finish merge
- `deepseek-no-think-proxy.mjs` / `:18036`(生产 parity 未退役)
- MindSpace / Page Data / 微信 / 计费 / capabilities 策略
- 1.41 stable 端口池 `:18006`–`:18014` 与 launchd 配置
## 合并前闸门(Memind 仓库)
1. [ ] `upgrade/v1.49-tkmind` worktree 变更已 review(`tkmind_go-v149` 独立仓库/worktree)
2. [ ] Memind 侧脚本/文档/canary 开关 PR 就绪
3. [ ] `node scripts/run-goosed-v149-phase3.mjs` 连续通过
4. [ ] Memory manifest 无 drift(`compare-goose-v149-memory-manifest.mjs`)
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 构建
## 合并后 / 生产前(仍须单独审批)
- `bash scripts/check-release-ready.sh`
- 生产 Gate report(Core + Impact)
- `npm run verify:mindspace-publish-guards:full` 等受保护路径 verify
- Memory owner 切换:**不在** v1.49 运行时升级包内默认开启
## 回滚(任意时刻)
```bash
bash scripts/switch-goose-v149-canary.sh rollback
pnpm dev
# stable :18006 继续服务;v1.49 :18049 可单独停进程
```
## 基线产物索引
见 [docs/baselines/README.md](./baselines/README.md)。
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Build Goose v1.49 CLI for local `goose serve`.
# Default includes code-mode (required for TKMind). Use GOOSE_V149_BUILD_MODE=minimal to skip v8.
set -euo pipefail
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"
cd "${GOOSED_ROOT}"
: > "${LOG}"
echo "[goose-v149-build] mode=${MODE} start $(date -u +%Y-%m-%dT%H:%M:%SZ)" | tee -a "${LOG}"
case "${MODE}" in
full)
bash "$(dirname "$0")/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}"
# 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 \
--features rustls-tls,aws-providers,system-keyring,code-mode,telemetry 2>&1 | tee -a "${LOG}"
;;
minimal)
echo "[goose-v149-build] WARNING: minimal skips code-mode; not suitable for TKMind prod parity" | tee -a "${LOG}"
rustup run 1.96.1 cargo build --release --package goose-cli --bin goose \
--no-default-features --features rustls-tls,aws-providers,system-keyring 2>&1 | tee -a "${LOG}"
;;
*)
echo "[goose-v149-build] unknown mode: ${MODE} (use codemode|full|minimal)" >&2
exit 1
;;
esac
echo "[goose-v149-build] finished $(date -u +%Y-%m-%dT%H:%M:%SZ)" | tee -a "${LOG}"
"${GOOSED_ROOT}/target/release/goose" --version 2>&1 | tee -a "${LOG}"
if strings "${GOOSED_ROOT}/target/release/goose" | grep -q 'platform_extensions::code_execution'; then
echo "[goose-v149-build] code-mode: code_execution present in binary" | tee -a "${LOG}"
elif [[ "${MODE}" != "minimal" ]]; then
echo "[goose-v149-build] WARNING: code_execution not found in binary" | tee -a "${LOG}"
fi
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env node
/**
* Capture Goose v1.49 local baseline samples (status, sessions, optional SSE reply).
*/
import fs from 'node:fs/promises';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { Readable } from 'node:stream';
import { Agent, fetch } from 'undici';
const port = process.env.GOOSE_V149_PORT || '18049';
const host = process.env.GOOSE_V149_HOST || '127.0.0.1';
const secret = process.env.GOOSE_SERVER__SECRET_KEY || 'local-v149-dev-secret';
const base = `https://${host}:${port}`;
const blockedHosts = ['58.38.22.103', '120.26.184.105'];
function usage() {
return [
'Usage: node scripts/capture-goose-v149-baseline.mjs [--output <dir>] [--skip-reply]',
'',
'Captures /status, /sessions, and optional reply SSE terminal event for Phase 0 baseline.',
].join('\n');
}
function parseArgs(argv) {
const args = { output: '', skipReply: false };
for (let i = 2; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--output' && argv[i + 1]) args.output = argv[++i];
else if (arg === '--skip-reply') args.skipReply = true;
else if (arg === '--help' || arg === '-h') {
console.log(usage());
process.exit(0);
} else {
console.error(`Unknown argument: ${arg}`);
console.error(usage());
process.exit(2);
}
}
return args;
}
if (blockedHosts.includes(host)) {
console.error(`GOOSE_V149_BASELINE_FAIL: refusing production host ${host}`);
process.exit(1);
}
const dispatcher = new Agent({ connect: { rejectUnauthorized: false } });
async function apiFetch(pathname, init = {}) {
const headers = { ...(init.headers ?? {}), 'X-Secret-Key': secret };
if (init.body && !headers['Content-Type']) headers['Content-Type'] = 'application/json';
return fetch(`${base}${pathname}`, { ...init, headers, dispatcher });
}
async function curlJson(url) {
const body = execFileSync('curl', ['-sk', '--connect-timeout', '5', url], { encoding: 'utf8' }).trim();
return body === 'ok' ? { status: 'ok' } : JSON.parse(body);
}
async function captureReplySample(sessionId) {
const requestId = randomUUID();
const events = [];
const eventsResponse = await apiFetch(`/sessions/${sessionId}/events`, {
method: 'GET',
headers: { Accept: 'text/event-stream' },
});
if (!eventsResponse.ok || !eventsResponse.body) {
throw new Error(`events ${eventsResponse.status}`);
}
const replyResponse = await apiFetch(`/sessions/${sessionId}/reply`, {
method: 'POST',
body: JSON.stringify({
request_id: requestId,
user_message: {
role: 'user',
created: Date.now(),
content: [{ type: 'text', text: 'baseline ping' }],
metadata: { userVisible: true, agentVisible: true, displayText: 'baseline ping' },
},
}),
});
replyResponse.body?.cancel?.();
const reader = Readable.fromWeb(eventsResponse.body);
const decoder = new TextDecoder();
let buffer = '';
const deadline = Date.now() + Number(process.env.GOOSE_V149_BASELINE_REPLY_TIMEOUT_MS || 30_000);
for await (const chunk of reader) {
if (Date.now() > deadline) break;
buffer += decoder.decode(chunk, { stream: true });
const frames = buffer.split('\n\n');
buffer = frames.pop() ?? '';
for (const frame of frames) {
let data = '';
for (const line of frame.split('\n')) {
if (line.startsWith('data:')) data += line.slice(5).trim();
}
if (!data) continue;
let event;
try {
event = JSON.parse(data);
} catch {
continue;
}
events.push(event);
const routingId = event.chat_request_id ?? event.request_id;
if (routingId && routingId !== requestId) continue;
if (event.type === 'Finish' || event.type === 'Error') {
return { requestId, outcome: event.type.toLowerCase(), events };
}
}
}
return { requestId, outcome: 'timeout', events };
}
async function main() {
const args = parseArgs(process.argv);
const capturedAt = new Date().toISOString();
const status = await curlJson(`${base}/status`);
const sessions = await curlJson(`${base}/sessions`);
const baseline = {
schemaVersion: 'goose-v149-baseline-v1',
capturedAt,
target: base,
status,
sessionsSummary: {
count: Array.isArray(sessions?.sessions) ? sessions.sessions.length : null,
sample: Array.isArray(sessions?.sessions) ? sessions.sessions.slice(0, 3) : sessions,
},
reply: null,
};
if (!args.skipReply) {
const startResponse = await apiFetch('/agent/start', {
method: 'POST',
body: JSON.stringify({ working_dir: process.cwd() }),
});
const startBody = await startResponse.json();
if (startResponse.ok && startBody?.id) {
baseline.reply = await captureReplySample(startBody.id);
baseline.reply.sessionId = startBody.id;
} else {
baseline.reply = { error: 'agent/start failed', status: startResponse.status, body: startBody };
}
}
const outputDir = args.output
? path.resolve(args.output)
: path.join(process.cwd(), 'docs', 'baselines');
await fs.mkdir(outputDir, { recursive: true });
const outputPath = path.join(
outputDir,
`goose-v149-runtime-baseline-${capturedAt.replace(/[:.]/g, '-')}.json`,
);
await fs.writeFile(outputPath, `${JSON.stringify(baseline, null, 2)}\n`, 'utf8');
console.log(`GOOSE_V149_BASELINE_OK: ${outputPath}`);
}
main().catch((error) => {
console.error(`GOOSE_V149_BASELINE_FAIL: ${error.message}`);
process.exit(1);
});
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env node
/**
* Phase 1 smoke: POST /agent/start on local Goose v1.49 candidate.
*/
import { execFileSync } from 'node:child_process';
const port = process.env.GOOSE_V149_PORT || '18049';
const host = process.env.GOOSE_V149_HOST || '127.0.0.1';
const secret = process.env.GOOSE_SERVER__SECRET_KEY || 'local-v149-dev-secret';
const workingDir = process.env.GOOSE_V149_WORKING_DIR || process.cwd();
const blockedHosts = ['58.38.22.103', '120.26.184.105'];
if (blockedHosts.includes(host)) {
console.error(`GOOSE_V149_AGENT_START_FAIL: refusing production host ${host}`);
process.exit(1);
}
try {
const body = execFileSync(
'curl',
[
'-sk',
'--connect-timeout',
'10',
'-X',
'POST',
'-H',
'Content-Type: application/json',
'-H',
`X-Secret-Key: ${secret}`,
'-d',
JSON.stringify({ working_dir: workingDir }),
`https://${host}:${port}/agent/start`,
],
{ encoding: 'utf8' },
).trim();
const session = JSON.parse(body);
if (!session?.id) {
console.error(`GOOSE_V149_AGENT_START_FAIL: missing session id: ${body}`);
process.exit(1);
}
console.log(`GOOSE_V149_AGENT_START_OK: session=${session.id}`);
} catch (error) {
console.error(`GOOSE_V149_AGENT_START_FAIL: ${error.message}`);
process.exit(1);
}
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env node
/**
* Run all local Goose v1.49 smoke checks in sequence.
*/
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const checks = [
{ name: 'memory-policy', script: 'check-goosed-v149-memory-policy.mjs', required: true },
{ name: 'local', script: 'check-goosed-v149-local.mjs', required: true },
{ name: 'agent-start', script: 'check-goosed-v149-agent-start.mjs', required: true },
{ name: 'harness', script: 'check-goosed-v149-harness.mjs', required: true },
{ name: 'session-reconcile', script: 'check-goosed-v149-session-reconcile.mjs', required: true },
{ name: 'config-read', script: 'check-goosed-v149-config-read.mjs', required: true },
{ name: 'web-smoke', script: 'check-goosed-v149-web-smoke.mjs', required: true },
{ name: 'skills-smoke', script: 'check-goosed-v149-skills-smoke.mjs', required: true },
{
name: 'deepseek-tools',
script: 'check-goosed-v149-deepseek-tools.mjs',
required: Boolean(process.env.DEEPSEEK_API_KEY || process.env.GOOSE_V149_TEST_API_KEY),
},
{
name: 'thinking-preservation',
script: 'check-goosed-v149-thinking-preservation.mjs',
required: true,
},
{
name: 'message-sanitize',
script: 'compare-goose-v149-message-sanitize.mjs',
required: true,
},
{ name: 'reply-smoke', script: 'check-goosed-v149-reply-smoke.mjs', required: true },
{ name: 'sandbox-fs', script: 'check-goosed-v149-sandbox-fs.mjs', required: true },
{ name: 'executors', script: 'check-goosed-v149-executors.mjs', required: true },
{ name: 'memory-loop', script: 'check-goosed-v149-memory-loop.mjs', required: true },
{ name: 'portal-smoke', script: 'check-goosed-v149-portal-smoke.mjs', required: false },
{
name: 'provider',
script: 'check-goosed-v149-provider.mjs',
required: Boolean(process.env.DEEPSEEK_API_KEY || process.env.GOOSE_V149_TEST_API_KEY),
},
];
function runCheck(check) {
const scriptPath = path.join(root, 'scripts', check.script);
const result = spawnSync(process.execPath, [scriptPath], {
cwd: root,
env: process.env,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
return {
...check,
ok: result.status === 0,
status: result.status,
stdout: result.stdout?.trim() ?? '',
stderr: result.stderr?.trim() ?? '',
};
}
const results = checks.map(runCheck);
const failedRequired = results.filter((item) => item.required && !item.ok);
const failedOptional = results.filter((item) => !item.required && !item.ok);
for (const item of results) {
const label = item.required ? 'required' : 'optional';
console.log(`[goose-v149-all] ${item.ok ? 'OK' : 'FAIL'} (${label}) ${item.name}`);
if (item.stdout) console.log(item.stdout);
if (!item.ok && item.stderr) console.error(item.stderr);
}
if (failedRequired.length) {
console.error(
`GOOSE_V149_ALL_FAIL: ${failedRequired.map((item) => item.name).join(', ')}`,
);
process.exit(1);
}
if (failedOptional.length) {
console.log(
`GOOSE_V149_ALL_OK_WITH_SKIPS: optional skipped/failed: ${failedOptional.map((item) => item.name).join(', ')}`,
);
} else {
console.log('GOOSE_V149_ALL_OK');
}
+207
View File
@@ -0,0 +1,207 @@
#!/usr/bin/env node
/**
* Verify Portal boots with Goose v1.49 canary (.env.local block) and routes to :18049.
* Spawns an isolated Portal on H5_PORT=8083 to avoid disrupting the main dev server.
*/
import { spawn } from 'node:child_process';
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadMemindEnvFiles } from './memind-runtime-profile.mjs';
import { describeGooseCanaryConfig, resolveGooseApiTargetsFromEnv, applyGooseV149CanaryBlockEnv } from './goose-v149-canary.mjs';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const portalPort = Number(process.env.GOOSE_V149_CANARY_PORTAL_PORT ?? 8083);
const baseUrl = `http://127.0.0.1:${portalPort}`;
const startupTimeoutMs = Number(process.env.GOOSE_V149_CANARY_PORTAL_STARTUP_MS ?? 45_000);
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitForPortal(fetchImpl, timeoutMs) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const response = await fetchImpl(`${baseUrl}/auth/status`);
if (response.ok) return true;
} catch {
// retry
}
await sleep(500);
}
return false;
}
async function requestJson(fetchImpl, url, { method = 'GET', headers = {}, body } = {}) {
const response = await fetchImpl(url, {
method,
headers: {
...(body ? { 'Content-Type': 'application/json' } : {}),
...headers,
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await response.text();
let json = null;
try {
json = text ? JSON.parse(text) : null;
} catch {
json = null;
}
return { ok: response.ok, status: response.status, json, text, headers: response.headers };
}
function readCanaryBlock() {
const envFile = path.join(root, '.env.local');
if (!fs.existsSync(envFile)) return false;
return fs.readFileSync(envFile, 'utf8').includes('# GOOSE_V149_CANARY_BEGIN');
}
async function main() {
if (!readCanaryBlock()) {
throw new Error('canary block missing; run: bash scripts/switch-goose-v149-canary.sh on all');
}
const env = { ...process.env };
loadMemindEnvFiles(root, env);
applyGooseV149CanaryBlockEnv(env, root);
env.H5_PORT = String(portalPort);
env.MEMIND_WORKSPACE_MAINTENANCE = '0';
const canary = resolveGooseApiTargetsFromEnv(env);
if (!canary || canary.primary !== 'https://127.0.0.1:18049') {
throw new Error(`canary env not pointing to v149: ${JSON.stringify(describeGooseCanaryConfig(env))}`);
}
const server = spawn(process.execPath, ['server.mjs'], {
cwd: root,
env,
stdio: ['ignore', 'pipe', 'pipe'],
});
let logTail = '';
server.stdout?.on('data', (chunk) => {
logTail = `${logTail}${chunk}`.slice(-4000);
});
server.stderr?.on('data', (chunk) => {
logTail = `${logTail}${chunk}`.slice(-4000);
});
const cleanup = () => {
if (!server.killed) server.kill('SIGTERM');
};
process.on('exit', cleanup);
process.on('SIGINT', () => {
cleanup();
process.exit(130);
});
const ready = await waitForPortal(globalThis.fetch, startupTimeoutMs);
if (!ready) {
cleanup();
throw new Error(`isolated Portal did not start on :${portalPort} within ${startupTimeoutMs}ms\n${logTail}`);
}
const runtime = await requestJson(globalThis.fetch, `${baseUrl}/api/runtime/status`);
const targetStatuses = runtime.json?.targets ?? [];
const targets = Array.isArray(targetStatuses)
? targetStatuses.map((item) => item.target ?? item).filter(Boolean)
: [];
const primaryFromLog = logTail.includes('Goose v1.49 canary mode=all');
const hasV149 = targets.some((item) => String(item).includes(':18049')) || primaryFromLog;
if (!runtime.ok) {
cleanup();
throw new Error(`runtime status failed: ${runtime.status} ${runtime.text?.slice?.(0, 300)}`);
}
const memory = runtime.json?.memory ?? null;
if (!hasV149) {
cleanup();
throw new Error(`canary Portal must route to :18049, targets=${JSON.stringify(targets)}`);
}
if (memory?.backend !== 'legacy') {
const configSource = memory?.configSource ?? 'unknown';
if (configSource === 'admin-db' || configSource === 'admin-db-cache') {
console.log(
`GOOSE_V149_CANARY_PORTAL_WARN: memory.backend=${memory.backend} from ${configSource}; goose v1.49 routing verified`,
);
} else {
cleanup();
throw new Error(`canary Portal must use memory.backend=legacy, got ${memory?.backend} (${configSource})`);
}
}
const suffix = `${Date.now()}${Math.floor(Math.random() * 1000)}`;
const user = {
username: `gv149c_${suffix}`,
password: 'GooseV149-Canary-2026',
email: `gv149c-${suffix}@example.test`,
displayName: `gv149c_${suffix}`,
};
const register = await requestJson(globalThis.fetch, `${baseUrl}/auth/register`, {
method: 'POST',
body: user,
});
if (!register.ok) {
cleanup();
throw new Error(`register failed: ${register.status}`);
}
const login = await requestJson(globalThis.fetch, `${baseUrl}/auth/login`, {
method: 'POST',
body: { username: user.username, password: user.password },
});
const cookie = login.headers?.get?.('set-cookie')?.split(';', 1)[0] ?? null;
if (!login.ok || !cookie) {
cleanup();
throw new Error(`login failed: ${login.status}`);
}
const started = await requestJson(globalThis.fetch, `${baseUrl}/api/agent/start`, {
method: 'POST',
headers: { Cookie: cookie },
body: {},
});
const sessionId = started.json?.id ?? null;
if (!started.ok || !sessionId) {
cleanup();
throw new Error(`agent/start failed: ${started.status} ${started.text?.slice?.(0, 200)}`);
}
const requestId = crypto.randomUUID();
const run = await requestJson(globalThis.fetch, `${baseUrl}/api/agent/runs`, {
method: 'POST',
headers: { Cookie: cookie },
body: {
session_id: sessionId,
request_id: requestId,
user_message: {
role: 'user',
created: Math.floor(Date.now() / 1000),
content: [{ type: 'text', text: 'canary ping' }],
metadata: { userVisible: true, agentVisible: true, displayText: 'canary ping' },
},
},
});
cleanup();
await sleep(300);
if (!run.ok || run.status !== 202) {
throw new Error(`agent run failed: ${run.status} ${run.text?.slice?.(0, 200)}`);
}
console.log(`GOOSE_V149_CANARY_PORTAL_OK: isolated Portal :${portalPort} session=${sessionId}`);
console.log(` memory.backend=${memory.backend} canaryPrimary=${canary.primary} targets=${targets.join(',') || 'log-confirmed'}`);
}
main().catch((error) => {
console.error(`GOOSE_V149_CANARY_PORTAL_FAIL: ${error.message}`);
process.exit(1);
});
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env node
/**
* Verify v1.49 exposes POST /config/read (Portal readConfig contract).
*/
import { createV149Client } from './goose-v149-sse.mjs';
const client = createV149Client();
async function readKey(key, isSecret = false) {
const body = await client.apiJson('/config/read', { key, is_secret: isSecret });
return body;
}
async function main() {
const provider = await readKey('GOOSE_PROVIDER');
const model = await readKey('GOOSE_MODEL');
const missing = await readKey('__goose_v149_config_read_missing_probe__');
if (missing !== null) {
throw new Error(`missing key should return null, got ${JSON.stringify(missing)}`);
}
console.log(
`GOOSE_V149_CONFIG_READ_OK: provider=${JSON.stringify(provider)} model=${JSON.stringify(model)}`,
);
}
main().catch((error) => {
console.error(`GOOSE_V149_CONFIG_READ_FAIL: ${error.message}`);
process.exit(1);
});
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env node
/**
* Phase 2: verify Finish SSE carries token_state suitable for Portal billing.
* Requires DEEPSEEK_API_KEY (or GOOSE_V149_TEST_API_KEY).
*/
import fs from 'node:fs';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { normalizeTokenState } from '../billing.mjs';
import { enrichTokenStateForBilling, resolveBillingTokenState } from '../billing-token-state.mjs';
import { collectReplyEvents, createV149Client } from './goose-v149-sse.mjs';
const memindRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
function loadEnvFile(filePath) {
if (!fs.existsSync(filePath)) return;
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq < 0) continue;
const key = trimmed.slice(0, eq).trim();
let value = trimmed.slice(eq + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"'))
|| (value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
if (!process.env[key]) process.env[key] = value;
}
}
loadEnvFile(path.join(memindRoot, '.env'));
const client = createV149Client();
const workingDir = process.env.GOOSE_V149_WORKING_DIR || memindRoot;
const providerId = process.env.GOOSE_V149_PROVIDER_ID || 'custom_tkmind_relay_deepseek';
const model = process.env.GOOSE_V149_PROVIDER_MODEL || 'deepseek-chat';
const apiKey = process.env.DEEPSEEK_API_KEY || process.env.GOOSE_V149_TEST_API_KEY || '';
async function upsertProvider() {
const body = {
provider_name: providerId,
api_key: apiKey,
api_url: process.env.GOOSE_V149_PROVIDER_API_URL
|| process.env.DEEPSEEK_API_BASE_URL
|| 'https://api.deepseek.com/v1',
models: [model],
relay_provider: 'deepseek',
};
let response = await client.apiFetch(`/config/custom-providers/${encodeURIComponent(providerId)}`, {
method: 'PUT',
body: JSON.stringify(body),
});
if (!response.ok && (response.status === 404 || /not found/i.test(await response.text()))) {
await client.apiJson('/config/custom-providers', body);
}
}
async function main() {
if (!apiKey) {
console.log('GOOSE_V149_COST_SMOKE_SKIP: no DEEPSEEK_API_KEY');
process.exit(0);
}
await upsertProvider();
const session = await client.apiJson('/agent/start', { working_dir: workingDir });
if (!session?.id) throw new Error('missing session id');
await client.apiJson('/agent/update_provider', {
session_id: session.id,
provider: providerId,
model,
});
const requestId = randomUUID();
const result = await collectReplyEvents({
client,
sessionId: session.id,
requestId,
userMessage: {
role: 'user',
created: Date.now(),
content: [{ type: 'text', text: 'Reply with exactly one word: pong' }],
metadata: { userVisible: true, agentVisible: true, displayText: 'cost smoke ping' },
},
});
if (result.outcome !== 'finish') {
throw new Error(`expected Finish, got ${result.outcome}: ${result.errorEvent?.error ?? 'timeout'}`);
}
const rawTokenState = result.finishEvent?.token_state ?? null;
if (!rawTokenState) {
throw new Error('Finish event missing token_state');
}
const normalized = normalizeTokenState(rawTokenState);
const hasTokens =
normalized.accumulatedInputTokens > 0
|| normalized.accumulatedOutputTokens > 0
|| normalized.inputTokens > 0
|| normalized.outputTokens > 0;
if (!hasTokens) {
throw new Error(`Finish token_state has no token counts: ${JSON.stringify(normalized)}`);
}
const billingState = await resolveBillingTokenState(rawTokenState, { sessionId: session.id });
const enriched = enrichTokenStateForBilling(rawTokenState, {}, {
...process.env,
H5_USE_BACKEND_COST: process.env.H5_USE_BACKEND_COST ?? '1',
H5_COST_ESTIMATE_FROM_TOKENS: process.env.H5_COST_ESTIMATE_FROM_TOKENS ?? '1',
});
const outputDir = path.join(memindRoot, 'docs', 'baselines');
fs.mkdirSync(outputDir, { recursive: true });
const evidencePath = path.join(
outputDir,
`goose-v149-cost-evidence-${new Date().toISOString().replace(/[:.]/g, '-')}.json`,
);
const evidence = {
schemaVersion: 'goose-v149-cost-evidence-v1',
capturedAt: new Date().toISOString(),
sessionId: session.id,
requestId,
finishReason: result.finishEvent?.reason ?? null,
tokenState: normalized,
billingState: {
accumulatedInputTokens: billingState.accumulatedInputTokens,
accumulatedOutputTokens: billingState.accumulatedOutputTokens,
accumulatedCost: billingState.accumulatedCost,
},
enrichedCost: enriched.accumulatedCost,
};
fs.writeFileSync(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`, 'utf8');
console.log(`GOOSE_V149_COST_SMOKE_OK: session=${session.id}`);
console.log(
` tokens in=${normalized.accumulatedInputTokens} out=${normalized.accumulatedOutputTokens} cost=${billingState.accumulatedCost ?? enriched.accumulatedCost ?? 'null'}`,
);
console.log(` evidence=${evidencePath}`);
}
main().catch((error) => {
console.error(`GOOSE_V149_COST_SMOKE_FAIL: ${error.message}`);
process.exit(1);
});
@@ -0,0 +1,215 @@
#!/usr/bin/env node
/**
* Phase 2: DeepSeek provider completes a tool round (todo_write) on v1.49.
* Requires DEEPSEEK_API_KEY (or GOOSE_V149_TEST_API_KEY).
*/
import fs from 'node:fs';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { collectReplyEvents, createV149Client } from './goose-v149-sse.mjs';
const memindRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
function loadEnvFile(filePath) {
if (!fs.existsSync(filePath)) return;
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq < 0) continue;
const key = trimmed.slice(0, eq).trim();
let value = trimmed.slice(eq + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"'))
|| (value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
if (!process.env[key]) process.env[key] = value;
}
}
loadEnvFile(path.join(memindRoot, '.env'));
loadEnvFile(path.join(memindRoot, '.env.local'));
const client = createV149Client();
const workingDir = process.env.GOOSE_V149_WORKING_DIR || memindRoot;
const providerId = process.env.GOOSE_V149_PROVIDER_ID || 'custom_tkmind_relay_deepseek';
const model = process.env.GOOSE_V149_PROVIDER_MODEL || 'deepseek-chat';
const apiKey = process.env.DEEPSEEK_API_KEY || process.env.GOOSE_V149_TEST_API_KEY || '';
const timeoutMs = Number(process.env.GOOSE_V149_DEEPSEEK_TOOLS_TIMEOUT_MS || 120_000);
function contentBlocks(event) {
if (event?.type === 'Message') return event.message?.content ?? [];
if (event?.type === 'UpdateConversation') {
const conversation = event.conversation ?? [];
return conversation.flatMap((message) => message?.content ?? []);
}
return [];
}
function blockLooksLikeTool(block = {}) {
const type = String(block.type ?? block.content_type ?? '').toLowerCase();
if (type === 'toolrequest' || type === 'toolresponse') return true;
if (type.includes('tool')) return true;
if (block.tool_call || block.toolCall || block.tool_request || block.toolRequest) return true;
if (block.name && (block.arguments || block.input)) return true;
return false;
}
function resolveToolName(block = {}) {
const direct =
block.tool_call?.name
?? block.toolCall?.name
?? block.name
?? block.tool_request?.tool_call?.name
?? block.toolRequest?.tool_call?.name;
if (direct) return String(direct);
const nested =
block.toolCall?.value?.name
?? block.tool_call?.value?.name
?? block.toolRequest?.tool_call?.value?.name
?? block.tool_request?.tool_call?.value?.name;
return nested ? String(nested) : null;
}
function toolNamesFromEvents(events = []) {
const names = new Set();
for (const event of events) {
for (const block of contentBlocks(event)) {
if (!blockLooksLikeTool(block)) continue;
const name = resolveToolName(block);
if (name) names.add(name);
}
}
return [...names];
}
async function upsertProvider() {
const body = {
engine: 'openai',
display_name: 'tkmind_relay_deepseek',
api_url:
process.env.GOOSE_V149_PROVIDER_API_URL
|| process.env.DEEPSEEK_API_BASE_URL
|| 'https://api.deepseek.com/v1',
api_key: apiKey,
models: [model, 'deepseek-reasoner'],
supports_streaming: true,
requires_auth: true,
preserves_thinking: false,
};
let response = await client.apiFetch(
`/config/custom-providers/${encodeURIComponent(providerId)}`,
{ method: 'PUT', body: JSON.stringify(body) },
);
if (!response.ok && (response.status === 404 || /not found/i.test(await response.text()))) {
await client.apiJson('/config/custom-providers', body);
}
}
async function main() {
if (!apiKey) {
console.log('GOOSE_V149_DEEPSEEK_TOOLS_SKIP: no DEEPSEEK_API_KEY');
process.exit(0);
}
await upsertProvider();
const session = await client.apiJson('/agent/start', {
working_dir: workingDir,
extension_overrides: [
{
type: 'platform',
name: 'todo',
description: 'todo list for tool-round smoke',
display_name: 'Todo',
bundled: false,
available_tools: ['todo_write'],
},
{
type: 'platform',
name: 'skills',
description: 'skills',
available_tools: [],
},
],
});
if (!session?.id) throw new Error('missing session id');
await client.apiJson('/agent/update_provider', {
session_id: session.id,
provider: providerId,
model,
});
const requestId = randomUUID();
const result = await collectReplyEvents({
client,
sessionId: session.id,
requestId,
timeoutMs,
userMessage: {
role: 'user',
created: Date.now(),
content: [{
type: 'text',
text:
'You must call the todo_write tool exactly once with content "- [ ] goose v149 deepseek tool smoke". '
+ 'After the tool succeeds, reply with exactly one word: done',
}],
metadata: {
userVisible: true,
agentVisible: true,
displayText: 'deepseek tool smoke',
},
},
});
if (result.outcome !== 'finish') {
throw new Error(
`expected Finish, got ${result.outcome}: ${result.errorEvent?.error ?? 'timeout'}`,
);
}
const toolNames = toolNamesFromEvents(result.events);
const hasTodoTool = toolNames.some((name) => /todo_write/i.test(name));
if (!hasTodoTool) {
throw new Error(
`no todo_write tool activity in SSE events; tools=${toolNames.join(',') || 'none'}`,
);
}
const outputDir = path.join(memindRoot, 'docs', 'baselines');
fs.mkdirSync(outputDir, { recursive: true });
const evidencePath = path.join(
outputDir,
`goose-v149-deepseek-tools-evidence-${new Date().toISOString().replace(/[:.]/g, '-')}.json`,
);
fs.writeFileSync(
evidencePath,
`${JSON.stringify({
schemaVersion: 'goose-v149-deepseek-tools-v1',
capturedAt: new Date().toISOString(),
sessionId: session.id,
requestId,
providerId,
model,
toolNames,
finishReason: result.finishEvent?.reason ?? null,
eventTypes: result.events.map((event) => event.type),
}, null, 2)}\n`,
'utf8',
);
console.log(`GOOSE_V149_DEEPSEEK_TOOLS_OK: session=${session.id} tools=${toolNames.join(',')}`);
console.log(` evidence=${evidencePath}`);
}
main().catch((error) => {
console.error(`GOOSE_V149_DEEPSEEK_TOOLS_FAIL: ${error.message}`);
process.exit(1);
});
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env node
/**
* Smoke: aider + openhands platform extensions load on local Goose v1.49.
*/
import { Agent, fetch } from 'undici';
const port = process.env.GOOSE_V149_PORT || '18049';
const host = process.env.GOOSE_V149_HOST || '127.0.0.1';
const secret = process.env.GOOSE_SERVER__SECRET_KEY || 'local-v149-dev-secret';
const workingDir = process.env.GOOSE_V149_WORKING_DIR || process.cwd();
const pollMs = Number(process.env.GOOSE_V149_EXECUTOR_POLL_MS || 500);
const timeoutMs = Number(process.env.GOOSE_V149_EXECUTOR_TIMEOUT_MS || 30_000);
const blockedHosts = ['58.38.22.103', '120.26.184.105'];
if (blockedHosts.includes(host)) {
console.error(`GOOSE_V149_EXECUTOR_FAIL: refusing production host ${host}`);
process.exit(1);
}
const base = `https://${host}:${port}`;
const dispatcher = new Agent({ connect: { rejectUnauthorized: false } });
async function apiFetch(pathname, init = {}) {
const headers = {
...(init.headers ?? {}),
'X-Secret-Key': secret,
};
if (init.body && !headers['Content-Type']) {
headers['Content-Type'] = 'application/json';
}
return fetch(`${base}${pathname}`, {
...init,
headers,
dispatcher,
});
}
async function apiJson(pathname, body) {
const response = await apiFetch(pathname, {
method: 'POST',
body: JSON.stringify(body),
});
const text = await response.text();
if (!response.ok) {
throw new Error(`${pathname} ${response.status}: ${text.slice(0, 800)}`);
}
return text.trim() ? JSON.parse(text) : {};
}
async function waitForTool(sessionId, extensionName, toolName) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const response = await apiFetch(
`/agent/tools?session_id=${encodeURIComponent(sessionId)}&extension_name=${encodeURIComponent(extensionName)}`,
);
const text = await response.text();
if (!response.ok) {
throw new Error(`/agent/tools ${response.status}: ${text.slice(0, 400)}`);
}
const tools = JSON.parse(text);
if (tools.some((tool) => tool.name === toolName)) {
return tools.map((tool) => tool.name);
}
await new Promise((resolve) => setTimeout(resolve, pollMs));
}
throw new Error(`${toolName} not ready within ${timeoutMs}ms`);
}
try {
const session = await apiJson('/agent/start', {
working_dir: workingDir,
extension_overrides: [
{
type: 'platform',
name: 'aider',
description: 'Aider coding delegate',
available_tools: ['code'],
},
{
type: 'platform',
name: 'openhands',
description: 'OpenHands coding delegate',
available_tools: ['code'],
},
],
});
if (!session?.id) {
throw new Error('missing session id from /agent/start');
}
const aiderTools = await waitForTool(session.id, 'aider', 'aider__code');
const openHandsTools = await waitForTool(session.id, 'openhands', 'openhands__code');
console.log(
`GOOSE_V149_EXECUTOR_OK: session=${session.id} aider=${aiderTools.length} openhands=${openHandsTools.length}`,
);
} catch (error) {
console.error(`GOOSE_V149_EXECUTOR_FAIL: ${error.message}`);
process.exit(1);
}
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env node
/**
* Smoke harness bootstrap/remember on local Goose v1.49 candidate.
*/
import { Agent, fetch } from 'undici';
const port = process.env.GOOSE_V149_PORT || '18049';
const host = process.env.GOOSE_V149_HOST || '127.0.0.1';
const secret = process.env.GOOSE_SERVER__SECRET_KEY || 'local-v149-dev-secret';
const workingDir = process.env.GOOSE_V149_WORKING_DIR || process.cwd();
const base = `https://${host}:${port}`;
const blockedHosts = ['58.38.22.103', '120.26.184.105'];
if (blockedHosts.includes(host)) {
console.error(`GOOSE_V149_HARNESS_FAIL: refusing production host ${host}`);
process.exit(1);
}
const dispatcher = new Agent({ connect: { rejectUnauthorized: false } });
async function apiJson(pathname, body) {
const response = await fetch(`${base}${pathname}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Secret-Key': secret,
},
body: JSON.stringify(body),
dispatcher,
});
const text = await response.text();
if (!response.ok) {
throw new Error(`${pathname} ${response.status}: ${text.slice(0, 400)}`);
}
return text.trim() ? JSON.parse(text) : {};
}
async function main() {
const started = await apiJson('/agent/start', { working_dir: workingDir });
if (!started?.id) throw new Error('missing session id from /agent/start');
const bootstrap = await apiJson('/agent/harness_bootstrap', {
sessionId: started.id,
force: true,
query: 'project architecture conventions',
});
if (typeof bootstrap.summary !== 'string') {
throw new Error(`harness_bootstrap missing summary: ${JSON.stringify(bootstrap)}`);
}
const remember = await apiJson('/agent/harness_remember', {
sessionId: started.id,
title: 'goose-v149 harness smoke',
content: 'v1.49 harness remember smoke marker',
});
if (typeof remember.remembered !== 'boolean') {
throw new Error(`harness_remember missing remembered flag: ${JSON.stringify(remember)}`);
}
console.log(
`GOOSE_V149_HARNESS_OK: session=${started.id} bootstrapSource=${bootstrap.source} remembered=${remember.remembered}`,
);
}
main().catch((error) => {
console.error(`GOOSE_V149_HARNESS_FAIL: ${error.message}`);
process.exit(1);
});
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env node
/**
* Phase 0 health check for local Goose v1.49 candidate (loopback only).
*/
import { execFileSync } from 'node:child_process';
const port = process.env.GOOSE_V149_PORT || '18049';
const host = process.env.GOOSE_V149_HOST || '127.0.0.1';
const url = `https://${host}:${port}/status`;
const blockedHosts = ['58.38.22.103', '120.26.184.105'];
if (blockedHosts.includes(host)) {
console.error(`GOOSE_V149_CHECK_FAIL: refusing production host ${host}`);
process.exit(1);
}
try {
const body = execFileSync('curl', ['-sk', '--connect-timeout', '5', url], { encoding: 'utf8' }).trim();
if (body !== 'ok') {
console.error(`GOOSE_V149_CHECK_FAIL: unexpected status body: ${body}`);
process.exit(1);
}
console.log(`GOOSE_V149_CHECK_OK: ${url} -> ${body}`);
const sessionsUrl = `https://${host}:${port}/sessions`;
const sessionsBody = execFileSync('curl', ['-sk', '--connect-timeout', '5', sessionsUrl], {
encoding: 'utf8',
}).trim();
const sessions = JSON.parse(sessionsBody);
if (!Array.isArray(sessions.sessions)) {
console.error(`GOOSE_V149_CHECK_FAIL: /sessions missing sessions array: ${sessionsBody}`);
process.exit(1);
}
console.log(`GOOSE_V149_CHECK_OK: ${sessionsUrl} -> ${sessions.sessions.length} session(s)`);
} catch (error) {
console.error(`GOOSE_V149_CHECK_FAIL: ${error.message}`);
process.exit(1);
}
@@ -0,0 +1,50 @@
#!/usr/bin/env node
/**
* Phase 2: Memory fail-open drill for v1.49 canary profile (legacy owner).
* Verifies resolve failures degrade without throwing when MEMORY_FAIL_OPEN=1.
*/
import { createMemoryV2 } from '../memory-v2.mjs';
const silentLogger = () => ({
warn() {},
info() {},
log() {},
});
async function main() {
const memory = createMemoryV2({
logger: silentLogger(),
legacyMemoryService: {
listMemories: async () => {
throw new Error('simulated legacy resolve timeout');
},
},
env: {
MEMORY_ENABLED: '1',
MEMORY_FAIL_OPEN: '1',
MEMORY_BACKEND: 'legacy',
MEMORY_VECTOR_ENABLED: '0',
MEMORY_AGENT_INJECTION_MODE: 'off',
},
});
const status = memory.getStatus();
if (status.backend !== 'legacy') {
throw new Error(`expected legacy backend for v1.49 profile, got ${status.backend}`);
}
const resolved = await memory.resolve({ userId: 'u-failopen', query: 'marker' });
if (!resolved.degraded) {
throw new Error(`expected degraded resolve, got ${JSON.stringify(resolved)}`);
}
if (!Array.isArray(resolved.memories) || resolved.memories.length !== 0) {
throw new Error(`fail-open should return empty memories, got ${JSON.stringify(resolved.memories)}`);
}
console.log('GOOSE_V149_MEMORY_FAILOPEN_OK: legacy resolve failure degraded without throwing');
}
main().catch((error) => {
console.error(`GOOSE_V149_MEMORY_FAILOPEN_FAIL: ${error.message}`);
process.exit(1);
});
+181
View File
@@ -0,0 +1,181 @@
#!/usr/bin/env node
/**
* Phase 1: legacy Memory owner write → compact → new-session resolve (loopback MySQL).
*/
import crypto from 'node:crypto';
import mysql from 'mysql2/promise';
import { createConversationMemoryService } from '../conversation-memory.mjs';
import { createMemoryV2 } from '../memory-v2.mjs';
import { loadMemindEnvFiles } from './memind-runtime-profile.mjs';
const BLOCKED_DB_HOSTS = ['58.38.22.103', '120.26.184.105', 'rds.aliyuncs.com'];
const MARKER = `goose-v149-memory-loop-${Date.now()}`;
function assertLocalDatabase(env = process.env) {
const raw = String(env.DATABASE_URL ?? '').trim();
if (raw) {
const hostname = new URL(raw.replace(/^mysql:/, 'http:')).hostname.toLowerCase();
for (const blocked of BLOCKED_DB_HOSTS) {
if (hostname.includes(blocked)) throw new Error(`Refusing production DATABASE_URL host: ${hostname}`);
}
return;
}
const host = String(env.MYSQL_HOST ?? '127.0.0.1').trim().toLowerCase();
for (const blocked of BLOCKED_DB_HOSTS) {
if (host.includes(blocked)) throw new Error(`Refusing production MYSQL_HOST: ${host}`);
}
}
function createPool() {
loadMemindEnvFiles(process.cwd());
assertLocalDatabase(process.env);
const options = { connectionLimit: 3 };
if (process.env.DATABASE_URL) {
return mysql.createPool({ uri: process.env.DATABASE_URL, ...options });
}
return mysql.createPool({
host: process.env.MYSQL_HOST ?? '127.0.0.1',
port: Number(process.env.MYSQL_PORT ?? 3306),
user: process.env.MYSQL_USER ?? 'boot',
password: process.env.MYSQL_PASSWORD ?? '',
database: process.env.MYSQL_DATABASE ?? 'tkmind',
...options,
});
}
async function resolveUserId(pool) {
const explicit = String(process.env.GOOSE_V149_MEMORY_LOOP_USER_ID ?? '').trim();
if (explicit) return explicit;
const [rows] = await pool.query(`SELECT id FROM h5_users ORDER BY created_at DESC LIMIT 1`);
const userId = rows[0]?.id ? String(rows[0].id) : '';
if (!userId) throw new Error('No h5_users row found; set GOOSE_V149_MEMORY_LOOP_USER_ID');
return userId;
}
function buildMockLlmProvider() {
return {
async createChatCompletion() {
return {
ok: true,
reply: JSON.stringify({
memories: [{
label: 'fact',
text: `${MARKER} 用户偏好 v1.49 本地记忆闭环测试`,
}],
}),
};
},
};
}
async function ensureSession(pool, userId, sessionId) {
const ts = Date.now();
await pool.query(
`INSERT INTO h5_user_sessions (agent_session_id, user_id, origin, created_at)
VALUES (?, ?, 'h5', ?)
ON DUPLICATE KEY UPDATE user_id = VALUES(user_id)`,
[sessionId, userId, ts],
);
}
async function cleanup(pool, userId, sessionIds) {
await pool.query(
`DELETE FROM h5_user_memory_items WHERE user_id = ? AND memory_text LIKE ?`,
[userId, `%${MARKER}%`],
);
if (sessionIds.length) {
await pool.query(
`DELETE FROM h5_conversation_messages WHERE user_id = ? AND agent_session_id IN (?)`,
[userId, sessionIds],
);
await pool.query(
`DELETE FROM h5_user_sessions WHERE user_id = ? AND agent_session_id IN (?)`,
[userId, sessionIds],
);
}
}
async function main() {
const env = {
...process.env,
MEMORY_ENABLED: '1',
MEMORY_BACKEND: 'legacy',
MEMORY_VECTOR_ENABLED: '0',
MEMORY_AGENT_INJECTION_MODE: 'off',
MEMORY_LIFECYCLE_ENABLED: '0',
MEMORY_CANDIDATE_ENABLED: '0',
MEMORY_EVENT_LOG_ENABLED: '1',
MEMORY_FAIL_OPEN: '1',
USER_CONVERSATION_MEMORY_ENABLED: '1',
USER_CONVERSATION_MEMORY_LLM_ENABLED: '1',
};
const pool = createPool();
const userId = await resolveUserId(pool);
const sessionA = `v149-loop-a-${crypto.randomUUID().slice(0, 8)}`;
const sessionB = `v149-loop-b-${crypto.randomUUID().slice(0, 8)}`;
const messageId = crypto.randomUUID().replace(/-/g, '');
try {
const conversationMemory = createConversationMemoryService(pool, {
llmProviderService: buildMockLlmProvider(),
encryptionKey: process.env.TKMIND_ENCRYPTION_KEY ?? 'local-memory-loop-key',
getEffectiveEnv: async () => env,
});
const memory = createMemoryV2({
legacyMemoryService: conversationMemory,
env,
logger: console,
});
const status = memory.getStatus();
if (status.backend !== 'legacy' || status.selectedBackend !== 'legacy-conversation-memory') {
throw new Error(
`Expected legacy backend, got backend=${status.backend} selected=${status.selectedBackend}`,
);
}
await ensureSession(pool, userId, sessionA);
const written = await memory.write({
userId,
sessionId: sessionA,
messages: [{
id: messageId,
role: 'user',
content: [{ type: 'text', text: `${MARKER} 请记住我喜欢 v1.49 本地记忆闭环测试` }],
metadata: { userVisible: true, agentVisible: true },
}],
});
if (!written.ok || Number(written.memories) < 1) {
throw new Error(`write failed: ${JSON.stringify(written)}`);
}
const compacted = await memory.compact({ userId });
if (!compacted.ok || compacted.source !== 'legacy-conversation-memory') {
throw new Error(`compact failed: ${JSON.stringify(compacted)}`);
}
const resolved = await memory.resolve({
userId,
sessionId: sessionB,
query: MARKER,
});
const texts = (resolved.memories ?? []).map((item) => String(item.text ?? ''));
if (!texts.some((text) => text.includes(MARKER))) {
throw new Error(`resolve in new session missed marker; memories=${JSON.stringify(texts)}`);
}
console.log(`GOOSE_V149_MEMORY_LOOP_OK: user=${userId} sessionA=${sessionA} sessionB=${sessionB}`);
console.log(` write.memories=${written.memories} compact.analyzed=${compacted.analyzed} resolve.count=${texts.length}`);
} finally {
await cleanup(pool, userId, [sessionA, sessionB]);
await pool.end().catch(() => {});
}
}
main().catch((error) => {
console.error(`GOOSE_V149_MEMORY_LOOP_FAIL: ${error.message}`);
process.exit(1);
});
@@ -0,0 +1,89 @@
#!/usr/bin/env node
/**
* Verify Goose v1.49 local run profile keeps Memory on legacy owner (Phase 1 gate).
*/
import { readFileSync, existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describeGooseCanaryConfig } from './goose-v149-canary.mjs';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const REQUIRED_MEMORY = {
MEMORY_BACKEND: 'legacy',
MEMORY_VECTOR_ENABLED: '0',
MEMORY_AGENT_INJECTION_MODE: 'off',
MEMORY_LIFECYCLE_ENABLED: '0',
MEMORY_CANDIDATE_ENABLED: '0',
};
function readText(relativePath) {
const fullPath = path.join(root, relativePath);
if (!existsSync(fullPath)) return '';
return readFileSync(fullPath, 'utf8');
}
function parseEnvBlock(text) {
const env = {};
for (const line of text.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq < 0) continue;
env[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim();
}
return env;
}
function checkMemoryEnv(label, env) {
const failures = [];
for (const [key, expected] of Object.entries(REQUIRED_MEMORY)) {
const actual = String(env[key] ?? '').trim();
if (actual !== expected) {
failures.push(`${label}.${key}=${actual || '(unset)'} expected ${expected}`);
}
}
return failures;
}
function main() {
const failures = [];
const runScript = readText('scripts/run-goosed-v149-local.sh');
for (const [key, expected] of Object.entries(REQUIRED_MEMORY)) {
const pattern = new RegExp(`${key}=["']?\\$\\{${key}:-${expected}\\}["']?`);
if (!pattern.test(runScript)) {
failures.push(`run-goosed-v149-local.sh missing default ${key}=${expected}`);
}
}
const switchScript = readText('scripts/switch-goose-v149-canary.sh');
for (const key of Object.keys(REQUIRED_MEMORY)) {
if (!switchScript.includes(key)) {
failures.push(`switch-goose-v149-canary.sh missing ${key} in canary block`);
}
}
const canaryEnabled = String(process.env.TKMIND_GOOSE_CANARY ?? 'off').trim().toLowerCase() !== 'off';
if (canaryEnabled) {
failures.push(...checkMemoryEnv('process.env', process.env));
}
const canarySummary = describeGooseCanaryConfig(process.env);
if (canarySummary.enabled && canarySummary.mode === 'all') {
if (!String(canarySummary.primary).includes(':18049')) {
failures.push(`canary primary must be v149 loopback, got ${canarySummary.primary}`);
}
}
if (failures.length) {
console.error('GOOSE_V149_MEMORY_POLICY_FAIL:');
for (const item of failures) console.error(` - ${item}`);
process.exit(1);
}
console.log('GOOSE_V149_MEMORY_POLICY_OK: legacy owner enforced for v1.49 local/canary profile');
}
main();
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env node
/**
* Portal + Goose v1.49 integration smoke (optional when Portal is running).
*/
import crypto from 'node:crypto';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { loadMemindEnvFiles } from './memind-runtime-profile.mjs';
import { describeGooseCanaryConfig } from './goose-v149-canary.mjs';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
loadMemindEnvFiles(root, process.env);
const baseUrl = String(process.env.GOOSE_V149_PORTAL_BASE_URL ?? 'http://127.0.0.1:8081').replace(/\/+$/, '');
const v149Port = process.env.GOOSE_V149_PORT || '18049';
const v149Target = process.env.TKMIND_API_TARGET_V149 || `https://127.0.0.1:${v149Port}`;
const skipPortal = process.env.GOOSE_V149_PORTAL_SMOKE_SKIP === '1';
const canary = describeGooseCanaryConfig(process.env);
function curlJson(url, init = {}) {
const args = ['-sk', '--connect-timeout', '5'];
if (init.method && init.method !== 'GET') args.push('-X', init.method);
if (init.headers) {
for (const [key, value] of Object.entries(init.headers)) {
args.push('-H', `${key}: ${value}`);
}
}
if (init.body) args.push('-d', init.body);
args.push(url);
const body = execFileSync('curl', args, { encoding: 'utf8' }).trim();
return body ? JSON.parse(body) : null;
}
function portalReachable() {
try {
curlJson(`${baseUrl}/auth/status`);
return true;
} catch {
return false;
}
}
function v149Reachable() {
try {
const body = execFileSync(
'curl',
['-sk', '--connect-timeout', '5', `https://127.0.0.1:${v149Port}/status`],
{ encoding: 'utf8' },
).trim();
return body === 'ok';
} catch {
return false;
}
}
async function requestJson(url, { method = 'GET', headers = {}, body } = {}) {
const response = await fetch(url, {
method,
headers: {
...(body ? { 'Content-Type': 'application/json' } : {}),
...headers,
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await response.text();
let json = null;
try {
json = text ? JSON.parse(text) : null;
} catch {
json = null;
}
return { ok: response.ok, status: response.status, json, text, headers: response.headers };
}
async function main() {
if (!v149Reachable()) {
throw new Error(`v1.49 goosed not reachable on :${v149Port}; run scripts/run-goosed-v149-local.sh`);
}
if (skipPortal || !portalReachable()) {
console.log(
`GOOSE_V149_PORTAL_SMOKE_SKIP: Portal not running at ${baseUrl}; v149 direct checks only`,
);
console.log('GOOSE_V149_PORTAL_SMOKE_OK: skipped (v149 reachable)');
return;
}
const runtime = await requestJson(`${baseUrl}/api/runtime/status`);
const memory = runtime.json?.memory ?? null;
const targetStatuses = runtime.json?.targets ?? [];
const portalTargets = Array.isArray(targetStatuses)
? targetStatuses.map((item) => item.target ?? item).filter(Boolean)
: [];
const portalUsesV149 = portalTargets.some((item) => String(item).includes(`:${v149Port}`));
if (!runtime.ok || !memory) {
throw new Error(`runtime status failed: ${runtime.status} ${runtime.text?.slice?.(0, 200)}`);
}
if (canary.enabled && !portalUsesV149) {
console.log(
`GOOSE_V149_PORTAL_SMOKE_SKIP: .env.local canary=${canary.mode} primary=${canary.primary} `
+ `but Portal targets=${JSON.stringify(portalTargets)} — restart pnpm dev`,
);
console.log('GOOSE_V149_PORTAL_SMOKE_OK: skipped (stale Portal process)');
return;
}
if (canary.enabled && portalUsesV149) {
console.log(
`GOOSE_V149_PORTAL_SMOKE_NOTE: canary active targets=${portalTargets.join(',')}`,
);
}
const canaryMode = String(process.env.TKMIND_GOOSE_CANARY ?? 'off').trim().toLowerCase();
if (canaryMode !== 'off' && memory.backend !== 'legacy') {
const configSource = memory.configSource ?? 'unknown';
if (configSource === 'admin-db' || configSource === 'admin-db-cache') {
console.log(
`GOOSE_V149_PORTAL_SMOKE_WARN: memory.backend=${memory.backend} from ${configSource}; goose routing verified`,
);
} else {
throw new Error(
`Portal memory.backend must stay legacy during v1.49 canary (${canaryMode}), got ${memory.backend} (${configSource})`,
);
}
}
if (canaryMode === 'off' && memory.backend !== 'legacy') {
console.log(
`GOOSE_V149_PORTAL_SMOKE_NOTE: Portal memory.backend=${memory.backend} (canary off; enable switch-goose-v149-canary.sh for legacy gate)`,
);
}
const suffix = `${Date.now()}${Math.floor(Math.random() * 1000)}`;
const user = {
username: `gv149_${suffix}`,
password: 'GooseV149-Portal-2026',
email: `gv149-${suffix}@example.test`,
displayName: `gv149_${suffix}`,
};
const register = await requestJson(`${baseUrl}/auth/register`, { method: 'POST', body: user });
if (!register.ok) throw new Error(`register failed: ${register.status}`);
const login = await requestJson(`${baseUrl}/auth/login`, {
method: 'POST',
body: { username: user.username, password: user.password },
});
const cookie = login.headers?.get?.('set-cookie')?.split(';', 1)[0] ?? null;
if (!login.ok || !cookie) throw new Error(`login failed: ${login.status}`);
const started = await requestJson(`${baseUrl}/api/agent/start`, {
method: 'POST',
headers: { Cookie: cookie },
body: {},
});
const sessionId = started.json?.id ?? null;
if (!started.ok || !sessionId) {
throw new Error(`agent/start via Portal failed: ${started.status} ${started.text?.slice?.(0, 200)}`);
}
const requestId = crypto.randomUUID();
const run = await requestJson(`${baseUrl}/api/agent/runs`, {
method: 'POST',
headers: { Cookie: cookie },
body: {
session_id: sessionId,
request_id: requestId,
user_message: {
role: 'user',
created: Math.floor(Date.now() / 1000),
content: [{ type: 'text', text: 'ping from goose v1.49 portal smoke' }],
metadata: { userVisible: true, agentVisible: true, displayText: 'ping' },
},
},
});
if (!run.ok || run.status !== 202 || !run.json?.run?.id) {
throw new Error(`agent run failed: ${run.status} ${run.text?.slice?.(0, 200)}`);
}
console.log(
`GOOSE_V149_PORTAL_SMOKE_OK: base=${baseUrl} session=${sessionId} run=${run.json.run.id} memory.backend=${memory.backend}`,
);
}
main().catch((error) => {
console.error(`GOOSE_V149_PORTAL_SMOKE_FAIL: ${error.message}`);
process.exit(1);
});
+224
View File
@@ -0,0 +1,224 @@
#!/usr/bin/env node
/**
* Provider smoke for local Goose v1.49:
* 1. Upsert custom_tkmind_relay_deepseek via /config/custom-providers
* 2. POST /agent/update_provider
* 3. Optional full reply Finish when DEEPSEEK_API_KEY is set
*/
import fs from 'node:fs';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { Readable } from 'node:stream';
import { Agent, fetch } from 'undici';
const memindRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
function loadEnvFile(filePath) {
if (!fs.existsSync(filePath)) return;
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq < 0) continue;
const key = trimmed.slice(0, eq).trim();
let value = trimmed.slice(eq + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"'))
|| (value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
if (!process.env[key]) process.env[key] = value;
}
}
loadEnvFile(path.join(memindRoot, '.env'));
const port = process.env.GOOSE_V149_PORT || '18049';
const host = process.env.GOOSE_V149_HOST || '127.0.0.1';
const secret = process.env.GOOSE_SERVER__SECRET_KEY || 'local-v149-dev-secret';
const workingDir = process.env.GOOSE_V149_WORKING_DIR || memindRoot;
const providerId =
process.env.GOOSE_V149_PROVIDER_ID || 'custom_tkmind_relay_deepseek';
const model = process.env.GOOSE_V149_PROVIDER_MODEL || 'deepseek-chat';
const apiKey = process.env.DEEPSEEK_API_KEY || process.env.GOOSE_V149_TEST_API_KEY || '';
const apiUrl =
process.env.GOOSE_V149_PROVIDER_API_URL
|| process.env.DEEPSEEK_API_BASE_URL
|| 'https://api.deepseek.com/v1';
const replyTimeoutMs = Number(process.env.GOOSE_V149_PROVIDER_REPLY_TIMEOUT_MS || 90_000);
const blockedHosts = ['58.38.22.103', '120.26.184.105'];
if (blockedHosts.includes(host)) {
console.error(`GOOSE_V149_PROVIDER_FAIL: refusing production host ${host}`);
process.exit(1);
}
const base = `https://${host}:${port}`;
const dispatcher = new Agent({ connect: { rejectUnauthorized: false } });
async function apiFetch(pathname, init = {}) {
const headers = {
...(init.headers ?? {}),
'X-Secret-Key': secret,
};
if (init.body && !headers['Content-Type']) {
headers['Content-Type'] = 'application/json';
}
return fetch(`${base}${pathname}`, {
...init,
headers,
dispatcher,
});
}
async function apiJson(pathname, init = {}) {
const response = await apiFetch(pathname, init);
const text = await response.text();
if (!response.ok) {
throw new Error(`${pathname} ${response.status}: ${text.slice(0, 800)}`);
}
return text.trim() ? JSON.parse(text) : {};
}
async function upsertCustomProvider() {
const body = {
engine: 'openai',
display_name: 'tkmind_relay_deepseek',
api_url: apiUrl,
api_key: apiKey || 'placeholder-not-used-for-config-only',
models: [model, 'deepseek-reasoner'],
supports_streaming: true,
requires_auth: Boolean(apiKey),
preserves_thinking: false,
};
let response = await apiFetch(
`/config/custom-providers/${encodeURIComponent(providerId)}`,
{ method: 'PUT', body: JSON.stringify(body) },
);
if (response.ok) {
console.log(`GOOSE_V149_PROVIDER_CONFIG_OK: updated ${providerId}`);
return;
}
const putText = await response.text().catch(() => '');
const missing = response.status === 404 || /not found/i.test(putText);
if (!missing) {
throw new Error(`PUT custom provider failed: ${response.status} ${putText}`);
}
response = await apiFetch('/config/custom-providers', {
method: 'POST',
body: JSON.stringify(body),
});
const postText = await response.text();
if (!response.ok) {
throw new Error(`POST custom provider failed: ${response.status} ${postText}`);
}
const created = JSON.parse(postText);
console.log(
`GOOSE_V149_PROVIDER_CONFIG_OK: created ${created.provider_name ?? providerId}`,
);
}
async function waitForFinish(sessionId, requestId) {
const eventsResponse = await apiFetch(`/sessions/${sessionId}/events`, {
method: 'GET',
headers: { Accept: 'text/event-stream' },
});
if (!eventsResponse.ok || !eventsResponse.body) {
const text = await eventsResponse.text().catch(() => '');
throw new Error(`events ${eventsResponse.status}: ${text.slice(0, 400)}`);
}
const replyResponse = await apiFetch(`/sessions/${sessionId}/reply`, {
method: 'POST',
body: JSON.stringify({
request_id: requestId,
user_message: {
role: 'user',
created: Date.now(),
content: [{ type: 'text', text: 'Reply with exactly one word: pong' }],
metadata: { userVisible: true, agentVisible: true, displayText: 'ping' },
},
}),
});
if (!replyResponse.ok) {
const text = await replyResponse.text().catch(() => '');
throw new Error(`reply ${replyResponse.status}: ${text.slice(0, 400)}`);
}
replyResponse.body?.cancel?.();
const reader = Readable.fromWeb(eventsResponse.body);
const decoder = new TextDecoder();
let buffer = '';
const deadline = Date.now() + replyTimeoutMs;
for await (const chunk of reader) {
if (Date.now() > deadline) {
throw new Error(`timeout waiting for Finish (${replyTimeoutMs}ms)`);
}
buffer += decoder.decode(chunk, { stream: true });
const frames = buffer.split('\n\n');
buffer = frames.pop() ?? '';
for (const frame of frames) {
let data = '';
for (const line of frame.split('\n')) {
if (line.startsWith('data:')) data += line.slice(5).trim();
}
if (!data) continue;
let event;
try {
event = JSON.parse(data);
} catch {
continue;
}
const routingId = event.chat_request_id ?? event.request_id;
if (routingId && routingId !== requestId) continue;
if (event.type === 'Finish') return 'finish';
if (event.type === 'Error') {
throw new Error(String(event.error ?? 'provider reply error'));
}
}
}
throw new Error('SSE closed before Finish');
}
try {
if (!apiKey) {
console.log('GOOSE_V149_PROVIDER_SKIP_REPLY: no DEEPSEEK_API_KEY — config/update_provider only');
}
await upsertCustomProvider();
const session = await apiJson('/agent/start', {
method: 'POST',
body: JSON.stringify({ working_dir: workingDir }),
});
if (!session?.id) throw new Error('missing session id');
await apiJson('/agent/update_provider', {
method: 'POST',
body: JSON.stringify({
session_id: session.id,
provider: providerId,
model,
}),
});
console.log(`GOOSE_V149_PROVIDER_UPDATE_OK: session=${session.id} provider=${providerId} model=${model}`);
if (apiKey) {
const requestId = randomUUID();
const outcome = await waitForFinish(session.id, requestId);
console.log(
`GOOSE_V149_PROVIDER_REPLY_OK: session=${session.id} request=${requestId} outcome=${outcome}`,
);
}
console.log('GOOSE_V149_PROVIDER_SMOKE_OK');
} catch (error) {
console.error(`GOOSE_V149_PROVIDER_FAIL: ${error.message}`);
process.exit(1);
}
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env node
/**
* Phase 2 smoke: POST /sessions/{id}/reply + GET /sessions/{id}/events (SSE).
* Validates the event bus pipeline; Finish or Error both count as SSE_OK.
*/
import { randomUUID } from 'node:crypto';
import { Readable } from 'node:stream';
import { Agent, fetch } from 'undici';
const port = process.env.GOOSE_V149_PORT || '18049';
const host = process.env.GOOSE_V149_HOST || '127.0.0.1';
const secret = process.env.GOOSE_SERVER__SECRET_KEY || 'local-v149-dev-secret';
const workingDir = process.env.GOOSE_V149_WORKING_DIR || process.cwd();
const timeoutMs = Number(process.env.GOOSE_V149_REPLY_TIMEOUT_MS || 30_000);
const blockedHosts = ['58.38.22.103', '120.26.184.105'];
if (blockedHosts.includes(host)) {
console.error(`GOOSE_V149_REPLY_SMOKE_FAIL: refusing production host ${host}`);
process.exit(1);
}
const base = `https://${host}:${port}`;
const dispatcher = new Agent({ connect: { rejectUnauthorized: false } });
async function apiFetch(pathname, init = {}) {
const headers = {
...(init.headers ?? {}),
'X-Secret-Key': secret,
};
if (init.body && !headers['Content-Type']) {
headers['Content-Type'] = 'application/json';
}
return fetch(`${base}${pathname}`, {
...init,
headers,
dispatcher,
});
}
async function apiJson(pathname, body) {
const response = await apiFetch(pathname, {
method: 'POST',
body: JSON.stringify(body),
});
const text = await response.text();
if (!response.ok) {
throw new Error(`${pathname} ${response.status}: ${text.slice(0, 800)}`);
}
return text.trim() ? JSON.parse(text) : {};
}
async function waitForTerminalEvent(sessionId, requestId) {
const eventsResponse = await apiFetch(`/sessions/${sessionId}/events`, {
method: 'GET',
headers: { Accept: 'text/event-stream' },
});
if (!eventsResponse.ok || !eventsResponse.body) {
const text = await eventsResponse.text().catch(() => '');
throw new Error(`events ${eventsResponse.status}: ${text.slice(0, 400)}`);
}
const replyResponse = await apiFetch(`/sessions/${sessionId}/reply`, {
method: 'POST',
body: JSON.stringify({
request_id: requestId,
user_message: {
role: 'user',
created: Date.now(),
content: [{ type: 'text', text: 'ping' }],
metadata: { userVisible: true, agentVisible: true, displayText: 'ping' },
},
}),
});
if (!replyResponse.ok) {
const text = await replyResponse.text().catch(() => '');
throw new Error(`reply ${replyResponse.status}: ${text.slice(0, 400)}`);
}
replyResponse.body?.cancel?.();
const reader = Readable.fromWeb(eventsResponse.body);
const decoder = new TextDecoder();
let buffer = '';
const deadline = Date.now() + timeoutMs;
for await (const chunk of reader) {
if (Date.now() > deadline) {
throw new Error(`timeout waiting for Finish/Error (${timeoutMs}ms)`);
}
buffer += decoder.decode(chunk, { stream: true });
const frames = buffer.split('\n\n');
buffer = frames.pop() ?? '';
for (const frame of frames) {
let data = '';
for (const line of frame.split('\n')) {
if (line.startsWith('data:')) data += line.slice(5).trim();
}
if (!data) continue;
let event;
try {
event = JSON.parse(data);
} catch {
continue;
}
const routingId = event.chat_request_id ?? event.request_id;
if (routingId && routingId !== requestId) continue;
if (event.type === 'Finish') {
console.log('GOOSE_V149_REPLY_EVENT: type=Finish');
return 'finish';
}
if (event.type === 'Error') {
console.log(`GOOSE_V149_REPLY_EVENT: type=Error error=${String(event.error ?? '').slice(0, 200)}`);
return 'error';
}
if (event.type === 'Message') {
console.log('GOOSE_V149_REPLY_EVENT: type=Message');
}
}
}
throw new Error(`SSE stream closed before Finish/Error (${timeoutMs}ms)`);
}
try {
const session = await apiJson('/agent/start', { working_dir: workingDir });
if (!session?.id) throw new Error('missing session id from /agent/start');
const provider = process.env.GOOSE_V149_TEST_PROVIDER;
const model = process.env.GOOSE_V149_TEST_MODEL;
if (provider && model) {
await apiJson('/agent/update_provider', {
session_id: session.id,
provider,
model,
});
console.log(`GOOSE_V149_REPLY_PROVIDER_OK: ${provider}/${model}`);
}
const requestId = randomUUID();
const outcome = await waitForTerminalEvent(session.id, requestId);
console.log(`GOOSE_V149_REPLY_SMOKE_OK: session=${session.id} request=${requestId} outcome=${outcome}`);
} catch (error) {
console.error(`GOOSE_V149_REPLY_SMOKE_FAIL: ${error.message}`);
process.exit(1);
}
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env node
/**
* Milestone 2 smoke: load sandbox-fs stdio MCP via extension_overrides on local Goose v1.49.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { Agent, fetch } from 'undici';
const memindRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const port = process.env.GOOSE_V149_PORT || '18049';
const host = process.env.GOOSE_V149_HOST || '127.0.0.1';
const secret = process.env.GOOSE_SERVER__SECRET_KEY || 'local-v149-dev-secret';
const workingDir = process.env.GOOSE_V149_WORKING_DIR || memindRoot;
const nodeExec = process.env.GOOSED_MCP_NODE_PATH || process.execPath;
const serverPath =
process.env.GOOSED_MCP_SERVER_PATH || path.join(memindRoot, 'mindspace-sandbox-mcp.mjs');
const pollMs = Number(process.env.GOOSE_V149_SANDBOX_POLL_MS || 500);
const timeoutMs = Number(process.env.GOOSE_V149_SANDBOX_TIMEOUT_MS || 30_000);
const blockedHosts = ['58.38.22.103', '120.26.184.105'];
if (blockedHosts.includes(host)) {
console.error(`GOOSE_V149_SANDBOX_FS_FAIL: refusing production host ${host}`);
process.exit(1);
}
if (!fs.existsSync(serverPath)) {
console.error(`GOOSE_V149_SANDBOX_FS_FAIL: MCP entry missing: ${serverPath}`);
process.exit(1);
}
const sandboxRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'goose-v149-sandbox-'));
fs.mkdirSync(path.join(sandboxRoot, 'public'), { recursive: true });
const base = `https://${host}:${port}`;
const dispatcher = new Agent({ connect: { rejectUnauthorized: false } });
async function apiFetch(pathname, init = {}) {
const headers = {
...(init.headers ?? {}),
'X-Secret-Key': secret,
};
if (init.body && !headers['Content-Type']) {
headers['Content-Type'] = 'application/json';
}
return fetch(`${base}${pathname}`, {
...init,
headers,
dispatcher,
});
}
async function apiJson(pathname, body) {
const response = await apiFetch(pathname, {
method: 'POST',
body: JSON.stringify(body),
});
const text = await response.text();
if (!response.ok) {
throw new Error(`${pathname} ${response.status}: ${text.slice(0, 800)}`);
}
return text.trim() ? JSON.parse(text) : {};
}
async function waitForSandboxTools(sessionId) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const response = await apiFetch(
`/agent/tools?session_id=${encodeURIComponent(sessionId)}&extension_name=sandbox-fs`,
);
const text = await response.text();
if (!response.ok) {
throw new Error(`/agent/tools ${response.status}: ${text.slice(0, 400)}`);
}
const tools = JSON.parse(text);
const names = tools.map((tool) => tool.name);
const writeTool = names.find((name) => name === 'sandbox-fs__write_file');
if (writeTool) {
return names;
}
await new Promise((resolve) => setTimeout(resolve, pollMs));
}
throw new Error(`sandbox-fs tools not ready within ${timeoutMs}ms`);
}
try {
const session = await apiJson('/agent/start', {
working_dir: workingDir,
extension_overrides: [
{
type: 'stdio',
name: 'sandbox-fs',
description: 'MindSpace sandbox filesystem MCP (local v1.49 smoke)',
cmd: nodeExec,
args: [serverPath, sandboxRoot],
envs: {
ALLOWED_TOOLS: 'write_file,read_file,edit_file,create_dir,list_dir',
SANDBOX_ROOT: sandboxRoot,
},
available_tools: ['write_file', 'read_file', 'edit_file', 'create_dir', 'list_dir'],
},
{
type: 'platform',
name: 'skills',
description: 'skills',
available_tools: [],
},
],
});
if (!session?.id) {
throw new Error('missing session id from /agent/start');
}
const extResponse = await apiFetch(`/sessions/${session.id}/extensions`);
const extBody = await extResponse.json();
const configured = (extBody.extensions ?? []).some((ext) => ext.name === 'sandbox-fs');
if (!configured) {
throw new Error('sandbox-fs not present in session extension_data');
}
const toolNames = await waitForSandboxTools(session.id);
console.log(
`GOOSE_V149_SANDBOX_FS_OK: session=${session.id} sandbox=${sandboxRoot} tools=${toolNames.length}`,
);
} catch (error) {
console.error(`GOOSE_V149_SANDBOX_FS_FAIL: ${error.message}`);
process.exit(1);
}
@@ -0,0 +1,44 @@
#!/usr/bin/env node
/**
* Verify v1.49 exposes Portal session-reconcile REST routes.
*/
import { createV149Client } from './goose-v149-sse.mjs';
const client = createV149Client();
const workingDir = process.env.GOOSE_V149_WORKING_DIR || process.cwd();
async function main() {
const session = await client.apiJson('/agent/start', { working_dir: workingDir });
if (!session?.id) throw new Error('missing session id');
const getRes = await client.apiFetch(`/sessions/${session.id}`, { method: 'GET' });
if (!getRes.ok) {
throw new Error(`GET /sessions/{id} ${getRes.status}`);
}
const addRes = await client.apiFetch('/agent/add_extension', {
method: 'POST',
body: JSON.stringify({
session_id: session.id,
config: {
type: 'platform',
name: 'web',
description: 'compat probe',
display_name: 'web',
bundled: false,
available_tools: ['web_search'],
},
}),
});
if (!addRes.ok) {
const text = await addRes.text();
throw new Error(`add_extension probe ${addRes.status}: ${text.slice(0, 200)}`);
}
console.log(`GOOSE_V149_SESSION_RECONCILE_OK: session=${session.id} routes=GET /sessions/{id}, POST /agent/add_extension`);
}
main().catch((error) => {
console.error(`GOOSE_V149_SESSION_RECONCILE_FAIL: ${error.message}`);
process.exit(1);
});
@@ -0,0 +1,56 @@
#!/usr/bin/env node
/**
* Smoke: upstream skills extension exposes load_skill (web-search skill path).
*/
import { createV149Client } from './goose-v149-sse.mjs';
const client = createV149Client();
const workingDir = process.env.GOOSE_V149_WORKING_DIR || process.cwd();
const pollMs = Number(process.env.GOOSE_V149_SKILLS_POLL_MS || 500);
const timeoutMs = Number(process.env.GOOSE_V149_SKILLS_TIMEOUT_MS || 15_000);
async function waitForSkillsTools(sessionId) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const response = await client.apiFetch(
`/agent/tools?session_id=${encodeURIComponent(sessionId)}&extension_name=skills`,
);
const text = await response.text();
if (!response.ok) {
throw new Error(`/agent/tools ${response.status}: ${text.slice(0, 400)}`);
}
const tools = JSON.parse(text);
const names = tools.map((tool) => tool.name);
const hasLoadSkill = names.some((name) => /load_skill/i.test(name));
if (hasLoadSkill) return names;
await new Promise((resolve) => setTimeout(resolve, pollMs));
}
throw new Error(`skills load_skill not ready within ${timeoutMs}ms`);
}
async function main() {
const session = await client.apiJson('/agent/start', {
working_dir: workingDir,
extension_overrides: [
{
type: 'platform',
name: 'skills',
description: 'Skills extension (upstream web-search path)',
display_name: 'Skills',
bundled: false,
available_tools: ['load_skill'],
},
],
});
if (!session?.id) throw new Error('missing session id');
const toolNames = await waitForSkillsTools(session.id);
console.log(
`GOOSE_V149_SKILLS_OK: session=${session.id} tools=${toolNames.length} builtin=web-search-via-load_skill`,
);
}
main().catch((error) => {
console.error(`GOOSE_V149_SKILLS_FAIL: ${error.message}`);
process.exit(1);
});
@@ -0,0 +1,71 @@
#!/usr/bin/env node
/**
* Phase 2: run v1.49 agent integration tests for DeepSeek/Kimi reasoning preservation.
*/
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const memindRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const v149Root = process.env.GOOSED_V149_ROOT || '/Users/john/Project/tkmind_go-v149';
const THINKING_TESTS = [
'test_deepseek_thinking_preserved_in_tool_call_message',
'test_kimi_thinking_preserved_in_tool_call_message',
'test_reasoning_preserved_when_combined_with_tool_call',
'test_multi_tool_response_preserves_reasoning_and_message_id_correlation',
];
function runTest(testName) {
const result = spawnSync(
'cargo',
['test', '--test', 'agent', testName, '--quiet', '--', '--nocapture'],
{
cwd: v149Root,
encoding: 'utf8',
env: process.env,
},
);
const output = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim();
const passed = /test result: ok\. 1 passed/.test(output);
return { testName, ok: result.status === 0 && passed, output: output.split('\n').slice(-4).join('\n') };
}
function main() {
if (!fs.existsSync(v149Root)) {
throw new Error(`missing v1.49 worktree: ${v149Root}`);
}
const results = THINKING_TESTS.map(runTest);
const failed = results.filter((item) => !item.ok);
if (failed.length) {
const detail = failed.map((item) => `${item.testName}:\n${item.output}`).join('\n\n');
throw new Error(`thinking preservation tests failed:\n${detail}`);
}
const outputDir = path.join(memindRoot, 'docs', 'baselines');
fs.mkdirSync(outputDir, { recursive: true });
const evidencePath = path.join(outputDir, 'goose-v149-thinking-preservation-latest.json');
fs.writeFileSync(
evidencePath,
`${JSON.stringify({
schemaVersion: 'goose-v149-thinking-preservation-v1',
capturedAt: new Date().toISOString(),
worktree: v149Root,
tests: results.map((item) => item.testName),
passed: results.length,
}, null, 2)}\n`,
'utf8',
);
console.log(`GOOSE_V149_THINKING_PRESERVATION_OK: ${results.length} tests passed`);
console.log(` evidence=${evidencePath}`);
}
try {
main();
} catch (error) {
console.error(`GOOSE_V149_THINKING_PRESERVATION_FAIL: ${error.message}`);
process.exit(1);
}
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env node
/**
* Smoke: platform/web extension (web_search, fetch_url) on local Goose v1.49.
*/
import { createV149Client } from './goose-v149-sse.mjs';
const client = createV149Client();
const workingDir = process.env.GOOSE_V149_WORKING_DIR || process.cwd();
const pollMs = Number(process.env.GOOSE_V149_WEB_POLL_MS || 500);
const timeoutMs = Number(process.env.GOOSE_V149_WEB_TIMEOUT_MS || 15_000);
async function waitForWebTools(sessionId) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const response = await client.apiFetch(
`/agent/tools?session_id=${encodeURIComponent(sessionId)}&extension_name=web`,
);
const text = await response.text();
if (!response.ok) {
throw new Error(`/agent/tools ${response.status}: ${text.slice(0, 400)}`);
}
const tools = JSON.parse(text);
const names = tools.map((tool) => tool.name);
const hasSearch = names.some((name) => name === 'web__web_search' || name === 'web_search');
const hasFetch = names.some((name) => name === 'web__fetch_url' || name === 'fetch_url');
if (hasSearch && hasFetch) {
return names;
}
await new Promise((resolve) => setTimeout(resolve, pollMs));
}
throw new Error(`platform/web tools not ready within ${timeoutMs}ms`);
}
async function main() {
const session = await client.apiJson('/agent/start', {
working_dir: workingDir,
extension_overrides: [
{
type: 'platform',
name: 'web',
description: 'Web search and fetch (v1.49 smoke)',
display_name: 'Web',
bundled: false,
available_tools: ['web_search', 'fetch_url'],
},
{
type: 'platform',
name: 'skills',
description: 'skills',
available_tools: [],
},
],
});
if (!session?.id) throw new Error('missing session id');
const toolNames = await waitForWebTools(session.id);
console.log(
`GOOSE_V149_WEB_OK: session=${session.id} tools=${toolNames.length}`,
);
}
main().catch((error) => {
console.error(`GOOSE_V149_WEB_FAIL: ${error.message}`);
process.exit(1);
});
@@ -0,0 +1,149 @@
#!/usr/bin/env node
/**
* Compare two Goose v1.49 memory manifests and report drift.
*/
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
function usage() {
return [
'Usage: node scripts/compare-goose-v149-memory-manifest.mjs [--baseline <path>] [--current <path>] [--json]',
'',
'Defaults:',
' baseline docs/baselines/goose-v149-memory-latest.json',
' current freshly exported manifest (via child process) unless --current is set',
].join('\n');
}
function parseArgs(argv) {
const args = {
baseline: path.join(root, 'docs', 'baselines', 'goose-v149-memory-latest.json'),
current: '',
json: false,
exportCurrent: true,
};
for (let i = 2; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--baseline' && argv[i + 1]) args.baseline = path.resolve(argv[++i]);
else if (arg === '--current' && argv[i + 1]) {
args.current = path.resolve(argv[++i]);
args.exportCurrent = false;
} else if (arg === '--json') args.json = true;
else if (arg === '--help' || arg === '-h') {
console.log(usage());
process.exit(0);
} else {
console.error(`Unknown argument: ${arg}`);
console.error(usage());
process.exit(2);
}
}
return args;
}
async function readManifest(filePath) {
const raw = await fs.readFile(filePath, 'utf8');
return JSON.parse(raw);
}
function indexMemories(manifest) {
const byId = new Map();
for (const item of manifest.memories ?? []) {
byId.set(item.id, item);
}
return byId;
}
function compareManifests(baseline, current) {
const drift = {
baselineDigest: baseline.summary?.digest ?? null,
currentDigest: current.summary?.digest ?? null,
summary: {
userCountDelta: (current.summary?.userCount ?? 0) - (baseline.summary?.userCount ?? 0),
memoryCountDelta: (current.summary?.memoryItemCount ?? 0) - (baseline.summary?.memoryItemCount ?? 0),
candidateCountDelta: (current.summary?.candidateCount ?? 0) - (baseline.summary?.candidateCount ?? 0),
},
addedMemoryIds: [],
removedMemoryIds: [],
changedMemories: [],
};
const base = indexMemories(baseline);
const cur = indexMemories(current);
for (const [id, item] of cur) {
if (!base.has(id)) drift.addedMemoryIds.push(id);
}
for (const [id, item] of base) {
if (!cur.has(id)) drift.removedMemoryIds.push(id);
}
for (const [id, baseItem] of base) {
const curItem = cur.get(id);
if (!curItem) continue;
const fields = ['memoryHash', 'status', 'sourceSessionId', 'evidenceMessageId'];
const changes = {};
for (const field of fields) {
if (String(baseItem[field] ?? '') !== String(curItem[field] ?? '')) {
changes[field] = { baseline: baseItem[field] ?? null, current: curItem[field] ?? null };
}
}
if (Object.keys(changes).length) {
drift.changedMemories.push({ id, userId: baseItem.userId, changes });
}
}
drift.ok =
drift.addedMemoryIds.length === 0
&& drift.removedMemoryIds.length === 0
&& drift.changedMemories.length === 0;
return drift;
}
async function exportCurrentManifest() {
const { spawnSync } = await import('node:child_process');
const tmpPath = path.join(root, 'docs', 'baselines', '.goose-v149-memory-current.json');
const result = spawnSync(
process.execPath,
['scripts/export-goose-v149-memory-manifest.mjs', '--output', tmpPath, '--json'],
{ cwd: root, encoding: 'utf8', env: process.env },
);
if (result.status !== 0) {
throw new Error(result.stderr || result.stdout || 'export manifest failed');
}
return readManifest(tmpPath);
}
async function main() {
const args = parseArgs(process.argv);
const baseline = await readManifest(args.baseline);
const current = args.exportCurrent ? await exportCurrentManifest() : await readManifest(args.current);
const report = compareManifests(baseline, current);
if (args.json) {
console.log(JSON.stringify(report, null, 2));
} else if (report.ok) {
console.log('GOOSE_V149_MEMORY_DRIFT_OK: no ID/hash/status/session/evidence drift');
console.log(` baselineDigest=${report.baselineDigest}`);
} else {
console.error('GOOSE_V149_MEMORY_DRIFT_FAIL:');
console.error(` added=${report.addedMemoryIds.length} removed=${report.removedMemoryIds.length} changed=${report.changedMemories.length}`);
if (report.addedMemoryIds.length) {
console.error(` added sample: ${report.addedMemoryIds.slice(0, 5).join(', ')}`);
}
if (report.removedMemoryIds.length) {
console.error(` removed sample: ${report.removedMemoryIds.slice(0, 5).join(', ')}`);
}
if (report.changedMemories.length) {
console.error(` changed sample: ${JSON.stringify(report.changedMemories.slice(0, 3), null, 2)}`);
}
process.exit(1);
}
}
main().catch((error) => {
console.error(`GOOSE_V149_MEMORY_DRIFT_FAIL: ${error.message}`);
process.exit(1);
});
@@ -0,0 +1,83 @@
#!/usr/bin/env node
/**
* Phase 2: compare provider message sanitize unit tests (v1.41 baseline vs v1.49).
*/
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const memindRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const v149Root = process.env.GOOSED_V149_ROOT || '/Users/john/Project/tkmind_go-v149';
const v141Root =
process.env.GOOSED_V141_ROOT || '/Users/john/Project/tkmind_go-v141-prod-prep';
function runSanitizeTests(root, label) {
if (!fs.existsSync(root)) {
return { label, ok: false, skipped: true, reason: `missing worktree: ${root}` };
}
const result = spawnSync(
'cargo',
['test', '-p', 'goose-providers', 'sanitize', '--quiet', '--', '--nocapture'],
{
cwd: root,
encoding: 'utf8',
env: process.env,
},
);
const output = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim();
const match = output.match(/test result: ok\. (\d+) passed/);
const passed = match ? Number(match[1]) : null;
return {
label,
root,
ok: result.status === 0,
passed,
output: output.split('\n').slice(-6).join('\n'),
};
}
function main() {
const v149 = runSanitizeTests(v149Root, 'v1.49');
const v141 = runSanitizeTests(v141Root, 'v1.41');
if (v149.skipped) {
throw new Error(v149.reason);
}
if (!v149.ok || v149.passed == null) {
throw new Error(`v1.49 sanitize tests failed:\n${v149.output}`);
}
const outputDir = path.join(memindRoot, 'docs', 'baselines');
fs.mkdirSync(outputDir, { recursive: true });
const evidencePath = path.join(outputDir, 'goose-v149-message-sanitize-latest.json');
const evidence = {
schemaVersion: 'goose-v149-message-sanitize-v1',
capturedAt: new Date().toISOString(),
v149: { passed: v149.passed, root: v149Root },
v141: v141.skipped
? { skipped: true, reason: v141.reason }
: { passed: v141.passed, ok: v141.ok, root: v141Root },
equivalent:
!v141.skipped
&& v141.ok
&& v141.passed != null
&& v141.passed === v149.passed,
};
fs.writeFileSync(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`, 'utf8');
console.log(`GOOSE_V149_MESSAGE_SANITIZE_OK: v149=${v149.passed} passed`);
if (!v141.skipped) {
console.log(` v141=${v141.passed} passed equivalent=${evidence.equivalent}`);
} else {
console.log(` v141=skipped (${v141.reason})`);
}
console.log(` evidence=${evidencePath}`);
}
try {
main();
} catch (error) {
console.error(`GOOSE_V149_MESSAGE_SANITIZE_FAIL: ${error.message}`);
process.exit(1);
}
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Create isolated PostgreSQL database for Goose v1.49 local upgrade testing.
# Refuses production hosts (103/105/RDS).
set -euo pipefail
PG_HOST="${GOOSE_V149_PG_HOST:-127.0.0.1}"
PG_PORT="${GOOSE_V149_PG_PORT:-5432}"
DB_NAME="${GOOSE_V149_SESSION_DB:-goose_sessions_v149_dev}"
PG_USER="${GOOSE_V149_PG_USER:-${USER:-john}}"
fail() {
echo "[goose-v149-db] FAIL: $*" >&2
exit 1
}
case "${PG_HOST}" in
127.0.0.1|localhost|::1) ;;
58.38.22.103|120.26.184.105|*rds.aliyuncs.com*|*tkmind.cn*)
fail "refusing production or remote host: ${PG_HOST}"
;;
*)
fail "unexpected PG host (only loopback allowed): ${PG_HOST}"
;;
esac
export PATH="/opt/homebrew/opt/postgresql@17/bin:${PATH:-/usr/bin:/bin}"
if ! pg_isready -h "${PG_HOST}" -p "${PG_PORT}" -q 2>/dev/null; then
bash "$(cd "$(dirname "$0")/.." && pwd)/scripts/ensure-goose-session-postgres.sh"
fi
if psql -h "${PG_HOST}" -p "${PG_PORT}" -U "${PG_USER}" -d postgres -tAc \
"SELECT 1 FROM pg_database WHERE datname='${DB_NAME}'" | grep -q 1; then
echo "[goose-v149-db] database already exists: ${DB_NAME}"
else
createdb -h "${PG_HOST}" -p "${PG_PORT}" -U "${PG_USER}" "${DB_NAME}"
echo "[goose-v149-db] created database: ${DB_NAME}"
fi
echo "[goose-v149-db] GOOSE_SESSION_DB_URL=postgresql://${PG_USER}@${PG_HOST}:${PG_PORT}/${DB_NAME}"
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Ensure rusty_v8 static library is cached for Goose code-mode builds.
set -euo pipefail
ARCH="$(uname -m)"
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
if [[ "${ARCH}" != "arm64" || "${OS}" != "darwin" ]]; then
echo "[goose-v149-v8] unsupported platform ${OS}/${ARCH}; download manually if needed" >&2
exit 0
fi
V8_VERSION="v145.0.0"
FILE="librusty_v8_release_aarch64-apple-darwin.a.gz"
URL="https://github.com/denoland/rusty_v8/releases/download/${V8_VERSION}/${FILE}"
# rusty_v8 build.rs expects the escaped URL path to be a FILE, not a directory.
CACHE_FILE="${HOME}/.cargo/.rusty_v8/https___github_com_denoland_rusty_v8_releases_download_v145_0_0_librusty_v8_release_aarch64_apple_darwin_a_gz"
MIN_BYTES=$((30 * 1024 * 1024))
mkdir -p "$(dirname "${CACHE_FILE}")"
# Legacy layout stored the gzip inside a same-named directory — migrate once.
if [[ -d "${CACHE_FILE}" ]]; then
legacy="${CACHE_FILE}/${FILE}"
if [[ -f "${legacy}" ]]; then
mv "${legacy}" "${CACHE_FILE}.migrate"
rmdir "${CACHE_FILE}"
mv "${CACHE_FILE}.migrate" "${CACHE_FILE}"
echo "[goose-v149-v8] migrated legacy directory cache -> file"
else
echo "[goose-v149-v8] legacy cache dir is empty: ${CACHE_FILE}" >&2
rm -rf "${CACHE_FILE}"
fi
fi
if [[ -f "${CACHE_FILE}" ]]; then
size="$(wc -c < "${CACHE_FILE}" | tr -d ' ')"
if [[ "${size}" -ge "${MIN_BYTES}" ]]; then
echo "[goose-v149-v8] cache ok (${size} bytes): ${CACHE_FILE}"
exit 0
fi
echo "[goose-v149-v8] cache too small (${size} bytes), re-downloading" >&2
rm -f "${CACHE_FILE}"
fi
echo "[goose-v149-v8] downloading ${URL}"
curl -fL --retry 5 --retry-delay 2 -o "${CACHE_FILE}.partial" "${URL}"
mv "${CACHE_FILE}.partial" "${CACHE_FILE}"
size="$(wc -c < "${CACHE_FILE}" | tr -d ' ')"
echo "[goose-v149-v8] downloaded ${size} bytes -> ${CACHE_FILE}"
GOOSED_ROOT="${GOOSED_V149_ROOT:-/Users/john/Project/tkmind_go-v149}"
GN_OUT="${GOOSED_ROOT}/target/release/gn_out/obj"
if [[ -d "${GOOSED_ROOT}/target/release" ]]; then
rm -rf "${GOOSED_ROOT}/target/release/gn_out"
rm -f "${GOOSED_ROOT}/target/release/build"/v8-*/out/*.a 2>/dev/null || true
echo "[goose-v149-v8] cleared stale gn_out under ${GOOSED_ROOT}/target/release"
fi
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Seed cargo git checkouts from local hermit cache (offline build when GitHub SSL fails).
set -euo pipefail
HERMIT_ROOT="${GOOSE_V149_HERMIT_ROOT:-/Users/john/Project/tkmind_go-v141-prod-prep/.hermit/rust/git/checkouts}"
CARGO_GIT="${CARGO_HOME:-${HOME}/.cargo}/git/checkouts"
seed_checkout() {
local prefix="$1"
local rev="$2"
local source="$3"
local dest="${CARGO_GIT}/${prefix}/${rev}"
if [[ ! -d "${source}" ]]; then
echo "[goose-v149-vendor] missing source: ${source}" >&2
exit 1
fi
mkdir -p "${CARGO_GIT}/${prefix}"
ln -sfn "${source}" "${dest}"
echo "[goose-v149-vendor] ${dest} -> ${source}"
}
seed_checkout rust-sdk-762d172c5e846930 c97a5203d3392f7f231514d84eea014f9f43e6fb \
"${HERMIT_ROOT}/rust-sdk-762d172c5e846930/c97a520"
seed_checkout cudaforge-5c30904947dfbb68 e7c1967340e40673db98dc9e17da0f04834a456f \
"${HERMIT_ROOT}/cudaforge-5c30904947dfbb68/e7c1967"
@@ -0,0 +1,305 @@
#!/usr/bin/env node
/**
* Export local Memory baseline manifest for Goose v1.49 cutover reconciliation.
* Loopback / local MySQL only; refuses production DB hosts.
*/
import fs from 'node:fs/promises';
import path from 'node:path';
import crypto from 'node:crypto';
import mysql from 'mysql2/promise';
import { loadMemindEnvFiles } from './memind-runtime-profile.mjs';
const BLOCKED_DB_HOSTS = new Set(['58.38.22.103', '120.26.184.105', 'rds.aliyuncs.com']);
function usage() {
return [
'Usage: node scripts/export-goose-v149-memory-manifest.mjs [--output <path>] [--user-id <id>] [--json]',
'',
'Exports per-user memory ID/hash/status/source session/evidence, V2 candidates,',
'vector namespace metadata, and schema/watermark fields for Phase 0 baseline.',
].join('\n');
}
function parseArgs(argv) {
const args = { output: '', userId: '', json: false };
for (let i = 2; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--output' && argv[i + 1]) args.output = argv[++i];
else if (arg === '--user-id' && argv[i + 1]) args.userId = argv[++i];
else if (arg === '--json') args.json = true;
else if (arg === '--help' || arg === '-h') {
console.log(usage());
process.exit(0);
} else {
console.error(`Unknown argument: ${arg}`);
console.error(usage());
process.exit(2);
}
}
return args;
}
function assertLocalDatabaseUrl(env = process.env) {
const raw = String(env.DATABASE_URL ?? '').trim();
if (raw) {
let hostname = '';
try {
hostname = new URL(raw.replace(/^mysql:/, 'http:')).hostname.toLowerCase();
} catch {
throw new Error('DATABASE_URL is not a valid URL');
}
for (const blocked of BLOCKED_DB_HOSTS) {
if (hostname.includes(blocked)) {
throw new Error(`Refusing production DATABASE_URL host: ${hostname}`);
}
}
return;
}
const host = String(env.MYSQL_HOST ?? '127.0.0.1').trim().toLowerCase();
for (const blocked of BLOCKED_DB_HOSTS) {
if (host.includes(blocked)) {
throw new Error(`Refusing production MYSQL_HOST: ${host}`);
}
}
}
function createMysqlPoolFromEnv() {
loadMemindEnvFiles(process.cwd());
assertLocalDatabaseUrl(process.env);
const poolOptions = { connectionLimit: 3 };
if (process.env.DATABASE_URL) {
return mysql.createPool({ uri: process.env.DATABASE_URL, ...poolOptions });
}
if (!process.env.MYSQL_HOST && !process.env.MYSQL_DATABASE) {
throw new Error('Memory manifest export requires DATABASE_URL or MYSQL_* configuration');
}
return mysql.createPool({
host: process.env.MYSQL_HOST ?? '127.0.0.1',
port: Number(process.env.MYSQL_PORT ?? 3306),
user: process.env.MYSQL_USER ?? 'boot',
password: process.env.MYSQL_PASSWORD ?? '',
database: process.env.MYSQL_DATABASE ?? 'tkmind',
...poolOptions,
});
}
async function tableExists(pool, tableName) {
const [rows] = await pool.query(
`SELECT COUNT(*) AS count
FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = ?`,
[tableName],
);
return Number(rows[0]?.count ?? 0) > 0;
}
async function fetchMemoryItems(pool, userId) {
const clauses = ["status IN ('active', 'archived')"];
const params = [];
if (userId) {
clauses.push('user_id = ?');
params.push(userId);
}
const [rows] = await pool.query(
`SELECT id, user_id, label, memory_hash, memory_text, evidence_message_id,
source_session_id, confidence, status, created_at, updated_at
FROM h5_user_memory_items
WHERE ${clauses.join(' AND ')}
ORDER BY user_id ASC, updated_at DESC`,
params,
);
return rows.map((row) => ({
id: row.id,
userId: row.user_id,
label: row.label,
memoryHash: row.memory_hash,
memoryTextPreview: String(row.memory_text ?? '').slice(0, 120),
evidenceMessageId: row.evidence_message_id ?? null,
sourceSessionId: row.source_session_id ?? null,
confidence: Number(row.confidence ?? 0),
status: row.status,
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
}));
}
async function fetchCandidates(pool, userId) {
if (!(await tableExists(pool, 'h5_memory_v2_candidates'))) return [];
const clauses = ['1=1'];
const params = [];
if (userId) {
clauses.push('user_id = ?');
params.push(userId);
}
const [rows] = await pool.query(
`SELECT id, user_id, session_id, memory_type, content, importance, confidence,
status, policy_reason, evidence_json, created_at, updated_at
FROM h5_memory_v2_candidates
WHERE ${clauses.join(' AND ')}
ORDER BY user_id ASC, updated_at DESC`,
params,
);
return rows.map((row) => ({
id: row.id,
userId: row.user_id,
sessionId: row.session_id ?? null,
memoryType: row.memory_type,
contentPreview: String(row.content ?? '').slice(0, 120),
importance: Number(row.importance ?? 0),
confidence: Number(row.confidence ?? 0),
status: row.status,
policyReason: row.policy_reason ?? null,
evidence: (() => {
try {
return typeof row.evidence_json === 'object'
? row.evidence_json
: JSON.parse(String(row.evidence_json ?? '{}'));
} catch {
return {};
}
})(),
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
}));
}
async function fetchVectorSummary(env) {
const connectionString = String(env.MEMORY_PGVECTOR_DATABASE_URL ?? '').trim();
if (!connectionString) {
return {
enabled: false,
table: env.MEMORY_PGVECTOR_TABLE ?? 'memory_embeddings',
rowCount: 0,
embeddingModule: env.MEMORY_PGVECTOR_EMBEDDING_MODULE ?? null,
};
}
for (const blocked of BLOCKED_DB_HOSTS) {
if (connectionString.includes(blocked)) {
throw new Error('Refusing production MEMORY_PGVECTOR_DATABASE_URL');
}
}
const imported = await import('pg');
const PgPool = imported?.Pool ?? imported?.default?.Pool;
if (typeof PgPool !== 'function') {
return { enabled: false, error: 'pg module unavailable' };
}
const table = String(env.MEMORY_PGVECTOR_TABLE ?? 'memory_embeddings').replace(/"/g, '');
const pool = new PgPool({ connectionString, max: 2 });
try {
const result = await pool.query(`SELECT COUNT(*)::int AS count FROM "${table}"`);
return {
enabled: true,
connectionHost: new URL(connectionString).hostname,
table,
rowCount: Number(result.rows[0]?.count ?? 0),
embeddingModule: env.MEMORY_PGVECTOR_EMBEDDING_MODULE ?? null,
dimensions: Number(env.MEMORY_V2_LOCAL_EMBEDDING_DIMENSIONS ?? 0) || null,
};
} finally {
await pool.end().catch(() => {});
}
}
function buildManifest({ memories, candidates, vectorSummary, env, userId }) {
const exportedAt = new Date().toISOString();
const watermarkMs = Date.now();
const digest = crypto
.createHash('sha256')
.update(JSON.stringify({ memories, candidates, vectorSummary, watermarkMs }))
.digest('hex');
const byUser = new Map();
for (const item of memories) {
if (!byUser.has(item.userId)) {
byUser.set(item.userId, { memoryCount: 0, memoryIds: [], memoryHashes: [] });
}
const bucket = byUser.get(item.userId);
bucket.memoryCount += 1;
bucket.memoryIds.push(item.id);
bucket.memoryHashes.push(item.memoryHash);
}
return {
schemaVersion: 'goose-v149-memory-manifest-v1',
exportedAt,
cutoverWatermarkMs: watermarkMs,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
textNormalization: 'utf8mb4_unicode_ci',
scope: userId ? { userId } : { allUsers: true },
memoryPolicy: {
backend: env.MEMORY_BACKEND ?? 'legacy',
vectorEnabled: env.MEMORY_VECTOR_ENABLED ?? '0',
agentInjectionMode: env.MEMORY_AGENT_INJECTION_MODE ?? 'off',
lifecycleEnabled: env.MEMORY_LIFECYCLE_ENABLED ?? '0',
candidateEnabled: env.MEMORY_CANDIDATE_ENABLED ?? '0',
},
gooseSessionDb: {
urlRedacted: String(env.GOOSE_SESSION_DB_URL ?? env.GOOSE_V149_SESSION_DB ?? 'goose_sessions_v149_dev')
.replace(/\/\/[^:]+:[^@]+@/, '//***:***@'),
schemaVersion: 16,
},
harnessMemoryKeys: [],
vector: vectorSummary,
summary: {
userCount: byUser.size,
memoryItemCount: memories.length,
candidateCount: candidates.length,
statusBreakdown: memories.reduce((acc, item) => {
acc[item.status] = (acc[item.status] ?? 0) + 1;
return acc;
}, {}),
digest,
},
users: [...byUser.entries()].map(([id, stats]) => ({
userId: id,
...stats,
})),
memories,
candidates,
};
}
async function main() {
const args = parseArgs(process.argv);
const pool = createMysqlPoolFromEnv();
try {
const memories = await fetchMemoryItems(pool, args.userId);
const candidates = await fetchCandidates(pool, args.userId);
const vectorSummary = await fetchVectorSummary(process.env);
const manifest = buildManifest({
memories,
candidates,
vectorSummary,
env: process.env,
userId: args.userId,
});
const defaultOutput = path.join(
process.cwd(),
'docs',
'baselines',
`goose-v149-memory-manifest-${manifest.exportedAt.replace(/[:.]/g, '-')}.json`,
);
const outputPath = args.output ? path.resolve(args.output) : defaultOutput;
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
if (args.json) {
console.log(JSON.stringify({ ok: true, outputPath, summary: manifest.summary }, null, 2));
} else {
console.log(`GOOSE_V149_MEMORY_MANIFEST_OK: ${outputPath}`);
console.log(
` users=${manifest.summary.userCount} memories=${manifest.summary.memoryItemCount} candidates=${manifest.summary.candidateCount}`,
);
console.log(` digest=${manifest.summary.digest}`);
}
} finally {
await pool.end().catch(() => {});
}
}
main().catch((error) => {
console.error(`GOOSE_V149_MEMORY_MANIFEST_FAIL: ${error.message}`);
process.exit(1);
});
+225
View File
@@ -0,0 +1,225 @@
/**
* Local Goose v1.49 canary routing helpers (loopback only).
* Production hosts are refused; see docs/goose-v149-canary-runbook.md.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createCanaryPolicy, resolveCanaryTarget } from '../release-gate/canary-routing.mjs';
const memindRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
function loadEnvFile(filePath) {
if (!fs.existsSync(filePath)) return;
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq < 0) continue;
const key = trimmed.slice(0, eq).trim();
let value = trimmed.slice(eq + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"'))
|| (value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
if (!(key in process.env)) process.env[key] = value;
}
}
loadEnvFile(path.join(memindRoot, '.env.local'));
export const GOOSE_V149_CANARY_BEGIN = '# GOOSE_V149_CANARY_BEGIN';
export const GOOSE_V149_CANARY_END = '# GOOSE_V149_CANARY_END';
/** Force-apply GOOSE_V149 canary block from .env.local (overrides .env). */
export function applyGooseV149CanaryBlockEnv(env = process.env, rootDir = memindRoot) {
const envFile = path.join(rootDir, '.env.local');
if (!fs.existsSync(envFile)) return;
const lines = fs.readFileSync(envFile, 'utf8').split('\n');
let inBlock = false;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed === GOOSE_V149_CANARY_BEGIN) {
inBlock = true;
continue;
}
if (trimmed === GOOSE_V149_CANARY_END) break;
if (!inBlock || !trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq < 0) continue;
const key = trimmed.slice(0, eq).trim();
let value = trimmed.slice(eq + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"'))
|| (value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
env[key] = value;
}
}
export const GOOSE_CANARY_MODES = new Set(['off', 'all', 'users']);
const BLOCKED_HOSTS = new Set([
'58.38.22.103',
'120.26.184.105',
'103.tkmind.cn',
'105.tkmind.cn',
]);
export const DEFAULT_GOOSE_STABLE_TARGET = 'https://127.0.0.1:18006';
export const DEFAULT_GOOSE_V149_TARGET = 'https://127.0.0.1:18049';
function csvSet(value) {
return new Set(
String(value ?? '')
.split(',')
.map((item) => item.trim())
.filter(Boolean),
);
}
export function parseGooseCanaryMode(env = process.env) {
const mode = String(env.TKMIND_GOOSE_CANARY ?? 'off').trim().toLowerCase();
if (!GOOSE_CANARY_MODES.has(mode)) {
throw new Error(
`Unsupported TKMIND_GOOSE_CANARY "${mode}". Expected: ${[...GOOSE_CANARY_MODES].join(', ')}`,
);
}
return mode;
}
export function assertLoopbackGooseTarget(rawTarget, label = 'goose target') {
let parsed;
try {
parsed = new URL(String(rawTarget ?? '').trim());
} catch {
throw new Error(`${label} must be a valid URL`);
}
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
throw new Error(`${label} must use http or https`);
}
const host = parsed.hostname.toLowerCase();
if (BLOCKED_HOSTS.has(host)) {
throw new Error(`${label} refuses production host ${host}`);
}
if (!['127.0.0.1', 'localhost', '::1'].includes(host)) {
throw new Error(`${label} must be loopback (127.0.0.1 / localhost / ::1)`);
}
parsed.pathname = parsed.pathname.replace(/\/+$/, '') || '';
parsed.search = '';
parsed.hash = '';
return parsed.toString().replace(/\/+$/, '');
}
export function gooseCanaryPolicyFromEnv(env = process.env) {
const userIds = csvSet(env.TKMIND_GOOSE_CANARY_USER_IDS);
const usernames = csvSet(env.TKMIND_GOOSE_CANARY_USERNAMES);
const wechatUserIds = csvSet(env.TKMIND_GOOSE_CANARY_WECHAT_USER_IDS);
if (!userIds.size && !usernames.size && !wechatUserIds.size) {
return null;
}
return createCanaryPolicy({ userIds, usernames, wechatUserIds, candidateTarget: 'candidate' });
}
/**
* Resolve Portal → goosed API targets for local v1.49 canary.
* Returns null when canary is off (caller keeps legacy TKMIND_API_TARGET parsing).
*/
export function resolveGooseApiTargetsFromEnv(env = process.env) {
const mode = parseGooseCanaryMode(env);
if (mode === 'off') return null;
const stable = assertLoopbackGooseTarget(
env.TKMIND_API_TARGET_STABLE ?? DEFAULT_GOOSE_STABLE_TARGET,
'TKMIND_API_TARGET_STABLE',
);
const v149 = assertLoopbackGooseTarget(
env.TKMIND_API_TARGET_V149 ?? DEFAULT_GOOSE_V149_TARGET,
'TKMIND_API_TARGET_V149',
);
if (mode === 'all') {
return Object.freeze({
mode,
stable,
v149,
primary: v149,
targets: [v149],
});
}
const policy = gooseCanaryPolicyFromEnv(env);
if (!policy) {
throw new Error(
'TKMIND_GOOSE_CANARY=users requires TKMIND_GOOSE_CANARY_USER_IDS and/or '
+ 'TKMIND_GOOSE_CANARY_USERNAMES / TKMIND_GOOSE_CANARY_WECHAT_USER_IDS',
);
}
return Object.freeze({
mode,
stable,
v149,
primary: stable,
targets: [stable, v149],
policy,
});
}
export function resolveGooseTargetForIdentity(identity, config) {
if (!config || config.mode === 'off') {
return config?.stable ?? DEFAULT_GOOSE_STABLE_TARGET;
}
if (config.mode === 'all') return config.v149;
const targetKey = resolveCanaryTarget(identity, config.policy);
return targetKey === 'candidate' ? config.v149 : config.stable;
}
export function describeGooseCanaryConfig(env = process.env) {
const config = resolveGooseApiTargetsFromEnv(env);
if (!config) {
return {
enabled: false,
mode: 'off',
stable: assertLoopbackGooseTarget(
env.TKMIND_API_TARGET ?? DEFAULT_GOOSE_STABLE_TARGET,
'TKMIND_API_TARGET',
),
v149: assertLoopbackGooseTarget(
env.TKMIND_API_TARGET_V149 ?? DEFAULT_GOOSE_V149_TARGET,
'TKMIND_API_TARGET_V149',
),
};
}
return {
enabled: true,
mode: config.mode,
stable: config.stable,
v149: config.v149,
primary: config.primary,
targets: [...config.targets],
policySelectors: config.policy
? {
userIds: [...config.policy.userIds],
usernames: [...config.policy.usernames],
wechatUserIds: [...config.policy.wechatUserIds],
}
: null,
};
}
function printCliStatus() {
const summary = describeGooseCanaryConfig(process.env);
console.log(JSON.stringify(summary, null, 2));
}
const modulePath = fileURLToPath(import.meta.url);
const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : '';
if (invokedPath && invokedPath === modulePath) {
printCliStatus();
}
+66
View File
@@ -0,0 +1,66 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
assertLoopbackGooseTarget,
describeGooseCanaryConfig,
parseGooseCanaryMode,
resolveGooseApiTargetsFromEnv,
resolveGooseTargetForIdentity,
} from './goose-v149-canary.mjs';
test('parseGooseCanaryMode defaults to off', () => {
assert.equal(parseGooseCanaryMode({}), 'off');
assert.equal(parseGooseCanaryMode({ TKMIND_GOOSE_CANARY: 'all' }), 'all');
});
test('resolveGooseApiTargetsFromEnv all mode pins Portal to v149', () => {
const config = resolveGooseApiTargetsFromEnv({
TKMIND_GOOSE_CANARY: 'all',
TKMIND_API_TARGET_STABLE: 'https://127.0.0.1:18006',
TKMIND_API_TARGET_V149: 'https://127.0.0.1:18049',
});
assert.equal(config.mode, 'all');
assert.equal(config.primary, 'https://127.0.0.1:18049');
assert.deepEqual(config.targets, ['https://127.0.0.1:18049']);
});
test('resolveGooseApiTargetsFromEnv users mode exposes dual targets', () => {
const config = resolveGooseApiTargetsFromEnv({
TKMIND_GOOSE_CANARY: 'users',
TKMIND_GOOSE_CANARY_USER_IDS: 'user-1',
});
assert.equal(config.mode, 'users');
assert.equal(config.primary, 'https://127.0.0.1:18006');
assert.deepEqual(config.targets, ['https://127.0.0.1:18006', 'https://127.0.0.1:18049']);
});
test('resolveGooseTargetForIdentity routes canary users to v149', () => {
const config = resolveGooseApiTargetsFromEnv({
TKMIND_GOOSE_CANARY: 'users',
TKMIND_GOOSE_CANARY_USER_IDS: 'user-abc',
});
assert.equal(
resolveGooseTargetForIdentity({ id: 'user-abc' }, config),
'https://127.0.0.1:18049',
);
assert.equal(
resolveGooseTargetForIdentity({ id: 'other-user' }, config),
'https://127.0.0.1:18006',
);
});
test('assertLoopbackGooseTarget refuses production hosts', () => {
assert.throws(
() => assertLoopbackGooseTarget('https://58.38.22.103:18006'),
/refuses production host/,
);
});
test('describeGooseCanaryConfig reports disabled when off', () => {
const summary = describeGooseCanaryConfig({
TKMIND_API_TARGET: 'https://127.0.0.1:18006',
});
assert.equal(summary.enabled, false);
assert.equal(summary.mode, 'off');
});
+132
View File
@@ -0,0 +1,132 @@
/**
* Shared SSE helpers for local Goose v1.49 smoke scripts.
*/
import { randomUUID } from 'node:crypto';
import { Readable } from 'node:stream';
import { Agent, fetch } from 'undici';
const blockedHosts = new Set(['58.38.22.103', '120.26.184.105']);
export function createV149Client(options = {}) {
const host = options.host ?? process.env.GOOSE_V149_HOST ?? '127.0.0.1';
const port = options.port ?? process.env.GOOSE_V149_PORT ?? '18049';
const secret = options.secret ?? process.env.GOOSE_SERVER__SECRET_KEY ?? 'local-v149-dev-secret';
if (blockedHosts.has(host)) {
throw new Error(`Refusing production host ${host}`);
}
const base = `https://${host}:${port}`;
const dispatcher = new Agent({ connect: { rejectUnauthorized: false } });
async function apiFetch(pathname, init = {}) {
const headers = { ...(init.headers ?? {}), 'X-Secret-Key': secret };
if (init.body && !headers['Content-Type']) headers['Content-Type'] = 'application/json';
return fetch(`${base}${pathname}`, { ...init, headers, dispatcher });
}
async function apiJson(pathname, body, init = {}) {
const response = await apiFetch(pathname, {
method: init.method ?? 'POST',
body: body == null ? undefined : JSON.stringify(body),
...init,
});
const text = await response.text();
if (!response.ok) {
throw new Error(`${pathname} ${response.status}: ${text.slice(0, 800)}`);
}
return text.trim() ? JSON.parse(text) : {};
}
return { base, apiFetch, apiJson };
}
function parseSseData(frame) {
let data = '';
for (const line of frame.split('\n')) {
if (line.startsWith('data:')) data += line.slice(5).trim();
}
if (!data) return null;
try {
return JSON.parse(data);
} catch {
return null;
}
}
export async function collectReplyEvents({
client,
sessionId,
requestId = randomUUID(),
userMessage,
timeoutMs = Number(process.env.GOOSE_V149_REPLY_TIMEOUT_MS || 90_000),
triggerReply = true,
}) {
const events = [];
const eventsResponse = await client.apiFetch(`/sessions/${sessionId}/events`, {
method: 'GET',
headers: { Accept: 'text/event-stream' },
});
if (!eventsResponse.ok || !eventsResponse.body) {
const text = await eventsResponse.text().catch(() => '');
throw new Error(`events ${eventsResponse.status}: ${text.slice(0, 400)}`);
}
if (triggerReply) {
const replyResponse = await client.apiFetch(`/sessions/${sessionId}/reply`, {
method: 'POST',
body: JSON.stringify({
request_id: requestId,
user_message: userMessage,
}),
});
if (!replyResponse.ok) {
const text = await replyResponse.text().catch(() => '');
throw new Error(`reply ${replyResponse.status}: ${text.slice(0, 400)}`);
}
replyResponse.body?.cancel?.();
}
const reader = Readable.fromWeb(eventsResponse.body);
const decoder = new TextDecoder();
let buffer = '';
const deadline = Date.now() + timeoutMs;
let finishEvent = null;
let errorEvent = null;
for await (const chunk of reader) {
if (Date.now() > deadline) break;
buffer += decoder.decode(chunk, { stream: true });
const frames = buffer.split('\n\n');
buffer = frames.pop() ?? '';
for (const frame of frames) {
const event = parseSseData(frame);
if (!event) continue;
const routingId = event.chat_request_id ?? event.request_id;
if (routingId && routingId !== requestId) continue;
events.push(event);
if (event.type === 'Finish') finishEvent = event;
if (event.type === 'Error') errorEvent = event;
if (finishEvent || errorEvent) {
reader.cancel?.().catch(() => {});
break;
}
}
if (finishEvent || errorEvent) break;
}
return {
requestId,
events,
finishEvent,
errorEvent,
outcome: finishEvent ? 'finish' : errorEvent ? 'error' : 'timeout',
};
}
export function extractTokenStateFromEvents(events = []) {
for (let i = events.length - 1; i >= 0; i -= 1) {
const event = events[i];
if (event?.type === 'Finish' && event.token_state) return event.token_state;
if (event?.type === 'Message' && event.token_state) return event.token_state;
}
return null;
}
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
# Run upstream Goose v1.49 candidate on isolated local port (default 18049).
# Does not touch 103 production ports 18006-18014 or Portal 8081.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
GOOSED_ROOT="${GOOSED_V149_ROOT:-/Users/john/Project/tkmind_go-v149}"
PORT="${GOOSE_V149_PORT:-18049}"
RUNTIME_ROOT="${GOOSE_V149_RUNTIME_ROOT:-/tmp/goose-v149-runtime}"
GOOSE_BIN_NAME="${GOOSE_V149_BIN:-goose}"
for blocked in 18006 18007 18008 18009 18010 18011 18012 18013 18014 8081; do
if [[ "${PORT}" == "${blocked}" ]]; then
echo "[goose-v149] refusing stable/production port ${PORT}" >&2
exit 1
fi
done
if [[ ! -d "${GOOSED_ROOT}" ]]; then
echo "[goose-v149] worktree missing: ${GOOSED_ROOT}" >&2
exit 1
fi
bash "${ROOT}/scripts/ensure-goose-v149-vendor.sh" 2>/dev/null || true
bash "${ROOT}/scripts/ensure-goose-v149-local-db.sh"
PG_USER="${GOOSE_V149_PG_USER:-${USER:-john}}"
PG_HOST="${GOOSE_V149_PG_HOST:-127.0.0.1}"
PG_PORT="${GOOSE_V149_PG_PORT:-5432}"
DB_NAME="${GOOSE_V149_SESSION_DB:-goose_sessions_v149_dev}"
mkdir -p "${RUNTIME_ROOT}/"{config,data,state,cache}
if [[ -x "${GOOSED_ROOT}/target/release/${GOOSE_BIN_NAME}" ]]; then
GOOSED_BIN="${GOOSED_ROOT}/target/release/${GOOSE_BIN_NAME}"
elif [[ -x "${GOOSED_ROOT}/target/debug/${GOOSE_BIN_NAME}" ]]; then
GOOSED_BIN="${GOOSED_ROOT}/target/debug/${GOOSE_BIN_NAME}"
else
echo "[goose-v149] goose binary not found; build with:" >&2
echo " bash ${ROOT}/scripts/build-goosed-v149-local.sh" >&2
exit 1
fi
export XDG_CONFIG_HOME="${RUNTIME_ROOT}/config"
export XDG_DATA_HOME="${RUNTIME_ROOT}/data"
export XDG_STATE_HOME="${RUNTIME_ROOT}/state"
export XDG_CACHE_HOME="${RUNTIME_ROOT}/cache"
export GOOSE_PORT="${PORT}"
export GOOSE_HOST="${GOOSE_HOST:-127.0.0.1}"
export GOOSE_TLS="${GOOSE_TLS:-true}"
export GOOSE_SERVER__SECRET_KEY="${GOOSE_SERVER__SECRET_KEY:-local-v149-dev-secret}"
export GOOSE_DISABLE_KEYRING="${GOOSE_DISABLE_KEYRING:-1}"
# Phase 1: vanilla v1.49 may still use default SQLite unless PG adapter is ported.
export GOOSE_SESSION_DB_URL="${GOOSE_SESSION_DB_URL:-postgresql://${PG_USER}@${PG_HOST}:${PG_PORT}/${DB_NAME}}"
export GOOSED_MCP_NODE_PATH="${GOOSED_MCP_NODE_PATH:-$(command -v node)}"
export GOOSED_MCP_SERVER_PATH="${GOOSED_MCP_SERVER_PATH:-${ROOT}/mindspace-sandbox-mcp.mjs}"
export PORTAL_RUNTIME_DIR="${PORTAL_RUNTIME_DIR:-${ROOT}}"
export MINDSPACE_ROOT="${MINDSPACE_ROOT:-${ROOT}/MindSpace}"
# Memory: force legacy owner during local upgrade (see TKMIND_V1_49_MIGRATION.md)
export MEMORY_BACKEND="${MEMORY_BACKEND:-legacy}"
export MEMORY_VECTOR_ENABLED="${MEMORY_VECTOR_ENABLED:-0}"
export MEMORY_AGENT_INJECTION_MODE="${MEMORY_AGENT_INJECTION_MODE:-off}"
export MEMORY_LIFECYCLE_ENABLED="${MEMORY_LIFECYCLE_ENABLED:-0}"
export MEMORY_CANDIDATE_ENABLED="${MEMORY_CANDIDATE_ENABLED:-0}"
echo "[goose-v149] binary=$(${GOOSED_BIN} --version 2>&1 | head -1)"
echo "[goose-v149] port=${PORT} session_db=${DB_NAME} runtime=${RUNTIME_ROOT}"
echo "[goose-v149] mode=goose serve (ACP HTTP/WS); legacy REST /agent/* requires tkmind_compat port"
cd "${GOOSED_ROOT}"
SERVE_TLS_FLAG=()
if [[ "${GOOSE_TLS:-true}" == "true" ]]; then
SERVE_TLS_FLAG=(--tls)
fi
exec "${GOOSED_BIN}" serve --host "${GOOSE_HOST:-127.0.0.1}" --port "${PORT}" "${SERVE_TLS_FLAG[@]}"
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env node
/**
* Phase 2 orchestrator: cost / memory drift / portal canary verification.
*/
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const checks = [
{ name: 'phase1-all', script: 'check-goosed-v149-all.mjs', required: true },
{ name: 'cost-smoke', script: 'check-goosed-v149-cost-smoke.mjs', required: false },
{ 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: 'web-smoke', script: 'check-goosed-v149-web-smoke.mjs', required: true },
{ name: 'skills-smoke', script: 'check-goosed-v149-skills-smoke.mjs', required: true },
{
name: 'deepseek-tools',
script: 'check-goosed-v149-deepseek-tools.mjs',
required: false,
},
{
name: 'thinking-preservation',
script: 'check-goosed-v149-thinking-preservation.mjs',
required: true,
},
{
name: 'message-sanitize',
script: 'compare-goose-v149-message-sanitize.mjs',
required: true,
},
{ name: 'canary-portal', script: 'check-goosed-v149-canary-portal.mjs', required: true },
{ name: 'portal-smoke', script: 'check-goosed-v149-portal-smoke.mjs', required: false },
];
function run(script, extraEnv = {}) {
const result = spawnSync(process.execPath, [path.join(root, 'scripts', script)], {
cwd: root,
env: { ...process.env, ...extraEnv },
encoding: 'utf8',
});
return {
ok: result.status === 0,
status: result.status,
stdout: result.stdout?.trim() ?? '',
stderr: result.stderr?.trim() ?? '',
};
}
const results = [];
for (const check of checks) {
const item = { ...check, ...run(check.script) };
results.push(item);
const label = check.required ? 'required' : 'optional';
console.log(`[goose-v149-phase2] ${item.ok ? 'OK' : 'FAIL'} (${label}) ${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);
const failedOptional = results.filter((item) => !item.required && !item.ok);
if (failedRequired.length) {
console.error(`GOOSE_V149_PHASE2_FAIL: ${failedRequired.map((item) => item.name).join(', ')}`);
process.exit(1);
}
if (failedOptional.length) {
console.log(
`GOOSE_V149_PHASE2_OK_WITH_SKIPS: ${failedOptional.map((item) => item.name).join(', ')}`,
);
} else {
console.log('GOOSE_V149_PHASE2_OK');
}
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env node
/**
* Phase 3 closeout gate: Phase 2 evidence + DeepSeek parity + canary unit tests.
* Does not authorize 103/105; local loopback only.
*/
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const checks = [
{ name: 'phase2', script: 'run-goosed-v149-phase2.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-drift-again',
script: 'compare-goose-v149-memory-manifest.mjs',
required: true,
},
];
function runScript(script, extraEnv = {}) {
const result = spawnSync(process.execPath, [path.join(root, 'scripts', script)], {
cwd: root,
env: { ...process.env, ...extraEnv },
encoding: 'utf8',
});
return {
ok: result.status === 0,
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: { ...process.env, ...extraEnv },
encoding: 'utf8',
});
return {
ok: result.status === 0,
status: result.status,
stdout: result.stdout?.trim() ?? '',
stderr: result.stderr?.trim() ?? '',
};
}
const results = [];
for (const check of checks) {
const item = {
...check,
...(check.script ? runScript(check.script) : runCommand(check.command)),
};
results.push(item);
console.log(`[goose-v149-phase3] ${item.ok ? 'OK' : 'FAIL'} ${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_FAIL: ${failedRequired.map((item) => item.name).join(', ')}`);
process.exit(1);
}
console.log('GOOSE_V149_PHASE3_OK');
console.log(' next: docs/goose-v149-phase3-closeout.md (merge prep, no 103 until approved)');
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env bash
# Toggle local Goose v1.49 canary for Portal dev (loopback only).
# Does not modify 103/105 production. See docs/goose-v149-canary-runbook.md.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
ENV_FILE="${GOOSE_V149_CANARY_ENV_FILE:-${ROOT}/.env.local}"
MARKER_BEGIN='# GOOSE_V149_CANARY_BEGIN'
MARKER_END='# GOOSE_V149_CANARY_END'
STABLE_TARGET="${TKMIND_API_TARGET_STABLE:-https://127.0.0.1:18006}"
V149_TARGET="${TKMIND_API_TARGET_V149:-https://127.0.0.1:18049}"
V149_PORT="${GOOSE_V149_PORT:-18049}"
usage() {
cat <<EOF
Usage: bash scripts/switch-goose-v149-canary.sh <command>
Commands:
on [all|users] Enable v1.49 canary (default: all → Portal uses ${V149_TARGET})
off|rollback Restore stable target (${STABLE_TARGET}) and disable canary flags
status Print current canary block / env summary
Env overrides:
GOOSE_V149_CANARY_ENV_FILE Target env file (default: .env.local)
TKMIND_GOOSE_CANARY_USER_IDS Comma-separated user IDs (users mode)
TKMIND_GOOSE_CANARY_USERNAMES Comma-separated usernames (users mode)
After "on", restart local Portal (pnpm dev) to pick up .env.local changes.
EOF
}
remove_canary_block() {
if [[ ! -f "${ENV_FILE}" ]]; then
return 0
fi
awk -v begin="${MARKER_BEGIN}" -v end="${MARKER_END}" '
$0 == begin { skip=1; next }
$0 == end { skip=0; next }
skip == 0 { print }
' "${ENV_FILE}" > "${ENV_FILE}.tmp"
mv "${ENV_FILE}.tmp" "${ENV_FILE}"
}
write_canary_block() {
local mode="$1"
remove_canary_block
touch "${ENV_FILE}"
{
echo "${MARKER_BEGIN}"
echo "TKMIND_GOOSE_CANARY=${mode}"
echo "TKMIND_API_TARGET_STABLE=${STABLE_TARGET}"
echo "TKMIND_API_TARGET_V149=${V149_TARGET}"
if [[ "${mode}" == "all" ]]; then
echo "TKMIND_API_TARGET=${V149_TARGET}"
echo "TKMIND_API_TARGETS="
elif [[ "${mode}" == "users" ]]; then
echo "TKMIND_API_TARGET=${STABLE_TARGET}"
echo "TKMIND_API_TARGETS=${STABLE_TARGET},${V149_TARGET}"
if [[ -n "${TKMIND_GOOSE_CANARY_USER_IDS:-}" ]]; then
echo "TKMIND_GOOSE_CANARY_USER_IDS=${TKMIND_GOOSE_CANARY_USER_IDS}"
fi
if [[ -n "${TKMIND_GOOSE_CANARY_USERNAMES:-}" ]]; then
echo "TKMIND_GOOSE_CANARY_USERNAMES=${TKMIND_GOOSE_CANARY_USERNAMES}"
fi
if [[ -n "${TKMIND_GOOSE_CANARY_WECHAT_USER_IDS:-}" ]]; then
echo "TKMIND_GOOSE_CANARY_WECHAT_USER_IDS=${TKMIND_GOOSE_CANARY_WECHAT_USER_IDS}"
fi
fi
echo "MEMORY_BACKEND=legacy"
echo "MEMORY_VECTOR_ENABLED=0"
echo "MEMORY_AGENT_INJECTION_MODE=off"
echo "MEMORY_LIFECYCLE_ENABLED=0"
echo "MEMORY_CANDIDATE_ENABLED=0"
echo "MEMIND_PRODUCT_ANALYTICS_ENABLED=false"
echo "H5_PUBLIC_BASE_URL=http://127.0.0.1:8081"
echo "${MARKER_END}"
} >> "${ENV_FILE}"
}
cmd="${1:-}"
case "${cmd}" in
on)
mode="${2:-all}"
if [[ "${mode}" != "all" && "${mode}" != "users" ]]; then
echo "Invalid mode: ${mode} (expected all or users)" >&2
exit 2
fi
if [[ "${mode}" == "users" && -z "${TKMIND_GOOSE_CANARY_USER_IDS:-}${TKMIND_GOOSE_CANARY_USERNAMES:-}${TKMIND_GOOSE_CANARY_WECHAT_USER_IDS:-}" ]]; then
echo "users mode requires TKMIND_GOOSE_CANARY_USER_IDS and/or USERNAMES / WECHAT_USER_IDS" >&2
exit 2
fi
write_canary_block "${mode}"
echo "[goose-v149-canary] enabled mode=${mode} env=${ENV_FILE}"
echo "[goose-v149-canary] ensure v1.49 is running: bash scripts/run-goosed-v149-local.sh"
echo "[goose-v149-canary] verify: curl -sk ${V149_TARGET}/status"
;;
off|rollback)
remove_canary_block
echo "[goose-v149-canary] disabled; restored env block removed from ${ENV_FILE}"
echo "[goose-v149-canary] Portal falls back to default TKMIND_API_TARGET (${STABLE_TARGET}) from .env"
;;
status)
if [[ -f "${ENV_FILE}" ]] && grep -q "${MARKER_BEGIN}" "${ENV_FILE}"; then
echo "[goose-v149-canary] block present in ${ENV_FILE}:"
awk -v begin="${MARKER_BEGIN}" -v end="${MARKER_END}" '
$0 == begin { show=1; next }
$0 == end { show=0; next }
show == 1 { print " " $0 }
' "${ENV_FILE}"
else
echo "[goose-v149-canary] no active block in ${ENV_FILE}"
fi
node "${ROOT}/scripts/goose-v149-canary.mjs" 2>/dev/null || true
;;
-h|--help|help|'')
usage
[[ -z "${cmd}" ]] && exit 0
;;
*)
echo "Unknown command: ${cmd}" >&2
usage
exit 2
;;
esac
+11
View File
@@ -117,9 +117,11 @@ import {
describeMemindRuntimeProfile,
loadMemindEnvFiles,
} from './scripts/memind-runtime-profile.mjs';
import { resolveGooseApiTargetsFromEnv, applyGooseV149CanaryBlockEnv } from './scripts/goose-v149-canary.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
loadMemindEnvFiles(__dirname);
applyGooseV149CanaryBlockEnv(process.env, __dirname);
const H5_ROOT = resolvePortalH5Root(__dirname, process.env);
if (!fs.existsSync(H5_ROOT) || !fs.statSync(H5_ROOT).isDirectory()) {
throw new Error(
@@ -130,11 +132,20 @@ if (H5_ROOT !== __dirname) {
console.log(`[Portal] Code root: ${__dirname}`);
console.log(`[Portal] Persistent H5 root: ${H5_ROOT}`);
loadMemindEnvFiles(H5_ROOT);
applyGooseV149CanaryBlockEnv(process.env, H5_ROOT);
}
applyMemindRuntimeProfile({ rootDir: H5_ROOT });
function parseApiTargets() {
const gooseCanary = resolveGooseApiTargetsFromEnv(process.env);
if (gooseCanary?.targets?.length) {
console.log(
`[Portal] Goose v1.49 canary mode=${gooseCanary.mode} primary=${gooseCanary.primary}`,
);
return [...gooseCanary.targets];
}
const csvTargets = (process.env.TKMIND_API_TARGETS ?? '')
.split(',')
.map((value) => value.trim())
+32 -2
View File
@@ -23,6 +23,10 @@ import { reconcileAgentSession } from './session-reconcile.mjs';
import { consumeSessionEventsUntilFinish } from './session-reply-wait.mjs';
import { createSessionAccess, isSessionBrokerEnabled } from './session-broker.mjs';
import { createSessionBrokerMetrics, isSessionBrokerMetricsEnabled } from './session-broker-metrics.mjs';
import {
resolveGooseApiTargetsFromEnv,
resolveGooseTargetForIdentity,
} from './scripts/goose-v149-canary.mjs';
import { createImgproxySigner } from './imgproxy-signer.mjs';
import { isDirectChatSessionId } from './direct-chat-service.mjs';
import { ensureGooseUserMessageMetadata } from './goose-message.mjs';
@@ -1189,6 +1193,15 @@ export function createTkmindProxy({
: null;
const targets = apiTargets?.length ? apiTargets : apiTarget ? [apiTarget] : [];
const primaryTarget = targets[0] ?? apiTarget ?? '';
let gooseCanaryConfig = null;
try {
gooseCanaryConfig = resolveGooseApiTargetsFromEnv(process.env);
} catch (err) {
console.warn(
'[TKMindProxy] Goose v1.49 canary config ignored:',
err instanceof Error ? err.message : err,
);
}
let rrIdx = 0;
let imgproxySigner = null;
@@ -1369,6 +1382,23 @@ export function createTkmindProxy({
return fallbackPick();
}
async function pickTargetForUser(userId, user = null) {
if (gooseCanaryConfig?.mode === 'users' && userId) {
const target = resolveGooseTargetForIdentity(
{
id: String(userId),
username: user?.username ?? user?.name ?? '',
wechatUserId: user?.wechatUserId ?? user?.wechat_user_id ?? '',
},
gooseCanaryConfig,
);
if (target && (targets.includes(target) || target === gooseCanaryConfig.v149 || target === gooseCanaryConfig.stable)) {
if (await targetHealthy(target)) return target;
}
}
return pickTarget();
}
async function rememberSessionTarget(sessionId, target) {
if (!runtimeRouter || !sessionId || !target) return;
await runtimeRouter.registerSession(sessionId, target).catch(() => null);
@@ -1491,7 +1521,7 @@ export function createTkmindProxy({
const resolvedSessionPolicy = disableImageReading
? withoutSessionImageRead(baseSessionPolicy)
: baseSessionPolicy;
const startTarget = await pickTarget();
const startTarget = await pickTargetForUser(userId);
const upstream = await apiFetch(startTarget, apiSecret, '/agent/start', {
method: 'POST',
body: JSON.stringify({
@@ -2355,7 +2385,7 @@ export function createTkmindProxy({
try {
const workingDir = await userAuth.resolveWorkingDir(req.currentUser.id);
const sessionPolicy = await userAuth.getAgentSessionPolicy(req.currentUser.id);
const startTarget = await pickTarget();
const startTarget = await pickTargetForUser(req.currentUser.id, req.currentUser);
const upstream = await apiFetch(startTarget, apiSecret, '/agent/start', {
method: 'POST',
body: JSON.stringify({