refactor: apply repository naming contract

Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
Tianyi Cui
2026-08-13 00:36:22 +08:00
parent 101df7cf58
commit a2d0f7f411
3281 changed files with 21730 additions and 21592 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session/README.md
README.md: 64aa8e4fdf1e85d74dfa0a77803a04e881b547c4
README.zh.md: 14ff59fa137c74202c31dadc243a5c21fc15ab47
README.md: 2680bc3c6aea519427da2f31ab2526b5115cac96
README.zh.md: b446a6a1d17da39c2a3d29c10fbb8dddf573bf80

View File

@@ -35,12 +35,12 @@ Derives durable session titles from the session log, with an optional model-back
|---|---|---|
| [`session-title/`](session-title/README.md) | Owns title state, fallback behavior, provider registration, and refresh | `ctx.sessionTitle` |
| [`session-title-llm/`](session-title-llm/README.md) | Provides shared model-backed title generation | — |
| [`session-title-first-message-llm/`](session-title-first-message-llm/README.md) | Titles a session from its first eligible human message | registers on `ctx.sessionTitle` |
| [`session-title-all-messages-llm/`](session-title-all-messages-llm/README.md) | Titles a session from all eligible human messages | registers on `ctx.sessionTitle` |
| [`session-title-first-prompt-llm/`](session-title-first-prompt-llm/README.md) | Titles a session from its first eligible human message | registers on `ctx.sessionTitle` |
| [`session-title-all-prompts-llm/`](session-title-all-prompts-llm/README.md) | Titles a session from all eligible human messages | registers on `ctx.sessionTitle` |
Deployments may register one model-backed provider; the service retains a deterministic fallback when none is present.
## Telemetry
## SessionTelemetryBackend
Projects session activity into outbound telemetry and delegates delivery to a configured reporting backend. The [telemetry decision](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md) records the reporting boundary; the [mode decision](../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md) records immediate, feedback-gated, and disabled delivery.
@@ -49,4 +49,4 @@ Projects session activity into outbound telemetry and delegates delivery to a co
| [`session-telemetry/`](session-telemetry/README.md) | Defines capture, redaction, projection, and live or on-demand backend delivery. |
| [`session-telemetry-otel/`](session-telemetry-otel/README.md) | Delivers telemetry through OpenTelemetry logs in `FULL`, `FEEDBACK_ONLY`, or `DISABLED` mode. |
The subsystem references: [persistence.md](../../docs/subsystems/persistence.md), [session-projection.md](../../docs/subsystems/session-projection.md), [session-title.md](../../docs/subsystems/session-title.md), and [telemetry.md](../../docs/subsystems/telemetry.md). Only one title provider may register at a time; the demo spine mounts the fallback service and leaves both model providers out of default composition.
The subsystem references: [persistence.md](../../docs/subsystems/persistence.md), [session-projection.md](../../docs/subsystems/session-projection.md), [session-title.md](../../docs/subsystems/session-title.md), and [session-telemetry.md](../../docs/subsystems/session-telemetry.md). Only one title provider may register at a time; the demo spine mounts the fallback service and leaves both model providers out of default composition.

View File

@@ -35,8 +35,8 @@
|---|---|---|
| [`session-title/`](session-title/README.md) | 负责标题状态、回退行为、提供方注册与刷新 | `ctx.sessionTitle` |
| [`session-title-llm/`](session-title-llm/README.md) | 提供共享的模型标题生成能力 | — |
| [`session-title-first-message-llm/`](session-title-first-message-llm/README.md) | 根据第一条合格的人类消息生成会话标题 | 注册到 `ctx.sessionTitle` |
| [`session-title-all-messages-llm/`](session-title-all-messages-llm/README.md) | 根据所有合格的人类消息生成会话标题 | 注册到 `ctx.sessionTitle` |
| [`session-title-first-prompt-llm/`](session-title-first-prompt-llm/README.md) | 根据第一条合格的人类消息生成会话标题 | 注册到 `ctx.sessionTitle` |
| [`session-title-all-prompts-llm/`](session-title-all-prompts-llm/README.md) | 根据所有合格的人类消息生成会话标题 | 注册到 `ctx.sessionTitle` |
部署可以注册一个模型驱动提供方;未注册时,服务仍保留确定性回退机制。
@@ -49,4 +49,4 @@
| [`session-telemetry/`](session-telemetry/README.md) | 定义捕获、脱敏、投影,以及实时或按需后端投递。 |
| [`session-telemetry-otel/`](session-telemetry-otel/README.md) | 通过 OpenTelemetry 日志以 `FULL``FEEDBACK_ONLY``DISABLED` 模式投递遥测。 |
子系统参考:[persistence.md](../../docs/subsystems/persistence.md)、[session-projection.md](../../docs/subsystems/session-projection.md)、[session-title.md](../../docs/subsystems/session-title.md) 与 [telemetry.md](../../docs/subsystems/telemetry.md)。同一时间只允许一个标题提供方注册demo 主干挂载回退服务,两个模型提供方都留在默认组合之外。
子系统参考:[persistence.md](../../docs/subsystems/persistence.md)、[session-projection.md](../../docs/subsystems/session-projection.md)、[session-title.md](../../docs/subsystems/session-title.md) 与 [session-telemetry.md](../../docs/subsystems/session-telemetry.md)。同一时间只允许一个标题提供方注册demo 主干挂载回退服务,两个模型提供方都留在默认组合之外。

View File

@@ -9,7 +9,7 @@ import SessionStore, {
SessionId, TOOL_OUTCOME_UNKNOWN,
type SessionEvent,
} from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
const childScript = fileURLToPath(new URL('./fixtures/crash-child.ts', import.meta.url))
@@ -67,7 +67,7 @@ async function crashAt(mode: 'request' | 'tool'): Promise<{ root: string; marker
async function load(root: string): Promise<SessionEvent[]> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' })
try {
return [...(await ctx.sessionPersistence.load(sessionId)).events]
} finally {

View File

@@ -4,7 +4,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { createUserMessage, CallId, type GenerateOptions, LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as checkpointPolicy from '../../src/index.ts'
function waitForCrash(): Promise<never> {
@@ -38,7 +38,7 @@ class CrashAdapter extends LlmAdapter {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SessionPersistenceJsonl, { root: persistenceRoot, compression: 'none' })
await ctx.plugin(JsonlSessionPersistence, { root: persistenceRoot, compression: 'none' })
await ctx.plugin(checkpointPolicy)
ctx.llm.registerAdapter(['crash'], new CrashAdapter())
ctx.tools.register({

View File

@@ -2,12 +2,12 @@ import { afterEach, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import LlmService, { CallId, type GenerateOptions, LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm'
import LlmRuntime, { CallId, type GenerateOptions, LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import SessionPersistence from '@deepseek-ai/dsh-session-persistence'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
import ToolRuntime, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
import * as checkpointPolicy from '../src/index.ts'
const contexts: Context[] = []
@@ -43,9 +43,9 @@ async function setup(): Promise<Context> {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SessionStore)
await ctx.plugin(LlmService)
await ctx.plugin(LlmRuntime)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(ToolRuntime)
await ctx.plugin(TestPersistence)
await ctx.plugin(checkpointPolicy)
return ctx
@@ -242,9 +242,9 @@ describe('session-checkpoint-policy lifecycle', () => {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SessionStore)
await ctx.plugin(LlmService)
await ctx.plugin(LlmRuntime)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(ToolRuntime)
await ctx.plugin(TestPersistence)
const session = ctx.sessions.create(SessionId('disposed-policy'))
let flushes = 0

View File

@@ -27,7 +27,7 @@
"path": "../session-persistence"
},
{
"path": "../../support/invariants"
"path": "../../runtime-diagnostics/invariants"
},
{
"path": "../../core/tools"

View File

@@ -118,7 +118,7 @@ function isENOENT(error: unknown): boolean {
* listeners. Its torn-tail marker carries the byte offset and any events
* recovered from an incomplete final Zstandard frame.
*/
export class SessionPersistenceJsonl extends SessionPersistence implements PersistenceBackend<JsonlTornMarker> {
export class JsonlSessionPersistence extends SessionPersistence implements PersistenceBackend<JsonlTornMarker> {
override readonly supportsRawArtifacts = true
static inject = ['sessions']
@@ -964,4 +964,4 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
/* v8 ignore stop */
}
export default SessionPersistenceJsonl
export default JsonlSessionPersistence

View File

@@ -6,7 +6,7 @@ import { tmpdir } from 'node:os'
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import {
encodeSegment, eventLines, logPath, projectDir, projectKey, scanLog, sessionDir, SessionLogScanner, toHeaderLine,
} from '../src/format.ts'
@@ -104,7 +104,7 @@ runPersistenceContract('jsonl-none', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-'))
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: dir, compression: 'none' })
const fiber = await ctx.plugin(JsonlSessionPersistence, { root: dir, compression: 'none' })
return {
persistence: ctx.sessionPersistence,
dispose: async () => {
@@ -120,7 +120,7 @@ runCoordinatorContract('jsonl-none', async (): Promise<CoordinatorFixture> => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-coord-'))
return {
mount: async (ctx) => {
const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: dir, compression: 'none' })
const fiber = await ctx.plugin(JsonlSessionPersistence, { root: dir, compression: 'none' })
return fiber
},
corruptTail: async (id, cwd) => {
@@ -133,7 +133,7 @@ runCoordinatorContract('jsonl-none', async (): Promise<CoordinatorFixture> => {
}
})
describe('SessionPersistenceJsonl: format helpers', () => {
describe('JsonlSessionPersistence: format helpers', () => {
it('encodeSegment neutralizes traversal, separators, and absolute paths', () => {
expect(encodeSegment('..')).toBe('~002E~002E')
expect(encodeSegment('.')).toBe('~002E')
@@ -174,7 +174,7 @@ describe('SessionPersistenceJsonl: format helpers', () => {
const absoluteRoot = await freshRoot()
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(SessionPersistenceJsonl, {
const fiber = await ctx.plugin(JsonlSessionPersistence, {
root: relative(process.cwd(), absoluteRoot),
compression: 'none',
writeBatchMaxDelayMs: 1,
@@ -191,7 +191,7 @@ describe('SessionPersistenceJsonl: format helpers', () => {
const absoluteRoot = await freshRoot()
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: absoluteRoot, compression: 'none' })
const fiber = await ctx.plugin(JsonlSessionPersistence, { root: absoluteRoot, compression: 'none' })
// A future format need not satisfy today's header shape at all (no
// createdAt, unknown fields): the version must be refused before shape
// validation, so the user sees the upgrade direction.
@@ -210,7 +210,7 @@ describe('SessionPersistenceJsonl: format helpers', () => {
const absoluteRoot = await freshRoot()
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: absoluteRoot, compression: 'none' })
const fiber = await ctx.plugin(JsonlSessionPersistence, { root: absoluteRoot, compression: 'none' })
// Valid JSON that is no object carries no version to compare, so the
// version guard must pass it through to the corruption diagnostics.
const id = SessionId('scalar-header')
@@ -227,7 +227,7 @@ describe('SessionPersistenceJsonl: format helpers', () => {
const absoluteRoot = await freshRoot()
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: absoluteRoot, compression: 'none' })
const fiber = await ctx.plugin(JsonlSessionPersistence, { root: absoluteRoot, compression: 'none' })
// A future header's id field is as untrusted as the rest of its shape:
// the refusal must still name the session it read, not crash on the type.
const id = SessionId('numeric-id')
@@ -244,7 +244,7 @@ describe('SessionPersistenceJsonl: format helpers', () => {
const absoluteRoot = await freshRoot()
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: absoluteRoot, compression: 'none' })
const fiber = await ctx.plugin(JsonlSessionPersistence, { root: absoluteRoot, compression: 'none' })
const m = { ...meta('newer-format', '/work'), version: 7 }
await ctx.sessionPersistence.create(m)
await ctx.sessionPersistence.append(m.id, [
@@ -258,13 +258,13 @@ describe('SessionPersistenceJsonl: format helpers', () => {
})
})
describe('SessionPersistenceJsonl: durability and crash semantics', () => {
describe('JsonlSessionPersistence: durability and crash semantics', () => {
let ctx: Context
beforeEach(async () => {
root = await freshRoot()
ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' })
})
afterEach(async () => { await ctx.fiber.dispose() })
@@ -382,13 +382,13 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
const reopenedCtx = new Context()
await reopenedCtx.plugin(SessionStore)
await reopenedCtx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
await reopenedCtx.plugin(JsonlSessionPersistence, { root, compression: 'none' })
expect((await reopenedCtx.sessionPersistence.listSnapshots())[0]?.revision).toBe(revision)
const otherRoot = await freshRoot()
const otherCtx = new Context()
await otherCtx.plugin(SessionStore)
await otherCtx.plugin(SessionPersistenceJsonl, { root: otherRoot, compression: 'none' })
await otherCtx.plugin(JsonlSessionPersistence, { root: otherRoot, compression: 'none' })
await otherCtx.sessionPersistence.create(m)
await otherCtx.sessionPersistence.append(m.id, oneTurnLog())
expect((await otherCtx.sessionPersistence.listSnapshots())[0]?.revision).not.toBe(revision)
@@ -401,7 +401,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
const m = meta('stored-prefix-revision')
await ctx.sessionPersistence.create(m)
await ctx.sessionPersistence.append(m.id, oneTurnLog())
const persistence = ctx.sessionPersistence as SessionPersistenceJsonl
const persistence = ctx.sessionPersistence as JsonlSessionPersistence
const stored = await persistence.loadStored(m.id)
expect(stored?.revision).toBe(await persistence.readStoredRevision(m.id))
@@ -412,7 +412,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
const m = meta('stored-prefix-revision-race')
await ctx.sessionPersistence.create(m)
await ctx.sessionPersistence.append(m.id, oneTurnLog())
const persistence = ctx.sessionPersistence as SessionPersistenceJsonl
const persistence = ctx.sessionPersistence as JsonlSessionPersistence
statRace.path = rawLogPath(root, m.cwd, m.id)
await expect(persistence.loadStored(m.id)).resolves.toMatchObject({ events: oneTurnLog() })
@@ -423,7 +423,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
const m = meta('stored-revision-race')
await ctx.sessionPersistence.create(m)
await ctx.sessionPersistence.append(m.id, oneTurnLog())
const persistence = ctx.sessionPersistence as SessionPersistenceJsonl
const persistence = ctx.sessionPersistence as JsonlSessionPersistence
const internals = persistence as unknown as {
findLog(id: SessionId, signal?: AbortSignal): Promise<string | undefined>
}
@@ -780,12 +780,12 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
})
})
describe('SessionPersistenceJsonl: write path (session/event → flush)', () => {
describe('JsonlSessionPersistence: write path (session/event → flush)', () => {
it('concurrent sessions do not cross buffers', async () => {
root = await freshRoot()
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' })
const a = ctx.sessions.create(SessionId('sa'))
const b = ctx.sessions.create(SessionId('sb'))
@@ -814,7 +814,7 @@ describe('SessionPersistenceJsonl: write path (session/event → flush)', () =>
})
describe('SessionPersistenceJsonl: scanLog unit', () => {
describe('JsonlSessionPersistence: scanLog unit', () => {
it('requires exactly one newline-terminated header record', () => {
const header = JSON.stringify(toHeaderLine(meta('scanner-header')))
expect(() => new SessionLogScanner(Buffer.alloc(0))).toThrow(/header-less/)
@@ -1012,7 +1012,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => {
})
})
describe('SessionPersistenceJsonl: default packed chunk rows', () => {
describe('JsonlSessionPersistence: default packed chunk rows', () => {
let ctx: Context
beforeEach(async () => {
root = await freshRoot()
@@ -1020,7 +1020,7 @@ describe('SessionPersistenceJsonl: default packed chunk rows', () => {
await ctx.plugin(SessionStore)
// compression: 'none' — these tests assert the textual storage-record layout
// (row tags per line); packing is orthogonal to the physical encoding.
await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' })
})
afterEach(async () => { await ctx.fiber.dispose() })
@@ -1070,7 +1070,7 @@ describe('SessionPersistenceJsonl: default packed chunk rows', () => {
const unpackedRoot = await freshRoot()
const unpacked = new Context()
await unpacked.plugin(SessionStore)
await unpacked.plugin(SessionPersistenceJsonl, {
await unpacked.plugin(JsonlSessionPersistence, {
root: unpackedRoot,
packChunks: false,
compression: 'none',
@@ -1163,13 +1163,13 @@ describe('SessionPersistenceJsonl: default packed chunk rows', () => {
})
})
describe('SessionPersistenceJsonl: edge cases', () => {
describe('JsonlSessionPersistence: edge cases', () => {
let ctx: Context
beforeEach(async () => {
root = await freshRoot()
ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' })
})
afterEach(async () => { await ctx.fiber.dispose() })
@@ -1383,7 +1383,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
// stored cwd check rejects instead of grafting no-cwd events onto that log.
const ctx2 = new Context()
await ctx2.plugin(SessionStore)
await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
await ctx2.plugin(JsonlSessionPersistence, { root, compression: 'none' })
let b!: Session
await ctx2.plugin(Object.assign((inner: Context) => {
b = inner.sessions.create(SessionId('x')) // no cwd
@@ -1441,7 +1441,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
it('list returns nothing when the root directory does not exist', async () => {
const ctx2 = new Context()
await ctx2.plugin(SessionStore)
await ctx2.plugin(SessionPersistenceJsonl, {
await ctx2.plugin(JsonlSessionPersistence, {
root: join(root, 'does-not-exist-yet'),
compression: 'none',
})
@@ -1454,7 +1454,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
await writeFile(filePath, 'x')
const ctx2 = new Context()
await ctx2.plugin(SessionStore)
await expect(ctx2.plugin(SessionPersistenceJsonl, { root: filePath, compression: 'none' })).rejects.toThrow(/ENOTDIR/)
await expect(ctx2.plugin(JsonlSessionPersistence, { root: filePath, compression: 'none' })).rejects.toThrow(/ENOTDIR/)
await ctx2.fiber.dispose()
})
@@ -1477,7 +1477,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
const cwd = '/x'
const ctx2 = new Context()
await ctx2.plugin(SessionStore)
await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
await ctx2.plugin(JsonlSessionPersistence, { root, compression: 'none' })
await writeFile(projectDir(root, cwd), 'x') // project path is now a file
let s!: Session
await ctx2.plugin(Object.assign((inner: Context) => {
@@ -1500,7 +1500,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
// that the same append then performs before writing.
const ctx2 = new Context()
await ctx2.plugin(SessionStore)
await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
await ctx2.plugin(JsonlSessionPersistence, { root, compression: 'none' })
await ctx2.sessionPersistence.append(m.id, [
{ type: 'turn/start', seq: 6, time: 9, data: { turn: 2 } },
{ type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
@@ -1536,7 +1536,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
// nondeterministic. create scans every project, not just meta.cwd's.
const ctx2 = new Context()
await ctx2.plugin(SessionStore)
await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
await ctx2.plugin(JsonlSessionPersistence, { root, compression: 'none' })
await expect(ctx2.sessionPersistence.create(meta('dup-id', '/projB')))
.rejects.toThrow(/already has a persisted log on disk/)
await ctx2.fiber.dispose()
@@ -1546,7 +1546,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
root = await freshRoot()
const ctx2 = new Context()
await ctx2.plugin(SessionStore)
await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
await ctx2.plugin(JsonlSessionPersistence, { root, compression: 'none' })
const session = ctx2.sessions.create(SessionId('flush-fail'))
// A full turn lands in the write-behind buffer.
session.append('turn/start', { turn: 1 })

View File

@@ -7,7 +7,7 @@ import { join } from 'node:path'
import { performance } from 'node:perf_hooks'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import { logPath, scanLog, sessionDir, toHeaderLine, type JsonlCompression } from '../src/format.ts'
import {
compressZstdFrame, createZstdFrameDecoder, decompressZstdFrame, decompressZstdPrefix, scanZstdFrames,
@@ -44,7 +44,7 @@ async function mount(root: string, compression?: JsonlCompression): Promise<Cont
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionPersistenceJsonl, {
await ctx.plugin(JsonlSessionPersistence, {
root,
...(compression === undefined ? {} : { compression }),
})
@@ -116,7 +116,7 @@ runPersistenceContract('jsonl-zstd', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-jsonl-zstd-contract-'))
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(SessionPersistenceJsonl, { root })
const fiber = await ctx.plugin(JsonlSessionPersistence, { root })
return {
persistence: ctx.sessionPersistence,
dispose: async () => {
@@ -129,7 +129,7 @@ runPersistenceContract('jsonl-zstd', async () => {
runCoordinatorContract('jsonl-zstd', async (): Promise<CoordinatorFixture> => {
const root = await mkdtemp(join(tmpdir(), 'dsh-jsonl-zstd-coordinator-'))
return {
mount: async ctx => ctx.plugin(SessionPersistenceJsonl, { root }),
mount: async ctx => ctx.plugin(JsonlSessionPersistence, { root }),
corruptTail: async (id, cwd) => {
const line = JSON.stringify({
type: 'assistant/chunk',
@@ -331,7 +331,7 @@ describe('Zstandard frame structure', () => {
})
})
describe('SessionPersistenceJsonl: default Zstandard encoding', () => {
describe('JsonlSessionPersistence: default Zstandard encoding', () => {
it('writes .jsonl.zstd by default with one header frame and one first-batch frame', async () => {
const root = await freshRoot()
const ctx = await mount(root)
@@ -394,9 +394,9 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SessionStore)
let backend!: SessionPersistenceJsonl
let backend!: JsonlSessionPersistence
await ctx.plugin(Object.assign((inner: Context) => {
backend = new SessionPersistenceJsonl(inner, { root })
backend = new JsonlSessionPersistence(inner, { root })
}, { inject: ['sessions'] }))
const header = meta('direct-default')
const path = logPath(root, header.cwd, header.id, 'zstd')
@@ -695,7 +695,7 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => {
})
})
describe('SessionPersistenceJsonl: encoding selection', () => {
describe('JsonlSessionPersistence: encoding selection', () => {
it('rejects roots owned by the opposite encoding in both directions', async () => {
const rawRoot = await freshRoot('dsh-jsonl-raw-mismatch-')
const raw = await mount(rawRoot, 'none')
@@ -727,7 +727,7 @@ describe('SessionPersistenceJsonl: encoding selection', () => {
'',
].join('\n'))
await expect(ctx.sessionPersistence.load(loadHeader.id)).rejects.toThrow(/uses \.jsonl/)
await expect((ctx.sessionPersistence as SessionPersistenceJsonl).loadStored(loadHeader.id))
await expect((ctx.sessionPersistence as JsonlSessionPersistence).loadStored(loadHeader.id))
.rejects.toThrow(/uses \.jsonl/)
await expect(ctx.sessionPersistence.list()).rejects.toThrow(/uses \.jsonl/)
})

View File

@@ -24,7 +24,7 @@
"path": "../session-persistence"
},
{
"path": "../../support/invariants"
"path": "../../runtime-diagnostics/invariants"
}
]
}

View File

@@ -96,7 +96,7 @@ export interface Config {
* `ctx.sessionPersistence` and (via the coordinator) installs the write-path
* listeners. Its torn-tail marker is the seq to delete from.
*/
export class SessionPersistenceSqlite extends SessionPersistence implements PersistenceBackend<number> {
export class SqliteSessionPersistence extends SessionPersistence implements PersistenceBackend<number> {
override readonly supportsRawArtifacts = false
static inject = ['sessions']
@@ -411,4 +411,4 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
}
}
export default SessionPersistenceSqlite
export default SqliteSessionPersistence

View File

@@ -8,7 +8,7 @@ import { dirname, join } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session'
import SessionPersistenceSqlite, { SCHEMA_VERSION } from '@deepseek-ai/dsh-session-persistence-sqlite'
import SqliteSessionPersistence, { SCHEMA_VERSION } from '@deepseek-ai/dsh-session-persistence-sqlite'
import {
openDatabase,
rowToEvent,
@@ -44,7 +44,7 @@ async function freshDbPath(): Promise<string> {
async function backend(path = ':memory:'): Promise<{ ctx: Context; dispose: () => Promise<void> }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(SessionPersistenceSqlite, { path })
const fiber = await ctx.plugin(SqliteSessionPersistence, { path })
return { ctx, dispose: () => fiber.dispose() }
}
@@ -52,7 +52,7 @@ async function backend(path = ':memory:'): Promise<{ ctx: Context; dispose: () =
runPersistenceContract('sqlite', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' })
const fiber = await ctx.plugin(SqliteSessionPersistence, { path: ':memory:' })
return {
persistence: ctx.sessionPersistence,
dispose: async () => { await fiber.dispose() },
@@ -65,7 +65,7 @@ runCoordinatorContract('sqlite', async (): Promise<CoordinatorFixture> => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-sqlite-coord-'))
const path = join(dir, 'sessions.db')
return {
mount: async ctx => ctx.plugin(SessionPersistenceSqlite, { path }),
mount: async ctx => ctx.plugin(SqliteSessionPersistence, { path }),
corruptTail: async (id) => {
// A row past the committed region whose `data` does not parse: scanRows
// bounds the preserved prefix at it and returns its seq as tornFrom, which
@@ -212,7 +212,7 @@ describe('rowToMeta', () => {
})
})
describe('SessionPersistenceSqlite: durability and crash semantics', () => {
describe('SqliteSessionPersistence: durability and crash semantics', () => {
it('rejects a stored v0 log containing a legacy request/header-delta event', async () => {
const path = await freshDbPath()
const m = meta('legacy-header-delta', '/legacy')
@@ -261,7 +261,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
// Run 1: persist a complete turn, then a half-written second turn (no turn/end).
const ctx1 = new Context()
await ctx1.plugin(SessionStore)
const fiber1 = await ctx1.plugin(SessionPersistenceSqlite, { path })
const fiber1 = await ctx1.plugin(SqliteSessionPersistence, { path })
await ctx1.sessionPersistence.create(m)
await ctx1.sessionPersistence.append(m.id, oneTurnLog())
await ctx1.sessionPersistence.append(m.id, [
@@ -275,7 +275,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
// events: step/end (the step was open) then turn/end {interrupted}.
const ctx2 = new Context()
await ctx2.plugin(SessionStore)
const fiber2 = await ctx2.plugin(SessionPersistenceSqlite, { path })
const fiber2 = await ctx2.plugin(SqliteSessionPersistence, { path })
const loaded = await ctx2.sessionPersistence.load(m.id)
expect(loaded.events.map(e => e.type)).toEqual([
'turn/start', 'user/message', 'step/start', 'assistant/message', 'step/end', 'turn/end', // turn 1
@@ -525,7 +525,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
it('append rolls back the whole batch on a mid-batch seq collision (transaction)', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' })
const fiber = await ctx.plugin(SqliteSessionPersistence, { path: ':memory:' })
const m = meta('rollback')
await ctx.sessionPersistence.create(m)
await ctx.sessionPersistence.append(m.id, oneTurnLog()) // seqs 0..5
@@ -544,14 +544,14 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
const m = meta('persist', '/proj')
const ctx1 = new Context()
await ctx1.plugin(SessionStore)
const fiber1 = await ctx1.plugin(SessionPersistenceSqlite, { path })
const fiber1 = await ctx1.plugin(SqliteSessionPersistence, { path })
await ctx1.sessionPersistence.create(m)
await ctx1.sessionPersistence.append(m.id, oneTurnLog())
await fiber1.dispose()
const ctx2 = new Context()
await ctx2.plugin(SessionStore)
const fiber2 = await ctx2.plugin(SessionPersistenceSqlite, { path })
const fiber2 = await ctx2.plugin(SqliteSessionPersistence, { path })
expect((await ctx2.sessionPersistence.list()).map(x => x.id)).toContain(m.id)
const loaded = await ctx2.sessionPersistence.load(m.id)
expect(loaded.meta).toMatchObject({ id: m.id, cwd: '/proj' })
@@ -602,7 +602,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
const m = meta('stored-prefix-revision')
await b.ctx.sessionPersistence.create(m)
await b.ctx.sessionPersistence.append(m.id, oneTurnLog())
const persistence = b.ctx.sessionPersistence as SessionPersistenceSqlite
const persistence = b.ctx.sessionPersistence as SqliteSessionPersistence
const stored = await persistence.loadStored(m.id)
expect(stored?.revision).toBe(await persistence.readStoredRevision(m.id))
@@ -668,19 +668,19 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
await b.ctx.sessionPersistence.create(m)
await b.ctx.sessionPersistence.append(m.id, oneTurnLog())
const before = await b.ctx.sessionPersistence.listSnapshots()
await (b.ctx.sessionPersistence as SessionPersistenceSqlite).commitRepair(m, undefined, [])
await (b.ctx.sessionPersistence as SqliteSessionPersistence).commitRepair(m, undefined, [])
expect(await b.ctx.sessionPersistence.listSnapshots()).toEqual(before)
await b.dispose()
})
})
describe('SessionPersistenceSqlite: edge cases', () => {
describe('SqliteSessionPersistence: edge cases', () => {
it('resolves the preparation-cache default without schema normalization', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
let persistence!: SessionPersistenceSqlite
let persistence!: SqliteSessionPersistence
await ctx.plugin(Object.assign((inner: Context) => {
persistence = new SessionPersistenceSqlite(inner, {
persistence = new SqliteSessionPersistence(inner, {
path: ':memory:',
journalMode: 'wal',
})
@@ -693,7 +693,7 @@ describe('SessionPersistenceSqlite: edge cases', () => {
it('uses the configured preparation cache through the public service', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(SessionPersistenceSqlite, {
const fiber = await ctx.plugin(SqliteSessionPersistence, {
path: ':memory:',
preparedSessionCacheSize: 1,
writeBatchMaxDelayMs: 1,
@@ -740,7 +740,7 @@ describe('SessionPersistenceSqlite: edge cases', () => {
const path = await freshDbPath()
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(SessionPersistenceSqlite, { path, journalMode: 'persist' })
const fiber = await ctx.plugin(SqliteSessionPersistence, { path, journalMode: 'persist' })
const m = meta('persist-permissions')
await ctx.sessionPersistence.create(m)
@@ -759,7 +759,7 @@ describe('SessionPersistenceSqlite: edge cases', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(SessionPersistenceSqlite, { path, journalMode: 'delete' })
const fiber = await ctx.plugin(SqliteSessionPersistence, { path, journalMode: 'delete' })
await ctx.sessionPersistence.list()
expect((await stat(path)).mode & 0o777).toBe(0o644)
@@ -815,7 +815,7 @@ describe('SessionPersistenceSqlite: edge cases', () => {
const deletePath = await freshDbPath()
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: deletePath, journalMode: 'delete' })
const fiber = await ctx.plugin(SqliteSessionPersistence, { path: deletePath, journalMode: 'delete' })
await ctx.sessionPersistence.create(meta('jm-delete'))
// Probe through a second connection: journal_mode=delete is a per-database
// property only insofar as no WAL files exist — assert the world, not the
@@ -845,7 +845,7 @@ describe('SessionPersistenceSqlite: edge cases', () => {
session = inner.sessions.create(SessionId('hmr-collide'))
}, { inject: ['sessions'] }))
session.append('turn/start', { turn: 1 })
await ctx.plugin(SessionPersistenceSqlite, { path })
await ctx.plugin(SqliteSessionPersistence, { path })
await expectFlushError(ctx.sessions.flush(session), /id collision/)
await ctx.fiber.dispose()
})
@@ -897,7 +897,7 @@ describe('surface field round-trip', () => {
it('append and load round-trips surface fields through SQLite', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' })
const fiber = await ctx.plugin(SqliteSessionPersistence, { path: ':memory:' })
const session = ctx.sessions.create(SessionId('roundtrip-surface'))
session.append('turn/start', { turn: 1 })
session.append('step/start', { turn: 1, step: 1 })
@@ -932,7 +932,7 @@ describe('surface field round-trip', () => {
it('persists events with surfaceOp but no sourceEventSeqs (covers null branch in surfaceBindings)', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' })
const fiber = await ctx.plugin(SqliteSessionPersistence, { path: ':memory:' })
const session = ctx.sessions.create(SessionId('surface-noseq'))
session.append('turn/start', { turn: 1 })
session.append('user/message', createUserMessage({

View File

@@ -24,7 +24,7 @@
"path": "../session-persistence"
},
{
"path": "../../support/invariants"
"path": "../../runtime-diagnostics/invariants"
}
]
}

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session/session-persistence/README.md
README.md: 6e1898f8a49e54f8fe90ff27cf8571c5959f27e9
README.zh.md: 21ab77bf404899e620530c83460b5980a6044b66
README.md: 914e94a7adc83fba963ae651a81ac527cbc7b995
README.zh.md: 75aeec3c6a31e87b27cc2df3493e3fca9f808fbf

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Session persistence is a capability seam. The abstract `SessionPersistence` service (`ctx.sessionPersistence`) is its Service Definition. It requires a persistence backend to store, reload, and list sessions durably without defining the storage implementation. The seam follows the `dsh-bash` roles ([capability seams](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): this package owns the Service Definition, a sibling package owns the Service provider, and Consumers inject the service.
Session persistence is a capability seam. The abstract `SessionPersistence` service (`ctx.sessionPersistence`) is its Service Definition. It requires a persistence backend to store, reload, and list sessions durably without defining the storage implementation. The seam follows the `dsh-shell` roles ([capability seams](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): this package owns the Service Definition, a sibling package owns the Service provider, and Consumers inject the service.
The persisted unit IS the existing `SessionEvent` (event-sourced model — the log is the single source of truth), so there is no parallel "persisted message" type. Metadata that is NOT replayable conversation state (format version, cwd, lineage, seed boundary, origin, delegation depth) travels separately as `SessionHeader`, owned by `dsh-session` and re-exported here.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
会话持久化是一项能力 seam。抽象的 `SessionPersistence` 服务(`ctx.sessionPersistence`)是其 Service Definition。它要求持久化后端持久存储、重新加载和列出会话但不规定具体存储实现。该 seam 采用与 `dsh-bash` 相同的角色划分(见[能力 seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):本包负责 Service Definition同级包负责 Service providerConsumer 注入该服务。
会话持久化是一项能力 seam。抽象的 `SessionPersistence` 服务(`ctx.sessionPersistence`)是其 Service Definition。它要求持久化后端持久存储、重新加载和列出会话但不规定具体存储实现。该 seam 采用与 `dsh-shell` 相同的角色划分(见[能力 seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):本包负责 Service Definition同级包负责 Service providerConsumer 注入该服务。
持久化单元就是现有 `SessionEvent`事件溯源模型日志是唯一真源因此不存在另一套并行的「持久消息」类型。不属于可回放对话状态的元数据格式版本、cwd、血缘、种子边界、origin、委托深度作为 `SessionHeader` 单独传输,该类型归 `dsh-session` 所有,并在此重新导出。

View File

@@ -21,7 +21,7 @@
"path": "../../core/session"
},
{
"path": "../../support/invariants"
"path": "../../runtime-diagnostics/invariants"
}
]
}

View File

@@ -33,7 +33,7 @@
"path": "../../storage/storage-domain"
},
{
"path": "../../support/invariants"
"path": "../../runtime-diagnostics/invariants"
}
]
}

View File

@@ -18,7 +18,7 @@
"path": "../../core/session"
},
{
"path": "../../support/invariants"
"path": "../../runtime-diagnostics/invariants"
}
]
}

View File

@@ -15,7 +15,7 @@
"path": "../../../vendor/cordis"
},
{
"path": "../../support/invariants"
"path": "../../runtime-diagnostics/invariants"
},
{
"path": "../../llm/llm"

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session/session-telemetry-otel/README.md
README.md: 230efa3c8c77f1fea407cc02e493abea405169c9
README.zh.md: 79ef1131ad69ec2d10b9fe8ae3ea812f2b723c8e
README.md: 351690b5941bfe33cfbbf591269683f706701978
README.zh.md: e7031ff971cc8c9f2d1ce436513fbef1b9f27149

View File

@@ -2,13 +2,13 @@
English | [中文](README.zh.md)
The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — the only entry a deployment loads. Its `mode` decides whether the seam follows session events live, replays the canonical log only at recorded feedback, or keeps telemetry local. Uploading modes compose the OTel JS SDK as-is (`LoggerProvider``BatchLogRecordProcessor` → OTLP/HTTP log exporter) and map each handed-over record onto `logger.emit()`, under two instrumentation scopes: ledger records on `@deepseek-ai/dsh-session-telemetry-otel`, operational records on `@deepseek-ai/dsh-session-telemetry-otel/ops`. Resource identity contains `service.name`/`service.version` from `dsh-llm`'s `APP_IDENTITY` plus this package's anonymous `user.id` (`$DSH_HOME/.userid`, a random UUID created on first use and reset by deleting the file), carried once per export batch rather than per record.
The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — the only entry a deployment loads. Its `mode` decides whether the seam follows session events live, replays the canonical log only at recorded feedback, or keeps telemetry local. Uploading modes compose the OTel JS SDK as-is (`LoggerProvider``BatchLogRecordProcessor` → OTLP/HTTP log exporter) and map each handed-over record onto `logger.emit()`, under two instrumentation scopes: ledger records on `@deepseek-ai/dsh-session-sessionTelemetry-otel`, operational records on `@deepseek-ai/dsh-session-sessionTelemetry-otel/ops`. Resource identity contains `service.name`/`service.version` from `dsh-llm`'s `APP_IDENTITY` plus this package's anonymous `user.id` (`$DSH_HOME/.anonymous-user-id`, a random UUID created on first use and reset by deleting the file), carried once per export batch rather than per record.
## Config
```yaml
- id: telemetry-otel
name: '@deepseek-ai/dsh-session-telemetry-otel'
- id: sessionTelemetry-otel
name: '@deepseek-ai/dsh-session-sessionTelemetry-otel'
config:
mode: FULL # explicit opt-in; default: DISABLED
shutdownTimeoutMillis: 3000 # optional; defaults to 3000
@@ -23,19 +23,19 @@ The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — th
|---|---|
| `FULL` | Each projected record, including lifecycle ops records, is handed to the OTel SDK immediately. |
| `FEEDBACK_ONLY` | Each `feedback/record` replays, projects, and redacts the canonical session-log suffix through that event. Later records wait for another feedback event and remain local if none arrives. |
| `DISABLED` | Default. No coordinator, provider, processor, or exporter is constructed. No telemetry record leaves the process. A `feedback/record` logs `session telemetry is DISABLED; nothing will be shared and this feedback remains local`; the event remains in the local session log. |
| `DISABLED` | Default. No coordinator, provider, processor, or exporter is constructed. No telemetry record leaves the process. A `feedback/record` logs `session sessionTelemetry is DISABLED; nothing will be shared and this feedback remains local`; the event remains in the local session log. |
Programmatic TypeScript configuration uses the exported `TelemetryMode` enum (`TelemetryMode.FULL`, `TelemetryMode.FEEDBACK_ONLY`, or `TelemetryMode.DISABLED`); raw string literals are not assignable. Serialized Cordis configuration continues to use the string values shown above.
Programmatic TypeScript configuration uses the exported `SessionTelemetryMode` enum (`SessionTelemetryMode.FULL`, `SessionTelemetryMode.FEEDBACK_ONLY`, or `SessionTelemetryMode.DISABLED`); raw string literals are not assignable. Serialized Cordis configuration continues to use the string values shown above.
Upload authorization is positive and fail-closed. An unknown direct-construction mode fails before transport configuration is read. Only `FULL` accepts direct `ctx.telemetry.emit()` calls. `FEEDBACK_ONLY` gives its on-demand coordinator a private backend capability and treats only the exact `feedback/record` object already stored at `session.events[event.seq]` as consent; an independently emitted bus value is ignored. `DISABLED` never constructs the SDK pipeline, even when exporter options are present.
Upload authorization is positive and fail-closed. An unknown direct-construction mode fails before transport configuration is read. Only `FULL` accepts direct `ctx.sessionTelemetry.emit()` calls. `FEEDBACK_ONLY` gives its on-demand coordinator a private backend capability and treats only the exact `feedback/record` object already stored at `session.events[event.seq]` as consent; an independently emitted bus value is ignored. `DISABLED` never constructs the SDK pipeline, even when exporter options are present.
The mounted service discloses the resolved mode through the seam's [`TelemetrySharingStatus`](../session-telemetry/README.md#the-sharing-disclosure) `sharing` property (`full` / `feedback-only` / `disabled`), so the `/feedback` acknowledgement can report whether and how the session is shared. The disclosure is set in the constructor and is independent of capture: even `DISABLED` discloses `disabled`.
The mounted service discloses the resolved mode through the seam's [`SessionTelemetrySharingStatus`](../session-telemetry/README.md#the-sharing-disclosure) `sharing` property (`full` / `feedback-only` / `disabled`), so the `/feedback` acknowledgement can report whether and how the session is shared. The disclosure is set in the constructor and is independent of capture: even `DISABLED` discloses `disabled`.
`exporter.url` is required in `FULL` and `FEEDBACK_ONLY`, has no default, and must parse as `http(s)`; it is optional and unused in `DISABLED`. In uploading modes, `shutdownTimeoutMillis` is a positive finite DSH-owned outer deadline that defaults to 3000 ms, and a non-positive-integer `processor.maxExportBatchSize` also fails at plugin load because the SDK accepts it but then hangs on shutdown. Both SDK blocks pass through whole: every `OTLPExporterNodeConfigBase` field (`headers`, `timeoutMillis`, `compression`, `keepAlive`, …) reaches the exporter, and batching, export cadence (`scheduledDelayMillis`), retry, queue bounds, and loss policy under sustained failure are SDK behavior tuned through `processor`. The backend implements no `flush()`: the batch processor owns ordinary flushing. During shutdown, OTel awaits `exporter.forceFlush()` before the processor's `exportTimeoutMillis`-bounded completion promise; if that transport promise never settles, this package abandons the wait at `shutdownTimeoutMillis`, logs the contained shutdown failure through the coordinator, and lets application teardown continue. The deadline cannot cancel the SDK transport, so records still pending then may be lost at process exit.
## What leaves the machine
In uploading modes, records carry the complete `event.data` as the seam's `telemetry/record` waterfall returns it — user and assistant message content, tool arguments and results (command output, file contents), the full system prompt and tool schemas (`request/header`), todo text, compaction summaries, hook `stderrSummary`, feedback text, and the session `cwd` (a local path). The seam ships no redaction rules: with no `telemetry/record` listener mounted, that is the raw captured copy, so a deployment exporting beyond a trusted boundary mounts its own rules (see [the seam README](../session-telemetry/README.md#the-redact-waterfall)). `FULL` runs redaction at append time; `FEEDBACK_ONLY` retains no telemetry copy and runs the currently mounted rules when feedback triggers canonical-log replay. Provider credentials never appear regardless: adapter API keys are constructor parameters, not session events, so they are structurally absent from the log and therefore from telemetry. `DISABLED` does not construct the SDK pipeline or hand any capture to a backend.
In uploading modes, records carry the complete `event.data` as the seam's `sessionTelemetry/record` waterfall returns it — user and assistant message content, tool arguments and results (command output, file contents), the full system prompt and tool schemas (`request/header`), todo text, compaction summaries, hook `stderrSummary`, feedback text, and the session `cwd` (a local path). The seam ships no redaction rules: with no `sessionTelemetry/record` listener mounted, that is the raw captured copy, so a deployment exporting beyond a trusted boundary mounts its own rules (see [the seam README](../session-telemetry/README.md#the-redact-waterfall)). `FULL` runs redaction at append time; `FEEDBACK_ONLY` retains no telemetry copy and runs the currently mounted rules when feedback triggers canonical-log replay. Provider credentials never appear regardless: adapter API keys are constructor parameters, not session events, so they are structurally absent from the log and therefore from telemetry. `DISABLED` does not construct the SDK pipeline or hand any capture to a backend.
## Field mapping

View File

@@ -2,13 +2,13 @@
[English](README.md) | 中文
[遥测telemetryseam](../session-telemetry/) 的 OpenTelemetry 后端,也是部署方唯一要加载的条目。其 `mode` 决定 seam 是实时跟随会话事件、仅在记录反馈时回放权威日志,还是将遥测留在本地。上传模式会原样组合 OTel JS SDK`LoggerProvider``BatchLogRecordProcessor` → OTLP/HTTP 日志导出器),把每条已交接记录映射到 `logger.emit()`并使用两个插桩作用域instrumentation scopeledger 记录挂在 `@deepseek-ai/dsh-session-telemetry-otel` 下,运维记录挂在 `@deepseek-ai/dsh-session-telemetry-otel/ops` 下。资源身份包含 `service.name`/`service.version`(来自 `dsh-llm``APP_IDENTITY`),以及本包的匿名 `user.id``$DSH_HOME/.userid`;首次使用时创建的随机 UUID删除该文件可重置这些身份随每个导出批次携带一次而非逐条记录携带。
[遥测telemetryseam](../session-telemetry/) 的 OpenTelemetry 后端,也是部署方唯一要加载的条目。其 `mode` 决定 seam 是实时跟随会话事件、仅在记录反馈时回放权威日志,还是将遥测留在本地。上传模式会原样组合 OTel JS SDK`LoggerProvider``BatchLogRecordProcessor` → OTLP/HTTP 日志导出器),把每条已交接记录映射到 `logger.emit()`并使用两个插桩作用域instrumentation scopeledger 记录挂在 `@deepseek-ai/dsh-session-sessionTelemetry-otel` 下,运维记录挂在 `@deepseek-ai/dsh-session-sessionTelemetry-otel/ops` 下。资源身份包含 `service.name`/`service.version`(来自 `dsh-llm``APP_IDENTITY`),以及本包的匿名 `user.id``$DSH_HOME/.anonymous-user-id`;首次使用时创建的随机 UUID删除该文件可重置这些身份随每个导出批次携带一次而非逐条记录携带。
## 配置
```yaml
- id: telemetry-otel
name: '@deepseek-ai/dsh-session-telemetry-otel'
- id: sessionTelemetry-otel
name: '@deepseek-ai/dsh-session-sessionTelemetry-otel'
config:
mode: FULL # explicit opt-in; default: DISABLED
shutdownTimeoutMillis: 3000 # optional; defaults to 3000
@@ -23,19 +23,19 @@
|---|---|
| `FULL` | 每条已投影记录都立即交给 OTel SDK包括生命周期运维记录。 |
| `FEEDBACK_ONLY` | 每个 `feedback/record` 都会回放权威会话日志中截至该事件的后缀,并进行投影与脱敏。后续记录等待下一个反馈事件;如果没有后续反馈,则留在本地。 |
| `DISABLED` | 默认值。不构造协调器、提供方、处理器或导出器。没有遥测记录会离开进程。`feedback/record` 会记录 `session telemetry is DISABLED; nothing will be shared and this feedback remains local`;该事件留在本地会话日志中。 |
| `DISABLED` | 默认值。不构造协调器、提供方、处理器或导出器。没有遥测记录会离开进程。`feedback/record` 会记录 `session sessionTelemetry is DISABLED; nothing will be shared and this feedback remains local`;该事件留在本地会话日志中。 |
程序化 TypeScript 配置使用导出的 `TelemetryMode` 枚举(`TelemetryMode.FULL``TelemetryMode.FEEDBACK_ONLY``TelemetryMode.DISABLED`);原始字符串字面量不可赋值。序列化后的 Cordis 配置继续使用上表所示的字符串值。
程序化 TypeScript 配置使用导出的 `SessionTelemetryMode` 枚举(`SessionTelemetryMode.FULL``SessionTelemetryMode.FEEDBACK_ONLY``SessionTelemetryMode.DISABLED`);原始字符串字面量不可赋值。序列化后的 Cordis 配置继续使用上表所示的字符串值。
上传授权采用显式许可,且为 fail-closed。通过直接构造传入未知模式时会在读取传输配置前失败。只有 `FULL` 接受对 `ctx.telemetry.emit()` 的直接调用。`FEEDBACK_ONLY` 向其按需协调器提供私有后端能力,并且仅在 `feedback/record` 对象已经存储于 `session.events[event.seq]` 且对象身份完全相同时,才将其视为同意;独立发出的总线值会被忽略。即使存在导出器选项,`DISABLED` 也绝不会构造 SDK 流水线。
上传授权采用显式许可,且为 fail-closed。通过直接构造传入未知模式时会在读取传输配置前失败。只有 `FULL` 接受对 `ctx.sessionTelemetry.emit()` 的直接调用。`FEEDBACK_ONLY` 向其按需协调器提供私有后端能力,并且仅在 `feedback/record` 对象已经存储于 `session.events[event.seq]` 且对象身份完全相同时,才将其视为同意;独立发出的总线值会被忽略。即使存在导出器选项,`DISABLED` 也绝不会构造 SDK 流水线。
已挂载的服务通过 seam 的 [`TelemetrySharingStatus`](../session-telemetry/README.md#the-sharing-disclosure) `sharing` 属性披露解析后的模式(`full` / `feedback-only` / `disabled`),因此 `/feedback` 的确认文本可以报告会话是否以及如何被共享。该披露在构造函数中设置,与采集相互独立:即使 `DISABLED` 也会披露 `disabled`
已挂载的服务通过 seam 的 [`SessionTelemetrySharingStatus`](../session-telemetry/README.md#the-sharing-disclosure) `sharing` 属性披露解析后的模式(`full` / `feedback-only` / `disabled`),因此 `/feedback` 的确认文本可以报告会话是否以及如何被共享。该披露在构造函数中设置,与采集相互独立:即使 `DISABLED` 也会披露 `disabled`
`exporter.url``FULL``FEEDBACK_ONLY` 中必填,无默认值,且必须能解析为 `http(s)`;在 `DISABLED` 中可省略且不使用。在上传模式中,`shutdownTimeoutMillis` 是由 DSH 管理的有限正数外层截止时间,默认值为 3000 ms`processor.maxExportBatchSize` 不是正整数时也会在插件加载时失败,因为 SDK 会接受该值,随后却在关闭时挂起。两个 SDK 配置块都整体透传passthrough`OTLPExporterNodeConfigBase` 的每个字段(`headers``timeoutMillis``compression``keepAlive` 等)都会到达导出器;批处理、导出节奏(`scheduledDelayMillis`)、重试、队列上限,以及持续失败下的丢失策略,都是通过 `processor` 调节的 SDK 行为。该后端不实现 `flush()`:常规 flush 由批处理器负责。关闭期间OTel 会先等待 `exporter.forceFlush()`,再等待受处理器 `exportTimeoutMillis` 限制的完成 promise如果该传输 promise 始终不结算,本包会在 `shutdownTimeoutMillis` 到期时放弃等待,通过协调器记录已隔离的关闭失败,并让应用继续拆卸。该截止时间无法取消 SDK 传输,因此届时仍待处理的记录可能在进程退出时丢失。
## 哪些数据会离开本机
在上传模式中,记录携带完整的 `event.data`,内容以 seam 的 `telemetry/record` waterfall瀑布式事件返回的结果为准用户与 assistant 消息内容、工具参数与工具结果(命令输出、文件内容)、完整的系统提示词与工具 schema`request/header`、todo 文本、压缩compaction摘要、钩子的 `stderrSummary`、反馈文本,以及会话 `cwd`一个本地路径。seam 不带任何脱敏规则:未挂载 `telemetry/record` 监听器时,导出的就是捕获原样的副本,因此向可信边界之外导出的部署方要挂载自己的规则(见 [seam README](../session-telemetry/README.md#the-redact-waterfall))。`FULL` 在追加时运行脱敏;`FEEDBACK_ONLY` 不保留遥测副本,而是在反馈触发权威日志回放时运行当时挂载的规则。无论如何,提供方凭据都不会出现:适配器的 API key 是构造函数参数而非会话事件,因此它们在结构上就不存在于日志中,也就不存在于遥测中。`DISABLED` 不会构造 SDK 流水线,也不会将任何捕获内容交给后端。
在上传模式中,记录携带完整的 `event.data`,内容以 seam 的 `sessionTelemetry/record` waterfall瀑布式事件返回的结果为准用户与 assistant 消息内容、工具参数与工具结果(命令输出、文件内容)、完整的系统提示词与工具 schema`request/header`、todo 文本、压缩compaction摘要、钩子的 `stderrSummary`、反馈文本,以及会话 `cwd`一个本地路径。seam 不带任何脱敏规则:未挂载 `sessionTelemetry/record` 监听器时,导出的就是捕获原样的副本,因此向可信边界之外导出的部署方要挂载自己的规则(见 [seam README](../session-telemetry/README.md#the-redact-waterfall))。`FULL` 在追加时运行脱敏;`FEEDBACK_ONLY` 不保留遥测副本,而是在反馈触发权威日志回放时运行当时挂载的规则。无论如何,提供方凭据都不会出现:适配器的 API key 是构造函数参数而非会话事件,因此它们在结构上就不存在于日志中,也就不存在于遥测中。`DISABLED` 不会构造 SDK 流水线,也不会将任何捕获内容交给后端。
## 字段映射

View File

@@ -46,7 +46,7 @@
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-telemetry": "workspace:^",
"@deepseek-ai/dsh-user-id": "workspace:^",
"@deepseek-ai/dsh-anonymous-user-id": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
@@ -56,7 +56,7 @@
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-telemetry": "workspace:^",
"@deepseek-ai/dsh-user-id": "workspace:^",
"@deepseek-ai/dsh-anonymous-user-id": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}

View File

@@ -17,15 +17,15 @@ import z from '@deepseek-ai/schemastery'
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-command-feedback'
import {
Telemetry,
TelemetryCoordinator,
type TelemetryBackend,
type TelemetryRecord,
type TelemetrySeverity,
type TelemetrySharingStatus,
SessionTelemetryBackend,
SessionTelemetryCoordinator,
type SessionTelemetrySink,
type SessionTelemetryRecord,
type SessionTelemetrySeverity,
type SessionTelemetrySharingStatus,
} from '@deepseek-ai/dsh-session-telemetry'
import { APP_IDENTITY } from '@deepseek-ai/dsh-llm'
import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-user-id'
import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id'
import {
BatchLogRecordProcessor,
LoggerProvider,
@@ -41,26 +41,26 @@ import { resourceFromAttributes } from '@opentelemetry/resources'
const { version } = createRequire(import.meta.url)('../package.json') as { version: string }
/** Session-sharing policy selected by {@link Config.mode}. */
export enum TelemetryMode {
export enum SessionTelemetryMode {
FULL = 'FULL',
FEEDBACK_ONLY = 'FEEDBACK_ONLY',
DISABLED = 'DISABLED',
}
/** Default session-sharing policy for schema and direct construction. */
export const DEFAULT_TELEMETRY_MODE = TelemetryMode.DISABLED
export const DEFAULT_TELEMETRY_MODE = SessionTelemetryMode.DISABLED
const DISABLED_FEEDBACK_WARNING = 'session telemetry is DISABLED; nothing will be shared and this feedback remains local'
const NON_CANONICAL_FEEDBACK_WARNING = 'session telemetry ignored a feedback event absent from the canonical session log'
const DROP_RECORD: TelemetryBackend['emit'] = () => {}
const DROP_RECORD: SessionTelemetrySink['emit'] = () => {}
/** Resolve the default and reject unknown runtime values before transport setup. */
function resolveMode(mode: TelemetryMode | undefined): TelemetryMode {
function resolveMode(mode: SessionTelemetryMode | undefined): SessionTelemetryMode {
const resolved = mode ?? DEFAULT_TELEMETRY_MODE
switch (resolved) {
case TelemetryMode.FULL:
case TelemetryMode.FEEDBACK_ONLY:
case TelemetryMode.DISABLED:
case SessionTelemetryMode.FULL:
case SessionTelemetryMode.FEEDBACK_ONLY:
case SessionTelemetryMode.DISABLED:
return resolved
default:
return assertNever(resolved)
@@ -73,11 +73,11 @@ function assertNever(value: never): never {
}
/** Map the serialized mode onto the seam's backend-independent sharing vocabulary. */
function sharingStatusFor(mode: TelemetryMode): TelemetrySharingStatus {
function sharingStatusFor(mode: SessionTelemetryMode): SessionTelemetrySharingStatus {
switch (mode) {
case TelemetryMode.FULL: return 'full'
case TelemetryMode.FEEDBACK_ONLY: return 'feedback-only'
case TelemetryMode.DISABLED: return 'disabled'
case SessionTelemetryMode.FULL: return 'full'
case SessionTelemetryMode.FEEDBACK_ONLY: return 'feedback-only'
case SessionTelemetryMode.DISABLED: return 'disabled'
/* v8 ignore next 2 -- resolveMode already rejected unknown values before this switch; the closed enum cannot reach the default. */
default: return assertNever(mode)
}
@@ -90,7 +90,7 @@ function sharingStatusFor(mode: TelemetryMode): TelemetrySharingStatus {
*/
export interface Config {
/** Sharing policy; defaults to local-only `DISABLED` behavior. */
mode?: TelemetryMode
mode?: SessionTelemetryMode
/**
* Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete
* `OTLPExporterNodeConfigBase` shape (`headers`, `timeoutMillis`,
@@ -118,7 +118,7 @@ export interface Config {
* silently drop every field this plugin did not repeat.
*/
export const Config: z<Config> = z.object({
mode: z.union(Object.values(TelemetryMode)).default(DEFAULT_TELEMETRY_MODE),
mode: z.union(Object.values(SessionTelemetryMode)).default(DEFAULT_TELEMETRY_MODE),
exporter: z.any(),
processor: z.any(),
shutdownTimeoutMillis: z.number(),
@@ -132,7 +132,7 @@ export const DEFAULT_SHUTDOWN_TIMEOUT_MILLIS = 3_000
const MAX_TIMER_DELAY_MILLIS = 2_147_483_647
/** Severity mapping from the Service Definition's three-level vocabulary to OTel severity numbers. */
const SEVERITY: Record<TelemetrySeverity, { severityNumber: SeverityNumber; severityText: string }> = {
const SEVERITY: Record<SessionTelemetrySeverity, { severityNumber: SeverityNumber; severityText: string }> = {
info: { severityNumber: SeverityNumber.INFO, severityText: 'INFO' },
warn: { severityNumber: SeverityNumber.WARN, severityText: 'WARN' },
error: { severityNumber: SeverityNumber.ERROR, severityText: 'ERROR' },
@@ -141,23 +141,23 @@ const SEVERITY: Record<TelemetrySeverity, { severityNumber: SeverityNumber; seve
/**
* The backend plugin — the only entry a deployment loads. It always registers
* the `telemetry` service (duplicate load throws). Uploading modes wire the SDK
* pipeline and compose {@link TelemetryCoordinator}; `DISABLED` constructs no
* pipeline and compose {@link SessionTelemetryCoordinator}; `DISABLED` constructs no
* SDK state and listens only to warn when recorded feedback stays local.
*/
export class TelemetryOtel extends Telemetry {
export class OpenTelemetrySessionBackend extends SessionTelemetryBackend {
static inject = ['sessions']
static Config = Config
private readonly directEmit: TelemetryBackend['emit']
private readonly directEmit: SessionTelemetrySink['emit']
private readonly provider: LoggerProvider | undefined
private readonly shutdownTimeoutMillis: number
override readonly sharing: TelemetrySharingStatus
override readonly sharing: SessionTelemetrySharingStatus
constructor(ctx: Context, config: Config) {
const mode = resolveMode(config.mode)
super(ctx)
this.sharing = sharingStatusFor(mode)
if (mode === TelemetryMode.DISABLED) {
if (mode === SessionTelemetryMode.DISABLED) {
this.directEmit = DROP_RECORD
this.provider = undefined
this.shutdownTimeoutMillis = DEFAULT_SHUTDOWN_TIMEOUT_MILLIS
@@ -218,7 +218,7 @@ export class TelemetryOtel extends Telemetry {
})
const ledger = this.provider.getLogger('@deepseek-ai/dsh-session-telemetry-otel', version)
const ops = this.provider.getLogger('@deepseek-ai/dsh-session-telemetry-otel/ops', version)
const enqueue: TelemetryBackend['emit'] = (record) => {
const enqueue: SessionTelemetrySink['emit'] = (record) => {
const logger: Logger = record.channel === 'ops' ? ops : ledger
logger.emit({
timestamp: record.time,
@@ -230,17 +230,17 @@ export class TelemetryOtel extends Telemetry {
attributes: record.attributes,
})
}
const backend: TelemetryBackend = {
const backend: SessionTelemetrySink = {
emit: enqueue,
shutdown: () => this.shutdown(),
}
if (mode === TelemetryMode.FULL) {
if (mode === SessionTelemetryMode.FULL) {
this.directEmit = enqueue
new TelemetryCoordinator(ctx, backend, 'live')
new SessionTelemetryCoordinator(ctx, backend, 'live')
return
}
this.directEmit = DROP_RECORD
const coordinator = new TelemetryCoordinator(ctx, backend, 'on-demand')
const coordinator = new SessionTelemetryCoordinator(ctx, backend, 'on-demand')
ctx.on('session/event', (session, event) => {
if (event.type !== 'feedback/record') return
// Consent is the committed record, not an independently emitted bus value.
@@ -258,7 +258,7 @@ export class TelemetryOtel extends Telemetry {
* backend capability created only for the canonical feedback listener.
* @param record - the logical record offered directly to the service.
*/
emit(record: TelemetryRecord): void {
emit(record: SessionTelemetryRecord): void {
this.directEmit(record)
}
@@ -298,4 +298,4 @@ export class TelemetryOtel extends Telemetry {
}
}
export default TelemetryOtel
export default OpenTelemetrySessionBackend

View File

@@ -14,11 +14,11 @@ import { describe, expect, it } from 'vitest'
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
const driver = fileURLToPath(new URL(
'../../../../examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts',
'../../../../examples/headless-agent/tests/fixtures/session-telemetry-otel-driver.ts',
import.meta.url,
))
const configPath = fileURLToPath(new URL(
'../../../../examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml',
'../../../../examples/headless-agent/tests/fixtures/session-telemetry-otel.cordis.yml',
import.meta.url,
))
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))

View File

@@ -13,11 +13,11 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { gunzipSync } from 'node:zlib'
import { Context } from '@deepseek-ai/cordis'
import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-user-id'
import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import { recordFeedback } from '@deepseek-ai/dsh-command-feedback'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import TelemetryOtel, { Config, DEFAULT_TELEMETRY_MODE, TelemetryMode } from '../src/index.ts'
import OpenTelemetrySessionBackend, { Config, DEFAULT_TELEMETRY_MODE, SessionTelemetryMode } from '../src/index.ts'
interface Capture {
headers: import('node:http').IncomingHttpHeaders
@@ -98,8 +98,8 @@ async function mockCollector(
async function boot(url: string) {
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(TelemetryOtel, {
mode: TelemetryMode.FULL,
const fiber = await ctx.plugin(OpenTelemetrySessionBackend, {
mode: SessionTelemetryMode.FULL,
exporter: { url, headers: { authorization: 'Bearer test-token' } },
})
return { ctx, fiber }
@@ -118,14 +118,14 @@ function eventTypes(captures: Capture[]): string[] {
: []) ?? [])
}
describe('TelemetryOtel wire', () => {
describe('OpenTelemetrySessionBackend wire', () => {
it('ships session records and the ops shutdown marker through the real SDK pipeline', async () => {
const { url, captures } = await mockCollector()
const { ctx, fiber } = await boot(url)
const session = ctx.sessions.create(SessionId('wire'), { meta: { cwd: '/tmp/w' } })
session.append('turn/start', { turn: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'error', error: { message: 'boom', code: 'UNKNOWN' } } })
ctx.telemetry.emit({
ctx.sessionTelemetry.emit({
channel: 'ledger',
time: Date.now(),
severity: 'info',
@@ -180,8 +180,8 @@ describe('TelemetryOtel wire', () => {
})
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(TelemetryOtel, {
mode: TelemetryMode.FULL,
const fiber = await ctx.plugin(OpenTelemetrySessionBackend, {
mode: SessionTelemetryMode.FULL,
exporter: { url },
processor: { scheduledDelayMillis: 10 },
})
@@ -211,8 +211,8 @@ describe('TelemetryOtel wire', () => {
})
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(TelemetryOtel, {
mode: TelemetryMode.FULL,
const fiber = await ctx.plugin(OpenTelemetrySessionBackend, {
mode: SessionTelemetryMode.FULL,
exporter: { url, timeoutMillis: 60_000 },
processor: { scheduledDelayMillis: 10, exportTimeoutMillis: 60_000 },
shutdownTimeoutMillis: 50,
@@ -240,8 +240,8 @@ describe('TelemetryOtel wire', () => {
// `compression` is a documented SDK exporter option; the advertised
// verbatim passthrough must hand it (and every other field) to the
// exporter rather than silently rebuilding url/headers only.
const fiber = await ctx.plugin(TelemetryOtel, {
mode: TelemetryMode.FULL,
const fiber = await ctx.plugin(OpenTelemetrySessionBackend, {
mode: SessionTelemetryMode.FULL,
exporter: { url, compression: 'gzip' },
} as Config)
const session = ctx.sessions.create(SessionId('gzip'), { meta: {} })
@@ -258,12 +258,12 @@ describe('TelemetryOtel wire', () => {
it('maps warn severity from record policy and leaves the seam flush hint unimplemented', async () => {
const { url, captures } = await mockCollector()
const { ctx, fiber } = await boot(url)
ctx.on('telemetry/record', (_record, next) => ({ ...next(), severity: 'warn' }))
ctx.on('session-telemetry/record', (_record, next) => ({ ...next(), severity: 'warn' }))
const session = ctx.sessions.create(SessionId('warn'), { meta: {} })
session.append('turn/start', { turn: 1 })
// No flush(): the coordinator's optional-call forwarding no-ops, and the
// batch processor owns export cadence end to end (see the backend note).
expect('flush' in ctx.telemetry && ctx.telemetry.flush !== undefined).toBe(false)
expect('flush' in ctx.sessionTelemetry && ctx.sessionTelemetry.flush !== undefined).toBe(false)
await fiber.dispose()
const start = allRecords(captures).find(r =>
r.record.attributes?.some(a => a.key === 'event.type' && a.value.stringValue === 'turn/start'))
@@ -274,12 +274,12 @@ describe('TelemetryOtel wire', () => {
const { url, captures } = await mockCollector()
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(TelemetryOtel, {
mode: TelemetryMode.FEEDBACK_ONLY,
const fiber = await ctx.plugin(OpenTelemetrySessionBackend, {
mode: SessionTelemetryMode.FEEDBACK_ONLY,
exporter: { url },
})
ctx.on('telemetry/record', (_record, next) => {
ctx.telemetry.emit({
ctx.on('session-telemetry/record', (_record, next) => {
ctx.sessionTelemetry.emit({
channel: 'ledger',
time: Date.now(),
severity: 'info',
@@ -310,13 +310,13 @@ describe('TelemetryOtel wire', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const fiber = await ctx.plugin(TelemetryOtel, {
mode: TelemetryMode.FEEDBACK_ONLY,
const fiber = await ctx.plugin(OpenTelemetrySessionBackend, {
mode: SessionTelemetryMode.FEEDBACK_ONLY,
exporter: { url },
})
const session = ctx.sessions.create(SessionId('no-feedback'), { meta: {} })
session.append('turn/start', { turn: 1 })
ctx.telemetry.emit({
ctx.sessionTelemetry.emit({
channel: 'ledger',
time: Date.now(),
severity: 'info',
@@ -342,8 +342,8 @@ describe('TelemetryOtel wire', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const fiber = await ctx.plugin(TelemetryOtel, {
mode: TelemetryMode.DISABLED,
const fiber = await ctx.plugin(OpenTelemetrySessionBackend, {
mode: SessionTelemetryMode.DISABLED,
exporter: { url },
processor: { maxExportBatchSize: 0 },
})
@@ -354,14 +354,14 @@ describe('TelemetryOtel wire', () => {
expect(warn).toHaveBeenCalledWith(
'session telemetry is DISABLED; nothing will be shared and this feedback remains local',
)
ctx.telemetry.emit({
ctx.sessionTelemetry.emit({
channel: 'ledger',
time: 0,
severity: 'info',
attributes: {},
body: null,
})
await ctx.telemetry.shutdown()
await ctx.sessionTelemetry.shutdown()
await fiber.dispose()
recordFeedback(session, 'after disposal')
expect(warn).toHaveBeenCalledTimes(1)
@@ -373,27 +373,27 @@ describe('TelemetryOtel wire', () => {
const fullCtx = new Context()
await fullCtx.plugin(SessionStore)
const full = await fullCtx.plugin(TelemetryOtel, { mode: TelemetryMode.FULL, exporter: { url } })
expect(fullCtx.telemetry.sharing).toBe('full')
const full = await fullCtx.plugin(OpenTelemetrySessionBackend, { mode: SessionTelemetryMode.FULL, exporter: { url } })
expect(fullCtx.sessionTelemetry.sharing).toBe('full')
await full.dispose()
const gatedCtx = new Context()
await gatedCtx.plugin(SessionStore)
const gated = await gatedCtx.plugin(TelemetryOtel, { mode: TelemetryMode.FEEDBACK_ONLY, exporter: { url } })
expect(gatedCtx.telemetry.sharing).toBe('feedback-only')
const gated = await gatedCtx.plugin(OpenTelemetrySessionBackend, { mode: SessionTelemetryMode.FEEDBACK_ONLY, exporter: { url } })
expect(gatedCtx.sessionTelemetry.sharing).toBe('feedback-only')
await gated.dispose()
const disabledCtx = new Context()
await disabledCtx.plugin(SessionStore)
const disabled = await disabledCtx.plugin(TelemetryOtel, { mode: TelemetryMode.DISABLED })
expect(disabledCtx.telemetry.sharing).toBe('disabled')
const disabled = await disabledCtx.plugin(OpenTelemetrySessionBackend, { mode: SessionTelemetryMode.DISABLED })
expect(disabledCtx.sessionTelemetry.sharing).toBe('disabled')
await disabled.dispose()
// An omitted mode is DISABLED, so the default also shares nothing.
const defaultCtx = new Context()
await defaultCtx.plugin(SessionStore)
const defaulted = await defaultCtx.plugin(TelemetryOtel, {})
expect(defaultCtx.telemetry.sharing).toBe('disabled')
const defaulted = await defaultCtx.plugin(OpenTelemetrySessionBackend, {})
expect(defaultCtx.sessionTelemetry.sharing).toBe('disabled')
await defaulted.dispose()
// No record was emitted by any mode, so nothing reached the collector.
@@ -405,7 +405,7 @@ describe('TelemetryOtel wire', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
new TelemetryOtel(ctx, {
new OpenTelemetrySessionBackend(ctx, {
exporter: { url },
processor: { maxExportBatchSize: 0 },
})
@@ -421,32 +421,32 @@ describe('TelemetryOtel wire', () => {
})
})
describe('TelemetryOtel config fails loud', () => {
describe('OpenTelemetrySessionBackend config fails loud', () => {
it('exposes modes through the nominal enum', () => {
expectTypeOf<Config['mode']>().toEqualTypeOf<TelemetryMode | undefined>()
expectTypeOf<'FULL'>().not.toExtend<TelemetryMode>()
expectTypeOf<TelemetryMode.FULL>().toExtend<TelemetryMode>()
expect(DEFAULT_TELEMETRY_MODE).toBe(TelemetryMode.DISABLED)
expectTypeOf<Config['mode']>().toEqualTypeOf<SessionTelemetryMode | undefined>()
expectTypeOf<'FULL'>().not.toExtend<SessionTelemetryMode>()
expectTypeOf<SessionTelemetryMode.FULL>().toExtend<SessionTelemetryMode>()
expect(DEFAULT_TELEMETRY_MODE).toBe(SessionTelemetryMode.DISABLED)
expect(Config({}).mode).toBe(DEFAULT_TELEMETRY_MODE)
})
it.each([
[{ mode: TelemetryMode.FULL }, /exporter\.url is required/],
[{ mode: TelemetryMode.FULL, exporter: { url: '' } }, /exporter\.url is required/],
[{ mode: TelemetryMode.FULL, exporter: { url: 'not a url' } }, /not a valid URL/],
[{ mode: TelemetryMode.FULL, exporter: { url: 'ftp://collector' } }, /must be http\(s\)/],
[{ mode: TelemetryMode.FEEDBACK_ONLY }, /exporter\.url is required/],
[{ mode: SessionTelemetryMode.FULL }, /exporter\.url is required/],
[{ mode: SessionTelemetryMode.FULL, exporter: { url: '' } }, /exporter\.url is required/],
[{ mode: SessionTelemetryMode.FULL, exporter: { url: 'not a url' } }, /not a valid URL/],
[{ mode: SessionTelemetryMode.FULL, exporter: { url: 'ftp://collector' } }, /must be http\(s\)/],
[{ mode: SessionTelemetryMode.FEEDBACK_ONLY }, /exporter\.url is required/],
[{ mode: 'INVALID' }, /INVALID/],
// The SDK accepts a non-positive batch size but its shutdown drain then
// splices empty batches forever — dispose would hang, so reject at load.
[{ mode: TelemetryMode.FULL, exporter: { url: 'http://c/v1/logs' }, processor: { maxExportBatchSize: 0 } }, /maxExportBatchSize/],
[{ mode: TelemetryMode.FULL, exporter: { url: 'http://c/v1/logs' }, processor: { maxExportBatchSize: 0.5 } }, /maxExportBatchSize/],
[{ mode: TelemetryMode.FULL, exporter: { url: 'http://c/v1/logs' }, shutdownTimeoutMillis: 0 }, /shutdownTimeoutMillis/],
[{ mode: TelemetryMode.FULL, exporter: { url: 'http://c/v1/logs' }, shutdownTimeoutMillis: Number.POSITIVE_INFINITY }, /shutdownTimeoutMillis/],
[{ mode: SessionTelemetryMode.FULL, exporter: { url: 'http://c/v1/logs' }, processor: { maxExportBatchSize: 0 } }, /maxExportBatchSize/],
[{ mode: SessionTelemetryMode.FULL, exporter: { url: 'http://c/v1/logs' }, processor: { maxExportBatchSize: 0.5 } }, /maxExportBatchSize/],
[{ mode: SessionTelemetryMode.FULL, exporter: { url: 'http://c/v1/logs' }, shutdownTimeoutMillis: 0 }, /shutdownTimeoutMillis/],
[{ mode: SessionTelemetryMode.FULL, exporter: { url: 'http://c/v1/logs' }, shutdownTimeoutMillis: Number.POSITIVE_INFINITY }, /shutdownTimeoutMillis/],
])('rejects %j at plugin load', async (config, message) => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await expect(ctx.plugin(TelemetryOtel, config as Config)).rejects.toThrow(message)
await expect(ctx.plugin(OpenTelemetrySessionBackend, config as Config)).rejects.toThrow(message)
})
it('rejects an unknown direct mode before reading transport config', async () => {
@@ -461,7 +461,7 @@ describe('TelemetryOtel config fails loud', () => {
},
} as unknown as Config
expect(() => new TelemetryOtel(ctx, config)).toThrow(/unsupported mode "INVALID"/)
expect(() => new OpenTelemetrySessionBackend(ctx, config)).toThrow(/unsupported mode "INVALID"/)
expect(exporterRead).toBe(false)
})
@@ -472,7 +472,7 @@ describe('TelemetryOtel config fails loud', () => {
throw new Error('transport config was read')
})
const config = {
mode: TelemetryMode.DISABLED,
mode: SessionTelemetryMode.DISABLED,
get exporter() {
return transportRead()
},
@@ -484,7 +484,7 @@ describe('TelemetryOtel config fails loud', () => {
},
} as unknown as Config
new TelemetryOtel(ctx, config)
new OpenTelemetrySessionBackend(ctx, config)
expect(transportRead).not.toHaveBeenCalled()
await ctx.fiber.dispose()
})
@@ -494,21 +494,21 @@ describe('dsh-session-telemetry-otel real-load-path guard', () => {
it('keeps the Service class with inject/Config through unwrapExports', async () => {
const module = await import('../src/index.ts')
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(module) as typeof TelemetryOtel
expect(unwrapped).toBe(TelemetryOtel)
const unwrapped = loader.unwrapExports(module) as typeof OpenTelemetrySessionBackend
expect(unwrapped).toBe(OpenTelemetrySessionBackend)
expect(unwrapped.inject).toEqual(['sessions'])
expect(typeof unwrapped.Config).toBe('function')
})
it('boots through the unwrapped class and registers ctx.telemetry', async () => {
it('boots through the unwrapped class and registers ctx.sessionTelemetry', async () => {
const { url } = await mockCollector()
const module = await import('../src/index.ts')
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(module) as Parameters<Context['plugin']>[0]
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(unwrapped, { mode: TelemetryMode.FULL, exporter: { url } })
expect(ctx.telemetry).toBeInstanceOf(TelemetryOtel)
const fiber = await ctx.plugin(unwrapped, { mode: SessionTelemetryMode.FULL, exporter: { url } })
expect(ctx.sessionTelemetry).toBeInstanceOf(OpenTelemetrySessionBackend)
await fiber.dispose()
})
})

View File

@@ -30,10 +30,10 @@
"path": "../session-telemetry"
},
{
"path": "../user-id"
"path": "../../identity/anonymous-user-id"
},
{
"path": "../../support/invariants"
"path": "../../runtime-diagnostics/invariants"
}
]
}

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session/session-telemetry/README.md
README.md: 707dcfcdb0c8dfbd622630351928ac43562535ec
README.zh.md: bd080adceebf83cd9e53d72a7093db376cf6cbd1
README.md: e4ebee1324bc1228d6d97d891159685b05f94e4f
README.zh.md: 963d5f6d1c5c0f60f5bf14956f3b490d60bd6574

View File

@@ -2,13 +2,13 @@
English | [中文](README.zh.md)
The telemetry Service Definition declares the `TelemetryBackend` contract, and its capture coordinator passes session records to any reporting SDK backend that implements it. Capture can follow live session events or replay a canonical session-log prefix on demand. This package stops after it calls `emit()`: batching, retry, queueing, and loss policy belong to the backend's SDK and are neither specified nor wrapped. Rationale and rejected alternatives: [the revival Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md), [feedback-gated delivery](../../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md), and [buffer-free feedback replay](../../../.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md).
The telemetry Service Definition declares the `SessionTelemetrySink` contract, and its capture coordinator passes session records to any reporting SDK backend that implements it. Capture can follow live session events or replay a canonical session-log prefix on demand. This package stops after it calls `emit()`: batching, retry, queueing, and loss policy belong to the backend's SDK and are neither specified nor wrapped. Rationale and rejected alternatives: [the revival Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md), [feedback-gated delivery](../../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md), and [buffer-free feedback replay](../../../.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md).
## The backend contract
`TelemetryBackend` has three members: `emit(record)` MUST enqueue without blocking because it runs synchronously during `session/event` or explicit canonical-log replay; optional `flush()` is a fire-and-forget hint after a turn ends, and most backends omit it and use their SDK's normal batching schedule; `shutdown()` drains queued records and resolves when the SDK stops, and disposal awaits it. An implementation that provides `flush()` must order concurrent flushes with the final `shutdown()` drain. `Telemetry` registers this API under the `telemetry` context key; each context accepts one implementation, and a duplicate load throws. A backend constructs `TelemetryCoordinator` with `live` or `on-demand` capture and calls `captureSession(session, throughSeq?)` at its chosen trigger.
`SessionTelemetrySink` has three members: `emit(record)` MUST enqueue without blocking because it runs synchronously during `session/event` or explicit canonical-log replay; optional `flush()` is a fire-and-forget hint after a turn ends, and most backends omit it and use their SDK's normal batching schedule; `shutdown()` drains queued records and resolves when the SDK stops, and disposal awaits it. An implementation that provides `flush()` must order concurrent flushes with the final `shutdown()` drain. `SessionTelemetryBackend` registers this API under the `sessionTelemetry` context key; each context accepts one implementation, and a duplicate load throws. A backend constructs `SessionTelemetryCoordinator` with `live` or `on-demand` capture and calls `captureSession(session, throughSeq?)` at its chosen trigger.
The service also carries the required [`TelemetrySharingStatus`](#the-sharing-disclosure) `sharing` member: the deployment-selected sharing policy every backend must disclose to human-facing acknowledgement surfaces (the `/feedback` command's confirmation). A consumer renders "not configured" only when no telemetry service is mounted. The seam owns the vocabulary (`full` | `feedback-only` | `disabled`) so any backend can disclose a policy without depending on the OTel package.
The service also carries the required [`SessionTelemetrySharingStatus`](#the-sharing-disclosure) `sharing` member: the deployment-selected sharing policy every backend must disclose to human-facing acknowledgement surfaces (the `/feedback` command's confirmation). A consumer renders "not configured" only when no telemetry service is mounted. The seam owns the vocabulary (`full` | `feedback-only` | `disabled`) so any backend can disclose a policy without depending on the OTel package.
## The sharing disclosure
@@ -20,7 +20,7 @@ In `live` mode the coordinator registers, all through the composing fiber's effe
## The redact waterfall
Every record passes the `telemetry/record` waterfall immediately after projection — the Service Definition's scrubbing extension point. This package ships NO rules of its own: the innermost `next()` passes the record through unchanged, so with no listener mounted records reach the backend exactly as captured, and exported data is precisely as clean as the rules a deployment mounts. Listeners stack by transforming `next()`'s return value; returning without `next()` replaces everything beneath, and a throwing listener withholds that one record fail-closed inside the coordinator's containment. Live capture runs the waterfall at append time; on-demand capture runs it while replaying the canonical log, using the rules mounted at that time. Redaction applies to the outbound copy only; the canonical session log is never rewritten.
Every record passes the `sessionTelemetry/record` waterfall immediately after projection — the Service Definition's scrubbing extension point. This package ships NO rules of its own: the innermost `next()` passes the record through unchanged, so with no listener mounted records reach the backend exactly as captured, and exported data is precisely as clean as the rules a deployment mounts. Listeners stack by transforming `next()`'s return value; returning without `next()` replaces everything beneath, and a throwing listener withholds that one record fail-closed inside the coordinator's containment. Live capture runs the waterfall at append time; on-demand capture runs it while replaying the canonical log, using the rules mounted at that time. Redaction applies to the outbound copy only; the canonical session log is never rewritten.
## The handoff cursor
@@ -32,7 +32,7 @@ Only the first `assistant/chunk` of each `(turn, step)` ships; the rest are drop
## The logical record
`TelemetryRecord`: `channel` (`ledger` | `ops`), `time` (epoch ms), `severity` (pre-mapped: ERROR for `tool/result.isError`, `turn/end` error reasons, and `agent-error`; INFO for other captured records, while `telemetry/record` policies may assign WARN), identity-only `attributes` (`session.id`, `event.type`, `event.seq`, plus `session.cwd`/`session.parent_id`/`session.seed_length` when the header has them), and the complete deep-copied `event.data` as `body` — post-redaction. Operational records carry `telemetry.op` (`agent-error` | `shutdown`) and `session.id`, and deliberately NO `event.seq`/`event.type` — signals to alert on, not entries to sum; `agent-error` normalizes its arbitrary thrown value into a stable `{ name, message }` body. Delivery downstream of the handoff is the backend SDK's; duplicates remain possible (cursor-less re-adoption, SDK retries), so receivers dedupe on `(session.id, event.seq)`.
`SessionTelemetryRecord`: `channel` (`ledger` | `ops`), `time` (epoch ms), `severity` (pre-mapped: ERROR for `tool/result.isError`, `turn/end` error reasons, and `agent-error`; INFO for other captured records, while `sessionTelemetry/record` policies may assign WARN), identity-only `attributes` (`session.id`, `event.type`, `event.seq`, plus `session.cwd`/`session.parent_id`/`session.seed_length` when the header has them), and the complete deep-copied `event.data` as `body` — post-redaction. Operational records carry `sessionTelemetry.op` (`agent-error` | `shutdown`) and `session.id`, and deliberately NO `event.seq`/`event.type` — signals to alert on, not entries to sum; `agent-error` normalizes its arbitrary thrown value into a stable `{ name, message }` body. Delivery downstream of the handoff is the backend SDK's; duplicates remain possible (cursor-less re-adoption, SDK retries), so receivers dedupe on `(session.id, event.seq)`.
## Model Experience
@@ -45,5 +45,5 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Best-effort delivery** — the cursor marks handed-off, not delivered; a session torn down inside a reload window cannot be re-adopted; whatever sits in a backend queue at crash time is lost. A durable outbox (spool, per-sink cursors, at-least-once) is deferred until a deployment states a crash-loss requirement — see [the revival Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md).
- **No built-in redaction rules** — with no `telemetry/record` listener mounted, records leave the process exactly as captured, including any credentials embedded in file contents or command output; a deployment exporting to a shared collector owns its rule set.
- **No built-in redaction rules** — with no `sessionTelemetry/record` listener mounted, records leave the process exactly as captured, including any credentials embedded in file contents or command output; a deployment exporting to a shared collector owns its rule set.
- **On-demand redaction uses current state** — uncaptured events exist only in the canonical session log. A later `captureSession()` deep-copies and redacts their current values with the policy mounted at that time; there is no capture-time telemetry snapshot or durable pre-capture spool.

View File

@@ -2,13 +2,13 @@
[English](README.md) | 中文
遥测telemetryService Definition 声明 `TelemetryBackend` 后端约定,捕获协调器把会话记录传给实现该约定的任意上报 SDK 后端。捕获侧可跟随实时会话事件,也可按需回放权威会话日志前缀。本包调用 `emit()` 后就停止处理:批处理、重试、排队与丢失策略都属于后端自身的 SDK本包既不规定也不包装。设计依据与被否决的替代方案见[复活 Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)、[反馈门控投递](../../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md)与[无缓冲反馈回放](../../../.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md)。
遥测telemetryService Definition 声明 `SessionTelemetrySink` 后端约定,捕获协调器把会话记录传给实现该约定的任意上报 SDK 后端。捕获侧可跟随实时会话事件,也可按需回放权威会话日志前缀。本包调用 `emit()` 后就停止处理:批处理、重试、排队与丢失策略都属于后端自身的 SDK本包既不规定也不包装。设计依据与被否决的替代方案见[复活 Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)、[反馈门控投递](../../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md)与[无缓冲反馈回放](../../../.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md)。
## 后端约定
`TelemetryBackend` 有三个成员:`emit(record)` 必须入队且不能阻塞,因为它会在 `session/event` 或显式权威日志回放期间同步执行;可选的 `flush()` 是轮次结束后的提示,调用方不等待结果,多数后端省略它并使用 SDK 的常规批处理计划;`shutdown()` 排空已入队记录,并在 SDK 停止后结束dispose资源释放会等待它。提供 `flush()` 的实现必须安排并发 flush 与 `shutdown()` 最终排空的先后顺序。`Telemetry` 将此 API 注册在 `telemetry` 上下文键下:每个上下文只允许一个实现,重复加载会抛出异常。后端以 `live``on-demand` 捕获构造 `TelemetryCoordinator`,并在自己选择的触发器中调用 `captureSession(session, throughSeq?)`
`SessionTelemetrySink` 有三个成员:`emit(record)` 必须入队且不能阻塞,因为它会在 `session/event` 或显式权威日志回放期间同步执行;可选的 `flush()` 是轮次结束后的提示,调用方不等待结果,多数后端省略它并使用 SDK 的常规批处理计划;`shutdown()` 排空已入队记录,并在 SDK 停止后结束dispose资源释放会等待它。提供 `flush()` 的实现必须安排并发 flush 与 `shutdown()` 最终排空的先后顺序。`SessionTelemetryBackend` 将此 API 注册在 `sessionTelemetry` 上下文键下:每个上下文只允许一个实现,重复加载会抛出异常。后端以 `live``on-demand` 捕获构造 `SessionTelemetryCoordinator`,并在自己选择的触发器中调用 `captureSession(session, throughSeq?)`
该服务还携带必需的 [`TelemetrySharingStatus`](#the-sharing-disclosure) `sharing` 成员:每个后端都必须向面向用户的确认 surface`/feedback` 命令的确认文本披露的部署级共享策略。消费方只有在未挂载任何遥测服务时才渲染「未配置」。seam 拥有该词汇(`full` | `feedback-only` | `disabled`),因此任何后端都可以披露策略,而无需依赖 OTel 包。
该服务还携带必需的 [`SessionTelemetrySharingStatus`](#the-sharing-disclosure) `sharing` 成员:每个后端都必须向面向用户的确认 surface`/feedback` 命令的确认文本披露的部署级共享策略。消费方只有在未挂载任何遥测服务时才渲染「未配置」。seam 拥有该词汇(`full` | `feedback-only` | `disabled`),因此任何后端都可以披露策略,而无需依赖 OTel 包。
<a id="the-sharing-disclosure"></a>
@@ -22,7 +22,7 @@
## 脱敏 waterfall瀑布式事件
每条记录在投影后立即经过 `telemetry/record` waterfall这是 Service Definition 的脱敏扩展点。本包自身不带任何规则:最内层的 `next()` 原样透传记录,因此未挂载监听器时,记录以捕获时的原样到达后端;导出数据能干净到什么程度,恰恰取决于部署方挂载了什么规则。监听器通过变换 `next()` 的返回值来堆叠;不调用 `next()` 就返回,即替换其下方的全部逻辑;抛出异常的监听器会在协调器的隔离范围内以 fail-closed 方式拦下这一条记录。实时捕获在追加时运行 waterfall按需捕获则在回放权威日志时使用当时挂载的规则运行 waterfall。脱敏只作用于外发副本权威会话日志永不改写。
每条记录在投影后立即经过 `sessionTelemetry/record` waterfall这是 Service Definition 的脱敏扩展点。本包自身不带任何规则:最内层的 `next()` 原样透传记录,因此未挂载监听器时,记录以捕获时的原样到达后端;导出数据能干净到什么程度,恰恰取决于部署方挂载了什么规则。监听器通过变换 `next()` 的返回值来堆叠;不调用 `next()` 就返回,即替换其下方的全部逻辑;抛出异常的监听器会在协调器的隔离范围内以 fail-closed 方式拦下这一条记录。实时捕获在追加时运行 waterfall按需捕获则在回放权威日志时使用当时挂载的规则运行 waterfall。脱敏只作用于外发副本权威会话日志永不改写。
## handoff 游标
@@ -34,7 +34,7 @@
## 逻辑记录
`TelemetryRecord` 包含:`channel``ledger` | `ops`)、`time`epoch 毫秒)、`severity`(预先映射好的严重级别:`tool/result.isError``turn/end` 的错误原因与 `agent-error` 映射为 ERROR其他已捕获记录映射为 INFO`telemetry/record` 策略可以指定 WARN、只含身份信息的 `attributes``session.id``event.type``event.seq`header 中存在时再加 `session.cwd`/`session.parent_id`/`session.seed_length`),以及作为 `body` 的完整深拷贝 `event.data`,且以脱敏后的内容为准。运维记录携带 `telemetry.op``agent-error` | `shutdown`)和 `session.id`,并刻意不带 `event.seq`/`event.type`:它们是用来告警的信号,不是用来累加的条目;`agent-error` 会把任意抛出值规范化为稳定的 `{ name, message }` 记录主体。交接之后的投递由后端 SDK 负责重复仍然可能出现无游标的重新收养、SDK 重试),因此接收端基于 `(session.id, event.seq)` 去重。
`SessionTelemetryRecord` 包含:`channel``ledger` | `ops`)、`time`epoch 毫秒)、`severity`(预先映射好的严重级别:`tool/result.isError``turn/end` 的错误原因与 `agent-error` 映射为 ERROR其他已捕获记录映射为 INFO`sessionTelemetry/record` 策略可以指定 WARN、只含身份信息的 `attributes``session.id``event.type``event.seq`header 中存在时再加 `session.cwd`/`session.parent_id`/`session.seed_length`),以及作为 `body` 的完整深拷贝 `event.data`,且以脱敏后的内容为准。运维记录携带 `sessionTelemetry.op``agent-error` | `shutdown`)和 `session.id`,并刻意不带 `event.seq`/`event.type`:它们是用来告警的信号,不是用来累加的条目;`agent-error` 会把任意抛出值规范化为稳定的 `{ name, message }` 记录主体。交接之后的投递由后端 SDK 负责重复仍然可能出现无游标的重新收养、SDK 重试),因此接收端基于 `(session.id, event.seq)` 去重。
## 模型体验
@@ -47,5 +47,5 @@
## 已知限制与暂缓事项
- **尽力而为的投递**:游标标记的是已交接而非已投递;在重载窗口内被拆除的会话无法重新收养;崩溃时留在后端队列中的内容会丢失。持久化 outboxspool、每 sink 游标、at-least-once推迟到有部署方提出明确的崩溃丢失要求时再实现见[复活 Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)。
- **不内置脱敏规则**:未挂载 `telemetry/record` 监听器时,记录以捕获时的原样离开进程,包括文件内容或命令输出中内嵌的任何凭据;向共享 collector 导出的部署方自行负责其规则集。
- **不内置脱敏规则**:未挂载 `sessionTelemetry/record` 监听器时,记录以捕获时的原样离开进程,包括文件内容或命令输出中内嵌的任何凭据;向共享 collector 导出的部署方自行负责其规则集。
- **按需脱敏使用当前状态**:未捕获的事件只存在于权威会话日志中。后续的 `captureSession()` 会使用当时挂载的策略,深拷贝并脱敏其当前值;不存在捕获时的遥测快照或持久化的捕获前 spool。

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-session-telemetry",
"description": "Telemetry seam for the DeepSeek Harness: session-event capture, projection, redaction, and handoff to a reporting backend",
"description": "SessionTelemetryBackend seam for the DeepSeek Harness: session-event capture, projection, redaction, and handoff to a reporting backend",
"version": "0.0.1-rc.2",
"publishConfig": {
"access": "restricted"

View File

@@ -3,7 +3,7 @@
* the session firehose plus the one live-bus relay (`agent/error`). Both
* capture paths apply the fixed chunk projection, build logical records, and
* run each through the
* `telemetry/record` waterfall (deployment-mounted redaction rules;
* `session-telemetry/record` waterfall (deployment-mounted redaction rules;
* pass-through when none), then hands the result to the backend. Live capture
* follows the session firehose; on-demand capture replays the canonical log
* only when requested. Every synchronous handler is self-contained so a
@@ -17,14 +17,14 @@
import type { Context } from '@deepseek-ai/cordis'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { TelemetryBackend, TelemetryRecord, TelemetrySeverity } from './index.ts'
import type { SessionTelemetrySink, SessionTelemetryRecord, SessionTelemetrySeverity } from './index.ts'
/** Whether capture follows live events or reads the canonical log only when requested. */
export type TelemetryCapture = 'live' | 'on-demand'
export type SessionTelemetryCapture = 'live' | 'on-demand'
/** One projected record ready for backend handoff. */
interface ProjectedRecord {
readonly record: TelemetryRecord
readonly record: SessionTelemetryRecord
/** Ledger cursor advanced only after the backend accepts this record. */
readonly seq?: number
}
@@ -57,7 +57,7 @@ const handoffCursor = new WeakMap<Session, number>()
* instead of throwing — best-effort reporting must not fail application
* teardown.
*/
export class TelemetryCoordinator {
export class SessionTelemetryCoordinator {
/**
* Sessions adopted by THIS fiber and still live, for double-adoption
* protection and the teardown sweep of unmarked sessions;
@@ -73,8 +73,8 @@ export class TelemetryCoordinator {
*/
constructor(
private readonly ctx: Context,
private readonly backend: TelemetryBackend,
capture: TelemetryCapture = 'live',
private readonly backend: SessionTelemetrySink,
capture: SessionTelemetryCapture = 'live',
) {
if (capture === 'live') {
ctx.on('session/created', (session) => {
@@ -203,15 +203,15 @@ export class TelemetryCoordinator {
}
/**
* Run the `telemetry/record` waterfall at capture time. The innermost `next`
* Run the `session-telemetry/record` waterfall at capture time. The innermost `next`
* passes the record through unchanged — this package ships no rules; exported
* data is as clean as the listeners a deployment mounts. Callers run inside
* {@link contain}, so a throwing rule withholds the record instead of
* reaching the loop (fail-closed). On-demand capture invokes this waterfall
* while reading the canonical session log, not when the event was appended.
*/
private redact(record: TelemetryRecord): TelemetryRecord {
return this.ctx.waterfall('telemetry/record', record, () => record)
private redact(record: SessionTelemetryRecord): SessionTelemetryRecord {
return this.ctx.waterfall('session-telemetry/record', record, () => record)
}
/** Hand one redacted record to the backend, then advance its ledger cursor. */
@@ -271,7 +271,7 @@ export class TelemetryCoordinator {
* Build the per-session clean-exit marker: emitted at the session's own
* disposal edge, or at coordinator dispose for sessions still alive then.
*/
function shutdownRecord(session: Session): TelemetryRecord {
function shutdownRecord(session: Session): SessionTelemetryRecord {
return {
channel: 'ops',
time: Date.now(),
@@ -282,7 +282,7 @@ function shutdownRecord(session: Session): TelemetryRecord {
}
/** Map an event's own outcome flag to the pre-baked alerting severity. */
function severityOf(event: SessionEvent): TelemetrySeverity {
function severityOf(event: SessionEvent): SessionTelemetrySeverity {
switch (event.type) {
case 'tool/result':
return event.data.message.content[0].isError === true ? 'error' : 'info'

View File

@@ -1,12 +1,12 @@
/**
* Telemetry Service Definition for the DeepSeek Harness.
* SessionTelemetryBackend Service Definition for the DeepSeek Harness.
*
* This package owns the CAPTURE side of session-event reporting — which records
* exist (the chunk projection), what they carry (the logical record), when
* they are captured (adoption, the per-append firehose, lifecycle
* forwarding), live versus on-demand canonical-log capture, and the HMR
* cursor. Everything downstream of
* {@link Telemetry.emit} — batching, retry, queueing, and loss policy — is the
* {@link SessionTelemetryBackend.emit} — batching, retry, queueing, and loss policy — is the
* reporting SDK's territory and is deliberately not modelled here. The
* design and its trade-offs are pinned in
* .agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md.
@@ -18,7 +18,7 @@ import { Context, Service } from '@deepseek-ai/cordis'
declare module '@deepseek-ai/cordis' {
interface Context {
telemetry: Telemetry
sessionTelemetry: SessionTelemetryBackend
}
interface Events {
@@ -40,7 +40,7 @@ declare module '@deepseek-ai/cordis' {
* copy; listeners return a (possibly new) record and must not mutate it.
* @mode waterfall
*/
'telemetry/record'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord
'session-telemetry/record'(record: SessionTelemetryRecord, next: () => SessionTelemetryRecord): SessionTelemetryRecord
}
}
@@ -49,10 +49,10 @@ declare module '@deepseek-ai/cordis' {
* alert with zero configuration: `error` for events whose own outcome flag
* says so (the tool-result block's `isError`, `turn/end` error reasons) and for
* `agent-error` operational records. Captured events otherwise default to
* `info`; `warn` remains available to `telemetry/record` policies and
* `info`; `warn` remains available to `session-telemetry/record` policies and
* backends.
*/
export type TelemetrySeverity = 'info' | 'warn' | 'error'
export type SessionTelemetrySeverity = 'info' | 'warn' | 'error'
/**
* One logical record handed to a backend — the capture contract's whole outbound
@@ -61,13 +61,13 @@ export type TelemetrySeverity = 'info' | 'warn' | 'error'
* home (`agent-error`, `shutdown`) and deliberately omit `event.seq`-style
* identity so they can never be mistaken for ledger rows.
*/
export interface TelemetryRecord {
export interface SessionTelemetryRecord {
/** Ledger (session-log mirror) or ops (operational signal) channel; backends keep the two under separate instrumentation scopes. */
channel: 'ledger' | 'ops'
/** Unix epoch milliseconds — the source event's append time for ledger records, the emission time for ops records. */
time: number
/** Pre-mapped alerting severity; see {@link TelemetrySeverity}. */
severity: TelemetrySeverity
/** Pre-mapped alerting severity; see {@link SessionTelemetrySeverity}. */
severity: SessionTelemetrySeverity
/**
* Identity attributes, deliberately minimal: ledger records carry
* `session.id`, `event.type`, `event.seq`, plus `session.cwd` /
@@ -87,11 +87,11 @@ export interface TelemetryRecord {
}
/**
* The minimum backend contract the coordinator requires. {@link Telemetry} is
* The minimum backend contract the coordinator requires. {@link SessionTelemetryBackend} is
* its service-registered form; tests compose the coordinator with a bare
* implementation of this interface.
*/
export interface TelemetryBackend {
export interface SessionTelemetrySink {
/**
* Hand one record to the backend's pipeline. MUST be a non-blocking
* enqueue — the coordinator calls this synchronously from the
@@ -101,7 +101,7 @@ export interface TelemetryBackend {
* never reach the loop.
* @param record - the logical record to report; owned by the backend after the call.
*/
emit(record: TelemetryRecord): void
emit(record: SessionTelemetryRecord): void
/**
* Optional hint that a turn ended. A backend may forward it to its SDK's
* flush so records are exported after each turn. Called
@@ -132,22 +132,22 @@ export interface TelemetryBackend {
/**
* Deployment-selected session-sharing policy disclosed by a mounted
* {@link Telemetry} backend to human-facing acknowledgement surfaces (the
* {@link SessionTelemetryBackend} backend to human-facing acknowledgement surfaces (the
* `/feedback` command's confirmation text). The seam owns the vocabulary so
* any backend can disclose a policy without depending on the OTel package;
* the values mirror the OTel backend's serialized `TelemetryMode` choices.
* the values mirror the OTel backend's serialized `SessionTelemetryMode` choices.
*/
export type TelemetrySharingStatus = 'full' | 'feedback-only' | 'disabled'
export type SessionTelemetrySharingStatus = 'full' | 'feedback-only' | 'disabled'
/**
* Loadable form of the backend contract: one implementation per context —
* the cordis `Service` registration under the `telemetry` key throws on a
* duplicate, cordis' standard behavior. A backend composes a
* {@link TelemetryCoordinator} in its constructor to install the capture side.
* {@link SessionTelemetryCoordinator} in its constructor to install the capture side.
*/
export abstract class Telemetry extends Service implements TelemetryBackend {
export abstract class SessionTelemetryBackend extends Service implements SessionTelemetrySink {
constructor(ctx: Context) {
super(ctx, 'telemetry')
super(ctx, 'sessionTelemetry')
}
/**
@@ -157,22 +157,22 @@ export abstract class Telemetry extends Service implements TelemetryBackend {
* when no telemetry service is mounted. The seam owns this vocabulary so the
* disclosure is backend-independent.
*/
abstract readonly sharing: TelemetrySharingStatus
abstract readonly sharing: SessionTelemetrySharingStatus
/**
* See {@link TelemetryBackend.emit} — that declaration is the contract's one home.
* See {@link SessionTelemetrySink.emit} — that declaration is the contract's one home.
* @param record - the logical record to report; owned by the backend after the call.
*/
abstract emit(record: TelemetryRecord): void
abstract emit(record: SessionTelemetryRecord): void
/** See {@link TelemetryBackend.flush}. */
/** See {@link SessionTelemetrySink.flush}. */
flush?(): void
/**
* See {@link TelemetryBackend.shutdown}.
* See {@link SessionTelemetrySink.shutdown}.
* @returns resolves when the backend's pipeline has quiesced.
*/
abstract shutdown(): Promise<void>
}
export { TelemetryCoordinator, type TelemetryCapture } from './coordinator.ts'
export { SessionTelemetryCoordinator, type SessionTelemetryCapture } from './coordinator.ts'

View File

@@ -1,6 +1,6 @@
import { createUserMessage } from '@deepseek-ai/dsh-llm'
/**
* The `telemetry/record` waterfall contract: pass-through when no listener is
* The `session-telemetry/record` waterfall contract: pass-through when no listener is
* mounted, listener stacking and replacement, ops-record coverage, the
* untouched canonical log, and the fail-closed containment of a throwing rule.
*/
@@ -9,16 +9,16 @@ import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import {
TelemetryCoordinator,
type TelemetryBackend,
type TelemetryRecord,
SessionTelemetryCoordinator,
type SessionTelemetrySink,
type SessionTelemetryRecord,
} from '../src/index.ts'
const FIXTURE_SECRET = 'sk-fixture1234567890'
class CollectingBackend implements TelemetryBackend {
records: TelemetryRecord[] = []
emit(record: TelemetryRecord): void {
class CollectingBackend implements SessionTelemetrySink {
records: SessionTelemetryRecord[] = []
emit(record: SessionTelemetryRecord): void {
this.records.push(record)
}
async shutdown(): Promise<void> {}
@@ -31,12 +31,12 @@ async function setup() {
const fiber = await ctx.plugin({
name: 'fake-telemetry',
inject: ['sessions'],
apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
apply: (inner: Context) => void new SessionTelemetryCoordinator(inner, backend),
})
return { ctx, backend, fiber }
}
describe('telemetry/record waterfall', () => {
describe('session-telemetry/record waterfall', () => {
it('passes records through unchanged when no listener is mounted', async () => {
const { ctx, backend } = await setup()
const session = ctx.sessions.create(SessionId('w'))
@@ -49,7 +49,7 @@ describe('telemetry/record waterfall', () => {
it('applies a mounted rule to every outbound record, ops records included', async () => {
const { ctx, backend, fiber } = await setup()
ctx.on('telemetry/record', (_record, next) => {
ctx.on('session-telemetry/record', (_record, next) => {
const record = next()
return { ...record, body: { scrubbed: true } }
})
@@ -67,7 +67,7 @@ describe('telemetry/record waterfall', () => {
it('keeps the canonical log untouched by a mounted rule', async () => {
const { ctx } = await setup()
ctx.on('telemetry/record', (_record, next) => ({ ...next(), body: null }))
ctx.on('session-telemetry/record', (_record, next) => ({ ...next(), body: null }))
const session = ctx.sessions.create(SessionId('log'))
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: FIXTURE_SECRET }], source: { kind: 'user' },
@@ -79,13 +79,13 @@ describe('telemetry/record waterfall', () => {
it('stacks listeners outermost-first around next()', async () => {
const { ctx, backend } = await setup()
const order: string[] = []
ctx.on('telemetry/record', (_record, next) => {
ctx.on('session-telemetry/record', (_record, next) => {
order.push('outer-before')
const record = next()
order.push('outer-after')
return { ...record, attributes: { ...record.attributes, outer: 1 } }
})
ctx.on('telemetry/record', (_record, next) => {
ctx.on('session-telemetry/record', (_record, next) => {
order.push('inner')
const record = next()
return { ...record, attributes: { ...record.attributes, inner: 1 } }
@@ -101,8 +101,8 @@ describe('telemetry/record waterfall', () => {
it('a listener that skips next() replaces everything beneath it', async () => {
const { ctx, backend } = await setup()
const inner = { called: false }
ctx.on('telemetry/record', () => ({ channel: 'ops', time: 0, severity: 'info', attributes: {}, body: 'replaced' } satisfies TelemetryRecord))
ctx.on('telemetry/record', (_record, next) => {
ctx.on('session-telemetry/record', () => ({ channel: 'ops', time: 0, severity: 'info', attributes: {}, body: 'replaced' } satisfies SessionTelemetryRecord))
ctx.on('session-telemetry/record', (_record, next) => {
inner.called = true
return next()
})
@@ -116,7 +116,7 @@ describe('telemetry/record waterfall', () => {
it('a throwing rule withholds the record fail-closed without disturbing the log', async () => {
const { ctx, backend } = await setup()
ctx.on('telemetry/record', () => {
ctx.on('session-telemetry/record', () => {
throw new Error('rule exploded')
})
const session = ctx.sessions.create(SessionId('closed'))

View File

@@ -11,10 +11,10 @@ import { Context } from '@deepseek-ai/cordis'
import SessionStore, { SessionId, type Session, type SessionEvent } from '@deepseek-ai/dsh-session'
import type { Agent } from '@deepseek-ai/dsh-agent'
import {
TelemetryCoordinator,
type TelemetryBackend,
type TelemetryCapture,
type TelemetryRecord,
SessionTelemetryCoordinator,
type SessionTelemetrySink,
type SessionTelemetryCapture,
type SessionTelemetryRecord,
} from '../src/index.ts'
declare module '@deepseek-ai/dsh-session/types' {
@@ -28,15 +28,15 @@ declare module '@deepseek-ai/dsh-session/types' {
}
}
class FakeBackend implements TelemetryBackend {
records: TelemetryRecord[] = []
class FakeBackend implements SessionTelemetrySink {
records: SessionTelemetryRecord[] = []
calls: string[] = []
emitError: Error | undefined
rejectSeq: number | undefined
shutdownError: Error | undefined
shutdownResolved = false
emit(record: TelemetryRecord): void {
emit(record: SessionTelemetryRecord): void {
if (this.emitError) throw this.emitError
if (this.rejectSeq !== undefined && record.attributes['event.seq'] === this.rejectSeq) {
throw new Error(`backend rejected seq ${this.rejectSeq}`)
@@ -54,23 +54,23 @@ class FakeBackend implements TelemetryBackend {
this.shutdownResolved = true
}
ledger(): TelemetryRecord[] {
ledger(): SessionTelemetryRecord[] {
return this.records.filter(r => r.channel === 'ledger')
}
}
async function setup(
backend: FakeBackend = new FakeBackend(),
capture: TelemetryCapture = 'live',
capture: SessionTelemetryCapture = 'live',
) {
const ctx = new Context()
await ctx.plugin(SessionStore)
let coordinator!: TelemetryCoordinator
let coordinator!: SessionTelemetryCoordinator
const fiber = await ctx.plugin({
name: 'fake-telemetry',
inject: ['sessions'],
apply: (inner: Context) => {
coordinator = new TelemetryCoordinator(inner, backend, capture)
coordinator = new SessionTelemetryCoordinator(inner, backend, capture)
},
})
return { ctx, backend, coordinator, fiber }
@@ -87,7 +87,7 @@ function appendTurn(session: Session): void {
}), { surfaceOp: 'append' })
}
describe('TelemetryCoordinator capture', () => {
describe('SessionTelemetryCoordinator capture', () => {
it('hands every appended event over with envelope identity and cloned body', async () => {
const { ctx, backend } = await setup()
const session = liveSession(ctx, 'cap')
@@ -178,7 +178,7 @@ describe('TelemetryCoordinator capture', () => {
})
})
describe('TelemetryCoordinator on-demand capture', () => {
describe('SessionTelemetryCoordinator on-demand capture', () => {
it('captures one canonical-log prefix at a time without following later events', async () => {
const { ctx, backend, coordinator } = await setup(new FakeBackend(), 'on-demand')
const session = liveSession(ctx, 'on-demand-prefix')
@@ -207,7 +207,7 @@ describe('TelemetryCoordinator on-demand capture', () => {
const { ctx, backend, coordinator } = await setup(new FakeBackend(), 'on-demand')
const session = liveSession(ctx, 'on-demand-redacted')
session.append('turn/start', { turn: 1 })
const disposeRule = ctx.on('telemetry/record', (_record, next) => ({
const disposeRule = ctx.on('session-telemetry/record', (_record, next) => ({
...next(),
body: { scrubbed: true },
}))
@@ -244,12 +244,12 @@ describe('TelemetryCoordinator on-demand capture', () => {
expect(first.records).toEqual([])
const second = new FakeBackend()
let coordinator!: TelemetryCoordinator
let coordinator!: SessionTelemetryCoordinator
await ctx.plugin({
name: 'fake-telemetry-after-on-demand-reload',
inject: ['sessions'],
apply: (inner: Context) => {
coordinator = new TelemetryCoordinator(inner, second, 'on-demand')
coordinator = new SessionTelemetryCoordinator(inner, second, 'on-demand')
},
})
coordinator.captureSession(session)
@@ -258,8 +258,8 @@ describe('TelemetryCoordinator on-demand capture', () => {
it('registers no continuous capture, flush, or ops listeners', async () => {
const { ctx, backend, coordinator, fiber } = await setup(new FakeBackend(), 'on-demand')
const redact = vi.fn((_record: TelemetryRecord, next: () => TelemetryRecord) => next())
ctx.on('telemetry/record', redact)
const redact = vi.fn((_record: SessionTelemetryRecord, next: () => SessionTelemetryRecord) => next())
ctx.on('session-telemetry/record', redact)
const session = liveSession(ctx, 'on-demand-ledger-only')
session.append('turn/start', { turn: 1 })
await ctx.parallel('session/flush', session)
@@ -276,7 +276,7 @@ describe('TelemetryCoordinator on-demand capture', () => {
})
})
describe('TelemetryCoordinator adoption', () => {
describe('SessionTelemetryCoordinator adoption', () => {
it('exports an unpublished suffix without re-exporting constructor history', async () => {
const backend = new FakeBackend()
const ctx = new Context()
@@ -286,7 +286,7 @@ describe('TelemetryCoordinator adoption', () => {
await ctx.plugin({
name: 'fake-telemetry',
inject: ['sessions'],
apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
apply: (inner: Context) => void new SessionTelemetryCoordinator(inner, backend),
})
const child = ctx.sessions.prepare(SessionId('seeded'), { seed: [...parent.events], meta: {} })
child.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
@@ -311,7 +311,7 @@ describe('TelemetryCoordinator adoption', () => {
await ctx.plugin({
name: 'fake-telemetry',
inject: ['sessions'],
apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
apply: (inner: Context) => void new SessionTelemetryCoordinator(inner, backend),
})
const ofResumed = () => backend.ledger()
.filter(r => r.attributes['session.id'] === 'resumed')
@@ -341,7 +341,7 @@ describe('TelemetryCoordinator adoption', () => {
await ctx.plugin({
name: 'fake-telemetry',
inject: ['sessions'],
apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
apply: (inner: Context) => void new SessionTelemetryCoordinator(inner, backend),
})
child.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const record = backend.ledger().find(r => r.attributes['session.id'] === 'stitch-child')!
@@ -363,7 +363,7 @@ describe('TelemetryCoordinator adoption', () => {
await ctx.plugin({
name: 'fake-telemetry',
inject: ['sessions'],
apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
apply: (inner: Context) => void new SessionTelemetryCoordinator(inner, backend),
})
expect(backend.ledger()).toHaveLength(2)
ctx.sessions.announce(session)
@@ -387,7 +387,7 @@ describe('TelemetryCoordinator adoption', () => {
await ctx.plugin({
name: 'fake-telemetry-2',
inject: ['sessions'],
apply: (inner: Context) => void new TelemetryCoordinator(inner, second),
apply: (inner: Context) => void new SessionTelemetryCoordinator(inner, second),
})
// Only the window events past the cursor are re-handed, and the mid-step
// continuation is re-dropped because ≤cursor events rebuilt the projection.
@@ -410,7 +410,7 @@ describe('TelemetryCoordinator adoption', () => {
await ctx.plugin({
name: 'fake-telemetry',
inject: ['sessions'],
apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
apply: (inner: Context) => void new SessionTelemetryCoordinator(inner, backend),
})
expect(backend.ledger().map(r => r.attributes['event.seq'])).toEqual([0, 2])
expect(warn).toHaveBeenCalled()
@@ -425,13 +425,13 @@ describe('TelemetryCoordinator adoption', () => {
await ctx.plugin({
name: 'fake-telemetry',
inject: ['sessions'],
apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
apply: (inner: Context) => void new SessionTelemetryCoordinator(inner, backend),
})
expect(backend.ledger().map(r => r.attributes['event.seq'])).toEqual([0, 1])
})
})
describe('TelemetryCoordinator lifecycle and containment', () => {
describe('SessionTelemetryCoordinator lifecycle and containment', () => {
it('forwards session/flush as a hint without awaiting backend work', async () => {
const { ctx, backend } = await setup()
const session = liveSession(ctx)
@@ -466,7 +466,7 @@ describe('TelemetryCoordinator lifecycle and containment', () => {
await ctx.plugin({
name: 'fake-telemetry',
inject: ['sessions'],
apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
apply: (inner: Context) => void new SessionTelemetryCoordinator(inner, backend),
})
expect(() => ctx.sessions.create(SessionId('vetoed'), { meta: {} })).toThrow('vetoed')
expect(backend.records.filter(r => r.channel === 'ops')).toHaveLength(0)

View File

@@ -21,7 +21,7 @@
"path": "../../core/agent"
},
{
"path": "../../support/invariants"
"path": "../../runtime-diagnostics/invariants"
}
]
}

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session/user-id/README.md
README.md: 55bc54e4a5b666880f1908f4ccdf720e1122fc7a
README.zh.md: 5c953346ec7a5014058502f3bd5d5154b438d7b0
# pnpm run verify-translation-pairing --write packages/session/session-title-all-prompts-llm/README.md
README.md: cdda77f4bd92b927a156db093d690062ea10d531
README.zh.md: 25af029ce1002ecffcd40e453213899d8f0b8f31

View File

@@ -1,8 +1,8 @@
# @deepseek-ai/dsh-session-title-all-messages-llm
# @deepseek-ai/dsh-session-title-all-prompts-llm
English | [中文](README.zh.md)
Optional `ctx.sessionTitle` provider that summarizes every eligible human message through `ctx.llm`. It registers the `all-user-messages` cadence and starts a new revision after each new human prompt, using seeded history as well as child-session prompts. A newer revision aborts and supersedes older work; even a provider that ignores cancellation cannot commit stale output.
Optional `ctx.sessionTitle` provider that summarizes every eligible human message through `ctx.llm`. It registers the `all-prompts` cadence and starts a new revision after each new human prompt, using seeded history as well as child-session prompts. A newer revision aborts and supersedes older work; even a provider that ignores cancellation cannot commit stale output.
The plugin uses the complete required [shared LLM configuration](../session-title-llm/README.md#configuration). Omit both `provider` and `model` to inherit the exact route from each current logged main request, or set both to route title generation independently. If the final framed aggregate prompt exceeds `maxInputBytes`, the request fails instead of truncating history; automatic use warns and keeps the prior title.

View File

@@ -1,8 +1,8 @@
# @deepseek-ai/dsh-session-title-all-messages-llm
# @deepseek-ai/dsh-session-title-all-prompts-llm
[English](README.md) | 中文
可选的 `ctx.sessionTitle` 提供方,通过 `ctx.llm` 总结所有符合条件的用户消息。它注册 `all-user-messages` 节奏,并在每条新用户提示词后启动新 revision同时使用预置历史与子会话提示词。较新的 revision 会中止并取代旧工作;即使提供方忽略取消,也无法提交陈旧输出。
可选的 `ctx.sessionTitle` 提供方,通过 `ctx.llm` 总结所有符合条件的用户消息。它注册 `all-prompts` 节奏,并在每条新用户提示词后启动新 revision同时使用预置历史与子会话提示词。较新的 revision 会中止并取代旧工作;即使提供方忽略取消,也无法提交陈旧输出。
该插件使用完整且必填的[共享 LLM大语言模型配置](../session-title-llm/README.md#configuration)。同时省略 `provider``model` 时,会继承每个当前已记录主请求的确切路由;也可以同时设置二者,使标题生成使用独立路由。如果最终封装的聚合提示词超过 `maxInputBytes`,请求会失败而不是截断历史;自动使用时会发出警告并保留先前标题。

View File

@@ -1,5 +1,5 @@
{
"name": "@deepseek-ai/dsh-session-title-all-messages-llm",
"name": "@deepseek-ai/dsh-session-title-all-prompts-llm",
"description": "All-user-messages LLM provider plugin for DeepSeek Harness session titles",
"version": "0.0.1-rc.2",
"publishConfig": {
@@ -8,7 +8,7 @@
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/session/session-title-all-messages-llm"
"directory": "packages/session/session-title-all-prompts-llm"
},
"type": "module",
"main": "lib/index.js",

View File

@@ -8,12 +8,12 @@ import {
} from '@deepseek-ai/dsh-session-title-llm'
import type { SessionTitleLlmConfig } from '@deepseek-ai/dsh-session-title-llm'
export const name = 'session-title-all-messages-llm'
export const name = 'session-title-all-prompts-llm'
export const inject = ['sessionTitle', 'llm', 'sessions']
/** Required LLM policy; this plugin adds no defaults. */
export type Config = SessionTitleLlmConfig
/** Loader schema shared with the first-message provider. */
/** Loader schema shared with the first-prompt provider. */
/* jscpd:ignore-start -- Loader requires each plugin to export its own statically walkable schema; the field validators remain shared. */
export const Config: z<Config> = z.object({
targetWords: SessionTitleLlmConfigFields.targetWords,
@@ -27,10 +27,10 @@ export const Config: z<Config> = z.object({
/* jscpd:ignore-end */
/**
* Register the all-user-messages model provider.
* Register the all-prompts model provider.
* @param ctx - context exposing session-title, LLM, and session services.
* @param config - required route, target, byte, token, and timeout policy.
*/
export function apply(ctx: Context, config: Config): void {
registerSessionTitleLlmProvider(ctx, config, name, 'all-user-messages', messages => messages)
registerSessionTitleLlmProvider(ctx, config, name, 'all-prompts', messages => messages)
}

View File

@@ -1,16 +1,16 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-session-title-first-message-llm`.
* @module @deepseek-ai/dsh-session-title-first-message-llm/invariant
* Package-owned invariant companion for `@deepseek-ai/dsh-session-title-all-prompts-llm`.
* @module @deepseek-ai/dsh-session-title-all-prompts-llm/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-session-title-first-message-llm'
const PACKAGE_NAME = '@deepseek-ai/dsh-session-title-all-prompts-llm'
/** Cordis companion plugin name. */
export const name = 'session-title-first-message-llm-invariant'
export const name = 'session-title-all-prompts-llm-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']

View File

@@ -1,10 +1,10 @@
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import LlmService, { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm'
import LlmRuntime, { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import SessionTitleService from '@deepseek-ai/dsh-session-title'
import * as providerPlugin from '@deepseek-ai/dsh-session-title-all-messages-llm'
import * as providerPlugin from '@deepseek-ai/dsh-session-title-all-prompts-llm'
class RecordingAdapter extends LlmAdapter {
readonly requests: GenerateOptions[] = []
@@ -42,7 +42,7 @@ describe('all-messages LLM title provider', () => {
seeded.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmRuntime)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionTitleService, TITLE_CONFIG)
const adapter = new RecordingAdapter()

View File

@@ -6,7 +6,7 @@
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../../vendor/schemastery" },
{ "path": "../../support/invariants" },
{ "path": "../../runtime-diagnostics/invariants" },
{ "path": "../../llm/llm" },
{ "path": "../session-title" },
{ "path": "../session-title-llm" }

View File

@@ -1,6 +0,0 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session/session-title-first-message-llm/README.md
README.md: c24d4de1fa945d11b5a868cad88a94ed14ad3891
README.zh.md: 7304ddb4027afdf56a4271618e00643705a137aa

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session/session-title-all-messages-llm/README.md
README.md: 25ec92432b5c2d18624f2f4d851552ee2b3bf5d0
README.zh.md: be9399f9b116e0141de5b3ccd9d8065618f11ba3
# pnpm run verify-translation-pairing --write packages/session/session-title-first-prompt-llm/README.md
README.md: fef0651e1c4ba0032e6a1854590d5ac023bd71d8
README.zh.md: 377779467a091b2c68d91a8be1903702a0808d10

View File

@@ -1,8 +1,8 @@
# @deepseek-ai/dsh-session-title-first-message-llm
# @deepseek-ai/dsh-session-title-first-prompt-llm
English | [中文](README.zh.md)
Optional `ctx.sessionTitle` provider that summarizes the first eligible human message through `ctx.llm`. It registers the `first-message` cadence, runs automatically only when a fresh non-fork session first creates its fallback, and attributes the result to that message's exact seq. An automatic failure retains the fallback and is retried only through `ctx.sessionTitle.refresh()`.
Optional `ctx.sessionTitle` provider that summarizes the first eligible human message through `ctx.llm`. It registers the `first-prompt` cadence, runs automatically only when a fresh non-fork session first creates its fallback, and attributes the result to that message's exact seq. An automatic failure retains the fallback and is retried only through `ctx.sessionTitle.refresh()`.
The plugin uses the complete required [shared LLM configuration](../session-title-llm/README.md#configuration). Omit both `provider` and `model` to inherit the exact route from the current logged main request, or set both to route title generation independently.

View File

@@ -1,8 +1,8 @@
# @deepseek-ai/dsh-session-title-first-message-llm
# @deepseek-ai/dsh-session-title-first-prompt-llm
[English](README.md) | 中文
可选的 `ctx.sessionTitle` 提供方,通过 `ctx.llm` 总结第一条符合条件的用户消息。它注册 `first-message` 节奏,只在全新非 fork 会话首次创建回退时自动运行,并将结果归因于该消息的确切 seq。自动失败会保留回退之后只能通过 `ctx.sessionTitle.refresh()` 重试。
可选的 `ctx.sessionTitle` 提供方,通过 `ctx.llm` 总结第一条符合条件的用户消息。它注册 `first-prompt` 节奏,只在全新非 fork 会话首次创建回退时自动运行,并将结果归因于该消息的确切 seq。自动失败会保留回退之后只能通过 `ctx.sessionTitle.refresh()` 重试。
该插件使用完整且必填的[共享 LLM大语言模型配置](../session-title-llm/README.md#configuration)。同时省略 `provider``model` 时,会继承当前已记录主请求的确切路由;也可以同时设置二者,使标题生成使用独立路由。

View File

@@ -1,5 +1,5 @@
{
"name": "@deepseek-ai/dsh-session-title-first-message-llm",
"name": "@deepseek-ai/dsh-session-title-first-prompt-llm",
"description": "First-message LLM provider plugin for DeepSeek Harness session titles",
"version": "0.0.1-rc.2",
"publishConfig": {
@@ -8,7 +8,7 @@
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/session/session-title-first-message-llm"
"directory": "packages/session/session-title-first-prompt-llm"
},
"type": "module",
"main": "lib/index.js",

View File

@@ -8,7 +8,7 @@ import {
} from '@deepseek-ai/dsh-session-title-llm'
import type { SessionTitleLlmConfig } from '@deepseek-ai/dsh-session-title-llm'
export const name = 'session-title-first-message-llm'
export const name = 'session-title-first-prompt-llm'
export const inject = ['sessionTitle', 'llm', 'sessions']
/** Required LLM policy; this plugin adds no defaults. */
@@ -27,14 +27,14 @@ export const Config: z<Config> = z.object({
/* jscpd:ignore-end */
/**
* Register the first-message model provider.
* Register the first-prompt model provider.
* @param ctx - context exposing session-title, LLM, and session services.
* @param config - required route, target, byte, token, and timeout policy.
*/
export function apply(ctx: Context, config: Config): void {
registerSessionTitleLlmProvider(ctx, config, name, 'first-message', (messages) => {
registerSessionTitleLlmProvider(ctx, config, name, 'first-prompt', (messages) => {
const first = messages[0]
if (first === undefined) throw new Error('first-message title provider requires one human message')
if (first === undefined) throw new Error('first-prompt title provider requires one human message')
return [first]
})
}

View File

@@ -1,16 +1,16 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-session-title-all-messages-llm`.
* @module @deepseek-ai/dsh-session-title-all-messages-llm/invariant
* Package-owned invariant companion for `@deepseek-ai/dsh-session-title-first-prompt-llm`.
* @module @deepseek-ai/dsh-session-title-first-prompt-llm/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-session-title-all-messages-llm'
const PACKAGE_NAME = '@deepseek-ai/dsh-session-title-first-prompt-llm'
/** Cordis companion plugin name. */
export const name = 'session-title-all-messages-llm-invariant'
export const name = 'session-title-first-prompt-llm-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']

View File

@@ -6,11 +6,11 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import LlmService, { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm'
import LlmRuntime, { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SessionTitleService from '@deepseek-ai/dsh-session-title'
import * as providerPlugin from '@deepseek-ai/dsh-session-title-first-message-llm'
import * as providerPlugin from '@deepseek-ai/dsh-session-title-first-prompt-llm'
let root: string | undefined
let context: Context | undefined
@@ -43,7 +43,7 @@ async function loadComposition(): Promise<Context> {
' fallbackMaxWords: 5',
' fallbackMaxBytes: 40',
' maxTitleBytes: 80',
"- name: '@deepseek-ai/dsh-session-title-first-message-llm'",
"- name: '@deepseek-ai/dsh-session-title-first-prompt-llm'",
' config:',
' targetWords: 5',
' targetCjkCharacters: 10',
@@ -60,10 +60,10 @@ async function loadComposition(): Promise<Context> {
await context.plugin(Loader)
context.loader.builtins.include = Include
const modules = new Map<string, unknown>([
['@deepseek-ai/dsh-llm', LlmService],
['@deepseek-ai/dsh-llm', LlmRuntime],
['@deepseek-ai/dsh-session', SessionStore],
['@deepseek-ai/dsh-session-title', SessionTitleService],
['@deepseek-ai/dsh-session-title-first-message-llm', providerPlugin],
['@deepseek-ai/dsh-session-title-first-prompt-llm', providerPlugin],
])
context.loader.internal = {
version: 'v2',
@@ -111,7 +111,7 @@ describe('session-title Loader composition', () => {
messageSeqs: [message.seq],
source: {
kind: 'provider',
provider: 'session-title-first-message-llm',
provider: 'session-title-first-prompt-llm',
model: { provider: 'title-route', model: 'title-model' },
},
})

View File

@@ -1,11 +1,11 @@
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import LlmRuntime from '@deepseek-ai/dsh-llm'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SessionTitleService from '@deepseek-ai/dsh-session-title'
import * as FirstMessageTitleProvider from '@deepseek-ai/dsh-session-title-first-message-llm'
import * as FirstMessageTitleProvider from '@deepseek-ai/dsh-session-title-first-prompt-llm'
const contexts: Context[] = []
@@ -13,11 +13,11 @@ afterEach(async () => {
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
})
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('first-message title provider with real DeepSeek API', () => {
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('first-prompt title provider with real DeepSeek API', () => {
it('replaces the fallback with a short model title', async () => {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(LlmService)
await ctx.plugin(LlmRuntime)
await ctx.plugin(LlmDeepSeek, { thinking: 'disabled' })
await ctx.plugin(SessionStore)
await ctx.plugin(SessionTitleService, {
@@ -49,7 +49,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('first-message title provider wit
messageSeqs: [message.seq],
source: {
kind: 'provider',
provider: 'session-title-first-message-llm',
provider: 'session-title-first-prompt-llm',
model: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
},
})

View File

@@ -1,10 +1,10 @@
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import LlmService, { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm'
import LlmRuntime, { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import SessionTitleService, { type SessionTitleProvider } from '@deepseek-ai/dsh-session-title'
import * as providerPlugin from '@deepseek-ai/dsh-session-title-first-message-llm'
import * as providerPlugin from '@deepseek-ai/dsh-session-title-first-prompt-llm'
class RecordingAdapter extends LlmAdapter {
readonly requests: GenerateOptions[] = []
@@ -31,10 +31,10 @@ async function settle(): Promise<void> {
await new Promise(resolve => setTimeout(resolve, 0))
}
describe('first-message LLM title provider', () => {
describe('first-prompt LLM title provider', () => {
it('rejects an impossible empty provider request at its own boundary', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmRuntime)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionTitleService, TITLE_CONFIG)
let registered: SessionTitleProvider | undefined
@@ -53,7 +53,7 @@ describe('first-message LLM title provider', () => {
it('always selects only the first eligible human message, including explicit refresh', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmRuntime)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionTitleService, TITLE_CONFIG)
const adapter = new RecordingAdapter()

View File

@@ -6,7 +6,7 @@
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../../vendor/schemastery" },
{ "path": "../../support/invariants" },
{ "path": "../../runtime-diagnostics/invariants" },
{ "path": "../../llm/llm" },
{ "path": "../session-title" },
{ "path": "../session-title-llm" }

View File

@@ -1,6 +1,6 @@
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import LlmService, { createUserMessage, CallId, isAgentLoopRequest, LlmAdapter } from '@deepseek-ai/dsh-llm'
import LlmRuntime, { createUserMessage, CallId, isAgentLoopRequest, LlmAdapter } from '@deepseek-ai/dsh-llm'
import type { FinishReason, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import { SessionTitleProviderId } from '@deepseek-ai/dsh-session-title'
@@ -112,7 +112,7 @@ async function withScript(script: readonly StreamChunk[]): Promise<{
}> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(LlmService)
await ctx.plugin(LlmRuntime)
const adapter = new RecordingAdapter(script)
ctx.llm.registerAdapter(['current-route'], adapter)
return { ctx, adapter }
@@ -122,7 +122,7 @@ describe('generateSessionTitleWithLlm', () => {
it('uses the exact logged route, language targets, full framed input, and output token cap', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(LlmService)
await ctx.plugin(LlmRuntime)
const providerRequest = request(ctx)
let requestWasLoggedAtDispatch = false
const adapter = new RecordingAdapter(SCRIPT, () => {
@@ -176,7 +176,7 @@ describe('generateSessionTitleWithLlm', () => {
it('uses paired explicit overrides and bounds the final framed input before model dispatch', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(LlmService)
await ctx.plugin(LlmRuntime)
const adapter = new RecordingAdapter(SCRIPT)
ctx.llm.registerAdapter(['explicit-route'], adapter)
const oversized = request(ctx)
@@ -316,7 +316,7 @@ describe('generateSessionTitleWithLlm', () => {
try {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(LlmService)
await ctx.plugin(LlmRuntime)
ctx.llm.registerAdapter(['current-route'], new CooperativeAdapter())
const providerRequest = request(ctx)
const pending = generateSessionTitleWithLlm(
@@ -342,7 +342,7 @@ describe('generateSessionTitleWithLlm', () => {
try {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(LlmService)
await ctx.plugin(LlmRuntime)
ctx.llm.registerAdapter(['current-route'], new DelayedSuccessAdapter(20))
const providerRequest = request(ctx)
const pending = generateSessionTitleWithLlm(

View File

@@ -9,7 +9,7 @@
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../../vendor/schemastery" },
{ "path": "../../support/invariants" },
{ "path": "../../runtime-diagnostics/invariants" },
{ "path": "../../llm/llm" },
{ "path": "../../util/timeout" },
{ "path": "../session-title" }

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session/session-title/README.md
README.md: e7004eb2e03757cb28b4b5756a876e1dee4b6229
README.zh.md: a5f29426e460266d347c9c86564cf9a84428405d
README.md: e923bd9700180214f235c4971d4b020565bfee43
README.zh.md: 8e84ac27d9f1509c8530b838eeb5eb2216d87199

View File

@@ -15,7 +15,7 @@ Only text blocks from human `user/message` events are eligible. The first eligib
Automatic work never delays the main agent response. A provider starts only after a marked loop-built request's exact route matches the current logged `request/header`, including when the unchanged header needs no new snapshot. Its late completion appends a standalone log-only event directly through `Session` without opening a turn. Persistence observes that event eagerly and drains on ordinary lifecycle checkpoints; title publication itself does not force a flush. Automatic failures warn and retain the latest title. New all-message revisions, provider disposal, session disposal, and explicit refresh abort older work, and a stale completion cannot append. Concurrent explicit refreshes reserve their revision before provider work, while overlapping automatic and explicit fallback requests share one session-local in-flight append. The service and bundled model provider each append their own literal event type, so no generic title-write marker, cast, or settlement queue is needed. Service teardown cancels queued work and drains calls that ignore cancellation before unloading completes.
Forks inherit title events in their seed unchanged. The first-message cadence does not automatically retitle a child; the all-messages cadence may append a new revision after the child receives a later human prompt.
Forks inherit title events in their seed unchanged. The first-prompt cadence does not automatically retitle a child; the all-messages cadence may append a new revision after the child receives a later human prompt.
## Configuration
@@ -29,7 +29,7 @@ All limits are required; the library supplies no defaults.
## Provider contract
A provider supplies a branded stable id, automatic mode (`first-message` or `all-user-messages`), and `generate(request)`. The request carries the live session, all eligible messages through one fixed revision, the current logged main-request route when available, and cancellation. The result identifies a non-empty title, unique ordered source-message seqs from that request, and the optional provider/model route used to generate it. The service normalizes and validates the result before it becomes durable.
A provider supplies a branded stable id, automatic mode (`first-prompt` or `all-prompts`), and `generate(request)`. The request carries the live session, all eligible messages through one fixed revision, the current logged main-request route when available, and cancellation. The result identifies a non-empty title, unique ordered source-message seqs from that request, and the optional provider/model route used to generate it. The service normalizes and validates the result before it becomes durable.
See the [session-title data structures](../../../docs/subsystems/session-title.md) and [implemented decision](../../../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md).

View File

@@ -29,7 +29,7 @@ fork 出的会话会原样继承种子中的标题事件。首消息节奏不会
## 提供方约定
提供方会提供带品牌类型的稳定 id、自动模式`first-message``all-user-messages`)和 `generate(request)`。请求携带活跃会话、截至一次固定修订的所有符合条件消息、可用时当前已记录的主请求路由,以及取消信号。结果包含非空标题、该请求中互不重复且有序的来源消息 seq以及生成该标题时使用的可选提供方模型路由。服务会在结果持久保存前进行规范化和验证。
提供方会提供带品牌类型的稳定 id、自动模式`first-prompt``all-prompts`)和 `generate(request)`。请求携带活跃会话、截至一次固定修订的所有符合条件消息、可用时当前已记录的主请求路由,以及取消信号。结果包含非空标题、该请求中互不重复且有序的来源消息 seq以及生成该标题时使用的可选提供方模型路由。服务会在结果持久保存前进行规范化和验证。
参见[会话标题数据结构](../../../docs/subsystems/session-title.md)与[已实现决策](../../../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md)。

View File

@@ -120,7 +120,7 @@ export interface SessionTitleUserMessage {
}
/** Automatic generation cadence owned by a registered provider. */
export type SessionTitleAutomaticMode = 'first-message' | 'all-user-messages'
export type SessionTitleAutomaticMode = 'first-prompt' | 'all-prompts'
/** Immutable input supplied to one title-provider call. */
export interface SessionTitleProviderRequest {
@@ -467,7 +467,7 @@ export class SessionTitleService extends Service {
const registration = this.registration
if (registration !== undefined && !registration.closing) {
const messages = collectSessionTitleMessages(session.events, event.seq)
const shouldSchedule = registration.provider.automatic === 'all-user-messages'
const shouldSchedule = registration.provider.automatic === 'all-prompts'
|| (session.header.parentSession === undefined && messages.length === 1 && this.get(session) === undefined)
if (shouldSchedule) {
const state = this.stateFor(session)
@@ -726,7 +726,7 @@ export class SessionTitleService extends Service {
if (typeof candidate.id !== 'string' || candidate.id.length === 0) {
throw new Error('session-title provider id must be a non-empty string')
}
if (candidate.automatic !== 'first-message' && candidate.automatic !== 'all-user-messages') {
if (candidate.automatic !== 'first-prompt' && candidate.automatic !== 'all-prompts') {
throw new Error('session-title provider automatic mode is invalid')
}
if (typeof candidate.generate !== 'function') {

View File

@@ -61,7 +61,7 @@ export function normalizeSessionTitle(input: string, maxBytes: number): string {
}
/**
* Derive the deterministic first-message fallback.
* Derive the deterministic first-prompt fallback.
* @param input - text from the first eligible human message.
* @param maxWords - positive whitespace-delimited word cap.
* @param maxBytes - positive UTF-8 byte cap.

View File

@@ -3,13 +3,13 @@
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import * as SessionTitleInvariantCompanion from '@deepseek-ai/dsh-session-title/invariant'
import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants'
import InvariantRegistry, { InvariantError } from '@deepseek-ai/dsh-invariants'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
async function setup(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(InvariantService, { enabled: true })
await ctx.plugin(InvariantRegistry, { enabled: true })
await ctx.plugin(SessionTitleInvariantCompanion)
return ctx
}

View File

@@ -5,8 +5,8 @@ import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import SessionPersistenceSqlite from '@deepseek-ai/dsh-session-persistence-sqlite'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import SqliteSessionPersistence from '@deepseek-ai/dsh-session-persistence-sqlite'
import SessionTitleService, { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
const CONFIG = {
@@ -57,14 +57,14 @@ describe('session title persistence round trips', () => {
const id = SessionId('title-jsonl')
const writer = new Context()
await writer.plugin(SessionStore)
await writer.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
await writer.plugin(JsonlSessionPersistence, { root, compression: 'none' })
await writer.plugin(SessionTitleService, CONFIG)
await appendPersistedTitle(writer, id)
await writer.fiber.dispose()
const reader = new Context()
await reader.plugin(SessionStore)
await reader.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
await reader.plugin(JsonlSessionPersistence, { root, compression: 'none' })
await expectPersistedTitle(reader, id)
await reader.fiber.dispose()
})
@@ -76,14 +76,14 @@ describe('session title persistence round trips', () => {
const id = SessionId('title-sqlite')
const writer = new Context()
await writer.plugin(SessionStore)
await writer.plugin(SessionPersistenceSqlite, { path })
await writer.plugin(SqliteSessionPersistence, { path })
await writer.plugin(SessionTitleService, CONFIG)
await appendPersistedTitle(writer, id)
await writer.fiber.dispose()
const reader = new Context()
await reader.plugin(SessionStore)
await reader.plugin(SessionPersistenceSqlite, { path })
await reader.plugin(SqliteSessionPersistence, { path })
await expectPersistedTitle(reader, id)
await reader.fiber.dispose()
})

View File

@@ -1,6 +1,6 @@
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import LlmService, { createUserMessage, deepFreeze, markAgentLoopRequest } from '@deepseek-ai/dsh-llm'
import LlmRuntime, { createUserMessage, deepFreeze, markAgentLoopRequest } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SessionTitleService, {
SessionTitleProviderId,
@@ -48,7 +48,7 @@ function appendRoute(session: ReturnType<Context['sessions']['create']>, reason:
}
describe('SessionTitleService provider lifecycle', () => {
it('inherits title events across forks, skips first-message retitling, and lets all-messages update later', async () => {
it('inherits title events across forks, skips first-prompt retitling, and lets all-messages update later', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionTitleService, CONFIG)
@@ -71,7 +71,7 @@ describe('SessionTitleService provider lifecycle', () => {
}))
const disposeFirst = ctx.sessionTitle.register({
id: SessionTitleProviderId('fork-first'),
automatic: 'first-message',
automatic: 'first-prompt',
generate: firstGenerate,
})
child.append('turn/start', {
@@ -91,7 +91,7 @@ describe('SessionTitleService provider lifecycle', () => {
}))
ctx.sessionTitle.register({
id: SessionTitleProviderId('fork-all'),
automatic: 'all-user-messages',
automatic: 'all-prompts',
generate: allGenerate,
})
child.append('turn/start', {
@@ -112,14 +112,14 @@ describe('SessionTitleService provider lifecycle', () => {
expect(ctx.sessionTitle.get(parent)?.title).toBe('Inherited title prompt')
})
it('runs a first-message provider once after the routed request and retries only through refresh', async () => {
it('runs a first-prompt provider once after the routed request and retries only through refresh', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionTitleService, CONFIG)
const requests: SessionTitleProviderRequest[] = []
const provider: SessionTitleProvider = {
id: SessionTitleProviderId('first-model'),
automatic: 'first-message',
automatic: 'first-prompt',
async generate(request) {
requests.push(request)
return {
@@ -175,7 +175,7 @@ describe('SessionTitleService provider lifecycle', () => {
let observedSignal: AbortSignal | undefined
const first: SessionTitleProvider = {
id: SessionTitleProviderId('winner'),
automatic: 'all-user-messages',
automatic: 'all-prompts',
generate(request) {
observedSignal = request.signal
return pending.promise
@@ -184,7 +184,7 @@ describe('SessionTitleService provider lifecycle', () => {
const dispose = ctx.sessionTitle.register(first)
expect(() => ctx.sessionTitle.register({
id: SessionTitleProviderId('duplicate'),
automatic: 'first-message',
automatic: 'first-prompt',
generate: async () => ({ title: 'duplicate', messageSeqs: [0] }),
})).toThrow(/already registered/)
@@ -211,7 +211,7 @@ describe('SessionTitleService provider lifecycle', () => {
const replacement: SessionTitleProvider = {
id: SessionTitleProviderId('replacement'),
automatic: 'first-message',
automatic: 'first-prompt',
generate: async () => ({ title: 'replacement', messageSeqs: [message.seq] }),
}
const disposeReplacement = ctx.sessionTitle.register(replacement)
@@ -226,7 +226,7 @@ describe('SessionTitleService provider lifecycle', () => {
const requests: SessionTitleProviderRequest[] = []
const provider: SessionTitleProvider = {
id: SessionTitleProviderId('all-model'),
automatic: 'all-user-messages',
automatic: 'all-prompts',
generate(request) {
requests.push(request)
if (requests.length === 1) return firstResult.promise
@@ -262,13 +262,13 @@ describe('SessionTitleService provider lifecycle', () => {
it('runs an all-messages revision when the next main request reuses its logged header', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmRuntime)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionTitleService, CONFIG)
const requests: SessionTitleProviderRequest[] = []
ctx.sessionTitle.register({
id: SessionTitleProviderId('unchanged-route'),
automatic: 'all-user-messages',
automatic: 'all-prompts',
async generate(request) {
requests.push(request)
return {
@@ -316,7 +316,7 @@ describe('SessionTitleService provider lifecycle', () => {
it('ignores model streams that are not a matching loop request', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmRuntime)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionTitleService, CONFIG)
const generate = vi.fn(async (request: SessionTitleProviderRequest): Promise<SessionTitleProviderResult> => ({
@@ -325,7 +325,7 @@ describe('SessionTitleService provider lifecycle', () => {
}))
ctx.sessionTitle.register({
id: SessionTitleProviderId('request-filter'),
automatic: 'all-user-messages',
automatic: 'all-prompts',
generate,
})
const options = { provider: 'main-route', model: 'chat-model', messages: [] }
@@ -353,7 +353,7 @@ describe('SessionTitleService provider lifecycle', () => {
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const provider: SessionTitleProvider = {
id: SessionTitleProviderId('failing'),
automatic: 'all-user-messages',
automatic: 'all-prompts',
generate: async () => { throw new Error('title backend failed') },
}
ctx.sessionTitle.register(provider)

View File

@@ -75,7 +75,7 @@ describe('SessionTitleService.rename', () => {
}))
ctx.sessionTitle.register({
id: SessionTitleProviderId('pin-provider'),
automatic: 'all-user-messages',
automatic: 'all-prompts',
generate,
})
const session = ctx.sessions.create(SessionId('rename-pin'))
@@ -139,7 +139,7 @@ describe('SessionTitleService.rename', () => {
})
ctx.sessionTitle.register({
id: SessionTitleProviderId('deferred-provider'),
automatic: 'all-user-messages',
automatic: 'all-prompts',
generate,
})
const session = ctx.sessions.create(SessionId('rename-supersede'))

View File

@@ -74,7 +74,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => {
}))
withProvider.sessionTitle.register({
id: SessionTitleProviderId('empty-provider'),
automatic: 'first-message',
automatic: 'first-prompt',
generate,
})
const providerEmpty = withProvider.sessions.create(SessionId('empty-provider'))
@@ -94,7 +94,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => {
let observed: SessionTitleProviderRequest | undefined
ctx.sessionTitle.register({
id: SessionTitleProviderId('explicit-no-route'),
automatic: 'first-message',
automatic: 'first-prompt',
async generate(request) {
observed = request
return { title: 'Explicit title', messageSeqs: [request.messages[0]!.seq] }
@@ -117,7 +117,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => {
let callerSignal: AbortSignal | undefined
callerCtx.sessionTitle.register({
id: SessionTitleProviderId('caller-cancel'),
automatic: 'first-message',
automatic: 'first-prompt',
generate(request) {
callerSignal = request.signal
return callerPending.promise
@@ -139,7 +139,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => {
let disposeSignal: AbortSignal | undefined
disposeCtx.sessionTitle.register({
id: SessionTitleProviderId('session-dispose'),
automatic: 'first-message',
automatic: 'first-prompt',
generate(request) {
disposeSignal = request.signal
return disposePending.promise
@@ -215,7 +215,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => {
const results: Array<ReturnType<typeof deferred<SessionTitleProviderResult>>> = []
ctx.sessionTitle.register({
id: SessionTitleProviderId('refresh-order'),
automatic: 'first-message',
automatic: 'first-prompt',
generate(request) {
requests.push(request)
const result = deferred<SessionTitleProviderResult>()
@@ -293,7 +293,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => {
const requests: SessionTitleProviderRequest[] = []
ctx.sessionTitle.register({
id: SessionTitleProviderId('service-unload'),
automatic: 'all-user-messages',
automatic: 'all-prompts',
generate(request) {
requests.push(request)
return result.promise
@@ -362,12 +362,12 @@ describe('SessionTitleService provider validation and stale scheduling', () => {
expect(() => ctx.sessionTitle.register('provider' as never)).toThrow(/must be an object/)
expect(() => ctx.sessionTitle.register({
id: 1,
automatic: 'first-message',
automatic: 'first-prompt',
generate,
} as unknown as SessionTitleProvider)).toThrow(/id must be a non-empty string/)
expect(() => ctx.sessionTitle.register({
id: SessionTitleProviderId(''),
automatic: 'first-message',
automatic: 'first-prompt',
generate,
})).toThrow(/id must be a non-empty string/)
expect(() => ctx.sessionTitle.register({
@@ -377,7 +377,7 @@ describe('SessionTitleService provider validation and stale scheduling', () => {
})).toThrow(/automatic mode is invalid/)
expect(() => ctx.sessionTitle.register({
id: SessionTitleProviderId('missing-generate'),
automatic: 'first-message',
automatic: 'first-prompt',
generate: undefined,
} as unknown as SessionTitleProvider)).toThrow(/requires generate/)
})
@@ -390,7 +390,7 @@ describe('SessionTitleService provider validation and stale scheduling', () => {
}))
const dispose = ctx.sessionTitle.register({
id: SessionTitleProviderId('queued-dispose'),
automatic: 'all-user-messages',
automatic: 'all-prompts',
generate,
})
const session = startSession(ctx, 'queued-dispose')
@@ -414,7 +414,7 @@ describe('SessionTitleService provider validation and stale scheduling', () => {
let result: unknown
ctx.sessionTitle.register({
id: SessionTitleProviderId('invalid-results'),
automatic: 'first-message',
automatic: 'first-prompt',
generate: async () => result as SessionTitleProviderResult,
})
const session = startSession(ctx, 'invalid-results')

View File

@@ -21,7 +21,7 @@
"path": "../../util/brand"
},
{
"path": "../../support/invariants"
"path": "../../runtime-diagnostics/invariants"
},
{
"path": "../../llm/llm"

View File

@@ -1,30 +0,0 @@
# @deepseek-ai/dsh-user-id
English | [中文](README.zh.md)
Shared anonymous identity for session telemetry, direct feedback acknowledgement, and DeepSeek provider requests. `getOrCreateAnonymousUserId()` returns a random UUID v4 scoped to one harness home, persisted as the bare line `$DSH_HOME/.userid` (`~/.dsh/.userid` when `DSH_HOME` is unset). The OpenTelemetry backend reports it as Resource `user.id`; `/feedback` includes the same value in its acknowledgement; and `dsh-llm-deepseek` sends it as `x-deepseek-harness-user-id`, allowing the receiving systems to correlate records without independently generated identities.
The identity is never derived from the hostname, network address, git remote, or another identifying source. Deleting `.userid` resets the identity on the next process launch. Separate harness homes have separate identities.
## Storage contract
Reads and writes are synchronous because both boot-time telemetry construction and direct command execution need one API. The result is memoized per resolved file path for the process lifetime. A first writer uses exclusive creation and a concurrent loser adopts the persisted winner; a corrupt file is replaced. Persistence is best-effort, so an unwritable home still receives a process-local UUID rather than blocking telemetry or feedback.
## Composition
This package is a shared library, not a Cordis plugin. Consumers import `getOrCreateAnonymousUserId()` directly. Its invariant companion is intentionally empty because the package owns no event stream or public mutable relation that can be checked without creating the identity as a side effect. `DSH_TELEMETRY_DISABLED` stops telemetry export only; it does not suppress direct feedback acknowledgement or the DeepSeek provider header.
## Model Experience
None, as the identifier reaches DeepSeek only as model-hidden HTTP transport metadata and never enters the request body, prompt, or model-visible content.
#### KV Cache effect
None; the transport header changes neither tokens nor the model-visible prefix.
## Known Limitations and Deferred Work
- **No recovery after deletion** — loss mints a new anonymous identity by design; recovery would require stable derivation material that weakens anonymity.
- **Best-effort concurrency** — a reader landing in the narrow interval between a concurrent process's exclusive create and completed write can use a different in-memory UUID for that run; later launches converge on the persisted value.
- **No cross-home identity** — different `$DSH_HOME` values cannot be correlated.
- **Configured DeepSeek gateways receive the id** — `dsh-llm-deepseek` sends the stable header to its resolved `baseURL`, including deployment overrides, independently of telemetry sharing mode.

View File

@@ -1,30 +0,0 @@
# @deepseek-ai/dsh-user-id
[English](README.md) | 中文
会话遥测、直接反馈确认与 DeepSeek 提供方请求共用的匿名身份。`getOrCreateAnonymousUserId()` 返回一个限定于单个 harness home 的随机 UUID v4并以裸行形式持久化到 `$DSH_HOME/.userid`(未设置 `DSH_HOME` 时为 `~/.dsh/.userid`。OpenTelemetry 后端将其作为 Resource 的 `user.id` 上报;`/feedback` 在确认文本中包含同一个值;`dsh-llm-deepseek` 则通过 `x-deepseek-harness-user-id` 发送该值,使接收系统无需独立生成身份即可关联记录。
该身份绝不从 hostname、网络地址、git remote 或其他可用于识别身份的来源派生。删除 `.userid` 后,下次启动进程时会重置身份。不同 harness home 拥有不同身份。
## 存储约定
读写采用同步方式,因为启动时构造遥测和直接执行命令都需要使用同一个 API。结果在进程生命周期内按解析后的文件路径缓存。首个写入方采用独占创建并发竞争中失败的一方会采用已持久化的胜出值。损坏的文件会被替换。持久化采用 best-effort因此即使 home 不可写,系统仍会返回进程本地 UUID而不会阻塞遥测或反馈。
## 组合
本包是共享库,并非 Cordis 插件。消费方直接导入 `getOrCreateAnonymousUserId()`。其不变式伴生插件刻意留空,因为本包既不拥有事件流,也不拥有任何可以在不触发创建身份这一副作用的情况下检查的公开可变关系。`DSH_TELEMETRY_DISABLED` 只会停止遥测导出,不会禁止直接反馈确认或 DeepSeek 提供方标头。
## 模型体验
无,因为该标识符只会作为模型不可见的 HTTP 传输元数据发送给 DeepSeek绝不会进入请求正文、提示词或模型可见内容。
#### KV Cache 影响
无;该传输标头既不会改变 token也不会改变模型可见前缀。
## 已知限制与暂缓工作
- **删除后无法恢复**:身份丢失后会按设计生成新的匿名身份;若要恢复身份,就需要稳定的派生材料,这会削弱匿名性。
- **Best-effort 并发**:如果读取方恰好落在并发进程完成独占创建但尚未写完的狭窄时间窗内,本次运行可能使用不同的内存 UUID后续启动会收敛到已持久化的值。
- **没有跨 home 身份**:不同 `$DSH_HOME` 值之间无法关联。
- **已配置的 DeepSeek gateway 会收到该 id**`dsh-llm-deepseek` 会把稳定标头发送至解析后的 `baseURL`(包括部署覆盖),且不受遥测共享模式影响。

View File

@@ -1,46 +0,0 @@
{
"name": "@deepseek-ai/dsh-user-id",
"description": "Shared anonymous user identity for DeepSeek Harness telemetry and feedback correlation",
"version": "0.0.1-rc.2",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/session/user-id"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}

View File

@@ -1,100 +0,0 @@
/**
* Per-harness-home anonymous user id shared by telemetry and feedback.
*
* The id is a random UUID persisted as a bare line in `.userid` inside the
* harness home resolved by {@link resolveDshHome} (`$DSH_HOME` > `~/.dsh`),
* and never derived from the hostname, network address, git remote, or any
* other identifying source. It is scoped to the harness home, not the
* machine: every process sharing one `$DSH_HOME` reports the same id, and
* deleting the file mints a fresh identity on the next launch.
*
* Reads and writes are synchronous so boot-time and command consumers can
* use one API. The result is memoized per resolved file path: one process
* touches the disk once, and a file deleted mid-run keeps the process's id
* until the next launch.
*
* @module @deepseek-ai/dsh-user-id
*/
import { randomUUID } from 'node:crypto'
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import type { Branded } from '@deepseek-ai/dsh-brand'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
/** A harness-home-scoped anonymous user id (random UUID v4). */
export type AnonymousUserId = Branded<'AnonymousUserId'>
/** File inside the harness home storing the id: a bare UUID line, no wrapper format. */
export const USER_ID_FILE_NAME = '.userid'
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
/** Ambient hooks for locating and generating the id; every field has a default. */
export interface AnonymousUserIdOptions {
/** Environment consulted for `DSH_HOME`; defaults to `process.env`. */
env?: NodeJS.ProcessEnv
/** UUID generator; defaults to `crypto.randomUUID` (test hook). */
randomUUID?: () => string
}
/** Process-lifetime memo keyed by resolved file path, so distinct test homes never share an id. */
const memo = new Map<string, AnonymousUserId>()
/** Read a valid persisted id from the file, or `undefined` when absent/corrupt. */
function readPersistedId(file: string): AnonymousUserId | undefined {
let text: string
try {
text = readFileSync(file, 'utf8')
} catch {
// Absent or unreadable: the caller mints and persists a fresh id.
return undefined
}
const value = text.trim()
return UUID_PATTERN.test(value) ? (value as AnonymousUserId) : undefined
}
/**
* Return the harness home's anonymous user id, creating and persisting one on
* first use. A concurrent first launch is settled by an exclusive-create
* write: the loser rereads the winner's id. (A reread landing in the winner's
* narrow create-to-write window can still yield two per-process ids for that
* run; the next launch converges on the persisted one.) Persistence is
* best-effort — a write failure (read-only home) still returns a usable id
* for the current run so feedback and telemetry are never blocked.
* @param options - home-location and UUID-generation seams.
* @returns the stable per-harness-home anonymous user id.
*/
export function getOrCreateAnonymousUserId(options: AnonymousUserIdOptions = {}): AnonymousUserId {
const file = join(resolveDshHome(undefined, options.env ?? process.env), USER_ID_FILE_NAME)
const cached = memo.get(file)
if (cached !== undefined) return cached
let id = readPersistedId(file)
if (id === undefined) {
const generate = options.randomUUID ?? randomUUID
const created = generate() as AnonymousUserId
try {
mkdirSync(dirname(file), { recursive: true })
writeFileSync(file, `${created}\n`, { encoding: 'utf8', flag: 'wx' })
id = created
} catch {
// A wx refusal (EEXIST) covers both a concurrent winner and a
// pre-existing corrupt file: the reread adopts a valid winner, and an
// invalid reread falls through to the overwrite path. Non-EEXIST
// failures (read-only home) land there too, accepted best-effort below.
id = readPersistedId(file)
if (id === undefined) {
try {
writeFileSync(file, `${created}\n`, 'utf8')
} catch {
// Best-effort persistence: keep the fresh id in memory even when the
// home is unwritable, so this run still reports a consistent id.
}
id = created
}
}
}
memo.set(file, id)
return id
}

View File

@@ -1,31 +0,0 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-user-id`.
* @module @deepseek-ai/dsh-user-id/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-user-id'
/** Cordis companion plugin name. */
export const name = 'user-id-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the API owns one private memo and one best-effort
* file, with no independent event stream or public mutable relation for a
* companion to compare without creating the identity as a side effect.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -1,12 +0,0 @@
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import InvariantService from '@deepseek-ai/dsh-invariants'
import * as UserIdInvariant from '@deepseek-ai/dsh-user-id/invariant'
describe('invariant companion', () => {
it('registers the package ownership with an empty installer', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService, { enabled: true })
await expect(ctx.plugin(UserIdInvariant).await()).resolves.toBeDefined()
})
})

View File

@@ -1,105 +0,0 @@
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
USER_ID_FILE_NAME,
getOrCreateAnonymousUserId,
} from '../src/index.ts'
const dirs: string[] = []
function tempHome(): string {
const dir = mkdtempSync(join(tmpdir(), 'dsh-userid-'))
dirs.push(dir)
return dir
}
afterEach(() => {
for (const dir of dirs.splice(0)) {
rmSync(dir, { recursive: true, force: true })
}
})
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
describe('getOrCreateAnonymousUserId', () => {
it('creates, persists, and returns a bare UUID line on first use', () => {
const home = tempHome()
const id = getOrCreateAnonymousUserId({ env: { DSH_HOME: home } })
expect(id).toMatch(UUID)
expect(readFileSync(join(home, USER_ID_FILE_NAME), 'utf8')).toBe(`${id}\n`)
})
it('creates the home directory when missing', () => {
const home = join(tempHome(), 'nested', 'home')
const id = getOrCreateAnonymousUserId({ env: { DSH_HOME: home } })
expect(readFileSync(join(home, USER_ID_FILE_NAME), 'utf8')).toBe(`${id}\n`)
})
it('returns the persisted id on subsequent calls, tolerating surrounding whitespace', () => {
const home = tempHome()
const existing = '01234567-89ab-4cde-8f01-23456789abcd'
writeFileSync(join(home, USER_ID_FILE_NAME), ` ${existing}\n\n`, 'utf8')
expect(getOrCreateAnonymousUserId({ env: { DSH_HOME: home } })).toBe(existing)
})
it('overwrites a corrupt file with a fresh id', () => {
const home = tempHome()
writeFileSync(join(home, USER_ID_FILE_NAME), 'not-a-uuid\n', 'utf8')
const id = getOrCreateAnonymousUserId({ env: { DSH_HOME: home } })
expect(id).toMatch(UUID)
expect(readFileSync(join(home, USER_ID_FILE_NAME), 'utf8')).toBe(`${id}\n`)
})
it('adopts a concurrent winner: exclusive create loses to an id written after the initial read', () => {
const home = tempHome()
const winner = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'
const file = join(home, USER_ID_FILE_NAME)
// The generator hook runs between the initial read (absent) and the wx
// write, so planting the winner here simulates the concurrent first launch.
const id = getOrCreateAnonymousUserId({
env: { DSH_HOME: home },
randomUUID: () => {
writeFileSync(file, `${winner}\n`, 'utf8')
return 'ffffffff-0000-4000-8000-000000000000'
},
})
expect(id).toBe(winner)
})
it('returns a usable id when the home cannot contain files, without persisting', () => {
const home = tempHome()
const blocked = join(home, 'blocked')
writeFileSync(blocked, 'occupied\n')
const id = getOrCreateAnonymousUserId({ env: { DSH_HOME: blocked } })
expect(id).toMatch(UUID)
expect(existsSync(join(blocked, USER_ID_FILE_NAME))).toBe(false)
})
it('memoizes per resolved home for the process lifetime: one read, deletion-proof', () => {
const home = tempHome()
const first = getOrCreateAnonymousUserId({ env: { DSH_HOME: home } })
rmSync(join(home, USER_ID_FILE_NAME))
expect(getOrCreateAnonymousUserId({ env: { DSH_HOME: home } })).toBe(first)
})
it('keeps distinct homes on distinct ids', () => {
const a = getOrCreateAnonymousUserId({ env: { DSH_HOME: tempHome() } })
const b = getOrCreateAnonymousUserId({ env: { DSH_HOME: tempHome() } })
expect(a).not.toBe(b)
})
it('reads process.env by default', () => {
const home = tempHome()
const previous = process.env.DSH_HOME
process.env.DSH_HOME = home
try {
const id = getOrCreateAnonymousUserId()
expect(readFileSync(join(home, USER_ID_FILE_NAME), 'utf8')).toBe(`${id}\n`)
} finally {
if (previous === undefined) delete process.env.DSH_HOME
else process.env.DSH_HOME = previous
}
})
})

View File

@@ -1,21 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../util/brand"
},
{
"path": "../../util/paths"
},
{
"path": "../../support/invariants"
}
]
}