@zoria-lind/dsh-ecolink-adapter 0.2.2 → 0.3.0

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.
@@ -1,88 +1,89 @@
1
- // 注入选择器(PLAN §3.5):打分排序 + token 预算 + #记忆名 显式调用。
2
- // 打分:关键词重叠(内容 2-gram 命中 ×5)+ 会话名匹配 ×15 + 时间衰减 + 访问计数 + pinned。
3
- // 预算:maxInjectionChars(默认 3000 字符),长提示词自动缩减。
4
- // ⚠ 双份维护:inject.js 有本文件的经典脚本内联镜像(页面世界无法 import ESM)。
5
- // 本文件是规范与单测基准;改逻辑时必须同步 inject.js 的 selectMemories/effectiveBudget/parseMemoryCommand。
6
-
7
- export const SCOPE_PREFIX = '[dsh-ecolink 记忆]'
8
-
9
- // 中文友好的大块匹配:内容 2-gram 与提示词 2-gram 的重叠命中数
10
- export function bigrams(s) {
11
- const t = String(s ?? '')
12
- if (t.length < 2) return new Set()
13
- const set = new Set()
14
- for (let i = 0; i < t.length - 1; i++) set.add(t.slice(i, i + 2))
15
- return set
16
- }
17
-
18
- export function keywordHits(content, promptBigrams, promptLower) {
19
- const c = String(content ?? '')
20
- const cb = bigrams(c)
21
- let hits = 0
22
- for (const g of cb) if (promptBigrams.has(g)) hits += 1
23
- // 整词附加分(英文/数字词)
24
- const words = promptLower.split(/[^\w一-龥]+/).filter((w) => w.length >= 2)
25
- const cl = c.toLowerCase()
26
- for (const w of words) if (cl.includes(w)) hits += 2
27
- return hits
28
- }
29
-
30
- export function scoreItem(item, prompt, now = Date.now()) {
31
- const promptLower = String(prompt ?? '').toLowerCase()
32
- const pb = bigrams(promptLower)
33
- const content = item?.content ?? ''
34
- let score = Math.min(20, keywordHits(content, pb, promptLower) * 5)
35
- // 会话名匹配(identity 即"名字",权重 ×15)
36
- const identity = item?.identity
37
- if (identity && promptLower.includes(String(identity).toLowerCase())) score += 15
38
- // 时间衰减:越新越相关(半衰 ~7 天)
39
- const ts = new Date(item?.timestamp).getTime()
40
- if (Number.isFinite(ts)) {
41
- const ageDays = Math.max(0, (now - ts) / 86400e3)
42
- score += 10 / (1 + ageDays / 7)
43
- }
44
- // 访问过的记忆加权(选择器自举:用过的更可能再用)
45
- if (item?.last_accessed) score += 5
46
- if (item?.pinned) score += 10
47
- return score
48
- }
49
-
50
- // 在预算内精选:按分数排序取前 N,单条截断到 500 字符
51
- export function selectMemories(items, prompt, budgetChars = 3000, now = Date.now()) {
52
- const scored = items
53
- .map((item) => ({ item, score: scoreItem(item, prompt, now) }))
54
- .sort((a, b) => b.score - a.score || (b.item?.timestamp ?? '').localeCompare(a.item?.timestamp ?? ''))
55
- const chosen = []
56
- let used = 0
57
- for (const { item } of scored) {
58
- const content = String(item?.content ?? '').trim()
59
- if (!content) continue
60
- const capped = content.length > 500 ? content.slice(0, 500) + '…' : content
61
- const cost = capped.length + 24 // 条目头部格式开销
62
- if (used + cost > budgetChars) break
63
- chosen.push({ ...item, content: capped })
64
- used += cost
65
- }
66
- return { memories: chosen, chars: used }
67
- }
68
-
69
- // 长提示词自动缩减预算:提示词本身越长,留给记忆的空间越小
70
- export function effectiveBudget(prompt, maxInjectionChars = 3000) {
71
- const len = String(prompt ?? '').length
72
- if (len <= 5000) return maxInjectionChars
73
- if (len <= 12000) return Math.floor(maxInjectionChars * 0.5)
74
- return Math.floor(maxInjectionChars * 0.25)
75
- }
76
-
77
- // #记忆名 显式调用(PLAN §3.5,WebTool parseMemoryCommand 模式):
78
- // 提示词里的 "#<会话名>" 命中会话池 identity → 只注入该会话的记忆,返回会话 id
79
- export function parseMemoryCommand(prompt, sessionPools) {
80
- const m = /#([^\s#,,。.!??、#]{1,40})/.exec(String(prompt ?? ''))
81
- if (!m) return null
82
- const name = m[1].trim()
83
- if (!name) return null
84
- for (const [sid, pool] of Object.entries(sessionPools ?? {})) {
85
- if (pool?.identity && String(pool.identity).toLowerCase() === name.toLowerCase()) return sid
86
- }
87
- return null
88
- }
1
+ // 注入选择器(PLAN §3.5):打分排序 + token 预算 + #记忆名 显式调用。
2
+ // 打分:关键词重叠(内容 2-gram 命中 ×5)+ 会话名匹配 ×15 + 时间衰减 + 访问计数 + pinned。
3
+ // 预算:maxInjectionChars(默认 3000 字符),长提示词自动缩减。
4
+ // ⚠ 双份维护:inject.js 有本文件的经典脚本内联镜像(页面世界无法 import ESM)。
5
+ // 本文件是规范与单测基准;改逻辑时必须同步 inject.js 的 selectMemories/effectiveBudget/parseMemoryCommand。
6
+
7
+ export const SCOPE_PREFIX = '[dsh-ecolink 记忆]'
8
+
9
+ // 中文友好的大块匹配:内容 2-gram 与提示词 2-gram 的重叠命中数
10
+ export function bigrams(s) {
11
+ const t = String(s ?? '')
12
+ if (t.length < 2) return new Set()
13
+ const set = new Set()
14
+ for (let i = 0; i < t.length - 1; i++) set.add(t.slice(i, i + 2))
15
+ return set
16
+ }
17
+
18
+ export function keywordHits(content, promptBigrams, promptLower) {
19
+ const c = String(content ?? '')
20
+ const cb = bigrams(c)
21
+ let hits = 0
22
+ for (const g of cb) if (promptBigrams.has(g)) hits += 1
23
+ // 整词附加分(英文/数字词)
24
+ const words = promptLower.split(/[^\w一-龥]+/).filter((w) => w.length >= 2)
25
+ const cl = c.toLowerCase()
26
+ for (const w of words) if (cl.includes(w)) hits += 2
27
+ return hits
28
+ }
29
+
30
+ export function scoreItem(item, prompt, now = Date.now()) {
31
+ const promptLower = String(prompt ?? '').toLowerCase()
32
+ const pb = bigrams(promptLower)
33
+ const content = item?.content ?? ''
34
+ let score = Math.min(20, keywordHits(content, pb, promptLower) * 5)
35
+ // 会话名匹配(identity 即"名字",权重 ×15)
36
+ const identity = item?.identity
37
+ if (identity && promptLower.includes(String(identity).toLowerCase())) score += 15
38
+ // 时间衰减:越新越相关(半衰 ~7 天)
39
+ const ts = new Date(item?.timestamp).getTime()
40
+ if (Number.isFinite(ts)) {
41
+ const ageDays = Math.max(0, (now - ts) / 86400e3)
42
+ score += 10 / (1 + ageDays / 7)
43
+ }
44
+ // 访问过的记忆加权(选择器自举:用过的更可能再用)
45
+ if (item?.last_accessed) score += 5
46
+ if (item?.pinned) score += 10
47
+ return score
48
+ }
49
+
50
+ // 在预算内精选:按分数排序取前 N,单条截断到 500 字符
51
+ export function selectMemories(items, prompt, budgetChars = 3000, now = Date.now()) {
52
+ const scored = items
53
+ .filter(item => item?.kind !== 'artifact')
54
+ .map((item) => ({ item, score: scoreItem(item, prompt, now) }))
55
+ .sort((a, b) => b.score - a.score || (b.item?.timestamp ?? '').localeCompare(a.item?.timestamp ?? ''))
56
+ const chosen = []
57
+ let used = 0
58
+ for (const { item } of scored) {
59
+ const content = String(item?.content ?? '').trim()
60
+ if (!content) continue
61
+ const capped = content.length > 500 ? content.slice(0, 500) + '…' : content
62
+ const cost = capped.length + 24 // 条目头部格式开销
63
+ if (used + cost > budgetChars) break
64
+ chosen.push({ ...item, content: capped })
65
+ used += cost
66
+ }
67
+ return { memories: chosen, chars: used }
68
+ }
69
+
70
+ // 长提示词自动缩减预算:提示词本身越长,留给记忆的空间越小
71
+ export function effectiveBudget(prompt, maxInjectionChars = 3000) {
72
+ const len = String(prompt ?? '').length
73
+ if (len <= 5000) return maxInjectionChars
74
+ if (len <= 12000) return Math.floor(maxInjectionChars * 0.5)
75
+ return Math.floor(maxInjectionChars * 0.25)
76
+ }
77
+
78
+ // #记忆名 显式调用(PLAN §3.5,WebTool parseMemoryCommand 模式):
79
+ // 提示词里的 "#<会话名>" 命中会话池 identity → 只注入该会话的记忆,返回会话 id
80
+ export function parseMemoryCommand(prompt, sessionPools) {
81
+ const m = /#([^\s#,,。.!??、#]{1,40})/.exec(String(prompt ?? ''))
82
+ if (!m) return null
83
+ const name = m[1].trim()
84
+ if (!name) return null
85
+ for (const [sid, pool] of Object.entries(sessionPools ?? {})) {
86
+ if (pool?.identity && String(pool.identity).toLowerCase() === name.toLowerCase()) return sid
87
+ }
88
+ return null
89
+ }
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@zoria-lind/dsh-ecolink-adapter",
3
- "version": "0.2.2",
4
- "description": "dsh-ecolink DSH 适配层:直读本地记忆池、pre-step 注入(打分+预算+会话去重)、#记忆名 过滤,打通网页端记忆生态",
3
+ "version": "0.3.0",
4
+ "description": "DSH 跨端记忆:指定全文读取、侧栏浏览与注入、通用/非通用分类",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "adapter/src/index.js",
8
8
  "exports": {
9
- ".": "./adapter/src/index.js"
9
+ ".": "./adapter/src/index.js",
10
+ "./client": "./adapter/src/client.js"
10
11
  },
11
12
  "files": [
12
13
  "adapter/src",
@@ -19,6 +20,7 @@
19
20
  "!service/config.json.bak-*",
20
21
  "!service/*.log",
21
22
  "!service/test.mjs",
23
+ "!service/*.test.mjs",
22
24
  "!service/fix-pool.mjs"
23
25
  ],
24
26
  "engines": {
@@ -33,13 +35,14 @@
33
35
  "patch": "./adapter/cordis.patch.yml"
34
36
  },
35
37
  "compatibility": {
36
- "dsh": ">=0.1.5-rc.1 <0.1.6-0 || >=0.1.6-rc.0 <0.1.7-0 || >=0.1.7-rc.0 <0.2.0-0",
38
+ "dsh": "0.2.1-rc.2",
37
39
  "dshReleases": {
38
- "0.1.5-rc.1": "compatible",
39
- "0.1.5-rc.2": "compatible",
40
- "0.1.7-rc.1": "compatible",
41
- "0.2.0-rc.2": "compatible"
40
+ "0.2.1-rc.2": "compatible"
42
41
  }
42
+ },
43
+ "client": {
44
+ "inject": [],
45
+ "platform": "web"
43
46
  }
44
47
  },
45
48
  "keywords": [
package/service/README.md CHANGED
@@ -1,3 +1,5 @@
1
+ > **v0.3.0(2026-10-29)**:新增 `/memory/artifact` 完整成果存储(独立于短记忆,保留历史修订)、池条目的类型与时间排序、跨池分页读取。规则与限制见 [adapter/README.md](../adapter/README.md)。
2
+
1
3
  # dsh-ecolink-service
2
4
 
3
5
  dsh-ecolink 的本地记忆池服务:memory.json 的**唯一写者**,浏览器扩展经 localhost POST 推送记忆,DSH 适配层只读文件(PLAN.md §3.3 / §7)。
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zoria-lind/dsh-ecolink-service",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "dsh-ecolink 本地记忆服务:网页扩展与 DSH 共享记忆池的唯一写者(零依赖、127.0.0.1、token 鉴权)",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/service/pool.mjs CHANGED
@@ -39,6 +39,11 @@ export function renderTimestamp(isoTs, precision = null) {
39
39
  return isoTs.slice(0, 16) // minute
40
40
  }
41
41
 
42
+ // Same wire fingerprint as extension/core/entry.mjs (covered by contract tests).
43
+ export function entryVersion(item) {
44
+ return createHash('sha256').update(JSON.stringify([item?.id ?? null,item?.key ?? null,item?.content ?? '',
45
+ item?.injectionPolicy === 'universal' ? 'universal' : 'manual',item?.kind ?? 'fact',item?.revision ?? null])).digest('hex')
46
+ }
42
47
  const EMPTY_POOL = { version: 1, shared_pool: [], session_pools: {} }
43
48
 
44
49
  // ---- 快照与 diff(E3:变更角标/同步的基础;此前服务侧完全没有 hash/快照能力) ----
@@ -164,7 +169,7 @@ export function createPool(options = {}) {
164
169
 
165
170
  // 串行执行变更(所有写操作经此排队,防并发交错)
166
171
  function mutate(fn) {
167
- const run = queue.then(() => { load(); const r = fn(); save(); return r })
172
+ const run = queue.then(() => { load(); const before = structuredClone(state); try { const r = fn(); save(); return r } catch (error) { state = before; throw error } })
168
173
  queue = run.catch(() => {})
169
174
  return run
170
175
  }
@@ -175,7 +180,7 @@ export function createPool(options = {}) {
175
180
  const cutoff = now - retentionDays * 86400e3
176
181
  const archived = []
177
182
  const keep = (item) => {
178
- if (item.pinned) return true
183
+ if (item.pinned || item.kind === 'artifact' || item.injectionPolicy === 'universal') return true
179
184
  const ts = new Date(item.timestamp).getTime()
180
185
  const acc = item.last_accessed ? new Date(item.last_accessed).getTime() : 0
181
186
  // <= 而非 <:条目时间戳可能与 cutoff 同毫秒(实测抖动),同刻即视为到期
@@ -213,6 +218,7 @@ export function createPool(options = {}) {
213
218
  importance,
214
219
  pinned: !!pinned,
215
220
  last_accessed: null,
221
+ injectionPolicy: 'manual',
216
222
  }
217
223
  }
218
224
 
@@ -246,7 +252,7 @@ export function createPool(options = {}) {
246
252
  if (isBlocked(m.content)) { blocked += 1; continue }
247
253
  if (m.action === 'replace' && typeof m.id === 'string') {
248
254
  const target = findItem(m.id)
249
- if (target) {
255
+ if (target && target.kind !== 'artifact') {
250
256
  target.content = m.content.trim()
251
257
  target.timestamp = new Date().toISOString()
252
258
  target.source = m.source ?? target.source
@@ -259,7 +265,7 @@ export function createPool(options = {}) {
259
265
  const key = typeof m.key === 'string' && m.key.trim().length > 0 ? m.key.trim() : null
260
266
  if (key) {
261
267
  const poolArr = sessionId ? (state.session_pools[sessionId]?.memories ?? []) : state.shared_pool
262
- const existing = poolArr.find((it) => it.key === key)
268
+ const existing = poolArr.find((it) => it.key === key && it.kind !== 'artifact')
263
269
  if (existing) {
264
270
  // DSM 同款:同 key 同值跳过写入(防时间戳无意义翻新与重复计数)
265
271
  if (existing.content === m.content.trim()) continue
@@ -279,7 +285,7 @@ export function createPool(options = {}) {
279
285
  {
280
286
  const trimmed = m.content.trim()
281
287
  const allPools = [...state.shared_pool, ...Object.values(state.session_pools ?? {}).flatMap((sp) => sp.memories ?? [])]
282
- if (allPools.some((it) => it.content === trimmed)) { deduped += 1; continue }
288
+ if (allPools.some((it) => it.kind !== 'artifact' && it.content === trimmed)) { deduped += 1; continue }
283
289
  }
284
290
  // 无 key 条目按内容去重:相同内容已存在则跳过
285
291
  // (模型回显示例会同一内容反复吐,池层是最后一道闸)
@@ -309,6 +315,49 @@ export function createPool(options = {}) {
309
315
  }
310
316
 
311
317
  return {
318
+
319
+ // Explicit metadata mutation is independent from text upsert/deduplication.
320
+ metadata({id,poolId,version,injectionPolicy}={}) {
321
+ return mutate(()=>{
322
+ const arr=poolId==='shared'?state.shared_pool:state.session_pools[poolId]?.memories
323
+ const found=(arr??[]).filter(it=>it.id===id)
324
+ if(found.length!==1) return {ok:false,error:'条目不存在或不唯一'}
325
+ const item=found[0]
326
+ if(!version || entryVersion(item)!==version) return {ok:false,conflict:true,error:'条目已变化,请刷新后重试'}
327
+ if(!['manual','universal'].includes(injectionPolicy)) return {ok:false,error:'类型无效'}
328
+ item.injectionPolicy=injectionPolicy
329
+ return {ok:true,id,version:entryVersion(item),injectionPolicy}
330
+ })
331
+ },
332
+ // Explicit artifact save bypasses short-fact trimming/harvest/blacklist/compression.
333
+ // requestId is stable across retry; same name requires the exact previewed version.
334
+ saveArtifact({name,content,requestId,expectedVersion}={}) {
335
+ return mutate(()=>{
336
+ if(typeof name!=='string'||!name.trim()||name.trim().length>200) return {ok:false,error:'名称须为1–200字符'}
337
+ if(typeof content!=='string'||!content.trim()||Buffer.byteLength(content,'utf8')>1500000) return {ok:false,error:'正文为空或超过1.5MB'}
338
+ if(typeof requestId!=='string'||!/^[a-zA-Z0-9_-]{8,100}$/.test(requestId)) return {ok:false,error:'缺少有效保存请求ID'}
339
+ const title=name.trim(), existing=state.shared_pool.find(it=>it.kind==='artifact'&&it.key===title)
340
+ const prior=existing&&[existing,...(existing.versions??[])].find(v=>v.requestId===requestId)
341
+ if(prior) {
342
+ if(prior.content!==content) return {ok:false,error:'保存请求ID已用于不同正文,请新建保存请求'}
343
+ return {ok:true,id:existing.id,revision:prior.revision,version:entryVersion(existing),duplicate:true}
344
+ }
345
+ if(existing&&expectedVersion!==entryVersion(existing))
346
+ return {ok:false,conflict:true,error:'已有同名完整成果;确认后保存新版本并保留旧版',id:existing.id,version:entryVersion(existing),revision:existing.revision}
347
+ const now=new Date().toISOString()
348
+ let item=existing
349
+ if(item) {
350
+ item.versions??=[]
351
+ item.versions.push({content:item.content,timestamp:item.timestamp,revision:item.revision,requestId:item.requestId})
352
+ item.content=content;item.timestamp=now;item.revision=(item.revision??1)+1;item.requestId=requestId
353
+ }else{
354
+ item={id:randomUUID(),key:title,content,timestamp:now,source:'web-artifact',kind:'artifact',revision:1,requestId,versions:[],
355
+ injectionPolicy:'manual',importance:'called',pinned:true,last_accessed:null}
356
+ state.shared_pool.push(item)
357
+ }
358
+ return {ok:true,id:item.id,revision:item.revision,version:entryVersion(item),retainedVersions:item.versions.length}
359
+ })
360
+ },
312
361
  poolFile,
313
362
  archiveFile,
314
363
  dir,
@@ -483,7 +532,7 @@ export function createPool(options = {}) {
483
532
  const push = (it, sessionId = null, identity = null) => {
484
533
  const ts = new Date(it.timestamp).getTime()
485
534
  // <= 与归档同语义:同毫秒即视为到期(days=0 时全部命中,测试用)
486
- if (Number.isFinite(ts) && ts <= cutoff) out.push({ ...it, session_id: sessionId, identity })
535
+ if (it.kind !== 'artifact' && it.injectionPolicy !== 'universal' && Number.isFinite(ts) && ts <= cutoff) out.push({ ...it, session_id: sessionId, identity })
487
536
  }
488
537
  for (const it of state.shared_pool) push(it)
489
538
  for (const [sid, sp] of Object.entries(state.session_pools)) {
@@ -518,12 +567,12 @@ export function createPool(options = {}) {
518
567
  const pull = (arr, sessionId = null) => {
519
568
  for (let i = arr.length - 1; i >= 0; i--) {
520
569
  const ts = new Date(arr[i].timestamp).getTime()
521
- if (Number.isFinite(ts) && ts <= cutoff) staged.push({ ...arr[i], session_id: sessionId })
570
+ if (arr[i].kind !== 'artifact' && arr[i].injectionPolicy !== 'universal' && Number.isFinite(ts) && ts <= cutoff) staged.push({ ...arr[i], session_id: sessionId })
522
571
  }
523
572
  }
524
573
  const keep = (arr, sessionId = null) => arr.filter((it) => {
525
574
  const ts = new Date(it.timestamp).getTime()
526
- return !(Number.isFinite(ts) && ts <= cutoff)
575
+ return it.kind === 'artifact' || it.injectionPolicy === 'universal' || !(Number.isFinite(ts) && ts <= cutoff)
527
576
  })
528
577
  pull(state.shared_pool)
529
578
  state.shared_pool = keep(state.shared_pool)
@@ -560,6 +609,7 @@ export function createPool(options = {}) {
560
609
  // 全池内容去重:同内容保留首条(暂存条目先放回,新标签若内容一致则被吸收)
561
610
  const seen = new Set()
562
611
  const dedupe = (arr) => arr.filter((it) => {
612
+ if (it.kind === 'artifact' || it.injectionPolicy === 'universal') return true
563
613
  const c = String(it.content ?? '').trim()
564
614
  if (!c || seen.has(c)) return false
565
615
  seen.add(c)
@@ -169,6 +169,15 @@ const server = createServer(async (req, res) => {
169
169
 
170
170
  try {
171
171
  // ---- POST ----
172
+ if (path === '/memory/metadata' && method === 'POST') {
173
+ const result = await pool.metadata(await readBody(req))
174
+ return send(res, result.ok ? 200 : (result.conflict ? 409 : 400), result)
175
+ }
176
+ if (path === '/memory/artifact' && method === 'POST') {
177
+ const result = await pool.saveArtifact(await readBody(req))
178
+ return send(res, result.ok ? 200 : (result.conflict ? 409 : 400), result)
179
+ }
180
+
172
181
  if (path === '/memory/sync' && method === 'POST') {
173
182
  const body = await readBody(req)
174
183
  const memories = Array.isArray(body.memories) ? body.memories : []