Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions dsh-mneme/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

## [Unreleased]

- **后台 LLM 调用一个 session id 都没带,走 OpenCode 路由必然 400(#668):七个调用点统一补上固定后台会话标识**。插件自己的后台调用(整理、蒸馏、实体抽取、注入门控、图像描述、连通性探测)此前只传 `provider`/`model`/`messages`,`sessionId` 全程缺席。这在多数网关上无声无息,但 OpenCode 适配器是硬要求:pi-ai 的 `dist/providers/opencode-headers.js` 里 `withSessionHeader` 只在 `options.sessionId` 存在时才注入 `x-opencode-session` 头,缺了就直接 400。上报者(#668)说的正是这条。
- **一处收口,不散点补**:新增 `src/llm-session.js`,`BACKGROUND_SESSION_ID = "dsh-mneme"` 与 `withBackgroundSession(options)` 两个导出。七个点全部经它包一层——`src/dream.js` 与 `src/dream/sleep.js` 各有本地 `streamText`,改单点即覆盖全部 7 个 dream/sleep 相位;另五处是 `src/index.js`、`src/summarize.js`、`src/pre-inject-gate.js`、`src/api.js`、`src/image-describe.js`。
- **固定标识而不是按 run id 派生**:按 run id 会让网关把每次整理当成一个新会话,限流按会话计数时反而更容易撞墙,排障时也分不清请求来自哪一层。固定成一个,既满足头部要求,也让网关侧能一眼认出「这是插件的后台流量」。
- **不覆盖宿主已经给的真会话**:`options.sessionId` 已存在时原样返回,不覆盖。注入链路上宿主可能带着真实用户会话进来,那时把 id 换成插件标识等于伪造来源。
- **测试**:`test/llm-session.test.js` 6 条(填 id 且不改入参、已给真 id 时不覆盖、容忍 undefined/null、七个调用点源码各含预期 needle、`src` 全域不得再出现裸 `.stream(options)` 或 `.stream({ ...route,`、常量值就是 `"dsh-mneme"` 且长度 1–64 符合网关约束)。变异自证五轮各自变红:撤掉 `src/dream.js` 接线、把 helper 改成恒等返回、撤掉 `src/image-describe.js` 接线、改成无条件覆盖 sessionId、撤掉 `src/summarize.js` 接线。

## [0.14.0-beta.3] - 2026-11-12

- **情绪页白屏(beta.1 起的回归,beta.2 仍在):点开「情感」页整块面板变空白,且此后每次打开都空白**。根因是 `lib/client.js` 的 `AffectivePanel` 里一行 `formatRelativeTime(it.lastSeen)` 漏传第二个参数 `t`——该函数内部要调 `t("memory.time.now")`,缺参就抛 `t is not a function`,而这行跑在 render 里,React 整棵树卸载,日志里什么都不留。引入提交是 #619(`244da66d`),`v0.14.0-beta.1` 与 `v0.14.0-beta.2` 都带它;只有库里存在带 `lastSeen` 的情绪边时才触发,所以不是每个用户都会遇到。修法是补上 `, t`,并加一条静态断言:扫出所有 `formatRelativeTime(` 调用点,逐个做括号配对、数顶层逗号,实参不足 2 个就红。全库只有这一处漏传(`formatDate`/`formatDateShort` 本来只收一个参数,扫描器报的几处是误报)。变异自证:撤掉修复后断言变红并直接指出 `:8168 (argc=1)`。
Expand Down
5 changes: 3 additions & 2 deletions dsh-mneme/lib/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { timingSafeEqual, randomBytes } from "node:crypto";
import { TYPES } from "./store.js";
import { TYPE_FILE, renderMirrorText, renderFileHeader, parseHumanEdits, MIRROR_READONLY_TYPES } from "./mirror.js";
import { langOf } from "./lang.js";
import { withBackgroundSession } from "./llm-session.js";
// issue #607:面板空态要显示「能直接粘贴就跑」的命令,而面板 bundle 拿不到
// CLI 入口路径(独立 bundle 不能 import src)——所以由这条路由把命令算好发给它。
import { choroCliCommandFrom } from "./wiki/cli-hint.js";
Expand Down Expand Up @@ -827,7 +828,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
const attempt = async (effort) => {
streamError = "";
let reply = "";
for await (const chunk of llm.stream({
for await (const chunk of llm.stream(withBackgroundSession({
provider,
model,
purpose: "dsh-mneme-connectivity-test",
Expand All @@ -847,7 +848,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" }
}
]
})) {
}))) {
if (chunk.type === "text-delta" && typeof chunk.text === "string") reply += chunk.text;
if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
streamError = describeStreamFailure(chunk.reason);
Expand Down
3 changes: 2 additions & 1 deletion dsh-mneme/lib/dream.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { scopeKeyOf } from "./scope.js";
import { isInPeakWindow, nextOffPeakAt } from "./peak-hours.js";
import { createHash, randomUUID } from "node:crypto";
import { STR, langOf } from "./lang.js";
import { withBackgroundSession } from "./llm-session.js";
// 巩固读的是行池,而动行的写手不经汇流口:applyDecisions 归档败者直接走
// service.setArchived(dream/decisions.js),所以「不许改写外来行」这条契约要在
// 每个池子各挡一遍。清单与静态锁见 share-imported-guard.test.js。
Expand Down Expand Up @@ -289,7 +290,7 @@ function buildRecordReceipts({ runId, committed, snapshot, policyEpoch }) {
*/
async function streamText(ctx, options, onUsage, onStreamError) {
let text = "";
for await (const chunk of ctx.llm.stream(options)) {
for await (const chunk of ctx.llm.stream(withBackgroundSession(options))) {
if (chunk.type === "text-delta" && typeof chunk.text === "string") text += chunk.text;
// DSH 的 StreamChunk 契约把用量嵌在 chunk.usage(TokenUsage:inputTokens /
// outputTokens / …),chunk 顶层没有 token 字段——把整个 chunk 当用量对象交给
Expand Down
3 changes: 2 additions & 1 deletion dsh-mneme/lib/dream/sleep.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { STR, langOf } from "../lang.js";
import { withBackgroundSession } from "../llm-session.js";
// System-level sleep (v0.4.0): an idle-triggered, LLM-assisted deep pass over
// the memory store. Four independent, fail-safe phases:
// 1. conflict resolution — high-similarity same-type pairs are either parked
Expand Down Expand Up @@ -86,7 +87,7 @@ function parseJsonArray(text) {
async function streamText(ctx, options, onUsage, onStreamError) {
if (!ctx?.llm?.stream) return undefined;
let text = "";
for await (const chunk of ctx.llm.stream(options)) {
for await (const chunk of ctx.llm.stream(withBackgroundSession(options))) {
if (chunk.type === "text-delta" && typeof chunk.text === "string") text += chunk.text;
// DSH 的 StreamChunk 契约把用量嵌在 chunk.usage(TokenUsage:inputTokens /
// outputTokens / totalTokens);兼容直接平铺在 chunk 上的旧形态。归一放在
Expand Down
5 changes: 3 additions & 2 deletions dsh-mneme/lib/image-describe.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
// 对话正文挤出窗口,那是拿主产物换辅产物。

import { STR, langOf } from "./lang.js";
import { withBackgroundSession } from "./llm-session.js";
import { imageLabel } from "./attachment-text.js";

/** 单张图描述的字数天花板(字符)。 */
Expand Down Expand Up @@ -92,7 +93,7 @@ export async function describeImages(ctx, route, config, blocks, options = {}) {
if (out.has(key)) continue;
try {
let text = "";
const stream = ctx.llm.stream({
const stream = ctx.llm.stream(withBackgroundSession({
provider: route.provider,
model: route.model,
purpose: "image-description",
Expand All @@ -108,7 +109,7 @@ export async function describeImages(ctx, route, config, blocks, options = {}) {
}
],
...(options.signal ? { signal: options.signal } : {})
});
}));
for await (const chunk of stream) {
if (chunk?.type === "text-delta") text += chunk.text ?? chunk.delta ?? "";
if (chunk?.type === "finish") {
Expand Down
5 changes: 3 additions & 2 deletions dsh-mneme/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import { createVectorIndex } from "./vector-index.js";
import { createSemantic } from "./semantic.js";
import { Config, applyLightModePreset, injectChildEnabled } from "./config.js";
import { langOf } from "./lang.js";
import { withBackgroundSession } from "./llm-session.js";
import { extractEntities } from "./entities/extractor.js";
import { createProfileExtractor } from "./profile-extract.js";
import { mkdirSync } from "node:fs";
Expand Down Expand Up @@ -149,12 +150,12 @@ export function createEntityStreamAdapter({ llm, agentDefaultModel, logger, serv
}
};
return (async () => {
for await (const chunk of llm.stream({
for await (const chunk of llm.stream(withBackgroundSession({
...route,
maxTokens: 4096,
...(withEffort && effort ? { reasoningEffort: effort } : {}),
messages
})) {
}))) {
if (chunk.type === "text-delta" && typeof chunk.text === "string") text += chunk.text;
// DSH 的 StreamChunk 契约把用量嵌在 chunk.usage(TokenUsage);兼容直接
// 平铺在 chunk 上的旧形态。归一放在这里,下面只需认平铺形状——与
Expand Down
33 changes: 33 additions & 0 deletions dsh-mneme/lib/llm-session.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* 后台 LLM 调用的会话标识(issue #668)。
*
* 主对话之所以能走网关,是因为 `@deepseek-ai/dsh-agent-loop` 在请求里带了
* `sessionId: this.session.id`;而 pi-ai 的 opencode / opencode-go 适配器
* (`dist/providers/opencode-headers.js` 的 `withSessionHeader`)**只在
* `options.sessionId` 存在时**才注入 `x-opencode-session` 头,缺了就被网关
* 直接 400 `MissingSessionID` 拒绝。
*
* 插件这一侧有 7 处后台调用:总结提炼、记忆巩固、sleep 各相位、实体抽取、
* 注入门控、连通性探测、图像描述。它们都没有真实会话——不是「忘了传」,
* 而是本来就不属于任何用户会话(审计行 `session_key` 也一直是 NULL)。
* 于是传一个稳定的固定标识:只服务网关路由,不参与会话归属、不影响谁能看到
* 什么、也不改变记忆的作用域判定。
*
* 为什么不按 run id 派生:那会让网关把同一批后台调用当成 N 个不同会话,
* 既拿不到路由复用的好处,也让排障时「这是插件干的」和「这是某个用户在聊」
* 混在一起。固定值一眼能分辨来源。
*/
export const BACKGROUND_SESSION_ID = "dsh-mneme";

/**
* 给一份 GenerateOptions 补上后台会话标识。
*
* 已自带 `sessionId` 的调用方保持原值不动(宿主若哪天替我们传了真实会话,
* 那个更精确,不该被覆盖);其余情况补 `BACKGROUND_SESSION_ID`。
* 纯函数,不修改入参。
*/
export function withBackgroundSession(options) {
if (!options || typeof options !== "object") return options;
if (options.sessionId) return options;
return { ...options, sessionId: BACKGROUND_SESSION_ID };
}
3 changes: 2 additions & 1 deletion dsh-mneme/lib/pre-inject-gate.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
// 一条指令型记忆可诱导模型标记全池 → 整个记忆块静默失明。该形态本帧退回观察档
// 原样注入并 warn;判定结果仍在缓存与审计里(n_flagged == n_pool 即此形态)。
import { STR, langOf } from "./lang.js";
import { withBackgroundSession } from "./llm-session.js";

export const GATE_TRIGGER_SOURCE = "preInjectGate";
export const GATE_OPERATION_TYPE = "pre_inject_gate";
Expand Down Expand Up @@ -236,7 +237,7 @@ export function createPreInjectGate({ llm, agentDefaultModel, service, config, l
const abort = new AbortController();
const consume = (async () => {
try {
for await (const chunk of llm.stream({ ...route, maxTokens: JUDGE_MAX_TOKENS, messages, signal: abort.signal })) {
for await (const chunk of llm.stream(withBackgroundSession({ ...route, maxTokens: JUDGE_MAX_TOKENS, messages, signal: abort.signal }))) {
if (chunk.type === "text-delta" && typeof chunk.text === "string") text = (text ?? "") + chunk.text;
if (chunk.type === "usage") {
const u = chunk.usage ?? chunk;
Expand Down
5 changes: 3 additions & 2 deletions dsh-mneme/lib/summarize.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm";
import { STR, langOf } from "./lang.js";
import { withBackgroundSession } from "./llm-session.js";
// Issue #315:蒸馏链路的 effort 降级复用巩固侧的 withEffortFallback(拒绝
// 重试一次不带 effort 字段)与 describeStreamFailure(流失败原因归一)——
// 三条链路同一降级语义,不另造第二份实现。
Expand Down Expand Up @@ -997,9 +998,9 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate =
const baseDelayMs = config.distillRateLimitBaseDelayMs ?? 1000;
// 每次尝试独立拼 effort 字段:降级重试(withEffort=false)必须真的
// 不带 reasoningEffort,不能复用带字段的同一 options 对象。
const callOptions = withEffort && summarizeEffort
const callOptions = withBackgroundSession(withEffort && summarizeEffort
? { ...options, reasoningEffort: summarizeEffort }
: options;
: options);
for (let attempt = 0; ; attempt++) {
// 每次尝试重置审计状态:effort 拒收/429 的失败 attempt 会把 status 置
// error,若后续重试成功,审计必须记录最终成功而不是残留第一次的失败
Expand Down
5 changes: 3 additions & 2 deletions dsh-mneme/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { timingSafeEqual, randomBytes } from "node:crypto";
import { TYPES } from "./store.js";
import { TYPE_FILE, renderMirrorText, renderFileHeader, parseHumanEdits, MIRROR_READONLY_TYPES } from "./mirror.js";
import { langOf } from "./lang.js";
import { withBackgroundSession } from "./llm-session.js";
// issue #607:面板空态要显示「能直接粘贴就跑」的命令,而面板 bundle 拿不到
// CLI 入口路径(独立 bundle 不能 import src)——所以由这条路由把命令算好发给它。
import { choroCliCommandFrom } from "./wiki/cli-hint.js";
Expand Down Expand Up @@ -827,7 +828,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
const attempt = async (effort) => {
streamError = "";
let reply = "";
for await (const chunk of llm.stream({
for await (const chunk of llm.stream(withBackgroundSession({
provider,
model,
purpose: "dsh-mneme-connectivity-test",
Expand All @@ -847,7 +848,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" }
}
]
})) {
}))) {
if (chunk.type === "text-delta" && typeof chunk.text === "string") reply += chunk.text;
if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
streamError = describeStreamFailure(chunk.reason);
Expand Down
3 changes: 2 additions & 1 deletion dsh-mneme/src/dream.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { scopeKeyOf } from "./scope.js";
import { isInPeakWindow, nextOffPeakAt } from "./peak-hours.js";
import { createHash, randomUUID } from "node:crypto";
import { STR, langOf } from "./lang.js";
import { withBackgroundSession } from "./llm-session.js";
// 巩固读的是行池,而动行的写手不经汇流口:applyDecisions 归档败者直接走
// service.setArchived(dream/decisions.js),所以「不许改写外来行」这条契约要在
// 每个池子各挡一遍。清单与静态锁见 share-imported-guard.test.js。
Expand Down Expand Up @@ -289,7 +290,7 @@ function buildRecordReceipts({ runId, committed, snapshot, policyEpoch }) {
*/
async function streamText(ctx, options, onUsage, onStreamError) {
let text = "";
for await (const chunk of ctx.llm.stream(options)) {
for await (const chunk of ctx.llm.stream(withBackgroundSession(options))) {
if (chunk.type === "text-delta" && typeof chunk.text === "string") text += chunk.text;
// DSH 的 StreamChunk 契约把用量嵌在 chunk.usage(TokenUsage:inputTokens /
// outputTokens / …),chunk 顶层没有 token 字段——把整个 chunk 当用量对象交给
Expand Down
3 changes: 2 additions & 1 deletion dsh-mneme/src/dream/sleep.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { STR, langOf } from "../lang.js";
import { withBackgroundSession } from "../llm-session.js";
// System-level sleep (v0.4.0): an idle-triggered, LLM-assisted deep pass over
// the memory store. Four independent, fail-safe phases:
// 1. conflict resolution — high-similarity same-type pairs are either parked
Expand Down Expand Up @@ -86,7 +87,7 @@ function parseJsonArray(text) {
async function streamText(ctx, options, onUsage, onStreamError) {
if (!ctx?.llm?.stream) return undefined;
let text = "";
for await (const chunk of ctx.llm.stream(options)) {
for await (const chunk of ctx.llm.stream(withBackgroundSession(options))) {
if (chunk.type === "text-delta" && typeof chunk.text === "string") text += chunk.text;
// DSH 的 StreamChunk 契约把用量嵌在 chunk.usage(TokenUsage:inputTokens /
// outputTokens / totalTokens);兼容直接平铺在 chunk 上的旧形态。归一放在
Expand Down
5 changes: 3 additions & 2 deletions dsh-mneme/src/image-describe.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
// 对话正文挤出窗口,那是拿主产物换辅产物。

import { STR, langOf } from "./lang.js";
import { withBackgroundSession } from "./llm-session.js";
import { imageLabel } from "./attachment-text.js";

/** 单张图描述的字数天花板(字符)。 */
Expand Down Expand Up @@ -92,7 +93,7 @@ export async function describeImages(ctx, route, config, blocks, options = {}) {
if (out.has(key)) continue;
try {
let text = "";
const stream = ctx.llm.stream({
const stream = ctx.llm.stream(withBackgroundSession({
provider: route.provider,
model: route.model,
purpose: "image-description",
Expand All @@ -108,7 +109,7 @@ export async function describeImages(ctx, route, config, blocks, options = {}) {
}
],
...(options.signal ? { signal: options.signal } : {})
});
}));
for await (const chunk of stream) {
if (chunk?.type === "text-delta") text += chunk.text ?? chunk.delta ?? "";
if (chunk?.type === "finish") {
Expand Down
5 changes: 3 additions & 2 deletions dsh-mneme/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import { createVectorIndex } from "./vector-index.js";
import { createSemantic } from "./semantic.js";
import { Config, applyLightModePreset, injectChildEnabled } from "./config.js";
import { langOf } from "./lang.js";
import { withBackgroundSession } from "./llm-session.js";
import { extractEntities } from "./entities/extractor.js";
import { createProfileExtractor } from "./profile-extract.js";
import { mkdirSync } from "node:fs";
Expand Down Expand Up @@ -149,12 +150,12 @@ export function createEntityStreamAdapter({ llm, agentDefaultModel, logger, serv
}
};
return (async () => {
for await (const chunk of llm.stream({
for await (const chunk of llm.stream(withBackgroundSession({
...route,
maxTokens: 4096,
...(withEffort && effort ? { reasoningEffort: effort } : {}),
messages
})) {
}))) {
if (chunk.type === "text-delta" && typeof chunk.text === "string") text += chunk.text;
// DSH 的 StreamChunk 契约把用量嵌在 chunk.usage(TokenUsage);兼容直接
// 平铺在 chunk 上的旧形态。归一放在这里,下面只需认平铺形状——与
Expand Down
Loading
Loading