dsh-lite-memory 0.11.1 → 0.16.2
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 +44 -18
- package/anchor.js +152 -13
- package/client.js +1083 -120
- package/docs/RELEASE-0.12.0.md +15 -0
- package/docs/RELEASE-0.12.1.md +17 -0
- package/docs/RELEASE-0.12.2.md +17 -0
- package/docs/RELEASE-0.13.0.md +7 -0
- package/docs/RELEASE-0.14.0.md +7 -0
- package/docs/RELEASE-0.15.0.md +8 -0
- package/docs/RELEASE-0.16.0.md +10 -0
- package/index.js +704 -102
- package/learning.js +58 -32
- package/package.json +2 -2
- package/state.js +112 -11
- package/store.js +343 -44
- package/test/anchor.test.mjs +311 -0
- package/test/client.test.mjs +48 -6
- package/test/contract.test.mjs +214 -11
- package/test/control.test.mjs +297 -0
- package/test/evolution-read.test.mjs +196 -11
- package/test/learning.test.mjs +4 -2
- package/test/lexical.test.mjs +33 -0
- package/test/recall.test.mjs +25 -9
- package/test/state.test.mjs +21 -0
- package/test/store.test.mjs +98 -8
- package/view.js +16 -1
package/README.md
CHANGED
|
@@ -19,13 +19,15 @@ Lightweight memory for the DeepSeek Harness. No standing system prompt, nothing
|
|
|
19
19
|
| **L1** 它发现有跨会话价值的经验 | 一行灰字 `memory · suggested "X" · sug_x` + 一个 `keep` 按钮;标题加一句话,看得懂才点,不点就永远不写,48 小时内不重复 |
|
|
20
20
|
| **L2** 常识、一次性参数、凭据的值 | 永不写入 |
|
|
21
21
|
|
|
22
|
-
这些行只属于产生它的那一轮:每轮按那轮自己的工具结果重建,重进会话还能看见,别的轮不会混进来。再记一次时插件自己判是更新还是新增:同层、同前置条件下,标题(或这条用过的旧标题)完全一致就更新原条目,换过说法而标题变了的旧标题记成别名以后也认得。像但不同名的不写:先在结果里回「相近:X」和它的 id,确认要更新就带那个 id 再存,确认是另一条才加 `resolution="distinct"
|
|
22
|
+
这些行只属于产生它的那一轮:每轮按那轮自己的工具结果重建,重进会话还能看见,别的轮不会混进来。再记一次时插件自己判是更新还是新增:同层、同前置条件下,标题(或这条用过的旧标题)完全一致就更新原条目,换过说法而标题变了的旧标题记成别名以后也认得。像但不同名的不写:先在结果里回「相近:X」和它的 id,确认要更新就带那个 id 再存,确认是另一条才加 `resolution="distinct"`。更新时没给的字段保持原值,正文整段替换——旧正文只作内部撤回依据,不展示、不参与检索;前置条件不同的同一主题是两条。
|
|
23
23
|
|
|
24
24
|
写入时分两层:默认只在这个项目生效;用户说「以后所有项目都这样」才进全局层(200 条起提示,硬上限 1000 条),检索时一起查。同一主题在不同前提下用 `when` 区分,各存一条。
|
|
25
25
|
|
|
26
|
+
写入前先蒸馏:标题是结论,正文只留原则、判断规则和触发条件,不把具体尺寸、字号、层级、颜色或一次性数字记进来;需要这些值时按记忆里的指针查当前代码或文档。单条正文超过 4096 字节会被拒绝,先删细节再存;摘要超长自动截到 60 字。
|
|
27
|
+
|
|
26
28
|
触发词:`记住`、`记一下`、`上次`、`规范`、`约定`、`路径`、`凭据怎么取`。
|
|
27
29
|
|
|
28
|
-
真命中看这句话和记忆的标题(含旧标题)、标签、触发词、摘要有没有对得上的词:有就多一行「已有记忆可能相关」,最多三条标题和 id
|
|
30
|
+
真命中看这句话和记忆的标题(含旧标题)、标签、触发词、摘要有没有对得上的词:有就多一行「已有记忆可能相关」,最多三条标题和 id,每条前面标归属(`[global]` 或项目目录名如 `[DSH]`);没对上就不出现,不改命中计数,正文要 `memory_read` 才读。一条记忆写了触发词,用户说到那个词也算命中。
|
|
29
31
|
|
|
30
32
|
| Case | Behaviour |
|
|
31
33
|
| --- | --- |
|
|
@@ -33,54 +35,78 @@ Lightweight memory for the DeepSeek Harness. No standing system prompt, nothing
|
|
|
33
35
|
| **L1** it spots something worth keeping | one muted line `memory · suggested "X" · sug_x` with a single `keep` button — readable before you click, nothing written unless you click, at most once per two days |
|
|
34
36
|
| **L2** common sense, one-off parameters, credential values | never written |
|
|
35
37
|
|
|
36
|
-
Those lines belong to the turn that made them: each turn is rebuilt from its own tool results, so reopening the session shows them again and no other turn leaks in. Saving again updates an entry only when the title (or one of its earlier titles) matches exactly under the same precondition. A merely similar title writes nothing first: the result reports the closest entry and its id, so the caller either updates that id or re-saves with `resolution="distinct"`. An update keeps every field it was not given and replaces the body wholesale; the previous body
|
|
38
|
+
Those lines belong to the turn that made them: each turn is rebuilt from its own tool results, so reopening the session shows them again and no other turn leaks in. Saving again updates an entry only when the title (or one of its earlier titles) matches exactly under the same precondition. A merely similar title writes nothing first: the result reports the closest entry and its id, so the caller either updates that id or re-saves with `resolution="distinct"`. An update keeps every field it was not given and replaces the body wholesale; the previous body is kept only for internal undo and never shown or searched. The same subject under different preconditions stays two entries.
|
|
37
39
|
|
|
38
40
|
Writes land in one of two layers: the current project by default, or the global layer (warns past 200, hard ceiling 1000) when the user says the rule holds for every project. Both layers are searched together, and a `when` field separates the same subject under different preconditions.
|
|
39
41
|
|
|
42
|
+
Before writing, distill: the title is the conclusion and the body keeps only the principle, decision rule and trigger condition. Concrete dimensions, font sizes, z-index, colors and one-off numbers stay out; the memory points at where to read the current value instead. A body over 4096 bytes is refused, so drop detail before saving; an over-long summary is truncated to 60 characters.
|
|
43
|
+
|
|
40
44
|
## 点一下就存 / One click, no turn
|
|
41
45
|
|
|
42
|
-
`keep` 是同源直写:不产生消息、不动输入框、不打断当前回复。按钮变 `kept` 就结束了。真写过的行尾才有 `✕`,按 id
|
|
46
|
+
`keep` 是同源直写:不产生消息、不动输入框、不打断当前回复。按钮变 `kept` 就结束了。真写过的行尾才有 `✕`,按 id 撤销:改过的条目还原成上一版(显示 `restored`),刚建的条目移入归档(显示 `archived`);条目后来又改过就停下并说明已经变过,不覆盖后来的修改。没变、需要确认、保存失败的行没有撤销。
|
|
43
47
|
|
|
44
|
-
`keep` writes straight to disk from the page: no message, no extra model turn, the input box untouched. The button turns into `kept` and that is the whole interaction. Only a row that really changed gets the `✕`, and it undoes by id
|
|
48
|
+
`keep` writes straight to disk from the page: no message, no extra model turn, the input box untouched. The button turns into `kept` and that is the whole interaction. Only a row that really changed gets the `✕`, and it undoes by id: an edited entry goes back to its previous body (`restored`), a freshly created one moves to the archive (`archived`); if the entry changed again it stops and says so instead of overwriting. Noop, needs-confirmation and failed saves get no undo.
|
|
45
49
|
|
|
46
50
|
## 每轮引用条 / Per-turn references
|
|
47
51
|
|
|
48
|
-
每次回复末尾、在「已编辑 N 个文件」下面,按这一轮实际发生的工具结果留几行:`本轮参考 N 条` 和 `本轮更新 N
|
|
52
|
+
每次回复末尾、在「已编辑 N 个文件」下面,按这一轮实际发生的工具结果留几行:`本轮参考 N 条` 和 `本轮更新 N 条`。点开是记忆名,再点一条就地展开当前正文;名字和正文都取当前值——改过名显示新名字,归档的标「已归档」,删掉的显示「已删除」。同一轮同一条只出现一次;没读过也没写过就整段不出现。切换会话不丢,每轮只按自己的工具结果重建。
|
|
53
|
+
|
|
54
|
+
Each completed turn keeps a few lines under 「已编辑 N 个文件」, built from that turn's own tool results: `本轮参考 N 条` and `本轮更新 N 条`. A line expands to memory names, a name expands to the body as it is now — current title, current body, archived marked, deleted said outright. One row per entry per turn; nothing read or written, nothing shown. Switching conversations keeps them, because each turn is rebuilt from its own tool results only.
|
|
55
|
+
|
|
56
|
+
## 记忆面板 / Memory panel
|
|
57
|
+
|
|
58
|
+
会话头的大脑图标打开居中的记忆面板。只有两个开关:「启用记忆」「自进化」;内容分「记忆」「自进化」两个页签。默认看当前项目,也可以切到其他项目或全局层;切过去只读,不回写、不迁移旧格式。
|
|
59
|
+
|
|
60
|
+
「记忆」页签里,搜索框右侧可以在「列表」「图谱」之间切换;列表或图谱占满内容宽度,选中记忆后右侧才出现正文与归属。图谱按真实数据画,点记忆节点读正文,点标签或主题节点转成搜索,右上角可缩放。「自进化」页签只作用于当前项目,按「全部 / 候选 / 试用 / 生效 / 暂停」筛选,也能切列表与图谱;详情给出当前经验、适用范围、当前状态、学习依据和变化记录。候选可采纳、修改、放弃,试用可采纳、修改、暂停,生效可修改、暂停,暂停可恢复或查看记录。
|
|
61
|
+
|
|
62
|
+
The brain icon in the session header opens a centered memory panel. It has exactly two switches, Memory and Self-evolution, and two tabs, Memories and Self-evolution. It starts on the current project and can switch to another project or the global layer; switched scopes are read-only and never migrate or create state.
|
|
63
|
+
|
|
64
|
+
On the Memories tab, the search row also switches between list and graph; either one fills the content width, and the body pane only appears after a memory is selected. The graph is drawn from real data: a memory node opens its body, a tag or topic hub becomes a search, and the top-right controls zoom it. The Self-evolution tab acts on the current project only, filters by All / Candidate / Trial / Active / Paused, and also offers list and graph views. It shows the current rule, scope, state, evidence and change history. Candidates can be adopted, edited or discarded; trials can be adopted, edited or paused; active rules can be edited or paused; paused rules can be resumed or inspected.
|
|
65
|
+
|
|
66
|
+
- 关闭项目记忆后,这个项目不再使用任何记忆(包括全局层):自动注入、检索、读取、引用、保存全部停,服务端也拒绝记忆工具和面板修改动作。自进化同时关闭并禁用;重新打开记忆后自进化仍为关闭,要单独开启。
|
|
67
|
+
- 关闭只改权限,不删数据:候选、试用、生效、证据、整理额度和默认绑定都保留,计数照常显示。
|
|
68
|
+
- 记忆工具与面板管理动作都在服务端拒绝,不能靠改前端绕过;其他项目不受影响。
|
|
69
|
+
- 范围只来自服务端枚举的层;不接受客户端拼路径,也不能写别的项目。
|
|
70
|
+
- 图谱只按数据画:每条有效记忆一个节点,共享标签和主题做枢纽,显式关联画实线;自进化图谱只连共用条件。位置由 id 决定,同一批数据每次一样。
|
|
49
71
|
|
|
50
|
-
|
|
72
|
+
- Turning project memory off stops all memory use in that project, including the global layer: no injection, search, read, citation or save, and the server rejects both memory tools and panel mutations. Self-evolution turns off with it; after memory is reopened, self-evolution stays off until switched on.
|
|
73
|
+
- Closing is a permission change, not deletion: candidates, trials, active rules, evidence, organize quota and default bindings are kept, and counts stay visible.
|
|
74
|
+
- Memory tools and panel management actions are rejected server-side, so a modified client cannot bypass the switch; other projects are unaffected.
|
|
75
|
+
- Scopes come only from the server-side list; the page cannot assemble a path or write another project's notes.
|
|
76
|
+
- The graph is data only: one node per active memory, shared tags and topics as hubs, explicit links as solid edges; positions derive from ids, so the same data looks the same every time.
|
|
51
77
|
|
|
52
78
|
## 为什么轻 / Why it stays light
|
|
53
79
|
|
|
54
80
|
- **0 依赖**,存储层无定时器、无监听、无网络,只有同步单文件写;写入用一把有超时的锁,别的进程正在写就明确报「忙」,不抢别人的锁。
|
|
55
81
|
- 搜索最多 4 条、400 字;正文只在 `memory_read` 里出现,全文永不进上下文。
|
|
56
|
-
-
|
|
57
|
-
- 上限:单条正文 4096 字节、整库正文
|
|
82
|
+
- 普通检索只列当前有效的记录;已经归档或被更新替换的旧说法不混进来,要查历史得在 `memory_search` 里显式要 `history`。
|
|
83
|
+
- 上限:单条正文 4096 字节、整库正文 1 MB、每层 1000 条、标题与摘要各 60 字;每层 200 条只当提示线。90 天未用只留 `id + 标题`。到了硬上限不拒绝新记忆:把同层最久没被读的一条移进归档,用户固定过的规则不动,保存结果里写明是哪条(正文与完整记录都留着,可人工取回),单条本身超预算才报错;用户明确说「别记」也进归档。文件从不删除。
|
|
58
84
|
- 一个项目一个目录,项目之间互相看不见。
|
|
59
85
|
|
|
60
86
|
- **Zero dependencies.** The store has no timers, no listeners, no network — just synchronous single-file writes, guarded by a lock that gives up after 500 ms instead of stealing it.
|
|
61
87
|
- Search returns at most 4 hits and 400 characters. Full text only appears in `memory_read`.
|
|
62
88
|
- Normal search lists current entries only; archived notes and superseded wording stay out unless `memory_search` asks for `history`.
|
|
63
|
-
- Limits: 4096 bytes per body,
|
|
89
|
+
- Limits: 4096 bytes per body, 1 MB of bodies per store, 1000 entries per layer, 60 characters per title or summary; 200 entries per layer is just a warning line. At the hard ceiling a save is never refused: the least recently read entry in the same layer moves to archive (pinned rules are never evicted) and the result names it (body and full record kept, restorable by hand); only an entry that alone exceeds the budget fails. Saying 「别记」 also archives. Files are never deleted.
|
|
64
90
|
- One directory per project, so projects never see each other's notes.
|
|
65
91
|
|
|
66
92
|
## 项目自进化〔实验〕 / Project self-evolution (experimental)
|
|
67
93
|
|
|
68
|
-
|
|
94
|
+
默认关闭,只对当前项目生效。开启后,它从你的纠正和认可里整理出少量项目规范:纠正更新做法,认可作品只给已有规范留一条带维度的样例;执行漏掉只补检查点,不改写成禁令。下次匹配任务按需读取,不逐轮注入。会增加一次有上限的后台整理调用,以及命中的记忆读取消耗。
|
|
69
95
|
|
|
70
|
-
Off by default, per project only. When enabled it distills a few project rules from your corrections and approvals: corrections revise the approach, approvals attach one bounded
|
|
96
|
+
Off by default, per project only. When enabled it distills a few project rules from your corrections and approvals: corrections revise the approach, approvals attach one bounded sample to an existing rule, and an execution miss adds a checkpoint instead of a ban. Matching tasks read them on demand, never per turn. It adds one bounded background organize call and the memory reads that actually hit.
|
|
71
97
|
|
|
72
98
|
- 一次整理最多 2 处改动、768 输出 token、15 秒;输入不超过 8 KiB,同一作品最多 2 次,同一项目 24 小时内最多 6 次。
|
|
73
|
-
- 成果停在 `pending` / `trial` / `active` / `paused`;明确说「以后都这样」或两个独立作品支持才 `active`,试用与暂停 30
|
|
99
|
+
- 成果停在 `pending` / `trial` / `active` / `paused`;明确说「以后都这样」或两个独立作品支持才 `active`,试用与暂停 30 天后清理。试用会参与条件匹配任务的读取,不是预览稿;到期自动暂停。
|
|
74
100
|
- 规则 32 条、待定 12 条、每规则样例 4 个/检查点 3 个/来源 3 条、旧版本 2 个,都有硬上限;到顶先暂停新学习,不静默丢已生效规则。
|
|
75
|
-
-
|
|
76
|
-
-
|
|
101
|
+
- 面板显示整理次数和已知用量;usage 没返回就写未知,不补零。
|
|
102
|
+
- 关闭后停止学习、读取和晚到写入;成果保留可查看。记忆总开关关闭时自进化一定也是关闭,不能单独打开。
|
|
77
103
|
- 同一项目换栏目、新一期、临时例外不会互相继承;仓库子目录、符号链接、worktree 视为同一项目,不同克隆分开。
|
|
78
104
|
|
|
79
105
|
- At most 2 changes, 768 output tokens and 15 s per organize call; input ≤ 8 KiB, ≤ 2 calls per episode and ≤ 6 per project in 24 h.
|
|
80
|
-
- Results stay `pending` / `trial` / `active` / `paused`; they become `active` only on an explicit long-term ask or two independent episodes; trial and paused entries expire after 30 days.
|
|
106
|
+
- Results stay `pending` / `trial` / `active` / `paused`; they become `active` only on an explicit long-term ask or two independent episodes; trial and paused entries expire after 30 days. A trial is read by matching tasks — it is not a preview — and pauses when it expires.
|
|
81
107
|
- Hard caps: 32 rules, 12 pending, 4 samples / 3 checkpoints / 3 sources per rule, 2 old versions. At the ceiling new learning pauses; active explicit rules are never silently dropped.
|
|
82
|
-
- The panel reports organize
|
|
83
|
-
- Turning it off stops learning, reads and late writes;
|
|
108
|
+
- The panel reports organize calls and known usage; missing usage is shown as unknown, never zero.
|
|
109
|
+
- Turning it off stops learning, reads and late writes; results stay viewable. While project memory is off, self-evolution is necessarily off and cannot be switched on by itself.
|
|
84
110
|
- Switching columns, a new episode and one-off exceptions do not leak into each other; a repository subdirectory, symlink or worktree is the same project, separate clones are not.
|
|
85
111
|
|
|
86
112
|
## 安装 / Install
|
package/anchor.js
CHANGED
|
@@ -8,10 +8,69 @@ import crypto from 'node:crypto';
|
|
|
8
8
|
export const ANCHOR_STATUSES = ['unbound', 'proposed', 'active', 'continuable', 'ambiguous', 'closed'];
|
|
9
9
|
export const TASK_LIMITS = { sessions: 8, episodes: 12, sourceMessages: 8, idleMs: 7 * 24 * 60 * 60 * 1000, indexBytes: 600 };
|
|
10
10
|
|
|
11
|
-
const SWITCH_WORDS = /(
|
|
12
|
-
const CONTINUE_WORDS = /(
|
|
11
|
+
const SWITCH_WORDS = /(改做|改为|改到|改成|换个|换一个|换成|转到|现在做|接下来做|新做|另做|开始做|去做)/;
|
|
12
|
+
const CONTINUE_WORDS = /(继续|再做一期|下一期|再来一期|再开一期|续一集|再拍一期)/;
|
|
13
13
|
const REVISE_WORDS = /(再改一下|改一版|再改改|调整一下|这版再|继续改)/;
|
|
14
14
|
const THANKS_WORDS = /^(谢谢|多谢|谢了|辛苦了|thanks|thank\s*you)[!!。.~~\s]*$/i;
|
|
15
|
+
const REFERENCE_WORDS = /(这个|这条|这次|上次|之前|刚才|上面|前面|它|这里|继续|接着|还是|同样|再改)/;
|
|
16
|
+
const TASK_STOP = new Set(['一个','一下','这个','那个','我们','你们','他们','什么','怎么','可以','需要','帮我','帮忙','请问','然后','现在','还是','就是','没有','不是','以及','关于','进行','使用','问题','任务','内容','时候','已经','如果','因为','所以','但是','而且','或者','这些','那些','这样','那样','一下','一点','一些']);
|
|
17
|
+
/** 判断任务是否换了对象时,这些词只表示动作或指代,不算对象证据。 */
|
|
18
|
+
const TASK_NOISE = /(继续|再做一期|下一期|再来一期|再开一期|续一集|再拍一期|接着|然后|接下来|现在|帮我|帮忙|这个|这条|这次|上次|之前|刚才|上面|前面|下面|后面|这里|还是|同样|再改|排查|处理|检查|看看|看一下|看下|修复|解决|优化|调整|修改|分析|研究|对比|评估|调研|测试|设计|实现|开发|搭建|部署|定位|复现|查一下|找一下|问题|任务|内容|一下)/g;
|
|
19
|
+
/** 续接词后面还得跟一个动作,才算真正在改旧稿;单说「继续 / 这个 / 之前」不构成复用依据。 */
|
|
20
|
+
const CONTINUATION_ACTION = /(继续|接着|然后|再|还是|同样)\s*(做|改|调|弄|看|写|处理|优化|调整|检查|完成)/;
|
|
21
|
+
/** 明确指向刚才那一版作品的说法,是沿用旧范围的正面证据;「这版/那版」只是指示,不算旧稿。 */
|
|
22
|
+
const OLD_WORK_REFERENCE = /(刚才那版|之前那版|上一版|原稿|旧稿)/;
|
|
23
|
+
/** 旧稿被放下、否定或只作为话题提起时,不能因为它出现就恢复旧范围。 */
|
|
24
|
+
const OLD_WORK_PAUSE = /(先放着|放着|放一边|先不|先别|别管|不用管|搁置|暂停|不做了|先不做|不用了)/;
|
|
25
|
+
const OLD_WORK_ACTION = /(继续|接着|再改|改成|改一下|调整|优化|改改|开头|结尾|结构|节奏|语气|口吻|文案|措辞|细节|版本)/;
|
|
26
|
+
/** 创作类与工程类任务的默认判断不同;同一主题从写口播切到排查代码,不能沿用旧栏目。 */
|
|
27
|
+
const CONTENT_INTENT = /(口播|拍摄|录制|剪辑|文案|封面|字幕|直播|视频|选题|脚本|一期|栏目)/;
|
|
28
|
+
const ENGINEERING_INTENT = /(代码|排查|故障|部署|调试|重构|接口|数据库|查询|报错|日志|服务|环境|依赖|测试|配置|修复)/;
|
|
29
|
+
/** 这些词描述的是旧稿的某个方面,不是新对象;不该因为它们出现就撤销旧范围。 */
|
|
30
|
+
const ASPECT_NOISE = /(节奏|速度|风格|语气|口吻|结构|长度|时长|篇幅|配色|字号|字体|标题|文案|措辞|逻辑|细节|开头|结尾|过渡|画面|镜头|音乐|音效|字幕|参数|格式|排版)/g;
|
|
31
|
+
/** 程度、指代和虚词组成的二字片段不是对象证据。 */
|
|
32
|
+
const GENERIC_CHARS = /[再更稍很太挺点些快慢多少好坏大小高低长短早晚新旧一二三四五六七八九十百千万个把的得地了着过吗呢吧啊]/;
|
|
33
|
+
function taskTerms(value) {
|
|
34
|
+
const plain = text(value, 200).toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').replace(ASPECT_NOISE, ' ').replace(TASK_NOISE, ' ').replace(/\s+/g, ' ').trim();
|
|
35
|
+
const terms = new Set();
|
|
36
|
+
for (const word of plain.match(/[a-z][a-z0-9_-]{2,}/g) || []) terms.add(word);
|
|
37
|
+
const runs = plain.match(/[\u4e00-\u9fff]+/g) || [];
|
|
38
|
+
for (const run of runs) {
|
|
39
|
+
if (TASK_STOP.has(run)) continue;
|
|
40
|
+
for (let i = 0; i + 2 <= run.length; i += 1) {
|
|
41
|
+
const gram = run.slice(i, i + 2);
|
|
42
|
+
if (TASK_STOP.has(gram) || GENERIC_CHARS.test(gram)) continue;
|
|
43
|
+
terms.add(gram);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return terms;
|
|
47
|
+
}
|
|
48
|
+
/** 继承旧任务要有正面证据:新要求与本轮任务没有实质对象交集时,先按新任务处理。 */
|
|
49
|
+
export function looksLikeNewTask(query, previousText) {
|
|
50
|
+
const current = text(query, 200);
|
|
51
|
+
if (!current || isThanks(current)) return false;
|
|
52
|
+
const next = taskTerms(current);
|
|
53
|
+
const previous = taskTerms(previousText);
|
|
54
|
+
const cue = isContinuation(current) || REFERENCE_WORDS.test(current);
|
|
55
|
+
/** 只剩指代或续接词时,必须另有动作才算续接;否则宁可换任务也不沿用旧范围。 */
|
|
56
|
+
if (!next.size) return cue ? !CONTINUATION_ACTION.test(current) : false;
|
|
57
|
+
if (!previous.size) return false;
|
|
58
|
+
/** 同一主题换了工作类型(口播 → 排查代码)是新任务,不靠对象词重叠判续作。 */
|
|
59
|
+
const nextKind = CONTENT_INTENT.test(current) ? (ENGINEERING_INTENT.test(current) ? '' : 'content')
|
|
60
|
+
: (ENGINEERING_INTENT.test(current) ? 'engineering' : '');
|
|
61
|
+
const previousKind = CONTENT_INTENT.test(previousText) ? (ENGINEERING_INTENT.test(previousText) ? '' : 'content')
|
|
62
|
+
: (ENGINEERING_INTENT.test(previousText) ? 'engineering' : '');
|
|
63
|
+
if (nextKind && previousKind && nextKind !== previousKind) return true;
|
|
64
|
+
for (const term of next) if (previous.has(term)) return false;
|
|
65
|
+
/** 指向旧稿还要有继续改它的动作;「旧稿先放着」「这版数据库配置怎么改」都算新任务。 */
|
|
66
|
+
if (OLD_WORK_REFERENCE.test(current) && !OLD_WORK_PAUSE.test(current) && OLD_WORK_ACTION.test(current)) return false;
|
|
67
|
+
/**
|
|
68
|
+
* 有实质对象词却跟旧任务没有任何交集:这就是新任务,不因为句首带了
|
|
69
|
+
* 「继续」「这个」就把旧范围连同正文一起搬过来。继承只认对象交集、
|
|
70
|
+
* 明确的旧稿指代,或者除动作/指代外没有新对象(上面两条)。
|
|
71
|
+
*/
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
15
74
|
|
|
16
75
|
export function emptyTasks() {
|
|
17
76
|
return { revision: 0, defaults: null, sessions: {}, episodes: {} };
|
|
@@ -37,6 +96,27 @@ export function isRevision(query) {
|
|
|
37
96
|
return REVISE_WORDS.test(text(query, 160));
|
|
38
97
|
}
|
|
39
98
|
|
|
99
|
+
const NEGATED_SERIES = /(不要|不用|别|不按|不照|不是|并非|先不|不做|不搞|忽略|跳过|不管|别提)[^,。;!?\n]{0,10}$/;
|
|
100
|
+
const MENTION_SERIES = /(日志|报错|错误|信息|提到|说到|包含|关键词|这个词|源码|代码|插件|文档|规则名|栏目名|分析)[^,。;!?\n]{0,10}$/;
|
|
101
|
+
const PAUSED_SERIES = /^(先放着|先放|放一边|先不做|不做了|不用了|暂停)/;
|
|
102
|
+
const PAUSED_BEFORE = /(先放着|先放|放一边|先不做|不做了|不用了|暂停)[^,。;!?\n]{0,4}$/;
|
|
103
|
+
/** 提到栏目名不等于选中它:否定、引用、看源码或「先放着」都只是话题,不能建绑定。 */
|
|
104
|
+
export function seriesChosenBy(query, series) {
|
|
105
|
+
const value = text(query, 400);
|
|
106
|
+
const name = text(series, 40);
|
|
107
|
+
if (!value || !name) return false;
|
|
108
|
+
let from = 0;
|
|
109
|
+
while (from <= value.length) {
|
|
110
|
+
const at = value.indexOf(name, from);
|
|
111
|
+
if (at < 0) return false;
|
|
112
|
+
const before = value.slice(Math.max(0, at - 14), at);
|
|
113
|
+
const after = value.slice(at + name.length);
|
|
114
|
+
if (!NEGATED_SERIES.test(before) && !MENTION_SERIES.test(before) && !PAUSED_BEFORE.test(before) && !PAUSED_SERIES.test(after)) return true;
|
|
115
|
+
from = at + name.length;
|
|
116
|
+
}
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
40
120
|
export function requestBatchKey(messageIds) {
|
|
41
121
|
const ids = [...new Set((Array.isArray(messageIds) ? messageIds : []).map((id) => String(id || '')).filter(Boolean))].sort();
|
|
42
122
|
if (!ids.length) return '';
|
|
@@ -115,6 +195,7 @@ export function normalizeTasks(raw) {
|
|
|
115
195
|
status: ANCHOR_STATUSES.includes(episode.status) ? episode.status : 'unbound',
|
|
116
196
|
transitionId: text(episode.transitionId, 80),
|
|
117
197
|
previousEpisodeId: text(episode.previousEpisodeId, 80),
|
|
198
|
+
...(text(episode.taskText, 200) ? { taskText: text(episode.taskText, 200) } : {}),
|
|
118
199
|
createdAt: text(episode.createdAt, 40),
|
|
119
200
|
updatedAt: text(episode.updatedAt, 40),
|
|
120
201
|
};
|
|
@@ -173,7 +254,7 @@ function anchorOf(episode, { sessionId, batchKey, messageIds, status, bindingRev
|
|
|
173
254
|
};
|
|
174
255
|
}
|
|
175
256
|
|
|
176
|
-
function createEpisode(tasks, { taskKind, seriesId, phase, status, transitionId, previousEpisodeId, now }) {
|
|
257
|
+
function createEpisode(tasks, { taskKind, seriesId, phase, status, transitionId, previousEpisodeId, taskText, now }) {
|
|
177
258
|
const episodeId = episodeIdFor(transitionId);
|
|
178
259
|
const at = new Date(now).toISOString();
|
|
179
260
|
const episode = {
|
|
@@ -184,6 +265,7 @@ function createEpisode(tasks, { taskKind, seriesId, phase, status, transitionId,
|
|
|
184
265
|
status,
|
|
185
266
|
transitionId,
|
|
186
267
|
...(previousEpisodeId ? { previousEpisodeId } : {}),
|
|
268
|
+
...(text(taskText, 200) ? { taskText: text(taskText, 200) } : {}),
|
|
187
269
|
createdAt: at,
|
|
188
270
|
updatedAt: at,
|
|
189
271
|
};
|
|
@@ -229,7 +311,11 @@ export function routeTask(tasksInput, input = {}) {
|
|
|
229
311
|
}
|
|
230
312
|
}
|
|
231
313
|
const registered = (Array.isArray(input.registeredSeries) ? input.registeredSeries : []).map((item) => text(item, 40)).filter(Boolean);
|
|
232
|
-
|
|
314
|
+
/** 用户在这句话里点名的栏目优先于旧栏目和项目默认值。 */
|
|
315
|
+
const namedSeries = registered.find((series) => seriesChosenBy(query, series)) || '';
|
|
316
|
+
const changedSeries = current && namedSeries && namedSeries !== (current.seriesId || '') ? namedSeries : '';
|
|
317
|
+
const fallbackKind = (currentEpisode && currentEpisode.taskKind)
|
|
318
|
+
|| (input.defaults && input.defaults.taskKind) || '';
|
|
233
319
|
|
|
234
320
|
if ((!current || current.status === 'unbound' || current.status === 'proposed') && input.selection && (input.selection.seriesId || input.selection.taskKind)) {
|
|
235
321
|
const transitionId = transitionIdFor(batchKey, current ? current.bindingRevision || 0 : 0);
|
|
@@ -252,6 +338,7 @@ export function routeTask(tasksInput, input = {}) {
|
|
|
252
338
|
phase: input.selection.phase || 'brief',
|
|
253
339
|
status: 'proposed',
|
|
254
340
|
transitionId,
|
|
341
|
+
taskText: query,
|
|
255
342
|
now,
|
|
256
343
|
});
|
|
257
344
|
}
|
|
@@ -265,20 +352,44 @@ export function routeTask(tasksInput, input = {}) {
|
|
|
265
352
|
return { tasks, anchor: current, episode: currentEpisode, decision: { kind: 'none', reason: 'thanks', pointer: true }, changed: false };
|
|
266
353
|
}
|
|
267
354
|
|
|
355
|
+
/** 先认新对象,再认续作:带「继续 / 这个」但换了对象,也必须撤销旧范围。
|
|
356
|
+
* 本句点名了已登记栏目(「再做一期口播」)就是明确的对象证据,不算换任务。 */
|
|
357
|
+
if (current && currentEpisode && currentEpisode.status !== 'unbound'
|
|
358
|
+
&& !SWITCH_WORDS.test(query) && !changedSeries && !namedSeries
|
|
359
|
+
&& looksLikeNewTask(query, currentEpisode.taskText || '')) {
|
|
360
|
+
const transitionId = transitionIdFor(batchKey, tasks.revision);
|
|
361
|
+
const existing = Object.values(tasks.episodes).find((episode) => episode.transitionId === transitionId);
|
|
362
|
+
const episode = existing || createEpisode(tasks, {
|
|
363
|
+
status: 'unbound',
|
|
364
|
+
phase: 'brief',
|
|
365
|
+
transitionId,
|
|
366
|
+
previousEpisodeId: currentEpisode.episodeId,
|
|
367
|
+
taskText: query,
|
|
368
|
+
now,
|
|
369
|
+
});
|
|
370
|
+
const anchor = anchorOf(episode, { sessionId, batchKey, messageIds, status: 'unbound', bindingRevision: tasks.revision, now });
|
|
371
|
+
tasks.sessions[sessionId] = anchor;
|
|
372
|
+
return { tasks, anchor, episode, decision: { kind: 'unbound', anchor, reason: 'new_task' }, changed: true };
|
|
373
|
+
}
|
|
374
|
+
|
|
268
375
|
if (isContinuation(query) && current && currentEpisode) {
|
|
269
376
|
const previousRevision = current.bindingRevision || 1;
|
|
270
377
|
const transitionId = transitionIdFor(batchKey, previousRevision);
|
|
271
378
|
const existing = Object.values(tasks.episodes).find((episode) => episode.transitionId === transitionId);
|
|
379
|
+
/** 「再做一期 Vlog」:继承上一期的任务类型,但栏目换成本句点名的那个。 */
|
|
380
|
+
const seriesId = changedSeries || currentEpisode.seriesId || '';
|
|
381
|
+
const status = changedSeries ? 'active' : 'continuable';
|
|
272
382
|
const episode = existing || createEpisode(tasks, {
|
|
273
|
-
taskKind:
|
|
274
|
-
seriesId:
|
|
383
|
+
...(fallbackKind ? { taskKind: fallbackKind } : {}),
|
|
384
|
+
...(seriesId ? { seriesId } : {}),
|
|
275
385
|
phase: 'brief',
|
|
276
|
-
status
|
|
386
|
+
status,
|
|
277
387
|
transitionId,
|
|
278
388
|
previousEpisodeId: currentEpisode.episodeId,
|
|
389
|
+
taskText: query,
|
|
279
390
|
now,
|
|
280
391
|
});
|
|
281
|
-
const anchor = anchorOf(episode, { sessionId, batchKey, messageIds, status
|
|
392
|
+
const anchor = anchorOf(episode, { sessionId, batchKey, messageIds, status, bindingRevision: tasks.revision, now });
|
|
282
393
|
tasks.sessions[sessionId] = anchor;
|
|
283
394
|
return { tasks, anchor, episode, decision: { kind: 'continuation', anchor, inherited: currentEpisode }, changed: true };
|
|
284
395
|
}
|
|
@@ -291,24 +402,51 @@ export function routeTask(tasksInput, input = {}) {
|
|
|
291
402
|
}
|
|
292
403
|
|
|
293
404
|
if (current && (SWITCH_WORDS.test(query) || changedSeries)) {
|
|
294
|
-
/**
|
|
405
|
+
/** 明确点到已登记的栏目就切过去并立即生效;只说「换个」没点栏目才留无正文作品壳。 */
|
|
295
406
|
const nextSeries = changedSeries || '';
|
|
296
407
|
const transitionId = transitionIdFor(batchKey, tasks.revision);
|
|
297
408
|
const existing = Object.values(tasks.episodes).find((episode) => episode.transitionId === transitionId);
|
|
409
|
+
const status = nextSeries ? 'active' : 'unbound';
|
|
298
410
|
const episode = existing || createEpisode(tasks, {
|
|
411
|
+
...(fallbackKind ? { taskKind: fallbackKind } : {}),
|
|
299
412
|
...(nextSeries ? { seriesId: nextSeries } : {}),
|
|
300
|
-
status
|
|
413
|
+
status,
|
|
301
414
|
phase: 'brief',
|
|
302
415
|
transitionId,
|
|
303
416
|
previousEpisodeId: currentEpisode ? currentEpisode.episodeId : '',
|
|
417
|
+
taskText: query,
|
|
304
418
|
now,
|
|
305
419
|
});
|
|
306
|
-
const anchor = anchorOf(episode, { sessionId, batchKey, messageIds, status
|
|
420
|
+
const anchor = anchorOf(episode, { sessionId, batchKey, messageIds, status, bindingRevision: tasks.revision, now });
|
|
307
421
|
tasks.sessions[sessionId] = anchor;
|
|
308
422
|
return { tasks, anchor, episode, decision: { kind: nextSeries ? 'selected' : 'unbound', anchor, switched: true }, changed: true };
|
|
309
423
|
}
|
|
310
424
|
|
|
425
|
+
/** 新会话或尚无有效栏目时,本句点名的已登记栏目直接生效,不被项目默认值覆盖。 */
|
|
426
|
+
if (namedSeries && !changedSeries && (!current || current.status === 'unbound' || current.status === 'proposed')) {
|
|
427
|
+
const transitionId = transitionIdFor(batchKey, tasks.revision);
|
|
428
|
+
const existing = Object.values(tasks.episodes).find((episode) => episode.transitionId === transitionId);
|
|
429
|
+
const episode = existing || createEpisode(tasks, {
|
|
430
|
+
...(fallbackKind ? { taskKind: fallbackKind } : {}),
|
|
431
|
+
seriesId: namedSeries,
|
|
432
|
+
status: 'active',
|
|
433
|
+
phase: 'brief',
|
|
434
|
+
transitionId,
|
|
435
|
+
taskText: query,
|
|
436
|
+
now,
|
|
437
|
+
});
|
|
438
|
+
const anchor = anchorOf(episode, { sessionId, batchKey, messageIds, status: 'active', bindingRevision: tasks.revision, now });
|
|
439
|
+
tasks.sessions[sessionId] = anchor;
|
|
440
|
+
return { tasks, anchor, episode, decision: { kind: 'selected', anchor, explicit: true }, changed: true };
|
|
441
|
+
}
|
|
442
|
+
|
|
311
443
|
if (current && currentEpisode) {
|
|
444
|
+
/** 任务文本只作后续「是否换任务」的比对材料,不改锚点时间,免得把正在整理的这期作废。 */
|
|
445
|
+
const nextTaskText = text(query, 200);
|
|
446
|
+
if (nextTaskText && currentEpisode.taskText !== nextTaskText) {
|
|
447
|
+
currentEpisode.taskText = nextTaskText;
|
|
448
|
+
tasks.episodes[currentEpisode.episodeId] = currentEpisode;
|
|
449
|
+
}
|
|
312
450
|
const anchor = anchorOf(currentEpisode, {
|
|
313
451
|
sessionId, batchKey, messageIds,
|
|
314
452
|
status: currentEpisode.status,
|
|
@@ -329,6 +467,7 @@ export function routeTask(tasksInput, input = {}) {
|
|
|
329
467
|
phase: 'brief',
|
|
330
468
|
status: 'active',
|
|
331
469
|
transitionId,
|
|
470
|
+
taskText: query,
|
|
332
471
|
now,
|
|
333
472
|
});
|
|
334
473
|
const anchor = anchorOf(episode, { sessionId, batchKey, messageIds, status: 'active', bindingRevision: tasks.revision, now });
|
|
@@ -339,7 +478,7 @@ export function routeTask(tasksInput, input = {}) {
|
|
|
339
478
|
/** 新会话/新任务、无法确定栏目:只建无正文作品壳,不猜 taskKind/seriesId。 */
|
|
340
479
|
const transitionId = transitionIdFor(batchKey, tasks.revision);
|
|
341
480
|
const existing = Object.values(tasks.episodes).find((episode) => episode.transitionId === transitionId);
|
|
342
|
-
const episode = existing || createEpisode(tasks, { status: 'unbound', phase: 'brief', transitionId, now });
|
|
481
|
+
const episode = existing || createEpisode(tasks, { status: 'unbound', phase: 'brief', transitionId, taskText: query, now });
|
|
343
482
|
const anchor = anchorOf(episode, { sessionId, batchKey, messageIds, status: 'unbound', bindingRevision: tasks.revision, now });
|
|
344
483
|
tasks.sessions[sessionId] = anchor;
|
|
345
484
|
return { tasks, anchor, episode, decision: { kind: 'unbound', anchor }, changed: true };
|
|
@@ -366,7 +505,7 @@ export function decideRead(input = {}) {
|
|
|
366
505
|
const ranges = (Array.isArray(input.ranges) ? input.ranges : []).filter(Boolean).slice(0, 3);
|
|
367
506
|
return ranges.length
|
|
368
507
|
? { kind: 'index', ranges, reason: 'unbound' }
|
|
369
|
-
: { kind: '
|
|
508
|
+
: { kind: 'none', reason: 'no_range' };
|
|
370
509
|
}
|
|
371
510
|
if (anchor.status === 'ambiguous') {
|
|
372
511
|
const ranges = (Array.isArray(input.ranges) ? input.ranges : []).filter(Boolean).slice(0, 3);
|