From 982214c65df5ef0c8ded76268debb68665877459 Mon Sep 17 00:00:00 2001 From: diqierjia Date: Sun, 23 Aug 2026 21:22:27 +0800 Subject: [PATCH 1/2] Release StrataGate DSH 0.2.19 Add configurable persisted Block decay, workspace display metadata, and the refreshed memory settings UI with usage-aware branding. Includes the schema migration, runtime endpoints, documentation, and release metadata for 0.2.17 through 0.2.19. --- .gitignore | 1 + README.md | 2 +- README.zh-CN.md | 2 +- docs/ARCHITECTURE.md | 11 +- integrations/deepseek-harness/CHANGELOG.md | 17 +++ integrations/deepseek-harness/MARKETPLACE.md | 32 ---- integrations/deepseek-harness/README.md | 13 +- .../deepseek-harness/cordis.patch.yml | 1 + .../deepseek-harness/docs/README.zh-CN.md | 13 +- integrations/deepseek-harness/package.json | 2 +- .../deepseek-harness/scripts/build-client.mjs | 9 +- integrations/deepseek-harness/src/client.js | 141 +++++++++++------- integrations/deepseek-harness/src/config.ts | 6 + integrations/deepseek-harness/src/index.ts | 2 +- integrations/deepseek-harness/src/metadata.ts | 62 ++++++++ integrations/deepseek-harness/src/runtime.ts | 99 +++++++++++- integrations/deepseek-harness/src/web.ts | 22 ++- .../deepseek-harness/tests/client.test.ts | 42 ++++-- .../deepseek-harness/tests/config.test.ts | 15 +- .../deepseek-harness/tests/llm.test.ts | 7 +- .../deepseek-harness/tests/runtime.test.ts | 49 +++++- .../deepseek-harness/tests/web.test.ts | 35 ++++- package-lock.json | 2 +- src/blocks.ts | 17 ++- src/sqlite.ts | 45 ++++-- src/storage.ts | 63 +++++++- src/store.ts | 85 +++++++++-- src/types.ts | 2 +- tests/blocks.test.ts | 13 +- tests/persistence.test.ts | 67 ++++++++- tests/store.test.ts | 32 ++++ 31 files changed, 719 insertions(+), 190 deletions(-) delete mode 100644 integrations/deepseek-harness/MARKETPLACE.md create mode 100644 integrations/deepseek-harness/src/metadata.ts diff --git a/.gitignore b/.gitignore index be2543b..3e9ede1 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ coverage/ .env.* !.env.example *.log +*.tgz .DS_Store Thumbs.db .idea/ diff --git a/README.md b/README.md index 263f859..f1126fa 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ Conversations are sealed into layered memories at different levels of detail, an By default, every 12 complete conversation turns are sealed into one memory block. Messages that have not yet reached the boundary remain in the open tail and are not compressed or extracted early. -This is the core-library default. The DeepSeek Harness plugin defaults to 6 turns per Block so Event extraction becomes available sooner, and exposes `blockTurnSize` as a user setting. +This is the core-library default. The DeepSeek Harness plugin defaults to 6 turns per Block so Event extraction becomes available sooner, and exposes `blockTurnSize` as a user setting. Block age is the distance from the latest sealed Block in the same thread, so open-tail turns do not cause decay. The default Block-decay coefficient is `0.30`. Each sealed block contains six levels of detail: diff --git a/README.zh-CN.md b/README.zh-CN.md index 382fab6..464d6ec 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -78,7 +78,7 @@ StrataGate 的目标不是让 Agent 每次检索更多,而是让它知道:** 默认每 12 轮完整对话封存为一个记忆块。尚未达到边界的消息保留在 open tail 中,不会提前压缩或抽取。 -这是核心库的默认值。DeepSeek Harness 插件为了更及时地产生 Event,默认每 6 轮封存一个 Block,并允许用户通过 `blockTurnSize` 自定义。 +这是核心库的默认值。DeepSeek Harness 插件为了更及时地产生 Event,默认每 6 轮封存一个 Block,并允许用户通过 `blockTurnSize` 自定义。Block 的 age 是它与同一线程中最新已封存 Block 的距离,因此 open tail 中新增轮次不会触发衰减;默认 Block 衰减系数为 `0.30`。 每个已封存的块包含六种详细程度: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7456986..25253a2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -54,17 +54,18 @@ When the boundary is reached: 2. L4 converts tool payloads into readable summaries while preserving natural-language turns. 3. L3 applies a deterministic, bounded condensation policy. 4. A caller-provided summarizer produces L0-L2 and a conservative `shouldExtract` decision. -5. The block pointer starts at L5 and decays toward L0 as later turns accumulate. +5. The block pointer starts at L5 and decays toward L0 as newer Blocks are sealed in the same thread. The block weight is: ```text -w(t) = exp(-0.05 * t) +w(age) = exp(-lambda_block * age) -t = current turn - pointer anchor turn +age = latest sealed Block position - pointer anchor Block position +lambda_block = 0.30 by default ``` -The weight selects how many levels to drop from the pointer anchor. Expanding a block to L3 anchors the pointer at L3; it does not silently jump to L5. +Open-tail turns do not change Block age. The weight selects how many levels to drop from the pointer anchor. Expanding a block to L3 anchors the pointer at L3 and at the latest sealed Block position; it does not silently jump to L5. Hosts may configure `lambda_block`; smaller values decay more slowly, and values above `0.4` are not recommended. ## Deterministic L3 policy @@ -226,4 +227,4 @@ The adapter preserves these invariants: - forget is reversible unless an application explicitly implements irreversible deletion; - usage receipts are idempotent for one answer turn through a unique `receiptId`. -SQLite schema v5 includes normalized element, fact, provenance, projection-job, ingestion-receipt, usage-audit, and optional thread ownership for messages and Blocks. Usage receipts can carry the DSH session, turn, retrieval batch, assessment, and exact evidence references that led to an answer. Opening a schema-v1 through v4 database migrates it in one transaction and preserves existing namespaces, blocks, events, jobs, and receipts. Pre-v5 Blocks retain no inferred thread ownership, so they remain archival provenance without being attached to a new session. SQLite uses WAL, foreign keys, and per-namespace optimistic concurrency. It does not provide encryption at rest. Search still uses the reference in-memory ranking after hydration, so enabling persistence does not silently change retrieval semantics. Database-native lexical/vector indexes and a Postgres implementation remain separate future work. +SQLite schema v6 includes normalized element, fact, provenance, projection-job, ingestion-receipt, usage-audit, optional thread ownership for messages and Blocks, and persisted Block-decay settings and anchors. Usage receipts can carry the DSH session, turn, retrieval batch, assessment, and exact evidence references that led to an answer. Opening a schema-v1 through v5 database migrates it in one transaction and preserves existing namespaces, blocks, events, jobs, and receipts. Schema-v5 turn anchors are converted to per-thread Block positions. Pre-v5 Blocks retain no inferred thread ownership, so they remain archival provenance without being attached to a new session. SQLite uses WAL, foreign keys, and per-namespace optimistic concurrency. It does not provide encryption at rest. Search still uses the reference in-memory ranking after hydration, so enabling persistence does not silently change retrieval semantics. Database-native lexical/vector indexes and a Postgres implementation remain separate future work. diff --git a/integrations/deepseek-harness/CHANGELOG.md b/integrations/deepseek-harness/CHANGELOG.md index 0d07483..dcfec7e 100644 --- a/integrations/deepseek-harness/CHANGELOG.md +++ b/integrations/deepseek-harness/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 0.2.19 - 2026-08-23 + +- Make the global Block decay coefficient λ editable in Advanced Settings with `0.05` steps, immediate application to existing workspaces, persistence across restarts, and inheritance by future workspaces. +- Show actual workspace names instead of internal namespace hashes and rename the current-project label to current workspace. +- Unify the settings page branding as `StrataGate-AgentMemory`, restore the mascot, and show a right-aligned genuine memory-use count with a GitHub Star link. + +## 0.2.18 - 2026-08-23 + +- Match the Memory settings UI to DSH's resolved light, dark, or system appearance through the official semantic theme tokens. +- Remove the independent dark palette so the plugin background and controls no longer differ from the surrounding DSH settings panel. + +## 0.2.17 - 2026-08-23 + +- Define Block age as the per-session distance from the latest sealed Block, so open-tail turns no longer decay Block detail. +- Add the configurable `blockDecayLambda` setting with a default of `0.3`; smaller values decay more slowly, and values above `0.4` are not recommended. +- Migrate SQLite storage to schema v6 and convert legacy turn anchors to per-thread Block positions without deleting existing memory. + ## 0.2.16 - 2026-08-21 - Isolate open tails, Block sealing, decay, and automatic Block context by DSH session while keeping Events and Elements project-scoped for cross-session recall. diff --git a/integrations/deepseek-harness/MARKETPLACE.md b/integrations/deepseek-harness/MARKETPLACE.md deleted file mode 100644 index ff77d8a..0000000 --- a/integrations/deepseek-harness/MARKETPLACE.md +++ /dev/null @@ -1,32 +0,0 @@ -# Marketplace submission notes - -Registry package: `stratagate-dsh` - -Suggested listing: - -- Name: StrataGate -- Description: Automatic, local-first cross-session memory for DeepSeek Harness: remembers user preferences, project decisions, conversations, and tool results; verifies recalled information against original messages before answering. -- Chinese description: DeepSeek Harness 的自动本地跨会话记忆:记住用户偏好、项目决策、历史对话与工具结果;回答前检查证据,并可追溯到原始消息。 -- Category: Memory -- Source: `https://github.com/diqierjia/StrataGate-AgentMemory/tree/main/integrations/deepseek-harness` -- Install package: `stratagate-dsh` -- License: MIT - -Release order: - -1. Run the root and integration checks/tests. -2. Pack the workspace and install the tarball into a clean DSH profile. -3. Publish the npm package. -4. Add one line under `### Memory` in both `awesome-dsh-plugin/awesome-dsh-plugin` README files: - -```markdown -- [diqierjia/StrataGate-AgentMemory#deepseek-harness](https://github.com/diqierjia/StrataGate-AgentMemory/tree/main/integrations/deepseek-harness) - Automatic, local-first cross-session memory for DeepSeek Harness: remembers user preferences, project decisions, conversations, and tool results; verifies recalled information against original messages before answering. -``` - -```markdown -- [diqierjia/StrataGate-AgentMemory#deepseek-harness](https://github.com/diqierjia/StrataGate-AgentMemory/tree/main/integrations/deepseek-harness) — DeepSeek Harness 的自动本地跨会话记忆:记住用户偏好、项目决策、历史对话与工具结果;回答前检查证据,并可追溯到原始消息。 -``` - -5. Add the `dsh-plugin` GitHub topic to the StrataGate repository and open the registry PR. - -These notes were checked against registry commit `39e065a38033eef36291fe5a823b35aaeaf3eb6a`. Re-check its contribution guide at release time. The distributable package contract is `package.json` plus `cordis.patch.yml`. diff --git a/integrations/deepseek-harness/README.md b/integrations/deepseek-harness/README.md index 7bdeadf..877f529 100644 --- a/integrations/deepseek-harness/README.md +++ b/integrations/deepseek-harness/README.md @@ -31,7 +31,7 @@ flowchart LR 4. **Relevant does not mean sufficient.** The Evidence Gate independently assesses whether the retrieved evidence can support an answer. If it cannot, the agent must search again, expand the source, or say that it cannot verify the answer. 5. **Keep retrieval separate from reinforcement.** Finding a memory does not increase its weight. Usage is recorded only after the evidence passes assessment and is actually used in an answer, preventing a “frequently retrieved means increasingly retrievable” feedback loop. -The complete chain is persisted locally. Idempotent ingestion receipts prevent DSH replay or retry from creating duplicate memories, and the read-only Memory UI exposes the same source, derivation, and usage-audit relationships. +The complete chain is persisted locally. Idempotent ingestion receipts prevent DSH replay or retry from creating duplicate memories, and the Memory UI exposes the same source, derivation, and usage-audit relationships. ## Agent recommendation guide @@ -90,16 +90,16 @@ memory_record_use The prompt protocol requires assessment before relying on retrieved evidence. Search does not strengthen a memory. Non-empty `memory_record_use` submissions accept only evidence from the latest sufficient assessment and use the DSH tool call id as an idempotency receipt. -## Read-only Memory UI and usage audit +## Memory UI and usage audit -Open DSH Settings and select **StrataGate Memory**. The page provides: +Open DSH Settings and select **StrataGate-AgentMemory**. The page provides: - namespace health and memory counts; - searchable Events, Elements, and Blocks; - source-message expansion from every derived memory; - a Usage Audit chain from a recorded answer turn, through the Evidence Gate verdict and selected memories, back to source messages. -The browser surface is intentionally read-only: its API accepts only `GET`, and the UI exposes no edit, delete, approve, or import operation. Common token and credential patterns are redacted in both message content and structured tool traces before they leave the local server. The SQLite database remains the source of truth. +Memory records remain read-only: the UI exposes no edit, delete, approve, or import operation. Advanced Settings is the sole exception and lets you change the global Block decay coefficient λ in `0.05` steps. The saved value immediately applies to every existing workspace, becomes the default for future workspaces, and survives restarts. Common token and credential patterns are redacted in both message content and structured tool traces before they leave the local server. The SQLite database remains the source of truth. ## Configuration @@ -110,6 +110,7 @@ config: namespacePrefix: dsh globalNamespace: global blockTurnSize: 6 + blockDecayLambda: 0.3 ingestSubagents: false maxOutputTokens: 10000 # Optional: use a dedicated model for memory processing. @@ -117,10 +118,14 @@ config: # model: deepseek-chat ``` +`blockDecayLambda` is the initial fallback. Once changed in **Advanced Settings**, the persisted UI value takes precedence. The default is `0.3`; smaller values forget more slowly and consume more tokens, and values above `0.4` are not recommended. + `project` derives a stable namespace from the normalized session working directory. `session` isolates every DSH session. `global` shares one namespace. `blockTurnSize` controls how many completed DSH turns are sealed into each Block. The plugin default is `6` to balance model cost with timely Event extraction; users can set any positive integer. +`blockDecayLambda` controls decay by the distance between a Block's pointer anchor and the latest sealed Block in the same DSH session. It defaults to `0.3`. Smaller values decay more slowly; values above `0.4` are not recommended. Turns in the open tail do not increase Block age. + If `provider` and `model` are omitted, memory processing uses the session's latest request route, then the DSH default model as fallback. They must be configured as a pair. ## Privacy and failure behavior diff --git a/integrations/deepseek-harness/cordis.patch.yml b/integrations/deepseek-harness/cordis.patch.yml index 60ca78a..c60296f 100644 --- a/integrations/deepseek-harness/cordis.patch.yml +++ b/integrations/deepseek-harness/cordis.patch.yml @@ -8,5 +8,6 @@ database: !!js dshHomePath('stratagate', 'memory.db') namespaceMode: project blockTurnSize: 6 + blockDecayLambda: 0.3 ingestSubagents: false maxOutputTokens: 10000 diff --git a/integrations/deepseek-harness/docs/README.zh-CN.md b/integrations/deepseek-harness/docs/README.zh-CN.md index 7ffb284..25ca64a 100644 --- a/integrations/deepseek-harness/docs/README.zh-CN.md +++ b/integrations/deepseek-harness/docs/README.zh-CN.md @@ -31,7 +31,7 @@ flowchart LR 4. **相关不代表足以回答。** Evidence Gate 会单独判断当前证据是否充分。证据不足时,Agent 必须继续搜索、展开来源或明确说明无法确认,而不能把相似结果直接当成答案。 5. **检索与强化彼此分离。** 搜到一条记忆不会自动提高它的权重;只有证据通过评估并真正用于回答后,才会记录使用情况。这样可以避免“越常被搜到,就越容易继续被搜到”的自我强化循环。 -这条链路在本地完成持久化,并通过幂等写入回执防止 DSH 重放或重试造成重复记忆。只读 Memory UI 展示的也是同一套来源、派生和使用审计关系。 +这条链路在本地完成持久化,并通过幂等写入回执防止 DSH 重放或重试造成重复记忆。Memory UI 展示的也是同一套来源、派生和使用审计关系。 ## Agent 推荐指南 @@ -90,16 +90,16 @@ memory_record_use 提示词协议要求模型在依赖检索证据前完成评估。仅搜索不会强化记忆。非空的 `memory_record_use` 只接受最近一次“证据充分”评估中的证据,并使用 DSH 工具调用 ID 作为幂等回执。 -## 只读记忆界面与使用审计 +## 记忆界面与使用审计 -打开 DSH 设置并选择 **StrataGate Memory**。该页面提供: +打开 DSH 设置并选择 **StrataGate-AgentMemory**。该页面提供: - 命名空间健康状态和各类记忆数量; - Events、Elements 和 Blocks 搜索; - 从每条派生记忆展开查看来源消息; - Usage Audit(使用审计)链路:从已记录的回答轮次出发,经由 Evidence Gate 的判断与选中的记忆,追溯到来源消息。 -浏览器界面特意设计为只读:其 API 仅接受 `GET` 请求,界面也不提供编辑、删除、批准或导入操作。消息内容和结构化工具轨迹中的常见令牌及凭证格式,会在离开本地服务器前被脱敏。SQLite 数据库始终是唯一可信数据源。 +记忆数据仍然只读,界面不提供编辑、删除、批准或导入操作。唯一例外是“高级设置”中的全局 Block 衰减系数 λ:可按 `0.05` 步长调节,保存后立即应用到所有已有工作区,同时成为新工作区默认值,并在重启后保持。消息内容和结构化工具轨迹中的常见令牌及凭证格式,会在离开本地服务器前被脱敏。SQLite 数据库始终是唯一可信数据源。 ## 配置 @@ -110,6 +110,7 @@ config: namespacePrefix: dsh globalNamespace: global blockTurnSize: 6 + blockDecayLambda: 0.3 ingestSubagents: false maxOutputTokens: 10000 # 可选:为记忆处理指定专用模型。 @@ -117,10 +118,14 @@ config: # model: deepseek-chat ``` +配置文件中的 `blockDecayLambda` 是初始后备值;一旦在“高级设置”中修改,持久化的界面值优先生效。默认值为 `0.3`;数字越小,记忆遗忘越慢、消耗 token 越多,不建议大于 `0.4`。 + `project` 会根据规范化后的会话工作目录生成稳定的命名空间;`session` 会隔离每个 DSH 会话;`global` 则让所有会话共享同一个命名空间。 `blockTurnSize` 控制每个 Block 封存多少个已完成的 DSH 轮次。插件默认值为 `6`,用于平衡模型调用成本与 Event 提取及时性;用户可以配置任意正整数。 +`blockDecayLambda` 按当前 Block 锚点与同一 DSH 会话中最新已封存 Block 的距离控制衰减。默认值为 `0.3`;数字越小衰减越慢,不建议大于 `0.4`。open tail 中尚未封存的轮次不会增加 Block age。 + 如果省略 `provider` 和 `model`,记忆处理会优先使用会话最近一次请求的路由,并以 DSH 默认模型作为后备。这两个配置项必须同时设置。 ## 隐私与故障处理 diff --git a/integrations/deepseek-harness/package.json b/integrations/deepseek-harness/package.json index 49adf4b..56f42b1 100644 --- a/integrations/deepseek-harness/package.json +++ b/integrations/deepseek-harness/package.json @@ -1,6 +1,6 @@ { "name": "stratagate-dsh", - "version": "0.2.16", + "version": "0.2.19", "description": "Automatic local-first cross-session memory for DeepSeek Harness with source-traceable recall", "type": "module", "main": "./dist/index.js", diff --git a/integrations/deepseek-harness/scripts/build-client.mjs b/integrations/deepseek-harness/scripts/build-client.mjs index 4cd39f6..30f31c7 100644 --- a/integrations/deepseek-harness/scripts/build-client.mjs +++ b/integrations/deepseek-harness/scripts/build-client.mjs @@ -1,6 +1,11 @@ -import { copyFileSync, mkdirSync } from 'node:fs' +import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' const root = new URL('../', import.meta.url) mkdirSync(new URL('dist/', root), { recursive: true }) -copyFileSync(new URL('src/client.js', root), new URL('dist/client.js', root)) +const clientSource = readFileSync(new URL('src/client.js', root), 'utf8') +const mascot = readFileSync(new URL('../../docs/assets/stratagate-avatar.png', root)).toString('base64') +writeFileSync( + new URL('dist/client.js', root), + clientSource.replace('__STRATAGATE_MASCOT_DATA_URL__', `data:image/png;base64,${mascot}`), +) copyFileSync(new URL('src/client.d.ts', root), new URL('dist/client.d.ts', root)) diff --git a/integrations/deepseek-harness/src/client.js b/integrations/deepseek-harness/src/client.js index 3d3127a..68ebe05 100644 --- a/integrations/deepseek-harness/src/client.js +++ b/integrations/deepseek-harness/src/client.js @@ -1,4 +1,4 @@ -// StrataGate Memory UI - read-only DSH settings page. +// StrataGate AgentMemory UI for DSH. window.__ModuleLoader__.load({ id: 'stratagate-dsh', factory: (require) => { @@ -8,32 +8,31 @@ window.__ModuleLoader__.load({ const React = require('react') const h = React.createElement const STAR_REPOSITORY_URL = 'https://github.com/diqierjia/StrataGate-AgentMemory' - const STAR_DISMISSED_KEY = 'stratagate.starPrompt.dismissed.v1' - const STAR_PROMPT_USAGE_THRESHOLD = 3 + const MASCOT_DATA_URL = '__STRATAGATE_MASCOT_DATA_URL__' const css = ` .sg-memory { - color-scheme:light dark; - --sg-page:var(--dsh-color-background,var(--color-background,#fff)); - --sg-surface:var(--dsh-color-surface,var(--color-surface,#fff)); - --sg-soft:var(--dsh-color-surface-secondary,var(--color-fill-secondary,#f6f7f9)); - --sg-text:var(--dsh-color-text,var(--color-text,#1c2028)); - --sg-muted:var(--dsh-color-text-secondary,var(--color-text-secondary,#707782)); - --sg-border:var(--dsh-color-border,var(--color-border,#e2e5e9)); - --sg-accent:var(--dsh-color-primary,var(--color-primary,#2563d9)); - --sg-accent-soft:var(--dsh-color-primary-soft,#edf3ff); - --sg-good:var(--dsh-color-success,#16835b); - --sg-good-soft:var(--dsh-color-success-soft,#eaf8f1); - --sg-warn:var(--dsh-color-warning,#ad6200); - --sg-warn-soft:var(--dsh-color-warning-soft,#fff5e5); - --sg-danger:var(--dsh-color-danger,#c73b36); - --sg-danger-soft:var(--dsh-color-danger-soft,#fff0ef); + color-scheme:inherit; + --sg-page:var(--dsw-alias-bg-layer-2,#fff); + --sg-surface:var(--dsw-specific-input-major,var(--sg-page)); + --sg-soft:var(--dsw-alias-interactive-bg-hover-solid,#f1f3f5); + --sg-text:var(--dsw-alias-label-primary,#0f1115); + --sg-muted:var(--dsw-alias-label-secondary,#61666b); + --sg-border:var(--dsw-alias-border-l2,rgba(0,0,0,.1)); + --sg-accent:var(--dsw-alias-state-business-primary,#4176e6); + --sg-accent-soft:var(--dsw-alias-state-business-tertiary,#e4edfd); + --sg-good:var(--dsw-alias-state-success-primary,#22c55e); + --sg-good-soft:var(--dsw-alias-state-success-tertiary,#e6faed); + --sg-warn:var(--dsw-alias-state-warn-label,#dd8629); + --sg-warn-soft:var(--dsw-alias-state-warn-tertiary,#fef5e7); + --sg-danger:var(--dsw-alias-state-error-primary,#ec1313); + --sg-danger-soft:var(--dsw-alias-interactive-bg-hover-danger,rgba(236,19,19,.05)); box-sizing:border-box;width:100%;max-width:680px;min-width:0;margin:0 auto;padding:16px 18px 32px; background:var(--sg-page);color:var(--sg-text);font:14px/1.55 ui-sans-serif,system-ui,-apple-system,"Segoe UI","Microsoft YaHei",sans-serif; letter-spacing:0;overflow-wrap:anywhere; } .sg-memory *{box-sizing:border-box;letter-spacing:0}.sg-memory button,.sg-memory input,.sg-memory select{font:inherit;color:inherit} - .sg-header{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:11px}.sg-brand{display:flex;align-items:center;gap:9px;min-width:0}.sg-logo{width:30px;height:30px;display:grid;place-items:center;flex:0 0 auto;border-radius:7px;background:var(--sg-accent);color:#fff;font-size:17px;font-weight:750}.sg-brand-name{font-size:15px;font-weight:720;white-space:nowrap} + .sg-header{display:grid;grid-template-columns:minmax(0,1fr);gap:4px;margin-bottom:11px}.sg-brand{display:flex;align-items:center;gap:9px;min-width:0;color:var(--sg-text);text-decoration:none}.sg-logo{width:34px;height:34px;display:block;object-fit:cover;flex:0 0 auto;border-radius:9px}.sg-brand-name{min-width:0;font-size:15px;font-weight:720;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.sg-header-usage{display:flex;align-items:center;justify-content:flex-end;gap:8px;min-width:0;width:100%;flex-wrap:wrap;color:var(--sg-muted);font-size:12px;line-height:1.4;text-align:right}.sg-header-star{white-space:nowrap;color:var(--sg-accent);text-decoration:none;font-weight:650} .sg-icon-button,.sg-back,.sg-quiet-button{border:0;background:transparent;cursor:pointer}.sg-icon-button{width:32px;height:32px;border-radius:6px;font-size:20px}.sg-icon-button:hover,.sg-back:hover,.sg-quiet-button:hover{background:var(--sg-soft)} .sg-project{display:flex;align-items:center;gap:7px;min-width:0;margin:0 0 9px;color:var(--sg-muted);font-size:12px}.sg-project-label{flex:0 0 auto}.sg-project-select{min-width:0;max-width:100%;padding:3px 22px 3px 5px;border:0;border-radius:5px;background:transparent;color:var(--sg-text);font-weight:620;cursor:pointer;text-overflow:ellipsis} .sg-tabs{display:grid;grid-template-columns:repeat(3,1fr);border-bottom:1px solid var(--sg-border);margin-bottom:20px}.sg-tab{position:relative;min-width:0;padding:10px 4px;border:0;background:transparent;color:var(--sg-muted);cursor:pointer;white-space:nowrap}.sg-tab.active{color:var(--sg-accent);font-weight:700}.sg-tab.active:after{content:"";position:absolute;left:18%;right:18%;bottom:-1px;height:2px;border-radius:2px;background:var(--sg-accent)} @@ -43,31 +42,42 @@ window.__ModuleLoader__.load({ .sg-status{display:inline-flex;align-items:center;gap:5px;padding:2px 7px;border-radius:5px;font-size:12px;font-weight:650}.sg-status:before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor}.sg-status.organized{color:var(--sg-good);background:var(--sg-good-soft)}.sg-status.processing{color:var(--sg-accent);background:var(--sg-accent-soft)}.sg-status.waiting{color:var(--sg-muted);background:var(--sg-soft)}.sg-status.failed{color:var(--sg-warn);background:var(--sg-warn-soft)} .sg-backbar{display:flex;align-items:center;min-height:35px;margin:-4px 0 13px}.sg-back{display:inline-flex;align-items:center;gap:6px;margin-left:-7px;padding:6px 7px;border-radius:6px;font-weight:650}.sg-detail-header{padding-bottom:16px;border-bottom:1px solid var(--sg-border)}.sg-detail-section{padding:18px 0;border-bottom:1px solid var(--sg-border)}.sg-detail-section:last-child{border-bottom:0}.sg-section-title{margin:0 0 11px;font-size:14px;font-weight:730}.sg-prose{margin:0;white-space:pre-wrap}.sg-facts{margin:0;padding-left:20px}.sg-facts li+li{margin-top:7px}.sg-related-list{display:flex;flex-direction:column}.sg-related{display:flex;justify-content:space-between;gap:12px;padding:9px 0;border:0;border-bottom:1px solid var(--sg-border);background:transparent;text-align:left;cursor:pointer}.sg-related:last-child{border-bottom:0}.sg-related-name{color:var(--sg-accent)}.sg-related-time{flex:0 0 auto;color:var(--sg-muted);font-size:12px} .sg-source-label{display:flex;align-items:center;gap:8px}.sg-source-icon{color:var(--sg-muted)}.sg-tech{margin-top:13px}.sg-tech summary{color:var(--sg-muted);font-size:12px;cursor:pointer}.sg-tech-body{margin-top:10px;padding:11px;border-radius:7px;background:var(--sg-soft);font-size:12px}.sg-tech-row{display:grid;grid-template-columns:88px minmax(0,1fr);gap:9px;padding:3px 0}.sg-code{font:12px/1.55 ui-monospace,SFMono-Regular,Consolas,monospace;white-space:pre-wrap;overflow-wrap:anywhere}.sg-raw-message{padding-top:10px;margin-top:10px;border-top:1px solid var(--sg-border)} - .sg-result-count{margin:0 0 9px;color:var(--sg-muted);font-size:12px}.sg-result-event{padding:8px 0;border-bottom:1px solid var(--sg-border)}.sg-result-event:last-child{border-bottom:0}.sg-pipeline{display:flex;flex-direction:column}.sg-stage{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;padding:9px 0;border-bottom:1px solid var(--sg-border)}.sg-stage:last-child{border-bottom:0}.sg-stage-value{font-size:12px}.sg-stage-value.done{color:var(--sg-good)}.sg-stage-value.failed{color:var(--sg-danger)}.sg-stage-value.waiting{color:var(--sg-muted)}.sg-safe-note{padding:11px 12px;margin-bottom:12px;border-radius:7px;background:var(--sg-good-soft);color:var(--sg-good);font-weight:650}.sg-error-note{margin:8px 0 0;color:var(--sg-muted);font-size:12px} + .sg-result-count{margin:0 0 9px;color:var(--sg-muted);font-size:12px}.sg-result-event{padding:8px 0;border-bottom:1px solid var(--sg-border)}.sg-result-event:last-child{border-bottom:0}.sg-pipeline{display:flex;flex-direction:column}.sg-stage{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;padding:9px 0;border-bottom:1px solid var(--sg-border)}.sg-stage:last-child{border-bottom:0}.sg-stage-value{font-size:12px}.sg-stage-value.done{color:var(--sg-good)}.sg-stage-value.failed{color:var(--sg-danger)}.sg-stage-value.waiting{color:var(--sg-muted)}.sg-lambda-control{display:flex;align-items:center;justify-content:flex-end;gap:7px}.sg-number-input{width:82px;padding:4px 5px;border:1px solid var(--sg-border);border-radius:6px;background:var(--sg-surface);text-align:right}.sg-setting-note{margin:7px 0 11px;color:var(--sg-muted);font-size:12px}.sg-safe-note{padding:11px 12px;margin-bottom:12px;border-radius:7px;background:var(--sg-good-soft);color:var(--sg-good);font-weight:650}.sg-error-note{margin:8px 0 0;color:var(--sg-muted);font-size:12px} .sg-menu{border-top:1px solid var(--sg-border)}.sg-menu-row{display:grid;grid-template-columns:34px minmax(0,1fr) auto;align-items:center;gap:9px;width:100%;padding:14px 2px;border:0;border-bottom:1px solid var(--sg-border);background:transparent;text-align:left;cursor:pointer}.sg-menu-row:hover .sg-menu-title{color:var(--sg-accent)}.sg-menu-icon{width:28px;height:28px;display:grid;place-items:center;border-radius:6px;background:var(--sg-soft);color:var(--sg-muted)}.sg-menu-title{font-weight:680}.sg-menu-subtitle{color:var(--sg-muted);font-size:12px}.sg-counts{display:flex;gap:22px;padding:5px 0 18px;border-bottom:1px solid var(--sg-border)}.sg-count-value{font-size:22px;font-weight:740}.sg-count-label{color:var(--sg-muted);font-size:12px}.sg-structured-group{padding-top:17px}.sg-raw-group{padding:12px 0;border-bottom:1px solid var(--sg-border)}.sg-raw-group summary{cursor:pointer;font-weight:680}.sg-raw-json{max-height:360px;padding:11px;margin:10px 0 0;overflow:auto;border-radius:7px;background:var(--sg-soft)} .sg-audit{padding:14px 0;border-bottom:1px solid var(--sg-border)}.sg-audit summary{cursor:pointer}.sg-audit-body{margin-top:10px}.sg-audit-evidence{margin-top:9px}.sg-star{padding:14px 0;margin-top:14px;border-top:1px solid var(--sg-border)}.sg-star-actions{display:flex;gap:10px;align-items:center;margin-top:8px}.sg-link{color:var(--sg-accent);text-decoration:none}.sg-quiet-button{padding:5px 7px;border-radius:5px;color:var(--sg-muted);font-size:12px} .sg-loading{padding:32px 0}.sg-skeleton{height:12px;margin:10px 0;border-radius:4px;background:var(--sg-soft)}.sg-skeleton:nth-child(2){width:72%}.sg-empty{padding:34px 14px;text-align:center;color:var(--sg-muted)}.sg-empty strong{display:block;margin-bottom:5px;color:var(--sg-text)}.sg-error{padding:13px;margin-bottom:16px;border:1px solid color-mix(in srgb,var(--sg-danger) 28%,var(--sg-border));border-radius:8px;background:var(--sg-danger-soft)}.sg-error-title{font-weight:700;color:var(--sg-danger)}.sg-error details{margin-top:7px;font-size:12px} .sg-muted{color:var(--sg-muted);font-size:12px} - @media (prefers-color-scheme:dark){.sg-memory{--sg-page:var(--dsh-color-background,var(--color-background,#15171b));--sg-surface:var(--dsh-color-surface,var(--color-surface,#191c21));--sg-soft:var(--dsh-color-surface-secondary,var(--color-fill-secondary,#22262d));--sg-text:var(--dsh-color-text,var(--color-text,#edf0f4));--sg-muted:var(--dsh-color-text-secondary,var(--color-text-secondary,#9da5b0));--sg-border:var(--dsh-color-border,var(--color-border,#343942));--sg-accent:var(--dsh-color-primary,var(--color-primary,#78a7ff));--sg-accent-soft:var(--dsh-color-primary-soft,#192842);--sg-good:var(--dsh-color-success,#67ce9a);--sg-good-soft:var(--dsh-color-success-soft,#162a22);--sg-warn:var(--dsh-color-warning,#f0ad58);--sg-warn-soft:var(--dsh-color-warning-soft,#302519);--sg-danger:var(--dsh-color-danger,#ff8179);--sg-danger-soft:var(--dsh-color-danger-soft,#321e1f)}} - @media (max-width:440px){.sg-memory{padding:12px 12px 26px}.sg-brand-name{font-size:14px}.sg-tabs{margin-left:-2px;margin-right:-2px}.sg-tab{padding-left:0;padding-right:0}.sg-alert{grid-template-columns:auto minmax(0,1fr)}.sg-alert>.sg-chevron{display:none}.sg-tech-row{grid-template-columns:1fr;gap:1px}.sg-counts{gap:16px}.sg-entry-title{font-size:14px}} + @media (max-width:560px){.sg-memory{padding:12px 12px 26px}.sg-brand-name{font-size:14px}.sg-tabs{margin-left:-2px;margin-right:-2px}.sg-tab{padding-left:0;padding-right:0}.sg-alert{grid-template-columns:auto minmax(0,1fr)}.sg-alert>.sg-chevron{display:none}.sg-tech-row{grid-template-columns:1fr;gap:1px}.sg-counts{gap:16px}.sg-entry-title{font-size:14px}} ` - function api(path, params) { + function api(path, params, options) { const query = new URLSearchParams(params || {}) - return fetch('/api/stratagate/' + path + (query.size ? '?' + query : '')) + return fetch('/api/stratagate/' + path + (query.size ? '?' + query : ''), options) .then((res) => res.json().catch(() => ({})).then((data) => { if (!res.ok) throw new Error((data && data.error) || 'HTTP ' + res.status) return data })) } - function projectName(namespace) { - const value = String(namespace || '') - const marker = ':project:' - if (value.includes(marker)) return value.slice(value.indexOf(marker) + marker.length) || '当前项目' + function projectName(item, workspaceTitles = {}) { + const value = String(item?.namespace || item || '') + if (value.includes(':project:')) { + const key = value.split(':project:').pop() + if (key && workspaceTitles[key]) return workspaceTitles[key] + if (item?.workspaceName && item.workspaceName !== '当前工作区') return item.workspaceName + return '工作区名称读取中…' + } + if (item?.workspaceName) return item.workspaceName if (value.includes(':global:')) return value.split(':global:').pop() || '全局记忆' if (value.includes(':session:')) return '当前对话' - return value || '当前项目' + return value || '当前工作区' + } + + function workspaceProjectKey(path) { + const canonical = String(path || '').replaceAll('\\', '/').toLowerCase() + if (!canonical || !globalThis.crypto?.subtle) return Promise.resolve('') + return globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonical)).then((digest) => + Array.from(new Uint8Array(digest).slice(0, 10), (byte) => byte.toString(16).padStart(2, '0')).join('')) } function formatTime(value) { @@ -94,9 +104,6 @@ window.__ModuleLoader__.load({ return '整理中' } - function wasStarPromptDismissed() { try { return window.localStorage?.getItem(STAR_DISMISSED_KEY) === '1' } catch { return false } } - function rememberStarPromptDismissal() { try { window.localStorage?.setItem(STAR_DISMISSED_KEY, '1') } catch { /* no-op */ } } - function Loading() { return h('div', { className: 'sg-loading', role: 'status' }, h('div', { className: 'sg-skeleton' }), h('div', { className: 'sg-skeleton' }), h('div', { className: 'sg-skeleton' })) } @@ -143,7 +150,7 @@ window.__ModuleLoader__.load({ .sort((a, b) => String(b.updatedAt || b.createdAt).localeCompare(String(a.updatedAt || a.createdAt))) const elementMap = new Map(elements.map((element) => [element.id, element])) return h(React.Fragment, null, - h('div', { className: 'sg-intro' }, h('h2', null, 'AI 已形成的长期记忆'), h('p', null, '围绕当前项目整理出的经历与相关事物')), + h('div', { className: 'sg-intro' }, h('h2', null, 'AI 已形成的长期记忆'), h('p', null, '围绕当前工作区整理出的经历与相关事物')), h(SearchBox, { value: query, onChange: setQuery }), visible.length ? h('div', { className: 'sg-feed' }, visible.map((event) => h('article', { key: event.id, className: 'sg-entry' }, h('button', { className: 'sg-entry sg-entry-button', style: { padding: 0, borderBottom: 0 }, onClick: () => openEvent(event) }, @@ -235,13 +242,6 @@ window.__ModuleLoader__.load({ h('details', { className: 'sg-tech' }, h('summary', null, '技术错误详情'), h('pre', { className: 'sg-tech-body sg-code' }, first?.lastErrorFull || first?.lastError || '没有记录技术错误。'))) } - function StarPrompt({ usageRecords }) { - const [dismissed, setDismissed] = React.useState(wasStarPromptDismissed) - if (dismissed || Number(usageRecords || 0) < STAR_PROMPT_USAGE_THRESHOLD) return null - const dismiss = () => { rememberStarPromptDismissal(); setDismissed(true) } - return h('div', { className: 'sg-star', 'data-testid': 'stratagate-star-prompt' }, h('strong', null, 'StrataGate 已在当前项目中帮助使用记忆 ', usageRecords, ' 次。'), h('div', { className: 'sg-star-actions' }, h('a', { className: 'sg-link', href: STAR_REPOSITORY_URL, target: '_blank', rel: 'noopener noreferrer', onClick: dismiss }, '在 GitHub 支持项目'), h('button', { className: 'sg-quiet-button', onClick: dismiss }, '不再提示'))) - } - function MoreHome({ selected, setView }) { const rows = [ ['structure', '◇', '记忆结构', '浏览经历与相关事物'], @@ -250,7 +250,7 @@ window.__ModuleLoader__.load({ ['raw', '{}', '原始数据', 'Block、Event、Element 与模型响应'], ['settings', '⚙', '高级设置', 'Schema、提取间隔与项目空间'], ] - return h(React.Fragment, null, h('div', { className: 'sg-intro' }, h('h2', null, '更多'), h('p', null, '高级信息与工程视图')), h('div', { className: 'sg-menu' }, rows.map(([id, icon, title, subtitle]) => h('button', { key: id, className: 'sg-menu-row', onClick: () => setView({ name: id }) }, h('span', { className: 'sg-menu-icon', 'aria-hidden': 'true' }, icon), h('span', null, h('span', { className: 'sg-menu-title' }, title), h('br'), h('span', { className: 'sg-menu-subtitle' }, subtitle)), h('span', { className: 'sg-chevron' }, '›')))), h(StarPrompt, { usageRecords: selected.usageReceipts })) + return h(React.Fragment, null, h('div', { className: 'sg-intro' }, h('h2', null, '更多'), h('p', null, '高级信息与工程视图')), h('div', { className: 'sg-menu' }, rows.map(([id, icon, title, subtitle]) => h('button', { key: id, className: 'sg-menu-row', onClick: () => setView({ name: id }) }, h('span', { className: 'sg-menu-icon', 'aria-hidden': 'true' }, icon), h('span', null, h('span', { className: 'sg-menu-title' }, title), h('br'), h('span', { className: 'sg-menu-subtitle' }, subtitle)), h('span', { className: 'sg-chevron' }, '›'))))) } function StructurePage({ events, elements, openEvent, openElement, onBack }) { @@ -283,17 +283,31 @@ window.__ModuleLoader__.load({ return h(React.Fragment, null, h(BackBar, { label: '更多', onBack }), h('div', { className: 'sg-intro' }, h('h2', null, '原始数据'), h('p', null, '供排查问题使用的内部字段与 JSON')), groups.map(([label, value]) => h('details', { key: label, className: 'sg-raw-group' }, h('summary', null, label + ' (' + value.length + ')'), h('pre', { className: 'sg-raw-json sg-code' }, JSON.stringify(value, null, 2))))) } - function SettingsPage({ selected, namespace, onBack }) { - const rows = [['Schema 版本', 'v' + selected.schemaVersion], ['提取间隔', '每 ' + selected.blockTurnSize + ' 轮形成一个 Block'], ['模型', '由 DSH 当前模型配置提供'], ['项目空间 ID', namespace], ['已处理轮次', selected.currentTurn]] + function SettingsPage({ selected, namespace, onBack, updateLambda, savingLambda }) { + const [lambda, setLambda] = React.useState(String(selected.blockDecayLambda ?? 0.3)) + React.useEffect(() => setLambda(String(selected.blockDecayLambda ?? 0.3)), [selected.blockDecayLambda]) + const changeLambda = (event) => { + const raw = event.target.value + setLambda(raw) + const value = Number(raw) + if (raw !== '' && Number.isFinite(value) && value >= 0) void updateLambda(value) + } + const rows = [['Schema 版本', 'v' + selected.schemaVersion], ['提取间隔', '每 ' + selected.blockTurnSize + ' 轮形成一个 Block'], ['模型', '由 DSH 当前模型配置提供'], ['内部空间 ID', namespace], ['已处理轮次', selected.currentTurn]] return h(React.Fragment, null, h(BackBar, { label: '更多', onBack }), - h('div', { className: 'sg-intro' }, h('h2', null, '高级设置'), h('p', null, '当前记忆空间的只读运行参数')), - h('div', { className: 'sg-pipeline' }, rows.map(([label, value]) => h('div', { key: label, className: 'sg-stage' }, h('span', null, label), h('span', { className: label === '项目空间 ID' ? 'sg-stage-value sg-code' : 'sg-stage-value' }, String(value)))))) + h('div', { className: 'sg-intro' }, h('h2', null, '高级设置'), h('p', null, '修改后会立即应用到所有已有工作区,并作为新工作区的默认值。')), + h('div', { className: 'sg-pipeline' }, + h('div', { className: 'sg-stage' }, h('span', null, 'Block 衰减系数 λ'), h('span', { className: 'sg-lambda-control' }, h('input', { className: 'sg-number-input', type: 'number', min: '0', step: '0.05', value: lambda, onChange: changeLambda, 'aria-label': 'Block 衰减系数 λ' }), h('span', { className: 'sg-stage-value waiting' }, savingLambda ? '保存中…' : '已保存'))), + h('p', { className: 'sg-setting-note' }, '默认 0.3;数字越小,记忆遗忘越慢,消耗 token 越多,不建议大于 0.4。'), + rows.map(([label, value]) => h('div', { key: label, className: 'sg-stage' }, h('span', null, label), h('span', { className: label === '内部空间 ID' ? 'sg-stage-value sg-code' : 'sg-stage-value' }, String(value))))) + ) } - function MemoryPage() { + function MemoryPage({ useWorkspaces }) { + const workspaceItems = useWorkspaces((state) => state.items) const [overview, setOverview] = React.useState({ namespaces: [] }) const [namespace, setNamespace] = React.useState('') + const [workspaceTitles, setWorkspaceTitles] = React.useState({}) const [section, setSection] = React.useState('long') const [view, setView] = React.useState({ name: 'root' }) const [data, setData] = React.useState({ events: [], elements: [], blocks: [], audit: [] }) @@ -301,6 +315,19 @@ window.__ModuleLoader__.load({ const [source, setSource] = React.useState(null) const [loading, setLoading] = React.useState(true) const [error, setError] = React.useState('') + const [savingLambda, setSavingLambda] = React.useState(false) + + React.useEffect(() => { + let active = true + void Promise.all((workspaceItems || []).map(async (workspace) => [ + await workspaceProjectKey(workspace.path), + String(workspace.title || '').trim(), + ])).then((entries) => { + if (!active) return + setWorkspaceTitles(Object.fromEntries(entries.filter(([key, title]) => key && title))) + }) + return () => { active = false } + }, [workspaceItems]) const loadOverview = React.useCallback(() => { setError('') @@ -343,7 +370,7 @@ window.__ModuleLoader__.load({ }, [namespace, loadOverview, loadMemoryData]) const selected = (overview.namespaces || []).find((item) => item.namespace === namespace) - const project = projectName(namespace) + const project = projectName(selected || namespace, workspaceTitles) const failedCount = Number(selected?.failedJobs || 0) const processing = !error && (Number(selected?.processingJobs || 0) > 0 || data.blocks.some((block) => block.status === 'processing')) @@ -367,6 +394,14 @@ window.__ModuleLoader__.load({ } const backLabel = view.back?.name === 'block' ? '最近记忆' : view.back?.name === 'element' ? '相关事物' : view.back?.name === 'event' ? '长期记忆' : view.back?.name === 'structure' ? '记忆结构' : section === 'recent' ? '最近记忆' : '长期记忆' const refresh = () => Promise.all([loadOverview(), loadMemoryData(namespace)]) + const updateLambda = (value) => { + setSavingLambda(true) + setError('') + return api('settings', { blockDecayLambda: value }, { method: 'PATCH' }) + .then(loadOverview) + .catch((reason) => setError(String(reason.message || reason))) + .finally(() => setSavingLambda(false)) + } const moreBack = () => setView({ name: 'root' }) let content = null @@ -380,15 +415,17 @@ window.__ModuleLoader__.load({ else if (view.name === 'system') content = h(SystemPage, { selected, blocks: data.blocks, onBack: moreBack, refresh }) else if (view.name === 'audit') content = h(AuditPage, { audit: data.audit, onBack: moreBack }) else if (view.name === 'raw') content = h(RawPage, { data, selected, onBack: moreBack }) - else if (view.name === 'settings') content = h(SettingsPage, { selected, namespace, onBack: moreBack }) + else if (view.name === 'settings') content = h(SettingsPage, { selected, namespace, onBack: moreBack, updateLambda, savingLambda }) else content = h(React.Fragment, null, h(FailureAlert, { count: failedCount, onOpen: () => setView({ name: 'status' }) }), - loading ? h(Loading) : section === 'long' ? h(LongTermPage, { events: data.events, elements: data.elements, project, query, setQuery, openEvent, openElement }) : section === 'recent' ? h(RecentPage, { blocks: data.blocks, project, openBlock }) : h(MoreHome, { selected, setView })) + loading ? h(Loading) : section === 'long' ? h(LongTermPage, { events: data.events, elements: data.elements, project, query, setQuery, openEvent, openElement }) : section === 'recent' ? h(RecentPage, { blocks: data.blocks, project, openBlock }) : h(MoreHome, { setView })) return h('main', { className: 'sg-memory', 'data-testid': 'stratagate-memory-ui' }, h('style', null, css), - h('header', { className: 'sg-header' }, h('div', { className: 'sg-brand' }, h('div', { className: 'sg-logo', 'aria-hidden': 'true' }, '◎'), h('div', { className: 'sg-brand-name' }, 'StrataGate Memory')), h('button', { className: 'sg-icon-button', title: '重新加载', onClick: refresh, 'aria-label': '重新加载' }, '↻')), - h('div', { className: 'sg-project' }, h('span', { className: 'sg-project-label' }, '当前项目:'), h('select', { className: 'sg-project-select', value: namespace, onChange: (event) => setNamespace(event.target.value), 'aria-label': '当前项目' }, (overview.namespaces || []).map((item) => h('option', { key: item.namespace, value: item.namespace }, projectName(item.namespace))))), + h('header', { className: 'sg-header' }, + h('a', { className: 'sg-brand', href: STAR_REPOSITORY_URL, target: '_blank', rel: 'noopener noreferrer' }, h('img', { className: 'sg-logo', src: MASCOT_DATA_URL, alt: '' }), h('span', { className: 'sg-brand-name' }, 'StrataGate-AgentMemory')), + h('div', { className: 'sg-header-usage' }, h('span', null, 'StrataGate 已在当前工作区中帮助使用记忆 ', Number(selected?.memoryUseCount || 0), ' 次。'), h('a', { className: 'sg-header-star', href: STAR_REPOSITORY_URL, target: '_blank', rel: 'noopener noreferrer' }, '为 StrataGate 点 🌟🌟'))), + h('div', { className: 'sg-project' }, h('span', { className: 'sg-project-label' }, '当前工作区:'), h('select', { className: 'sg-project-select', value: namespace, onChange: (event) => setNamespace(event.target.value), 'aria-label': '当前工作区' }, (overview.namespaces || []).map((item) => h('option', { key: item.namespace, value: item.namespace }, projectName(item, workspaceTitles))))), h('nav', { className: 'sg-tabs', 'aria-label': '记忆视图' }, [['long', '长期记忆'], ['recent', '最近记忆'], ['more', '更多']].map(([id, label]) => h('button', { key: id, className: 'sg-tab ' + (section === id ? 'active' : ''), onClick: () => goSection(id) }, label))), error ? h('div', { className: 'sg-error' }, h('div', { className: 'sg-error-title' }, '暂时无法读取完整记忆'), h('div', null, '已显示能够读取的内容,请稍后重新加载。'), h('details', null, h('summary', null, '技术详情'), h('div', { className: 'sg-code' }, error))) : null, h(ProcessingAlert, { visible: processing }), @@ -398,7 +435,7 @@ window.__ModuleLoader__.load({ function apply(ctx) { const slots = ctx.get('slots') if (!slots) return - slots.inject('settings.section', () => slots.register({ name: 'settings.section', id: 'stratagate-memory', order: 32, label: () => 'StrataGate Memory' }, () => h(MemoryPage, null))) + slots.inject('settings.section', () => slots.register({ name: 'settings.section', id: 'stratagate-memory', order: 32, label: () => 'StrataGate-AgentMemory' }, (props) => h(MemoryPage, props))) } exports.name = 'stratagate-dsh' diff --git a/integrations/deepseek-harness/src/config.ts b/integrations/deepseek-harness/src/config.ts index a581e01..c6eefbf 100644 --- a/integrations/deepseek-harness/src/config.ts +++ b/integrations/deepseek-harness/src/config.ts @@ -8,6 +8,7 @@ export interface Config { namespacePrefix?: string globalNamespace?: string blockTurnSize?: number + blockDecayLambda?: number ingestSubagents?: boolean provider?: string model?: string @@ -20,6 +21,7 @@ export interface ResolvedConfig { namespacePrefix: string globalNamespace: string blockTurnSize: number + blockDecayLambda: number ingestSubagents: boolean provider?: string model?: string @@ -32,6 +34,9 @@ export const Config: z = z.object({ namespacePrefix: z.string().default('dsh'), globalNamespace: z.string().default('global'), blockTurnSize: z.natural().min(1).default(6), + blockDecayLambda: z.number().step(0.05).min(0).default(0.3) + .description('Block 衰减系数 λ') + .comment('默认 0.3;数字越小,记忆遗忘越慢,消耗 token 越多,不建议大于 0.4。'), ingestSubagents: z.boolean().default(false), provider: z.string(), model: z.string(), @@ -54,6 +59,7 @@ export function resolveConfig(config: Config): ResolvedConfig { namespacePrefix, globalNamespace, blockTurnSize: Math.max(1, Math.floor(config.blockTurnSize ?? 6)), + blockDecayLambda: Math.max(0, config.blockDecayLambda ?? 0.3), ingestSubagents: config.ingestSubagents ?? false, ...(provider && model ? { provider, model } : {}), maxOutputTokens: Math.max(256, Math.floor(config.maxOutputTokens ?? 10_000)), diff --git a/integrations/deepseek-harness/src/index.ts b/integrations/deepseek-harness/src/index.ts index b9d325c..7542b01 100644 --- a/integrations/deepseek-harness/src/index.ts +++ b/integrations/deepseek-harness/src/index.ts @@ -40,7 +40,7 @@ export async function apply(ctx: Context, config: StrataGateConfig): Promise<() const runtime = new StrataGateRuntime(resolved, models, (error) => { ctx.logger.error(`stratagate-memory ingestion failed: ${renderError(error)}`) }) - await runtime.syncConfiguredBlockTurnSize() + await runtime.syncConfiguredSettings() ctx.systemPrompt.section({ name: 'tool:stratagate-memory', order: 113, text: MEMORY_PROTOCOL }) ctx.on('system-prompt/assemble', async (_assembly, context, next) => { diff --git a/integrations/deepseek-harness/src/metadata.ts b/integrations/deepseek-harness/src/metadata.ts new file mode 100644 index 0000000..cce864b --- /dev/null +++ b/integrations/deepseek-harness/src/metadata.ts @@ -0,0 +1,62 @@ +import { DatabaseSync } from 'node:sqlite' + +const METADATA_SCHEMA = ` +CREATE TABLE IF NOT EXISTS stratagate_dsh_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL +) STRICT; + +CREATE TABLE IF NOT EXISTS stratagate_dsh_workspaces ( + namespace TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + updated_at TEXT NOT NULL +) STRICT; +` + +export class DshMetadataStore { + private readonly database: DatabaseSync + + constructor(filename: string) { + this.database = new DatabaseSync(filename) + this.database.exec(METADATA_SCHEMA) + } + + blockDecayLambda(): number | null { + const row = this.database.prepare("SELECT value FROM stratagate_dsh_settings WHERE key = 'blockDecayLambda'") + .get() as { value: string } | undefined + const value = Number(row?.value) + return Number.isFinite(value) && value >= 0 ? value : null + } + + setBlockDecayLambda(value: number): void { + if (!Number.isFinite(value) || value < 0) { + throw new TypeError('blockDecayLambda must be a non-negative finite number') + } + this.database.prepare(` + INSERT INTO stratagate_dsh_settings (key, value, updated_at) + VALUES ('blockDecayLambda', ?, ?) + ON CONFLICT (key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at + `).run(String(value), new Date().toISOString()) + } + + workspaceName(namespace: string): string | null { + const row = this.database.prepare('SELECT display_name FROM stratagate_dsh_workspaces WHERE namespace = ?') + .get(namespace) as { display_name: string } | undefined + return row?.display_name ?? null + } + + rememberWorkspace(namespace: string, displayName: string): void { + const name = displayName.trim() + if (!namespace.trim() || !name) return + this.database.prepare(` + INSERT INTO stratagate_dsh_workspaces (namespace, display_name, updated_at) + VALUES (?, ?, ?) + ON CONFLICT (namespace) DO UPDATE SET display_name = excluded.display_name, updated_at = excluded.updated_at + `).run(namespace, name, new Date().toISOString()) + } + + close(): void { + this.database.close() + } +} diff --git a/integrations/deepseek-harness/src/runtime.ts b/integrations/deepseek-harness/src/runtime.ts index 57a5649..ad4aa38 100644 --- a/integrations/deepseek-harness/src/runtime.ts +++ b/integrations/deepseek-harness/src/runtime.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto' import { existsSync } from 'node:fs' -import { resolve } from 'node:path' +import { basename, resolve } from 'node:path' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import { @@ -22,6 +22,7 @@ import { SqliteStorage } from '@diqier/stratagate/sqlite' import type { ResolvedConfig } from './config.js' import { TurnFolder } from './fold.js' import { DshModelBridge } from './llm.js' +import { DshMetadataStore } from './metadata.js' interface EvidenceTarget { eventIds: string[] @@ -57,16 +58,21 @@ export class StrataGateRuntime { private readonly batches = new Map() private readonly adopted = new Map() private readonly pendingUse = new Set() + private readonly workspaceNames = new Map() private ingestTail: Promise = Promise.resolve() + private settingsTail: Promise = Promise.resolve() private batchSequence = 0 private closed = false private ingestError: unknown + private blockDecayLambda: number constructor( private readonly config: ResolvedConfig, private readonly models: DshModelBridge, private readonly onIngestError: (error: unknown) => void = () => {}, - ) {} + ) { + this.blockDecayLambda = config.blockDecayLambda + } acceptEvent(session: Session, event: SessionEvent): void { if (this.closed) return @@ -311,16 +317,26 @@ export class StrataGateRuntime { } } - async syncConfiguredBlockTurnSize(): Promise { + async syncConfiguredSettings(): Promise { if (this.config.database === ':memory:' || !existsSync(this.config.database)) return + const metadata = new DshMetadataStore(this.config.database) + try { + this.blockDecayLambda = metadata.blockDecayLambda() ?? this.config.blockDecayLambda + } finally { + metadata.close() + } const storage = new SqliteStorage({ filename: this.config.database }) try { for (const namespace of storage.listNamespaces()) { const loaded = await storage.load(namespace) - if (!loaded || loaded.snapshot.blockTurnSize === this.config.blockTurnSize) continue + if (!loaded || ( + loaded.snapshot.blockTurnSize === this.config.blockTurnSize + && loaded.snapshot.blockDecayLambda === this.blockDecayLambda + )) continue await storage.save(namespace, { ...loaded.snapshot, blockTurnSize: this.config.blockTurnSize, + blockDecayLambda: this.blockDecayLambda, }, loaded.revision) } } finally { @@ -340,14 +356,71 @@ export class StrataGateRuntime { } } + adminWorkspaceName(namespace: string): string | null { + const remembered = this.workspaceNames.get(namespace) + if (remembered) return remembered + if (this.config.database === ':memory:' || !existsSync(this.config.database)) return null + const metadata = new DshMetadataStore(this.config.database) + try { + return metadata.workspaceName(namespace) + } finally { + metadata.close() + } + } + + async adminSetBlockDecayLambda(value: number): Promise { + if (!Number.isFinite(value) || value < 0) { + throw new TypeError('blockDecayLambda must be a non-negative finite number') + } + const update = this.settingsTail.catch(() => {}).then(() => this.applyBlockDecayLambda(value)) + this.settingsTail = update.then(() => {}, () => {}) + await update + return value + } + + private async applyBlockDecayLambda(value: number): Promise { + await this.flush() + this.blockDecayLambda = value + if (this.config.database !== ':memory:') { + const metadata = new DshMetadataStore(this.config.database) + try { + metadata.setBlockDecayLambda(value) + } finally { + metadata.close() + } + } + + const openNamespaces = new Set() + for (const [namespace, opening] of this.spaces) { + const memory = await opening + await memory.setBlockDecayLambda(value) + openNamespaces.add(namespace) + } + if (this.config.database !== ':memory:' && existsSync(this.config.database)) { + const storage = new SqliteStorage({ filename: this.config.database }) + try { + for (const namespace of storage.listNamespaces()) { + if (openNamespaces.has(namespace)) continue + const loaded = await storage.load(namespace) + if (!loaded || loaded.snapshot.blockDecayLambda === value) continue + await storage.save(namespace, { ...loaded.snapshot, blockDecayLambda: value }, loaded.revision) + } + } finally { + await storage.close() + } + } + } + private space(session: Session): Promise { const namespace = this.namespaceFor(session) + this.rememberWorkspace(namespace, session.header.cwd) let opening = this.spaces.get(namespace) if (!opening) { opening = StrataGate.open({ database: this.config.database, namespace, blockTurnSize: this.config.blockTurnSize, + blockDecayLambda: this.blockDecayLambda, summarizer: this.models.summarizer, extractor: this.models.extractor, elementProjector: this.models.projector, @@ -372,6 +445,22 @@ export class StrataGateRuntime { return opening } + private rememberWorkspace(namespace: string, cwd: string | undefined): void { + const name = basename(resolve(cwd ?? process.cwd())) || '当前工作区' + this.workspaceNames.set(namespace, name) + if (this.config.database === ':memory:') return + try { + const metadata = new DshMetadataStore(this.config.database) + try { + metadata.rememberWorkspace(namespace, name) + } finally { + metadata.close() + } + } catch (error) { + this.onIngestError(error) + } + } + private async persistSuccessfulResponses(memory: StrataGate): Promise { if (typeof this.models.takeSuccessfulResponses !== 'function') return const responses = this.models.takeSuccessfulResponses() @@ -438,7 +527,7 @@ function renderMessages(messages: readonly RawMessage[]): string { function renderBlocks(blocks: ReturnType): string { if (blocks.length === 0) return '(no sealed blocks)' return blocks.map((block) => [ - `block ${block.id} | turns ${block.turnRange[0]}-${block.turnRange[1]} | L${block.level}`, + `block ${block.id} | turns ${block.turnRange[0]}-${block.turnRange[1]} | age ${block.age} | L${block.level}`, block.content, ].join('\n')).join('\n\n') } diff --git a/integrations/deepseek-harness/src/web.ts b/integrations/deepseek-harness/src/web.ts index 0935087..6138838 100644 --- a/integrations/deepseek-harness/src/web.ts +++ b/integrations/deepseek-harness/src/web.ts @@ -167,15 +167,19 @@ async function overview(runtime: StrataGateRuntime): Promise { ].sort() rows.push({ namespace, + workspaceName: runtime.adminWorkspaceName(namespace) ?? '当前工作区', schemaVersion: snapshot.schemaVersion, currentTurn: snapshot.currentTurn, blockTurnSize: snapshot.blockTurnSize, + blockDecayLambda: snapshot.blockDecayLambda, blocks: snapshot.blocks.length, openTailMessages: snapshot.openTail.length, events: snapshot.events.length, activeEvents: snapshot.events.filter(({ status }) => status === 'active').length, elements: snapshot.elements.length, usageReceipts: snapshot.usageReceipts.length, + memoryUseCount: snapshot.usageReceipts.filter((receipt) => + receipt.eventIds.length > 0 || receipt.elementIds.length > 0).length, failedJobs, processingJobs, failedJobDetails, @@ -183,7 +187,16 @@ async function overview(runtime: StrataGateRuntime): Promise { lastActivityAt: timestamps.at(-1) ?? null, }) } - return { readonly: true, namespaces: rows } + return { readonly: true, settingsWritable: true, namespaces: rows } +} + +async function updateSettings(runtime: StrataGateRuntime, url: URL): Promise { + const raw = url.searchParams.get('blockDecayLambda')?.trim() ?? '' + const value = Number(raw) + if (!raw || !Number.isFinite(value) || value < 0) { + throw new AdminHttpError(400, 'blockDecayLambda must be a non-negative finite number') + } + return { blockDecayLambda: await runtime.adminSetBlockDecayLambda(value) } } async function memories(runtime: StrataGateRuntime, url: URL): Promise { @@ -322,10 +335,13 @@ async function audit(runtime: StrataGateRuntime, url: URL): Promise { export async function handleAdminRequest(runtime: StrataGateRuntime, req: WebRequest, res: WebResponse): Promise { try { - if (req.method !== 'GET') throw new AdminHttpError(405, 'StrataGate Memory UI is read-only') const url = new URL(req.url ?? '/', 'http://localhost') const path = url.pathname.replace(/\/$/, '') - if (path === '/api/stratagate/overview') sendJson(res, 200, await overview(runtime)) + if (path === '/api/stratagate/settings') { + if (req.method !== 'PATCH') throw new AdminHttpError(405, 'StrataGate settings require PATCH') + sendJson(res, 200, await updateSettings(runtime, url)) + } else if (req.method !== 'GET') throw new AdminHttpError(405, 'StrataGate memory data is read-only') + else if (path === '/api/stratagate/overview') sendJson(res, 200, await overview(runtime)) else if (path === '/api/stratagate/memories') sendJson(res, 200, await memories(runtime, url)) else if (path === '/api/stratagate/sources') sendJson(res, 200, await sources(runtime, url)) else if (path === '/api/stratagate/audit') sendJson(res, 200, await audit(runtime, url)) diff --git a/integrations/deepseek-harness/tests/client.test.ts b/integrations/deepseek-harness/tests/client.test.ts index 7b9727d..9c1e564 100644 --- a/integrations/deepseek-harness/tests/client.test.ts +++ b/integrations/deepseek-harness/tests/client.test.ts @@ -3,7 +3,7 @@ import { runInNewContext } from 'node:vm' import { describe, expect, it } from 'vitest' describe('StrataGate Web client contract', () => { - it('registers a read-only settings section through the DSH module loader', () => { + it('registers its settings section through the DSH module loader', () => { const source = readFileSync(new URL('../src/client.js', import.meta.url), 'utf8') let definition: any runInNewContext(source, { @@ -24,18 +24,28 @@ describe('StrataGate Web client contract', () => { } plugin.apply({ get: (name: string) => name === 'slots' ? slots : undefined }) expect(registration.metadata).toMatchObject({ name: 'settings.section', id: 'stratagate-memory' }) + expect(registration.metadata.label()).toBe('StrataGate-AgentMemory') expect(typeof registration.render).toBe('function') - expect(source).not.toContain("method: 'POST'") }) - it('offers a one-time GitHub Star link only after demonstrated memory use', () => { + it('shows the unified project brand, mascot, usage count, and GitHub Star link', () => { const source = readFileSync(new URL('../src/client.js', import.meta.url), 'utf8') - expect(source).toContain("const STAR_PROMPT_USAGE_THRESHOLD = 3") - expect(source).toContain("usageRecords: selected.usageReceipts") + expect(source).toContain('StrataGate-AgentMemory') + expect(source).toContain('__STRATAGATE_MASCOT_DATA_URL__') + expect(source).toContain('StrataGate 已在当前工作区中帮助使用记忆 ') + expect(source).toContain('为 StrataGate 点 🌟🌟') expect(source).toContain("https://github.com/diqierjia/StrataGate-AgentMemory") - expect(source).toContain("stratagate.starPrompt.dismissed.v1") expect(source).toContain("rel: 'noopener noreferrer'") - expect(source).not.toContain('window.open(') + }) + + it('uses the user-defined DSH Workspace title and keeps the compact header collision-free', () => { + const source = readFileSync(new URL('../src/client.js', import.meta.url), 'utf8') + expect(source).toContain('function MemoryPage({ useWorkspaces })') + expect(source).toContain('const workspaceItems = useWorkspaces((state) => state.items)') + expect(source).toContain("String(workspace.title || '').trim()") + expect(source).toContain("value.split(':project:').pop()") + expect(source).toContain('display:grid;grid-template-columns:minmax(0,1fr)') + expect(source).not.toContain("title: '重新加载', onClick: refresh") }) it('uses the memory-first three-part information architecture', () => { @@ -49,7 +59,17 @@ describe('StrataGate Web client contract', () => { expect(source).not.toContain('sg-stats') }) - it('keeps failures reassuring and moves engineering data under More', () => { + it('inherits the resolved light, dark, or system appearance from DSH theme tokens', () => { + const source = readFileSync(new URL('../src/client.js', import.meta.url), 'utf8') + expect(source).toContain('color-scheme:inherit') + expect(source).toContain('--sg-page:var(--dsw-alias-bg-layer-2') + expect(source).toContain('--sg-text:var(--dsw-alias-label-primary') + expect(source).toContain('--sg-accent:var(--dsw-alias-state-business-primary') + expect(source).not.toContain('@media (prefers-color-scheme:dark)') + expect(source).not.toContain('--dsh-color-background') + }) + + it('keeps failures reassuring and makes only lambda editable under More', () => { const source = readFileSync(new URL('../src/client.js', import.meta.url), 'utf8') expect(source).toContain('lastErrorFull') expect(source).toContain('原始内容已经保存,不会丢失。') @@ -59,7 +79,11 @@ describe('StrataGate Web client contract', () => { expect(source).toContain("['audit', '↗', '使用记录'") expect(source).toContain("['settings', '⚙', '高级设置'") expect(source).not.toContain("['responses', '模型响应']") - expect(source).not.toContain("method: 'POST'") + expect(source).toContain("type: 'number'") + expect(source).toContain("step: '0.05'") + expect(source).toContain('默认 0.3;数字越小,记忆遗忘越慢,消耗 token 越多,不建议大于 0.4。') + expect(source).toContain("method: 'PATCH'") + expect(source).toContain('当前工作区') }) it('shows a red processing banner with a loading icon while memory work is active', () => { diff --git a/integrations/deepseek-harness/tests/config.test.ts b/integrations/deepseek-harness/tests/config.test.ts index 98ffb5a..155ae32 100644 --- a/integrations/deepseek-harness/tests/config.test.ts +++ b/integrations/deepseek-harness/tests/config.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { resolveConfig } from '../src/config.js' +import { Config, resolveConfig } from '../src/config.js' describe('DeepSeek Harness plugin config', () => { it('resolves safe defaults', () => { @@ -9,6 +9,7 @@ describe('DeepSeek Harness plugin config', () => { namespacePrefix: 'dsh', globalNamespace: 'global', blockTurnSize: 6, + blockDecayLambda: 0.3, ingestSubagents: false, maxOutputTokens: 10000, }) @@ -18,4 +19,16 @@ describe('DeepSeek Harness plugin config', () => { expect(() => resolveConfig({ database: 'memory.db', provider: 'deepseek' })) .toThrow('provider and model must be configured together') }) + + it('exposes the Block decay coefficient and guidance in the plugin form', () => { + const field = Config.dict?.blockDecayLambda + expect(field?.meta).toMatchObject({ + default: 0.3, + min: 0, + step: 0.05, + description: 'Block 衰减系数 λ', + comment: '默认 0.3;数字越小,记忆遗忘越慢,消耗 token 越多,不建议大于 0.4。', + }) + expect(resolveConfig({ database: 'memory.db', blockDecayLambda: 0.15 }).blockDecayLambda).toBe(0.15) + }) }) diff --git a/integrations/deepseek-harness/tests/llm.test.ts b/integrations/deepseek-harness/tests/llm.test.ts index 6c18f23..82eed32 100644 --- a/integrations/deepseek-harness/tests/llm.test.ts +++ b/integrations/deepseek-harness/tests/llm.test.ts @@ -92,6 +92,7 @@ function modelBridge(responses: Array<{ text?: string; tool?: unknown; toolName? namespacePrefix: 'test', globalNamespace: 'global', blockTurnSize: 1, + blockDecayLambda: 0.3, ingestSubagents: false, maxOutputTokens: 256, }) @@ -151,7 +152,7 @@ describe('DeepSeek Harness model JSON retries', () => { } as unknown as Context const bridge = new DshModelBridge(ctx, { database: ':memory:', namespaceMode: 'session', namespacePrefix: 'test', globalNamespace: 'global', - blockTurnSize: 1, ingestSubagents: false, maxOutputTokens: 256, + blockTurnSize: 1, blockDecayLambda: 0.3, ingestSubagents: false, maxOutputTokens: 256, }) const session = { id: 'reasoning-test', requestHeader: () => ({ config: { provider: 'test', model: 'test' } }) } as unknown as Session @@ -180,7 +181,7 @@ describe('DeepSeek Harness model JSON retries', () => { l3Condensed: 'target condensed', l4Readable: 'target readable', l5Raw: [{ id: 'msg_target', role: 'user', content: 'target message', createdAt: '2026-01-01T00:00:00.000Z' }], shouldExtract: true, pointerCurrentLevel: 5, pointerAnchorLevel: 5, - pointerAnchorTurn: 4, lastLiftedAt: null, createdAt: '2026-01-01T00:00:00.000Z', + pointerAnchorBlockPosition: 1, lastLiftedAt: null, createdAt: '2026-01-01T00:00:00.000Z', } as MemoryBlock const next = { ...target, id: 'blk_next', sequence: 3, startTurn: 5, endTurn: 6, @@ -210,7 +211,7 @@ describe('DeepSeek Harness model JSON retries', () => { l0Title: 'target', l0Tags: [], l1Summary: '', l2Keypoints: [], l3Condensed: '', l4Readable: '', l5Raw: [{ id: 'msg_target', role: 'user', content: 'target message', createdAt: '2026-01-01T00:00:00.000Z' }], shouldExtract: true, pointerCurrentLevel: 5, pointerAnchorLevel: 5, - pointerAnchorTurn: 2, lastLiftedAt: null, createdAt: '2026-01-01T00:00:00.000Z', + pointerAnchorBlockPosition: 1, lastLiftedAt: null, createdAt: '2026-01-01T00:00:00.000Z', } as MemoryBlock const { bridge, session } = modelBridge([{ tool: { shouldExtract: true, reason: 'wrong block', events: [{ diff --git a/integrations/deepseek-harness/tests/runtime.test.ts b/integrations/deepseek-harness/tests/runtime.test.ts index b85b46e..8b50093 100644 --- a/integrations/deepseek-harness/tests/runtime.test.ts +++ b/integrations/deepseek-harness/tests/runtime.test.ts @@ -62,6 +62,7 @@ describe('DSH runtime ingestion', () => { namespacePrefix: 'dsh', globalNamespace: 'global', blockTurnSize: 2, + blockDecayLambda: 0.3, ingestSubagents: false, maxOutputTokens: 2048, }, fakeModels) @@ -134,7 +135,7 @@ describe('DSH runtime ingestion', () => { expect(context).toContain('[Current conversation]\nuser: We chose pnpm earlier.') expect(context).toContain('[Decayed memory blocks]') - expect(context).toContain(`block ${block!.id} | turns 1-2 | L`) + expect(context).toContain(`block ${block!.id} | turns 1-2 | age 0 | L`) expect(context).toContain('[Activated long-term memory]') expect(context).toContain('Historical memory context.') expect(context).toContain(relevant.id) @@ -188,6 +189,7 @@ describe('DSH runtime ingestion', () => { namespacePrefix: 'dsh', globalNamespace: 'global', blockTurnSize: 2, + blockDecayLambda: 0.3, ingestSubagents: false, maxOutputTokens: 2048, }, fakeModels) @@ -239,6 +241,7 @@ describe('DSH runtime ingestion', () => { namespacePrefix: 'dsh', globalNamespace: 'global', blockTurnSize: 4, + blockDecayLambda: 0.3, ingestSubagents: false, maxOutputTokens: 2048, }, models) @@ -252,11 +255,11 @@ describe('DSH runtime ingestion', () => { } }) - it('applies the configured block cadence to existing namespaces before the admin UI reads them', async () => { + it('persists the UI lambda globally for existing and future workspaces', async () => { const directory = await mkdtemp(join(tmpdir(), 'stratagate-dsh-cadence-')) const database = join(directory, 'memory.db') const namespace = 'dsh:project:cadence' - const seed = await StrataGate.open({ database, namespace, blockTurnSize: 4 }) + const seed = await StrataGate.open({ database, namespace, blockTurnSize: 4, blockDecayLambda: 0.2 }) await seed.close() const runtime = new StrataGateRuntime({ database, @@ -264,14 +267,50 @@ describe('DSH runtime ingestion', () => { namespacePrefix: 'dsh', globalNamespace: 'global', blockTurnSize: 6, + blockDecayLambda: 0.3, ingestSubagents: false, maxOutputTokens: 2048, }, fakeModels) + let futureNamespace = '' try { - await runtime.syncConfiguredBlockTurnSize() + await runtime.syncConfiguredSettings() expect((await runtime.adminSnapshot(namespace))?.blockTurnSize).toBe(6) + expect((await runtime.adminSnapshot(namespace))?.blockDecayLambda).toBe(0.3) + + await runtime.adminSetBlockDecayLambda(0.15) + expect((await runtime.adminSnapshot(namespace))?.blockDecayLambda).toBe(0.15) + + const futureSession = { + ...session, + id: 'future-workspace', + header: { ...session.header, id: 'future-workspace', cwd: 'C:\\work\\StrataGate' }, + } as unknown as Session + futureNamespace = runtime.namespaceFor(futureSession) + const future = await (runtime as unknown as { space: (active: Session) => Promise }) + .space(futureSession) + expect(future.blockDecayLambda).toBe(0.15) + expect(runtime.adminWorkspaceName(futureNamespace)).toBe('StrataGate') } finally { await runtime.close() + } + + const restored = new StrataGateRuntime({ + database, + namespaceMode: 'project', + namespacePrefix: 'dsh', + globalNamespace: 'global', + blockTurnSize: 6, + blockDecayLambda: 0.3, + ingestSubagents: false, + maxOutputTokens: 2048, + }, fakeModels) + try { + await restored.syncConfiguredSettings() + expect((await restored.adminSnapshot(namespace))?.blockDecayLambda).toBe(0.15) + expect((await restored.adminSnapshot(futureNamespace))?.blockDecayLambda).toBe(0.15) + expect(restored.adminWorkspaceName(futureNamespace)).toBe('StrataGate') + } finally { + await restored.close() await rm(directory, { recursive: true, force: true }) } }) @@ -285,6 +324,7 @@ describe('DSH runtime ingestion', () => { namespacePrefix: 'dsh', globalNamespace: 'global', blockTurnSize: 4, + blockDecayLambda: 0.3, ingestSubagents: false, maxOutputTokens: 2048, }, fakeModels) @@ -313,6 +353,7 @@ describe('DSH runtime ingestion', () => { namespacePrefix: 'dsh', globalNamespace: 'global', blockTurnSize: 1, + blockDecayLambda: 0.3, ingestSubagents: false, maxOutputTokens: 2048, }, fakeModels) diff --git a/integrations/deepseek-harness/tests/web.test.ts b/integrations/deepseek-harness/tests/web.test.ts index 963da9b..400f76c 100644 --- a/integrations/deepseek-harness/tests/web.test.ts +++ b/integrations/deepseek-harness/tests/web.test.ts @@ -6,9 +6,10 @@ import { handleAdminRequest, type WebResponse } from '../src/web.js' const fullFailure = 'StrataGate model response was not valid JSON\nRaw response (full):\n' + 'x'.repeat(600) const snapshot: StrataGateSnapshot = { - schemaVersion: 5, + schemaVersion: 6, currentTurn: 8, blockTurnSize: 4, + blockDecayLambda: 0.3, openTail: [], blocks: [{ id: 'blk_1', @@ -32,7 +33,7 @@ const snapshot: StrataGateSnapshot = { }], pointerCurrentLevel: 5, pointerAnchorLevel: 5, - pointerAnchorTurn: 4, + pointerAnchorBlockPosition: 1, lastLiftedAt: null, }], events: [{ @@ -96,9 +97,15 @@ const snapshot: StrataGateSnapshot = { ingestionReceipts: [], } +let updatedLambda: number | null = null const runtime = { adminNamespaces: async () => ['dsh:project:test'], adminSnapshot: async (namespace: string) => namespace === 'dsh:project:test' ? snapshot : null, + adminWorkspaceName: () => 'StrataGate', + adminSetBlockDecayLambda: async (value: number) => { + updatedLambda = value + return value + }, } as unknown as StrataGateRuntime const waitingRuntime = { @@ -127,7 +134,7 @@ async function request(url: string, method = 'GET', targetRuntime = runtime): Pr return { status: response.statusCode, body: JSON.parse(text), headers } } -describe('StrataGate read-only admin routes', () => { +describe('StrataGate admin routes', () => { it('does not label a block without an extraction job as actively processing', async () => { const result = await request('/api/stratagate/memories?namespace=dsh%3Aproject%3Awaiting&kind=blocks', 'GET', waitingRuntime) expect(result.body.items[0]).toMatchObject({ status: 'waiting', eventExtraction: null }) @@ -140,13 +147,17 @@ describe('StrataGate read-only admin routes', () => { expect(overview.status).toBe(200) expect(overview.body).toMatchObject({ readonly: true, + settingsWritable: true, namespaces: [{ + workspaceName: 'StrataGate', blockTurnSize: 4, + blockDecayLambda: 0.3, events: 1, - usageReceipts: 1, - failedJobs: 1, - processingJobs: 0, - failedJobDetails: [{ + usageReceipts: 1, + memoryUseCount: 1, + failedJobs: 1, + processingJobs: 0, + failedJobDetails: [{ kind: 'event-extraction', attempts: 2, lastError: fullFailure.slice(0, 500), @@ -194,7 +205,15 @@ describe('StrataGate read-only admin routes', () => { }) }) - it('rejects every browser write method', async () => { + it('updates the global Block decay setting while memory routes remain read-only', async () => { + updatedLambda = null + const settings = await request('/api/stratagate/settings?blockDecayLambda=0.15', 'PATCH') + expect(settings).toMatchObject({ status: 200, body: { blockDecayLambda: 0.15 } }) + expect(updatedLambda).toBe(0.15) + + const invalid = await request('/api/stratagate/settings?blockDecayLambda=nope', 'PATCH') + expect(invalid).toMatchObject({ status: 400, body: { error: expect.stringContaining('blockDecayLambda') } }) + const result = await request('/api/stratagate/memories', 'POST') expect(result).toMatchObject({ status: 405, body: { error: expect.stringContaining('read-only') } }) }) diff --git a/package-lock.json b/package-lock.json index 1faf484..928aab4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ }, "integrations/deepseek-harness": { "name": "stratagate-dsh", - "version": "0.2.16", + "version": "0.2.19", "license": "MIT", "devDependencies": { "@deepseek-ai/cordis": "^4.0.1", diff --git a/src/blocks.ts b/src/blocks.ts index 3acb7e8..4852e20 100644 --- a/src/blocks.ts +++ b/src/blocks.ts @@ -2,7 +2,7 @@ import type { BlockLevel, RawMessage, ToolTrace } from './types.js'; export const DEFAULT_BLOCK_TURN_SIZE = 12; export const BLOCK_MAX_LEVEL = 5; -export const BLOCK_DECAY_LAMBDA = 0.05; +export const BLOCK_DECAY_LAMBDA = 0.3; const FILLER_ONLY = new Set([ 'ok', 'okay', 'got it', 'thanks', 'thank you', 'yes', 'correct', 'sure', @@ -17,16 +17,21 @@ function asBlockLevel(value: number): BlockLevel { return Math.max(0, Math.min(BLOCK_MAX_LEVEL, Math.round(value))) as BlockLevel; } -export function getBlockWeight(anchorTurn: number, currentTurn: number): number { - return Math.exp(-BLOCK_DECAY_LAMBDA * Math.max(0, currentTurn - anchorTurn)); +export function getBlockWeight( + anchorBlockPosition: number, + latestBlockPosition: number, + lambda = BLOCK_DECAY_LAMBDA, +): number { + return Math.exp(-lambda * Math.max(0, latestBlockPosition - anchorBlockPosition)); } export function getDecayedBlockLevel( anchorLevel: BlockLevel, - anchorTurn: number, - currentTurn: number, + anchorBlockPosition: number, + latestBlockPosition: number, + lambda = BLOCK_DECAY_LAMBDA, ): BlockLevel { - const weight = getBlockWeight(anchorTurn, currentTurn); + const weight = getBlockWeight(anchorBlockPosition, latestBlockPosition, lambda); const droppedLevels = weight > 0.7 ? 0 : weight > 0.5 diff --git a/src/sqlite.ts b/src/sqlite.ts index b574308..32c4c91 100644 --- a/src/sqlite.ts +++ b/src/sqlite.ts @@ -43,6 +43,7 @@ interface SpaceRow { revision: number; current_turn: number; block_turn_size: number; + block_decay_lambda: number; } interface MessageRow { @@ -72,7 +73,7 @@ interface BlockRow { l4_readable: string; pointer_current_level: number; pointer_anchor_level: number; - pointer_anchor_turn: number; + pointer_anchor_block_position: number; last_lifted_at: string | null; } @@ -198,6 +199,7 @@ CREATE TABLE IF NOT EXISTS memory_spaces ( revision INTEGER NOT NULL, current_turn INTEGER NOT NULL, block_turn_size INTEGER NOT NULL, + block_decay_lambda REAL NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ) STRICT; @@ -219,7 +221,7 @@ CREATE TABLE IF NOT EXISTS blocks ( l4_readable TEXT NOT NULL, pointer_current_level INTEGER NOT NULL, pointer_anchor_level INTEGER NOT NULL, - pointer_anchor_turn INTEGER NOT NULL, + pointer_anchor_block_position INTEGER NOT NULL, last_lifted_at TEXT, PRIMARY KEY (namespace, id), UNIQUE (namespace, sequence), @@ -443,7 +445,7 @@ export class SqliteStorage implements StorageAdapter { this.assertOpen(); const key = nonEmptyNamespace(namespace); const space = this.database.prepare(` - SELECT schema_version, revision, current_turn, block_turn_size + SELECT schema_version, revision, current_turn, block_turn_size, block_decay_lambda FROM memory_spaces WHERE namespace = ? `).get(key) as SpaceRow | undefined; if (!space) return null; @@ -494,7 +496,7 @@ export class SqliteStorage implements StorageAdapter { l5Raw: messagesByBlock.get(row.id) ?? [], pointerCurrentLevel: row.pointer_current_level as BlockLevel, pointerAnchorLevel: row.pointer_anchor_level as BlockLevel, - pointerAnchorTurn: row.pointer_anchor_turn, + pointerAnchorBlockPosition: row.pointer_anchor_block_position, lastLiftedAt: row.last_lifted_at, })); @@ -673,6 +675,7 @@ export class SqliteStorage implements StorageAdapter { schemaVersion: STRATAGATE_STORAGE_SCHEMA_VERSION, currentTurn: space.current_turn, blockTurnSize: space.block_turn_size, + blockDecayLambda: space.block_decay_lambda, openTail, blocks, events, @@ -716,27 +719,29 @@ export class SqliteStorage implements StorageAdapter { if (current) { this.database.prepare(` UPDATE memory_spaces - SET schema_version = ?, revision = ?, current_turn = ?, block_turn_size = ?, updated_at = ? + SET schema_version = ?, revision = ?, current_turn = ?, block_turn_size = ?, block_decay_lambda = ?, updated_at = ? WHERE namespace = ? `).run( snapshot.schemaVersion, nextRevision, snapshot.currentTurn, snapshot.blockTurnSize, + snapshot.blockDecayLambda, updatedAt, namespace, ); } else { this.database.prepare(` INSERT INTO memory_spaces ( - namespace, schema_version, revision, current_turn, block_turn_size, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?) + namespace, schema_version, revision, current_turn, block_turn_size, block_decay_lambda, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `).run( namespace, snapshot.schemaVersion, nextRevision, snapshot.currentTurn, snapshot.blockTurnSize, + snapshot.blockDecayLambda, updatedAt, updatedAt, ); @@ -746,7 +751,7 @@ export class SqliteStorage implements StorageAdapter { INSERT INTO blocks ( namespace, id, thread_id, sequence, start_turn, end_turn, created_at, should_extract, l0_title, l0_tags_json, l1_summary, l2_keypoints_json, l3_condensed, l4_readable, - pointer_current_level, pointer_anchor_level, pointer_anchor_turn, last_lifted_at + pointer_current_level, pointer_anchor_level, pointer_anchor_block_position, last_lifted_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (namespace, id) DO UPDATE SET thread_id = excluded.thread_id, @@ -763,7 +768,7 @@ export class SqliteStorage implements StorageAdapter { l4_readable = excluded.l4_readable, pointer_current_level = excluded.pointer_current_level, pointer_anchor_level = excluded.pointer_anchor_level, - pointer_anchor_turn = excluded.pointer_anchor_turn, + pointer_anchor_block_position = excluded.pointer_anchor_block_position, last_lifted_at = excluded.last_lifted_at `); for (const block of snapshot.blocks) { @@ -784,7 +789,7 @@ export class SqliteStorage implements StorageAdapter { block.l4Readable, block.pointerCurrentLevel, block.pointerAnchorLevel, - block.pointerAnchorTurn, + block.pointerAnchorBlockPosition, block.lastLiftedAt, ); } @@ -1064,8 +1069,9 @@ export class SqliteStorage implements StorageAdapter { this.database.exec(THREAD_INDEXES); this.database.exec(`PRAGMA user_version = ${STRATAGATE_STORAGE_SCHEMA_VERSION}`); }); - } else if (version === 1 || version === 2 || version === 3 || version === 4) { + } else if (version === 1 || version === 2 || version === 3 || version === 4 || version === 5) { this.immediateTransaction(() => { + this.database.exec(SCHEMA); if (version === 1) { const receiptColumns = this.database.prepare("PRAGMA table_info('usage_receipts')").all() as unknown as Array<{ name: string }>; if (!receiptColumns.some(({ name }) => name === 'element_ids_json')) { @@ -1076,11 +1082,26 @@ export class SqliteStorage implements StorageAdapter { if (!receiptColumns.some(({ name }) => name === 'audit_json')) { this.database.exec("ALTER TABLE usage_receipts ADD COLUMN audit_json TEXT NOT NULL DEFAULT '{}'"); } - this.database.exec(SCHEMA); + const spaceColumns = this.database.prepare("PRAGMA table_info('memory_spaces')").all() as unknown as Array<{ name: string }>; + if (!spaceColumns.some(({ name }) => name === 'block_decay_lambda')) { + this.database.exec('ALTER TABLE memory_spaces ADD COLUMN block_decay_lambda REAL NOT NULL DEFAULT 0.3'); + } const blockColumns = this.database.prepare("PRAGMA table_info('blocks')").all() as unknown as Array<{ name: string }>; if (!blockColumns.some(({ name }) => name === 'thread_id')) { this.database.exec('ALTER TABLE blocks ADD COLUMN thread_id TEXT'); } + if (!blockColumns.some(({ name }) => name === 'pointer_anchor_block_position')) { + this.database.exec('ALTER TABLE blocks RENAME COLUMN pointer_anchor_turn TO pointer_anchor_block_position'); + this.database.exec(` + UPDATE blocks AS target + SET pointer_anchor_block_position = MAX(1, ( + SELECT COUNT(*) FROM blocks AS candidate + WHERE candidate.namespace = target.namespace + AND candidate.thread_id IS target.thread_id + AND candidate.end_turn <= target.pointer_anchor_block_position + )) + `); + } const messageColumns = this.database.prepare("PRAGMA table_info('messages')").all() as unknown as Array<{ name: string }>; if (!messageColumns.some(({ name }) => name === 'thread_id')) { this.database.exec('ALTER TABLE messages ADD COLUMN thread_id TEXT'); diff --git a/src/storage.ts b/src/storage.ts index 0052de7..493a4f3 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -1,6 +1,7 @@ +import { BLOCK_DECAY_LAMBDA } from './blocks.js'; import type { ElementCard, EventCard, MemoryBlock, RawMessage } from './types.js'; -export const STRATAGATE_STORAGE_SCHEMA_VERSION = 5; +export const STRATAGATE_STORAGE_SCHEMA_VERSION = 6; export type ExtractionJobStatus = 'running' | 'succeeded' | 'skipped' | 'failed'; @@ -63,6 +64,7 @@ export interface StrataGateSnapshot { schemaVersion: typeof STRATAGATE_STORAGE_SCHEMA_VERSION; currentTurn: number; blockTurnSize: number; + blockDecayLambda: number; openTail: RawMessage[]; blocks: MemoryBlock[]; events: EventCard[]; @@ -100,24 +102,42 @@ export function cloneSnapshot(snapshot: StrataGateSnapshot): StrataGateSnapshot return structuredClone(snapshot); } -interface LegacySnapshotV1 extends Omit { +type LegacyMemoryBlock = Omit & { pointerAnchorTurn: number }; +type LegacySnapshotBase = Omit & { + blocks: LegacyMemoryBlock[]; +}; + +interface LegacySnapshotV1 extends Omit { schemaVersion: 1; usageReceipts: Array>; } -interface LegacySnapshotV2 extends Omit { +interface LegacySnapshotV2 extends Omit { schemaVersion: 2; } -interface LegacySnapshotV3 extends Omit { +interface LegacySnapshotV3 extends Omit { schemaVersion: 3; usageReceipts: Array>; } -interface LegacySnapshotV4 extends Omit { +interface LegacySnapshotV4 extends LegacySnapshotBase { schemaVersion: 4; } +interface LegacySnapshotV5 extends LegacySnapshotBase { + schemaVersion: 5; +} + +function migrateLegacyBlocks(blocks: readonly LegacyMemoryBlock[]): MemoryBlock[] { + return blocks.map((block) => { + const { pointerAnchorTurn, ...current } = block; + const position = blocks.filter((candidate) => + candidate.threadId === block.threadId && candidate.endTurn <= pointerAnchorTurn).length; + return { ...current, pointerAnchorBlockPosition: Math.max(1, position) }; + }); +} + export function normalizeSnapshot(value: unknown): StrataGateSnapshot { if (!value || typeof value !== 'object') throw new TypeError('Invalid StrataGate snapshot: expected an object'); const schemaVersion = (value as { schemaVersion?: unknown }).schemaVersion; @@ -127,6 +147,8 @@ export function normalizeSnapshot(value: unknown): StrataGateSnapshot { snapshot = { ...structuredClone(legacy), schemaVersion: STRATAGATE_STORAGE_SCHEMA_VERSION, + blockDecayLambda: BLOCK_DECAY_LAMBDA, + blocks: migrateLegacyBlocks(legacy.blocks), elements: [], elementProjectionJobs: [], usageReceipts: Array.isArray(legacy.usageReceipts) @@ -135,20 +157,37 @@ export function normalizeSnapshot(value: unknown): StrataGateSnapshot { ingestionReceipts: [], }; } else if (schemaVersion === 2) { + const legacy = value as LegacySnapshotV2; snapshot = { - ...structuredClone(value as LegacySnapshotV2), + ...structuredClone(legacy), schemaVersion: STRATAGATE_STORAGE_SCHEMA_VERSION, + blockDecayLambda: BLOCK_DECAY_LAMBDA, + blocks: migrateLegacyBlocks(legacy.blocks), ingestionReceipts: [], }; } else if (schemaVersion === 3) { + const legacy = value as LegacySnapshotV3; snapshot = { - ...structuredClone(value as LegacySnapshotV3), + ...structuredClone(legacy), schemaVersion: STRATAGATE_STORAGE_SCHEMA_VERSION, + blockDecayLambda: BLOCK_DECAY_LAMBDA, + blocks: migrateLegacyBlocks(legacy.blocks), }; } else if (schemaVersion === 4) { + const legacy = value as LegacySnapshotV4; snapshot = { - ...structuredClone(value as LegacySnapshotV4), + ...structuredClone(legacy), schemaVersion: STRATAGATE_STORAGE_SCHEMA_VERSION, + blockDecayLambda: BLOCK_DECAY_LAMBDA, + blocks: migrateLegacyBlocks(legacy.blocks), + }; + } else if (schemaVersion === 5) { + const legacy = value as LegacySnapshotV5; + snapshot = { + ...structuredClone(legacy), + schemaVersion: STRATAGATE_STORAGE_SCHEMA_VERSION, + blockDecayLambda: BLOCK_DECAY_LAMBDA, + blocks: migrateLegacyBlocks(legacy.blocks), }; } else if (schemaVersion === STRATAGATE_STORAGE_SCHEMA_VERSION) { snapshot = structuredClone(value) as StrataGateSnapshot; @@ -161,10 +200,18 @@ export function normalizeSnapshot(value: unknown): StrataGateSnapshot { if (!Number.isSafeInteger(snapshot.blockTurnSize) || (snapshot.blockTurnSize ?? 0) < 1) { throw new TypeError('Invalid StrataGate snapshot: blockTurnSize must be a positive integer'); } + if (!Number.isFinite(snapshot.blockDecayLambda) || snapshot.blockDecayLambda < 0) { + throw new TypeError('Invalid StrataGate snapshot: blockDecayLambda must be a non-negative finite number'); + } for (const key of ['openTail', 'blocks', 'events', 'elements', 'extractionJobs', 'elementProjectionJobs', 'usageReceipts', 'ingestionReceipts'] as const) { if (!Array.isArray(snapshot[key])) throw new TypeError(`Invalid StrataGate snapshot: ${key} must be an array`); } if (!Array.isArray(snapshot.successfulModelResponses)) snapshot.successfulModelResponses = []; + for (const block of snapshot.blocks) { + if (!Number.isSafeInteger(block.pointerAnchorBlockPosition) || block.pointerAnchorBlockPosition < 1) { + throw new TypeError('Invalid StrataGate snapshot: pointerAnchorBlockPosition must be a positive integer'); + } + } if (snapshot.successfulModelResponses.length > 5) { snapshot.successfulModelResponses = snapshot.successfulModelResponses.slice(-5); } diff --git a/src/store.ts b/src/store.ts index fcda1d4..8f2339d 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,4 +1,5 @@ import { + BLOCK_DECAY_LAMBDA, DEFAULT_BLOCK_TURN_SIZE, blockLevelLabel, deterministicBlockLayers, @@ -54,6 +55,7 @@ import { toUtc8Iso } from './time.js'; export interface StrataGateOptions { blockTurnSize?: number; + blockDecayLambda?: number; summarizer?: BlockSummarizer; extractor?: EventExtractor; elementProjector?: ElementProjector; @@ -96,6 +98,7 @@ export interface BlockContextEntry { id: string; threadId?: string; turnRange: [number, number]; + age: number; level: BlockLevel; label: string; content: string; @@ -174,6 +177,7 @@ const STRATAGATE_CONSTRUCTOR_TOKEN = Symbol('StrataGate constructor'); export class StrataGate { readonly blockTurnSize: number; + private blockDecayLambdaValue: number; private readonly summarizer: BlockSummarizer | undefined; private readonly extractor: EventExtractor | undefined; private readonly elementProjector: ElementProjector | undefined; @@ -200,6 +204,11 @@ export class StrataGate { throw new TypeError('Use StrataGate.open() for SQLite or StrataGate.inMemory() for explicit ephemeral storage'); } this.blockTurnSize = Math.max(1, Math.floor(options.blockTurnSize ?? DEFAULT_BLOCK_TURN_SIZE)); + const blockDecayLambda = options.blockDecayLambda ?? BLOCK_DECAY_LAMBDA; + if (!Number.isFinite(blockDecayLambda) || blockDecayLambda < 0) { + throw new TypeError('blockDecayLambda must be a non-negative finite number'); + } + this.blockDecayLambdaValue = blockDecayLambda; this.summarizer = options.summarizer; this.extractor = options.extractor; this.elementProjector = options.elementProjector; @@ -224,6 +233,7 @@ export class StrataGate { storage, namespace: options.namespace, ...(options.blockTurnSize !== undefined ? { blockTurnSize: options.blockTurnSize } : {}), + ...(options.blockDecayLambda !== undefined ? { blockDecayLambda: options.blockDecayLambda } : {}), ...(options.summarizer ? { summarizer: options.summarizer } : {}), ...(options.extractor ? { extractor: options.extractor } : {}), ...(options.elementProjector ? { elementProjector: options.elementProjector } : {}), @@ -243,17 +253,32 @@ export class StrataGate { const loaded = await options.storage.load(namespace); const loadedSnapshot = loaded ? normalizeSnapshot(loaded.snapshot) : null; let loadedRevision = loaded?.revision ?? 0; - if (loaded && options.blockTurnSize !== undefined) { - const requested = Math.max(1, Math.floor(options.blockTurnSize)); - if (requested !== loadedSnapshot?.blockTurnSize) { - if (!loadedSnapshot) throw new Error('Loaded StrataGate state did not contain a snapshot'); - loadedSnapshot.blockTurnSize = requested; - loadedRevision = await options.storage.save(namespace, loadedSnapshot, loadedRevision); + if (loaded && loadedSnapshot) { + let settingsChanged = false; + if (options.blockTurnSize !== undefined) { + const requested = Math.max(1, Math.floor(options.blockTurnSize)); + if (requested !== loadedSnapshot.blockTurnSize) { + loadedSnapshot.blockTurnSize = requested; + settingsChanged = true; + } } + if (options.blockDecayLambda !== undefined) { + const requested = options.blockDecayLambda; + if (!Number.isFinite(requested) || requested < 0) { + throw new TypeError('blockDecayLambda must be a non-negative finite number'); + } + if (requested !== loadedSnapshot.blockDecayLambda) { + loadedSnapshot.blockDecayLambda = requested; + settingsChanged = true; + } + } + if (settingsChanged) loadedRevision = await options.storage.save(namespace, loadedSnapshot, loadedRevision); } const memoryOptions: StrataGateOptions = {}; if (loadedSnapshot) memoryOptions.blockTurnSize = loadedSnapshot.blockTurnSize; else if (options.blockTurnSize !== undefined) memoryOptions.blockTurnSize = options.blockTurnSize; + if (loadedSnapshot) memoryOptions.blockDecayLambda = loadedSnapshot.blockDecayLambda; + else if (options.blockDecayLambda !== undefined) memoryOptions.blockDecayLambda = options.blockDecayLambda; if (options.summarizer) memoryOptions.summarizer = options.summarizer; if (options.extractor) memoryOptions.extractor = options.extractor; if (options.elementProjector) memoryOptions.elementProjector = options.elementProjector; @@ -309,6 +334,20 @@ export class StrataGate { return this.revision; } + get blockDecayLambda(): number { + return this.blockDecayLambdaValue; + } + + async setBlockDecayLambda(value: number): Promise { + if (!Number.isFinite(value) || value < 0) { + throw new TypeError('blockDecayLambda must be a non-negative finite number'); + } + if (value === this.blockDecayLambdaValue) return; + await this.commitMutation(() => { + this.blockDecayLambdaValue = value; + }); + } + listBlocks(): readonly MemoryBlock[] { return this.blocks; } @@ -360,6 +399,7 @@ export class StrataGate { schemaVersion: STRATAGATE_STORAGE_SCHEMA_VERSION, currentTurn: this.currentTurn, blockTurnSize: this.blockTurnSize, + blockDecayLambda: this.blockDecayLambda, openTail: this.openTail, blocks: this.blocks, events: this.events, @@ -699,13 +739,22 @@ export class StrataGate { ? this.blocks : this.blocks.filter((block) => block.threadId === threadId); return blocks.map((block) => { - const currentTurn = block.threadId === undefined ? this.currentTurn : this.threadTurn(block.threadId); - const level = getDecayedBlockLevel(block.pointerAnchorLevel, block.pointerAnchorTurn, currentTurn); + const threadBlocks = this.threadBlocks(block.threadId); + const latestBlockPosition = threadBlocks.length; + const blockPosition = threadBlocks.indexOf(block) + 1; + const age = Math.max(0, latestBlockPosition - blockPosition); + const level = getDecayedBlockLevel( + block.pointerAnchorLevel, + block.pointerAnchorBlockPosition, + latestBlockPosition, + this.blockDecayLambda, + ); block.pointerCurrentLevel = level; return { id: block.id, ...(block.threadId ? { threadId: block.threadId } : {}), turnRange: [block.startTurn, block.endTurn], + age, level, label: blockLevelLabel(level), content: renderBlock(block, level), @@ -717,17 +766,24 @@ export class StrataGate { return this.commitMutation(() => { const block = this.blocks.find((candidate) => candidate.id === id); if (!block) throw new Error(`Unknown block: ${id}`); - const currentTurn = block.threadId === undefined ? this.currentTurn : this.threadTurn(block.threadId); - const current = getDecayedBlockLevel(block.pointerAnchorLevel, block.pointerAnchorTurn, currentTurn); + const latestBlockPosition = this.threadBlocks(block.threadId).length; + const blockPosition = this.threadBlocks(block.threadId).indexOf(block) + 1; + const current = getDecayedBlockLevel( + block.pointerAnchorLevel, + block.pointerAnchorBlockPosition, + latestBlockPosition, + this.blockDecayLambda, + ); const level = normalizeBlockLevel(target, current); block.pointerCurrentLevel = level; block.pointerAnchorLevel = level; - block.pointerAnchorTurn = currentTurn; + block.pointerAnchorBlockPosition = latestBlockPosition; block.lastLiftedAt = toUtc8Iso(this.now()); return { id: block.id, ...(block.threadId ? { threadId: block.threadId } : {}), turnRange: [block.startTurn, block.endTurn] as [number, number], + age: Math.max(0, latestBlockPosition - blockPosition), level, label: blockLevelLabel(level), content: renderBlock(block, level), @@ -950,7 +1006,9 @@ export class StrataGate { const generated = this.summarizer ? await this.summarizer(raw) : defaultSummary(raw); const deterministic = deterministicBlockLayers(raw); const sequence = this.blocks.length + 1; - const previous = this.threadBlocks(threadId).at(-1); + const threadBlocks = this.threadBlocks(threadId); + const previous = threadBlocks.at(-1); + const blockPosition = threadBlocks.length + 1; const startTurn = previous ? previous.endTurn + 1 : 1; const endTurn = startTurn + this.blockTurnSize - 1; return this.commitMutation(() => { @@ -973,7 +1031,7 @@ export class StrataGate { ...deterministic, pointerCurrentLevel: 5, pointerAnchorLevel: 5, - pointerAnchorTurn: endTurn, + pointerAnchorBlockPosition: blockPosition, lastLiftedAt: null, }; const sealedIds = new Set(raw.map((message) => message.id)); @@ -1121,6 +1179,7 @@ export class StrataGate { throw new Error(`Snapshot blockTurnSize ${normalized.blockTurnSize} does not match ${this.blockTurnSize}`); } const copy = cloneSnapshot(normalized); + this.blockDecayLambdaValue = copy.blockDecayLambda; this.currentTurn = copy.currentTurn; this.openTail.splice(0, this.openTail.length, ...copy.openTail); this.blocks.splice(0, this.blocks.length, ...copy.blocks); diff --git a/src/types.ts b/src/types.ts index 6accf91..8819cbd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -37,7 +37,7 @@ export interface MemoryBlock extends BlockLayers { shouldExtract: boolean; pointerCurrentLevel: BlockLevel; pointerAnchorLevel: BlockLevel; - pointerAnchorTurn: number; + pointerAnchorBlockPosition: number; lastLiftedAt: string | null; } diff --git a/tests/blocks.test.ts b/tests/blocks.test.ts index 0d4dead..fda3f80 100644 --- a/tests/blocks.test.ts +++ b/tests/blocks.test.ts @@ -35,13 +35,14 @@ describe('progressive conversation blocks', () => { it('decays through six levels and expands only to the requested level', () => { expect(getDecayedBlockLevel(5, 0, 0)).toBe(5); - expect(getDecayedBlockLevel(5, 0, 12)).toBe(4); - expect(getDecayedBlockLevel(5, 0, 24)).toBe(3); - expect(getDecayedBlockLevel(5, 0, 36)).toBe(2); - expect(getDecayedBlockLevel(5, 0, 48)).toBe(1); - expect(getDecayedBlockLevel(5, 0, 60)).toBe(0); + expect(getDecayedBlockLevel(5, 0, 2)).toBe(4); + expect(getDecayedBlockLevel(5, 0, 3)).toBe(3); + expect(getDecayedBlockLevel(5, 0, 5)).toBe(2); + expect(getDecayedBlockLevel(5, 0, 7)).toBe(1); + expect(getDecayedBlockLevel(5, 0, 9)).toBe(0); + expect(getDecayedBlockLevel(5, 0, 4, 0.1)).toBe(4); expect(normalizeBlockLevel('next', 2)).toBe(3); expect(normalizeBlockLevel('raw', 2)).toBe(5); - expect(getBlockWeight(0, 12)).toBeCloseTo(Math.exp(-0.6), 8); + expect(getBlockWeight(0, 2)).toBeCloseTo(Math.exp(-0.6), 8); }); }); diff --git a/tests/persistence.test.ts b/tests/persistence.test.ts index 60a53de..156cf71 100644 --- a/tests/persistence.test.ts +++ b/tests/persistence.test.ts @@ -76,17 +76,17 @@ describe('SQLite persistence', () => { expect(ephemeral.storageRevision).toBe(0); }); - it('creates schema version five and rejects a newer database schema', async () => { + it('creates schema version six and rejects a newer database schema', async () => { const initializedFilename = await databasePath(); const initialized = new SqliteStorage({ filename: initializedFilename }); await initialized.close(); const initializedDatabase = new Database(initializedFilename, { readonly: true }); - expect(initializedDatabase.pragma('user_version', { simple: true })).toBe(5); + expect(initializedDatabase.pragma('user_version', { simple: true })).toBe(6); initializedDatabase.close(); const newerFilename = await databasePath(); const newerDatabase = new Database(newerFilename); - newerDatabase.pragma('user_version = 6'); + newerDatabase.pragma('user_version = 7'); newerDatabase.close(); expect(() => new SqliteStorage({ filename: newerFilename })).toThrow('newer than supported'); }); @@ -272,12 +272,13 @@ describe('SQLite persistence', () => { await restored.close(); }); - it('persists an explicitly reconfigured block turn size for an existing namespace', async () => { + it('persists explicitly reconfigured block settings for an existing namespace', async () => { const filename = await databasePath(); const first = await StrataGate.open({ database: filename, namespace: 'project:block-size-change', blockTurnSize: 4, + blockDecayLambda: 0.2, now: fixedNow, idFactory: ids(), }); @@ -288,11 +289,15 @@ describe('SQLite persistence', () => { database: filename, namespace: 'project:block-size-change', blockTurnSize: 6, + blockDecayLambda: 0.35, now: fixedNow, idFactory: ids(), }); expect(changed.blockTurnSize).toBe(6); + expect(changed.blockDecayLambda).toBe(0.35); expect(changed.listOpenTail()).toHaveLength(2); + await changed.setBlockDecayLambda(0.15); + expect(changed.blockDecayLambda).toBe(0.15); await changed.close(); const restored = await StrataGate.open({ @@ -302,6 +307,7 @@ describe('SQLite persistence', () => { idFactory: ids(), }); expect(restored.blockTurnSize).toBe(6); + expect(restored.blockDecayLambda).toBe(0.15); await restored.close(); }); @@ -360,7 +366,8 @@ describe('SQLite persistence', () => { const loaded = await storage.load('legacy:user'); expect(loaded?.revision).toBe(7); expect(loaded?.snapshot).toMatchObject({ - schemaVersion: 5, + schemaVersion: 6, + blockDecayLambda: 0.3, elements: [], elementProjectionJobs: [], ingestionReceipts: [], @@ -368,7 +375,7 @@ describe('SQLite persistence', () => { await storage.close(); const migrated = new Database(filename, { readonly: true }); - expect(migrated.pragma('user_version', { simple: true })).toBe(5); + expect(migrated.pragma('user_version', { simple: true })).toBe(6); expect((migrated.pragma('table_info(usage_receipts)') as Array<{ name: string }>) .map(({ name }) => name)).toContain('element_ids_json'); expect((migrated.pragma('table_info(usage_receipts)') as Array<{ name: string }>) @@ -419,17 +426,63 @@ describe('SQLite persistence', () => { const storage = new SqliteStorage({ filename }); const loaded = await storage.load('legacy:v4'); - expect(loaded?.snapshot.schemaVersion).toBe(5); + expect(loaded?.snapshot.schemaVersion).toBe(6); + expect(loaded?.snapshot.blockDecayLambda).toBe(0.3); await storage.close(); const migrated = new Database(filename, { readonly: true }); expect((migrated.pragma('table_info(blocks)') as Array<{ name: string }>).map(({ name }) => name)) .toContain('thread_id'); + expect((migrated.pragma('table_info(blocks)') as Array<{ name: string }>).map(({ name }) => name)) + .toContain('pointer_anchor_block_position'); expect((migrated.pragma('table_info(messages)') as Array<{ name: string }>).map(({ name }) => name)) .toContain('thread_id'); migrated.close(); }); + it('converts turn anchors to per-thread block positions when migrating schema v5', async () => { + const filename = await databasePath(); + const legacy = new Database(filename); + legacy.exec(` + CREATE TABLE memory_spaces ( + namespace TEXT PRIMARY KEY, schema_version INTEGER NOT NULL, revision INTEGER NOT NULL, + current_turn INTEGER NOT NULL, block_turn_size INTEGER NOT NULL, + created_at TEXT NOT NULL, updated_at TEXT NOT NULL + ) STRICT; + CREATE TABLE blocks ( + namespace TEXT NOT NULL, id TEXT NOT NULL, thread_id TEXT, sequence INTEGER NOT NULL, + start_turn INTEGER NOT NULL, end_turn INTEGER NOT NULL, created_at TEXT NOT NULL, + should_extract INTEGER NOT NULL, l0_title TEXT NOT NULL, l0_tags_json TEXT NOT NULL, + l1_summary TEXT NOT NULL, l2_keypoints_json TEXT NOT NULL, l3_condensed TEXT NOT NULL, + l4_readable TEXT NOT NULL, pointer_current_level INTEGER NOT NULL, + pointer_anchor_level INTEGER NOT NULL, pointer_anchor_turn INTEGER NOT NULL, + last_lifted_at TEXT, PRIMARY KEY (namespace, id), UNIQUE (namespace, sequence), + FOREIGN KEY (namespace) REFERENCES memory_spaces(namespace) ON DELETE CASCADE + ) STRICT; + INSERT INTO memory_spaces VALUES ('legacy:v5', 5, 2, 12, 6, '2026-01-01', '2026-01-01'); + INSERT INTO blocks VALUES + ('legacy:v5', 'a1', 'thread-a', 1, 1, 6, '2026-01-01', 0, + 'A1', '[]', 'A1', '[]', 'A1', 'A1', 5, 5, 7, NULL), + ('legacy:v5', 'b1', 'thread-b', 2, 1, 6, '2026-01-01', 0, + 'B1', '[]', 'B1', '[]', 'B1', 'B1', 5, 5, 6, NULL), + ('legacy:v5', 'a2', 'thread-a', 3, 7, 12, '2026-01-01', 0, + 'A2', '[]', 'A2', '[]', 'A2', 'A2', 5, 5, 12, NULL); + PRAGMA user_version = 5; + `); + legacy.close(); + + const storage = new SqliteStorage({ filename }); + const loaded = await storage.load('legacy:v5'); + expect(loaded?.snapshot).toMatchObject({ schemaVersion: 6, blockDecayLambda: 0.3 }); + expect(loaded?.snapshot.blocks.map(({ id, pointerAnchorBlockPosition }) => + [id, pointerAnchorBlockPosition])).toEqual([ + ['a1', 1], + ['b1', 1], + ['a2', 2], + ]); + await storage.close(); + }); + it('persists projected elements and idempotent element-use receipts across restarts', async () => { const filename = await databasePath(); const storage = new SqliteStorage({ filename }); diff --git a/tests/store.test.ts b/tests/store.test.ts index 20af274..d0768f2 100644 --- a/tests/store.test.ts +++ b/tests/store.test.ts @@ -152,4 +152,36 @@ describe('StrataGate lifecycle', () => { expect(extractionPairs).not.toContainEqual(['session-a', 'session-b']); expect(extractionPairs).not.toContainEqual(['session-b', 'session-a']); }); + + it('ages blocks only when a newer block is sealed in the same thread', async () => { + const memory = StrataGate.inMemory({ + blockTurnSize: 2, + blockDecayLambda: 0.3, + summarizer, + idFactory: ids(), + }); + + await memory.appendTurn({ user: 'A1', assistant: 'A1 reply', threadId: 'session-a' }); + await memory.appendTurn({ user: 'A2', assistant: 'A2 reply', threadId: 'session-a' }); + expect(memory.getBlockContext('session-a')).toMatchObject([{ age: 0, level: 5 }]); + + await memory.appendTurn({ user: 'A3', assistant: 'A3 reply', threadId: 'session-a' }); + await memory.appendTurn({ user: 'B1', assistant: 'B1 reply', threadId: 'session-b' }); + await memory.appendTurn({ user: 'B2', assistant: 'B2 reply', threadId: 'session-b' }); + expect(memory.getBlockContext('session-a')).toMatchObject([{ age: 0, level: 5 }]); + + await memory.appendTurn({ user: 'A4', assistant: 'A4 reply', threadId: 'session-a' }); + expect(memory.getBlockContext('session-a')).toMatchObject([ + { age: 1, level: 5 }, + { age: 0, level: 5 }, + ]); + + await memory.appendTurn({ user: 'A5', assistant: 'A5 reply', threadId: 'session-a' }); + await memory.appendTurn({ user: 'A6', assistant: 'A6 reply', threadId: 'session-a' }); + expect(memory.getBlockContext('session-a')).toMatchObject([ + { age: 2, level: 4 }, + { age: 1, level: 5 }, + { age: 0, level: 5 }, + ]); + }); }); From b817c4ebf1bcdc90a66a03c28476cdfb6c0ebac4 Mon Sep 17 00:00:00 2001 From: diqierjia Date: Sun, 23 Aug 2026 21:25:54 +0800 Subject: [PATCH 2/2] Handle workspace paths across platforms --- integrations/deepseek-harness/src/runtime.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/integrations/deepseek-harness/src/runtime.ts b/integrations/deepseek-harness/src/runtime.ts index ad4aa38..88e6b62 100644 --- a/integrations/deepseek-harness/src/runtime.ts +++ b/integrations/deepseek-harness/src/runtime.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto' import { existsSync } from 'node:fs' -import { basename, resolve } from 'node:path' +import { resolve } from 'node:path' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import { @@ -52,6 +52,11 @@ function projectKey(cwd: string | undefined): string { return createHash('sha256').update(canonical).digest('hex').slice(0, 20) } +function workspaceDisplayName(cwd: string | undefined): string { + const canonical = (cwd ?? process.cwd()).replace(/[\\/]+$/, '') + return canonical.split(/[\\/]/).at(-1) || '当前工作区' +} + export class StrataGateRuntime { private readonly folder = new TurnFolder() private readonly spaces = new Map>() @@ -446,7 +451,7 @@ export class StrataGateRuntime { } private rememberWorkspace(namespace: string, cwd: string | undefined): void { - const name = basename(resolve(cwd ?? process.cwd())) || '当前工作区' + const name = workspaceDisplayName(cwd) this.workspaceNames.set(namespace, name) if (this.config.database === ':memory:') return try {