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/README.md +56 -13
- package/cordis.patch.yml +0 -1
- package/lib/bailian-client.js +8 -3
- package/lib/bailian-client.js.map +1 -1
- package/lib/client/controller.d.ts +32 -0
- package/lib/client/controller.js +91 -0
- package/lib/client/controller.js.map +1 -0
- package/lib/client/index.d.ts +5 -0
- package/lib/client/index.js +16 -0
- package/lib/client/index.js.map +1 -0
- package/lib/client/page.d.ts +5 -0
- package/lib/client/page.js +19 -0
- package/lib/client/page.js.map +1 -0
- package/lib/client.js +274 -0
- package/lib/client.js.map +7 -0
- package/lib/config.d.ts +59 -1
- package/lib/config.js +27 -4
- package/lib/config.js.map +1 -1
- package/lib/index.d.ts +3 -3
- package/lib/index.js +5 -2
- package/lib/index.js.map +1 -1
- package/lib/model-parameters.d.ts +7 -0
- package/lib/model-parameters.js +38 -0
- package/lib/model-parameters.js.map +1 -0
- package/lib/orchestrator.js +1 -1
- package/lib/orchestrator.js.map +1 -1
- package/lib/retrieval-service.d.ts +12 -3
- package/lib/retrieval-service.js +25 -13
- package/lib/retrieval-service.js.map +1 -1
- package/package.json +124 -27
- package/src/bailian-client.ts +7 -3
- package/src/client/controller.ts +81 -0
- package/src/client/index.ts +21 -0
- package/src/client/page.tsx +68 -0
- package/src/config.ts +35 -4
- package/src/index.ts +9 -5
- package/src/model-parameters.ts +32 -0
- package/src/orchestrator.ts +1 -1
- package/src/retrieval-service.ts +27 -14
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
|
-
|
|
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('
|
|
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
|
-
|
|
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 {
|
|
3
|
-
import type {
|
|
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<
|
|
19
|
-
const memory = new RetrievalService(ctx,
|
|
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
|
+
}
|
package/src/orchestrator.ts
CHANGED
|
@@ -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('日记片段已经提交,拒绝重复压缩')
|
package/src/retrieval-service.ts
CHANGED
|
@@ -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.
|
|
42
|
-
this.store = dependencies.store ?? new MemoryStore(config.databasePath)
|
|
43
|
-
this.
|
|
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 ??
|
|
82
|
-
if (!Number.isInteger(limit) || limit < 1 || 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 =
|
|
86
|
-
if (!this.store.count(namespace,
|
|
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
|
|
91
|
-
const candidates = this.store.search(vector, { namespace, embeddingKey:
|
|
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
|
|
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]
|