dsh-diary-memory 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/config.ts CHANGED
@@ -1,13 +1,17 @@
1
1
  import z from '@deepseek-ai/schemastery'
2
2
  import { homedir } from 'node:os'
3
3
  import { join, resolve } from 'node:path'
4
+ import { validateModelParameters } from './model-parameters.js'
5
+ import type { ModelParameters } from './model-parameters.js'
4
6
 
5
7
  export interface DiaryConfig {
6
8
  apiKey: string
7
9
  baseUrl: string
8
10
  embeddingModel: string
9
11
  embeddingDimensions: number
12
+ embeddingParameters: ModelParameters
10
13
  rerankModel: string
14
+ rerankParameters: ModelParameters
11
15
  compressionRounds: number
12
16
  retainRounds: number
13
17
  namespace: string
@@ -30,7 +34,9 @@ export const defaults = Object.freeze({
30
34
  baseUrl: 'https://dashscope.aliyuncs.com',
31
35
  embeddingModel: 'text-embedding-v4',
32
36
  embeddingDimensions: 1024,
37
+ embeddingParameters: Object.freeze({}),
33
38
  rerankModel: 'gte-rerank-v2',
39
+ rerankParameters: Object.freeze({}),
34
40
  compressionRounds: 20,
35
41
  retainRounds: 2,
36
42
  namespace: 'companion',
@@ -47,13 +53,14 @@ export const defaults = Object.freeze({
47
53
  autoRecall: true,
48
54
  })
49
55
 
50
- // DSH reads this schema for its native plugin configuration form.
51
- export const Config: z<DiaryConfig> = z.object({
56
+ const fields = {
52
57
  apiKey: z.string().role('secret').default('').description('百炼 API Key;留空读取 DSH_DIARY_BAILIAN_API_KEY 或 DASHSCOPE_API_KEY。'),
53
58
  baseUrl: z.string().default(defaults.baseUrl).description('百炼地域端点;须与 API Key 的地域一致。'),
54
59
  embeddingModel: z.string().default(defaults.embeddingModel),
55
60
  embeddingDimensions: z.number().step(1).min(1).max(4096).default(defaults.embeddingDimensions),
61
+ embeddingParameters: z.dict(z.any()).default({}).description('Embedding API 的额外 parameters JSON;dimension 和 text_type 由插件设置。'),
56
62
  rerankModel: z.string().default(defaults.rerankModel),
63
+ rerankParameters: z.dict(z.any()).default({}).description('Rerank API 的额外 parameters JSON;top_n 由召回条数设置。'),
57
64
  compressionRounds: z.number().step(1).min(2).default(defaults.compressionRounds).description('累计完成多少个会话轮次后,在下一次安全的 pre-step 压缩。工具调用不额外计轮次。'),
58
65
  retainRounds: z.number().step(1).min(1).default(defaults.retainRounds).description('至少保留最近多少个用户输入及其后续消息。'),
59
66
  namespace: z.string().default(defaults.namespace).description('数字分身的记忆空间。只有使用同一空间的会话共享日记与自我演化。'),
@@ -67,14 +74,36 @@ export const Config: z<DiaryConfig> = z.object({
67
74
  diaryMaxTokens: z.number().step(1).min(256).max(16000).default(defaults.diaryMaxTokens),
68
75
  diaryInputMaxChars: z.number().step(1).min(1000).default(defaults.diaryInputMaxChars),
69
76
  recentDiaryCount: z.number().step(1).min(0).max(10).default(defaults.recentDiaryCount),
70
- autoRecall: z.boolean().default(true).description('每个新用户输入自动检索日记,并通过 agent.inject 写入可重放的上下文。'),
77
+ autoRecall: z.boolean().default(true).description('每个新用户输入自动检索日记,接纳到当前步骤并写入可重放的上下文。'),
78
+ }
79
+
80
+ const ValueConfig: z<DiaryConfig> = z.object(fields)
81
+ export const liveConfigFields = ['apiKey', 'baseUrl', 'embeddingModel', 'embeddingDimensions', 'embeddingParameters', 'rerankModel', 'rerankParameters', 'compressionRounds', 'retainRounds', 'candidateCount', 'recallCount', 'autoRecall'] as const
82
+ export type LiveConfigField = typeof liveConfigFields[number]
83
+ export type PluginConfig = { [K in keyof DiaryConfig]: K extends LiveConfigField ? DiaryConfig[K] | { get(): DiaryConfig[K] | undefined } : DiaryConfig[K] }
84
+
85
+ /** DSH only exposes volatile fields to its settings transport. */
86
+ export const Config = z.object({
87
+ ...fields,
88
+ ...Object.fromEntries(liveConfigFields.map(key => [key, fields[key].volatile()])),
89
+ // No empty default on the write-only credential: the secret sidecar can report absence.
90
+ apiKey: z.string().role('secret').volatile().description('百炼 API Key;留空保留当前密钥。'),
71
91
  })
72
92
 
93
+ export function resolvePluginConfig(input: Partial<PluginConfig> = {}, env: NodeJS.ProcessEnv = process.env): ResolvedConfig {
94
+ const values = Object.fromEntries(Object.entries(input).map(([key, value]) => [key,
95
+ liveConfigFields.includes(key as LiveConfigField) && value !== null && typeof value === 'object' && 'get' in value && typeof value.get === 'function' ? value.get() : value,
96
+ ]))
97
+ // Missing optional volatile values inherit the ordinary defaults.
98
+ return resolveConfig(Object.fromEntries(Object.entries(values).filter(([, value]) => value !== undefined)), env)
99
+ }
100
+
73
101
  export function resolveConfig(input: Partial<DiaryConfig> = {}, env: NodeJS.ProcessEnv = process.env): ResolvedConfig {
74
102
  for (const key of Object.keys(input)) {
75
103
  if (!(key in defaults)) throw new Error(`未知日记配置项:${key}`)
76
104
  }
77
- const config = Config({ ...defaults, ...input })
105
+ // Schemastery normalizes dictionaries in place; live references are immutable.
106
+ const config = ValueConfig(structuredClone({ ...defaults, ...input }))
78
107
  const ranges: [keyof Pick<DiaryConfig, 'embeddingDimensions' | 'compressionRounds' | 'retainRounds' | 'candidateCount' | 'recallCount' | 'timeoutMs' | 'retries' | 'diaryMaxTokens' | 'diaryInputMaxChars' | 'recentDiaryCount'>, number, number][] = [
79
108
  ['embeddingDimensions', 1, 4096], ['compressionRounds', 2, 1000000],
80
109
  ['retainRounds', 1, 1000000], ['candidateCount', 1, 100], ['recallCount', 1, 100],
@@ -99,6 +128,8 @@ export function resolveConfig(input: Partial<DiaryConfig> = {}, env: NodeJS.Proc
99
128
  if (endpoint.username || endpoint.password || endpoint.search || endpoint.hash || endpoint.pathname !== '/') throw new Error('baseUrl 必须是无路径、凭据和查询参数的百炼 origin')
100
129
  if (endpoint.protocol !== 'https:' && !(endpoint.protocol === 'http:' && ['127.0.0.1', '[::1]', 'localhost'].includes(endpoint.hostname))) throw new Error('baseUrl 需要 HTTPS;本地测试可以使用 loopback HTTP')
101
130
  config.baseUrl = endpoint.origin
131
+ config.embeddingParameters = validateModelParameters(config.embeddingParameters, 'embedding')
132
+ config.rerankParameters = validateModelParameters(config.rerankParameters, 'rerank')
102
133
  config.apiKey = config.apiKey.trim() || env.DSH_DIARY_BAILIAN_API_KEY?.trim() || env.DASHSCOPE_API_KEY?.trim() || ''
103
134
  const profileDir = env.DSH_PROFILE_DIR?.trim() || join(env.DSH_HOME?.trim() || join(homedir(), '.dsh'), 'profiles', env.DSH_PROFILE?.trim() || 'default')
104
135
  config.databasePath = config.databasePath || join(profileDir, 'diary-memory', 'memories.sqlite3')
package/src/index.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import type { Context } from '@deepseek-ai/cordis'
2
- import { resolveConfig } from './config.js'
3
- import type { DiaryConfig } from './config.js'
2
+ import { resolvePluginConfig } from './config.js'
3
+ import type { PluginConfig } from './config.js'
4
4
  import { RetrievalService } from './retrieval-service.js'
5
+ import type {} from '@deepseek-ai/dsh-settings'
5
6
 
6
7
  export { Config, defaults, resolveConfig } from './config.js'
7
- export type { DiaryConfig, ResolvedConfig } from './config.js'
8
+ export type { DiaryConfig, PluginConfig, ResolvedConfig } from './config.js'
8
9
  export { BailianClient, BailianError } from './bailian-client.js'
9
10
  export { MemoryStore } from './memory-store.js'
10
11
  export { RetrievalService } from './retrieval-service.js'
@@ -15,7 +16,10 @@ export type { Diary } from './diary.js'
15
16
  export const name = 'diary-memory'
16
17
  export const inject = ['sessions', 'sessionProjections']
17
18
 
18
- export async function apply(ctx: Context, input: Partial<DiaryConfig> = {}): Promise<void> {
19
- const memory = new RetrievalService(ctx, resolveConfig(input))
19
+ export async function apply(ctx: Context, input: Partial<PluginConfig> = {}): Promise<void> {
20
+ const memory = new RetrievalService(ctx, () => resolvePluginConfig(input))
20
21
  await memory.initialize()
22
+ ctx.inject(['settings'], (settingsCtx) => {
23
+ settingsCtx.effect(() => settingsCtx.settings.configure({ auto: false }, ctx.fiber), 'diary-memory: custom settings page')
24
+ })
21
25
  }
@@ -0,0 +1,32 @@
1
+ export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
2
+ export type ModelParameters = Readonly<Record<string, JsonValue>>
3
+
4
+ /** Shared by the native configuration reader and the browser parameter editors. */
5
+ export function validateModelParameters(value: unknown, kind: 'embedding' | 'rerank'): ModelParameters {
6
+ if (!value || typeof value !== 'object' || Array.isArray(value) || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw new Error(`${kind} 参数必须为 JSON 对象`)
7
+ const visit = (item: unknown, depth = 0): JsonValue => {
8
+ if (depth > 16) throw new Error(`${kind} 参数嵌套过深`)
9
+ if (item === null || typeof item === 'boolean' || typeof item === 'string') return item
10
+ if (typeof item === 'number' && Number.isFinite(item)) return item
11
+ if (Array.isArray(item)) return Object.freeze(item.map(child => visit(child, depth + 1))) as unknown as JsonValue[]
12
+ if (item && typeof item === 'object' && [Object.prototype, null].includes(Object.getPrototypeOf(item))) {
13
+ return Object.freeze(Object.fromEntries(Object.entries(item).map(([key, child]) => [key, visit(child, depth + 1)])))
14
+ }
15
+ throw new Error(`${kind} 参数只能包含 JSON 值`)
16
+ }
17
+ const parameters = visit(value) as ModelParameters
18
+ if (JSON.stringify(parameters).length > 16000) throw new Error(`${kind} 参数不能超过 16000 字符`)
19
+ const reserved = kind === 'embedding' ? ['dimension', 'text_type'] : ['top_n']
20
+ if (reserved.some(key => Object.hasOwn(parameters, key))) throw new Error(kind === 'embedding'
21
+ ? 'dimension 请在向量维度中填写;text_type 由入库和召回自动设置'
22
+ : 'top_n 请在召回条数中填写')
23
+ if (kind === 'embedding' && parameters.output_type !== undefined && parameters.output_type !== 'dense') throw new Error('本地向量检索需要 dense 向量,output_type 仅支持 dense')
24
+ if (kind === 'rerank' && parameters.return_documents !== undefined && typeof parameters.return_documents !== 'boolean') throw new Error('return_documents 必须是布尔值')
25
+ return parameters
26
+ }
27
+
28
+ export function stableParameters(value: ModelParameters): string {
29
+ const ordered = (item: JsonValue): JsonValue => Array.isArray(item) ? item.map(ordered)
30
+ : item !== null && typeof item === 'object' ? Object.fromEntries(Object.keys(item).sort().map(key => [key, ordered(item[key])])) : item
31
+ return JSON.stringify(ordered(value as JsonValue))
32
+ }
@@ -79,7 +79,7 @@ export class DiaryOrchestrator extends BasicCompactionEngine {
79
79
  // stores, so the local prepared journal is a recoverable publication barrier.
80
80
  if (!await this.ctx.sessions.flush(agent.session)) throw new Error('缺少会话持久化提供方,拒绝压缩')
81
81
  const span = captureSpan(agent.session, input.messages)
82
- const { config, store, client } = this.memory
82
+ const { config, store, client } = this.memory.snapshot()
83
83
  if (!config.apiKey) throw new Error('请先配置百炼 apiKey;原会话上下文已保留')
84
84
  const existing = store.findSource(config.namespace, agent.session.id, span.sourceHash, client.embeddingKey)
85
85
  if (existing?.state === 'committed') throw new Error('日记片段已经提交,拒绝重复压缩')
@@ -29,23 +29,35 @@ export interface RecallHit {
29
29
  }
30
30
 
31
31
  export class RetrievalService extends Service {
32
- readonly config: ResolvedConfig
33
- readonly client: BailianClient
34
32
  readonly store: MemoryStore
35
33
  readonly lifetime = new AbortController()
36
34
  private readonly jobs = new Set<Promise<unknown>>()
37
35
  private readonly recovery = new WeakMap<Session, Promise<void>>()
36
+ private readonly configuration: () => ResolvedConfig
37
+ private readonly fixedClient?: BailianClient
38
+ private cachedClient?: { key: string; client: BailianClient }
38
39
 
39
- constructor(ctx: Context, config: ResolvedConfig, dependencies: { store?: MemoryStore; client?: BailianClient } = {}) {
40
+ constructor(ctx: Context, config: ResolvedConfig | (() => ResolvedConfig), dependencies: { store?: MemoryStore; client?: BailianClient } = {}) {
40
41
  super(ctx, 'diaryMemory')
41
- this.config = config
42
- this.store = dependencies.store ?? new MemoryStore(config.databasePath)
43
- this.client = dependencies.client ?? new BailianClient(config, { signal: this.lifetime.signal })
42
+ this.configuration = typeof config === 'function' ? config : () => config
43
+ this.store = dependencies.store ?? new MemoryStore(this.config.databasePath)
44
+ this.fixedClient = dependencies.client
44
45
  ctx.effect(() => () => this.dispose(), 'diaryMemory.lifecycle()')
45
46
  }
46
47
 
48
+ get config(): ResolvedConfig { return this.configuration() }
49
+ get client(): BailianClient { return this.snapshot().client }
50
+
51
+ /** One operation keeps its route and vector space even if settings change in flight. */
52
+ snapshot(): { config: ResolvedConfig; client: BailianClient; store: MemoryStore } {
53
+ const config = this.config
54
+ const key = JSON.stringify([config.apiKey, config.baseUrl, config.embeddingModel, config.embeddingDimensions, config.embeddingParameters, config.rerankModel, config.rerankParameters, config.timeoutMs, config.retries])
55
+ if (!this.fixedClient && this.cachedClient?.key !== key) this.cachedClient = { key, client: new BailianClient(config, { signal: this.lifetime.signal }) }
56
+ return { config, client: this.fixedClient ?? this.cachedClient!.client, store: this.store }
57
+ }
58
+
47
59
  async initialize(): Promise<void> {
48
- this.ctx.sessionProjections.register(diaryProjection)
60
+ await this.ctx.sessionProjections.register(diaryProjection)
49
61
  this.ctx.on('session/created', session => {
50
62
  void this.track(() => this.reconcile(session)).catch(() => this.ctx.logger.warn('日记恢复未完成;保留待提交记录,下次使用时重试'))
51
63
  })
@@ -76,21 +88,22 @@ export class RetrievalService extends Service {
76
88
 
77
89
  recall(query: string, options: RecallOptions = {}): Promise<RecallHit[]> {
78
90
  return this.track(async () => {
91
+ const { config, client } = this.snapshot()
79
92
  const text = query.trim()
80
93
  if (!text || text.length > 8000) throw new TypeError('recall query 必须为 1–8000 字符')
81
- const limit = options.limit ?? this.config.recallCount
82
- if (!Number.isInteger(limit) || limit < 1 || limit > this.config.candidateCount) throw new TypeError('recall limit 超出候选数量')
94
+ const limit = options.limit ?? config.recallCount
95
+ if (!Number.isInteger(limit) || limit < 1 || limit > config.candidateCount) throw new TypeError('recall limit 超出候选数量')
83
96
  const signal = AbortSignal.any([this.lifetime.signal, ...options.signal ? [options.signal] : []])
84
97
  signal.throwIfAborted()
85
- const namespace = this.config.namespace
86
- if (!this.store.count(namespace, this.client.embeddingKey)) {
98
+ const namespace = config.namespace
99
+ if (!this.store.count(namespace, client.embeddingKey)) {
87
100
  if (this.store.count(namespace)) throw new Error('已有日记使用不同的 embedding 模型、维度或端点,请使用原向量配置或重新索引')
88
101
  return []
89
102
  }
90
- const vector = await this.client.embed(text, { textType: 'query', signal })
91
- const candidates = this.store.search(vector, { namespace, embeddingKey: this.client.embeddingKey, limit: this.config.candidateCount, sessionId: options.sessionId })
103
+ const vector = await client.embed(text, { textType: 'query', signal })
104
+ const candidates = this.store.search(vector, { namespace, embeddingKey: client.embeddingKey, limit: config.candidateCount, sessionId: options.sessionId })
92
105
  if (!candidates.length) return []
93
- const ranking = await this.client.rerank(text, candidates.map(item => item.search_text), { topN: Math.min(limit, candidates.length), signal })
106
+ const ranking = await client.rerank(text, candidates.map(item => item.search_text), { topN: Math.min(limit, candidates.length), signal })
94
107
  signal.throwIfAborted()
95
108
  return ranking.map(item => {
96
109
  const match = candidates[item.index]