From 55b027a19f3dd7ef48ccaa397b529ce4f1ffc495 Mon Sep 17 00:00:00 2001 From: modusensus Date: Thu, 5 Nov 2026 09:38:44 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=95=AA=E8=8C=84?= =?UTF-8?q?=E9=92=9F=E6=8F=90=E9=86=92=E6=8A=8A=E4=BC=9A=E8=AF=9D=E6=89=93?= =?UTF-8?q?=E6=AD=BB=EF=BC=9A=E6=B6=88=E6=81=AF=20source=20kind=20?= =?UTF-8?q?=E6=94=B9=E6=88=90=20plugin:dsh-study-desk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 番茄钟走完时插件用 agent.followup() 把提醒塞进起钟会话,source 写成 { kind: 'plugin', plugin: 'dsh-study-desk', … }。宿主会话格式 v4 把这个形状判为 退役语法(app.asar 的 source() 见到 kind === 'plugin' 就抛),agent/inbox/spliced 整条落盘被拒,会话从那一刻起每一轮都报 format v4 message requires a producer-owned source kind。 改用 plugin:<包名> 这种生产者自己命名的 kind(宿主只要求非空且不是裸 plugin), 并为它加一层单一出口:MESSAGE_SOURCE_KIND 与 timerEndSource(),不再手写字面量。 - index.js: 新增 MESSAGE_SOURCE_KIND / timerEndSource(),timerEndHook 改用工厂 - apply-probe.mjs: 端到端断言同步到新 kind - test/message-source.test.js: 4 条回归(含静态扫,禁止裸 kind 'plugin' 回潮) - AGENTS.md: 更新过时的 source.kind 说明、文件地图与单测数量 - CHANGELOG/package.json: v0.8.1 --- AGENTS.md | 9 +++-- CHANGELOG.md | 16 +++++++++ apply-probe.mjs | 4 ++- index.js | 17 +++++++++- package.json | 2 +- test/message-source.test.js | 68 +++++++++++++++++++++++++++++++++++++ 6 files changed, 111 insertions(+), 5 deletions(-) create mode 100644 test/message-source.test.js diff --git a/AGENTS.md b/AGENTS.md index 4e901c4..81c1628 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,7 @@ | `test/markdown.test.js` | 4 个单测:导出→读回往返、手写 Markdown 读回、id 命中即更新。 | | `test/bbdc.test.js` | 13 个单测:prefs 解析、按月求和(累计 293 词就是 48 个月的 `learnNum` 相加)、`sid` 编码变体、时长两边取新、本地刷盘滞后标记等。 | | `test/mini-dock.test.js` | 8 个单测:迷你条松手不贴边、仍会被收回视口、吸附实现已移除、声明了 `no-drag`、不许停进宿主标题栏那条拖拽带,以及计划表悬浮件的同款坑位守卫(no-drag / pointer-events / z-index / 默认位不重叠 / 钳制函数不许另起炉灶)。 | +| `test/message-source.test.js` | 4 个单测(会话消息 source 准入):kind 必须是 `plugin:dsh-study-desk`、工厂出的 source 能过宿主 v4 准入、`at` 默认取当前时间且不共享同一对象,外加一条**静态扫**把裸 `kind: 'plugin'` 挡在提交前。 | | `test/style.test.js` | 6 个单测(视觉层守卫):CSS 里 hex 必须为 0、饼图 8 色不许退回语义色、不许有零点几像素字号、标题不用衬线、字阶挂在各根上、图表配色归属。 | | `tools/client-loader.mjs` | 把 `client.js` 这个 loader 格式的 bundle 在纯 Node 里载进来、取 `exports.__test`。两个测试文件共用一份,免得各写一套桩。放在 `tools/` 是因为 `node --test` 会把 `test/` 下每个文件都当测试跑。 | | `apply-probe.mjs` | 离线冒烟:用 stub ctx 把两侧 `apply()` 真跑一遍,不启动 DSH 就能抓加载期错误。 | @@ -94,7 +95,11 @@ - **提醒发送**:`withDesk` 结账时把走完的段收进 sink,`emitTimerEnd` 过滤(开关关了 / 不是 AI 链 / 段走完超过 10 分钟才被结账的都跳过)后调钩子:`ctx.get('agents').get(sessionId).followup(消息)`。 这是官方 `dsh-goal-round-driver` 的同款姿势(app.asar 里有源码可对照);`followup` 空闲就开新一轮、 - 跑着就排下一轮。消息 `role:'user'`、`source.kind:'plugin'`,正文自带「这是系统自动消息,不是用户发言」。 + 跑着就排下一轮。消息 `role:'user'`、`source.kind:'plugin:dsh-study-desk'`(**必须带 `plugin:` 前缀**—— + 裸 `'plugin'` 是宿主会话格式 v4 已退役的写法,落盘会被整条拒绝,报 + `format v4 message requires a producer-owned source kind`,整个会话当场跑不起来; + kind 由 `MESSAGE_SOURCE_KIND` / `timerEndSource()` 统一产出,别手写), + 正文自带「这是系统自动消息,不是用户发言」。 宿主侧另有 10 秒看门狗:面板没开没人轮询 desk 时由它兜底结账。 - **开关**:`settings.notifySessionOnTimerEnd`(默认开,`!== false` 即开,语义同 `injectPrompt`), 设置页有勾选项。**手动停钟(客户端按钮 / 工具 `off=true`)永不提醒**;会话已关闭(agents 里没有) @@ -205,7 +210,7 @@ ctx.set('studyDesk', { ... }) // 抛 Error: cannot set property "studyDesk ## 测试 ```bash -node --test # 73 个单测:数据层 + 番茄钟状态机 + AI 起钟会话标记 + 复习排期 + 遗忘曲线/预报/建议 + Markdown 往返 + 不背单词 prefs 解析与时长取新 + 迷你条/计划表悬浮件落点与拖拽带 + 每日计划表 + 视觉层守卫(hex / 字阶 / 配色归属) +node --test # 77 个单测:数据层 + 番茄钟状态机 + AI 起钟会话标记 + 复习排期 + 遗忘曲线/预报/建议 + Markdown 往返 + 不背单词 prefs 解析与时长取新 + 迷你条/计划表悬浮件落点与拖拽带 + 每日计划表 + 视觉层守卫(hex / 字阶 / 配色归属)+ 消息 source kind 准入(含静态扫防回潮) node apply-probe.mjs # 离线冒烟:stub ctx 跑两侧 apply(),检查工具 / 路由 / 槽位注册 / 新 op / 快照新字段 / AI 起钟门禁与会话提醒 / 每日计划表校验 node install.mjs --check # 体检:本目录与某个 profile 的接线是否完整 ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c8d65b..0817a98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ 格式随版本号走;`package.json` 的 version 是唯一真相,tag 用 `v<版本>`。 +## v0.8.1 + +番茄钟走完的自动提醒会把整个会话打成跑不起来 —— 修掉那条写入。 + +- **会话消息的 source kind 带上生产者前缀**:番茄钟走完发回起钟会话的那条提醒,`source` 以前写成 + `{ kind: 'plugin', plugin: 'dsh-study-desk', … }`。宿主会话格式 v4 把这种「kind 一律 `plugin`、 + 只靠 `plugin` 字段认来源」的写法判为退役语法,落盘时整条拒绝,报 + `format v4 message requires a producer-owned source kind` —— 表现就是会话在番茄钟停止的那一刻 + 忽然**任何回复都发不出去**,历史还在、但每一轮都撞同一堵墙。现在 kind 写成 + `plugin:dsh-study-desk`(宿主只要求「非空且不是裸 `plugin`」,前缀正是来源标记, + `clearai` / `dsh-mneme` 等插件在现有会话里早已这么写),由 `MESSAGE_SOURCE_KIND` + 与 `timerEndSource()` 统一产出,不再手写。 +- **回归测试 + 静态扫**:新增 `test/message-source.test.js`(4 条)。除了断言工厂产出的 source + 能过宿主同款准入,还静态扫源码禁止裸 `kind: 'plugin'` 回潮;`apply-probe.mjs` 里那条端到端断言 + 同步改成新 kind。单测 73 → 77。 + ## v0.8.0 AI 把督促链路接上了:AI 起番茄钟必须先落卡、走完自动提醒开钟的那个会话;新增每日计划表,模型从系统提示里就知道「现在该做什么」。看板补上过卡模式、卡片截止日期与一批顺手的升级。 diff --git a/apply-probe.mjs b/apply-probe.mjs index fef36c7..471d958 100644 --- a/apply-probe.mjs +++ b/apply-probe.mjs @@ -268,7 +268,9 @@ check('专注走完 → 提醒以 user 消息送进起钟会话', () => { assert.equal(msg.role, 'user') assert.match(msg.content[0].text, /番茄钟·自动提醒/) assert.match(msg.content[0].text, /25 分钟/) - assert.equal(msg.source.kind, 'plugin') + // 必须是生产者自己命名的 kind:裸 'plugin' 是宿主会话格式 v4 已退役的写法, + // 落盘时会被整条拒绝(format v4 message requires a producer-owned source kind) + assert.equal(msg.source.kind, 'plugin:dsh-study-desk') }) check('自动摆的休息继承会话标记(链条不断)', () => { const t = JSON.parse(nudgeRes.body).state.timer diff --git a/index.js b/index.js index 021d753..f2153b1 100644 --- a/index.js +++ b/index.js @@ -182,6 +182,18 @@ const pluginVersion = (() => { } catch { return '' } })() +// 会话消息的 source kind 必须是「生产者自己命名」的:宿主会话格式 v4 把旧的共享包装 +// (kind 一律写 plugin、只靠 plugin 字段认来源的那种)判为退役语法,落盘即整条拒绝,报 +// format v4 message requires a producer-owned source kind +// (app.asar: lib/types/message-sources.js 的 source())。所以插件写给会话的消息一律用 +// `plugin:<包名>` 这种带前缀的 kind —— 宿主只要求「非空且不是 plugin」,前缀正是来源标记。 +export const MESSAGE_SOURCE_KIND = `plugin:${name}` + +/** 番茄钟走完那条自动提醒的 source(导出仅为单测能直接断言形状)。 */ +export function timerEndSource(at = Date.now()) { + return { kind: MESSAGE_SOURCE_KIND, plugin: name, event: 'timer-end', at } +} + // --------------------------------------------------------------------------- // 串行化的状态访问:客户端会 POST,模型会调工具,两边可能同时改同一份 JSON。 // 用一条 promise 链把「读-改-写」串起来,避免后写的把先写的覆盖掉。 @@ -853,7 +865,10 @@ export function apply(ctx, config) { id: randomUUID(), role: 'user', content: [{ type: 'text', text: timerEndText(info) }], - source: { kind: 'plugin', plugin: name, event: 'timer-end', at: Date.now() }, + // kind 必须是生产者自己命名的(plugin:<包名>):裸 'plugin' 是退役写法, + // 宿主会话格式 v4 会整条拒绝这个 splice,会话当场报 + // format v4 message requires a producer-owned source kind + source: timerEndSource(), }) } diff --git a/package.json b/package.json index 1c586b5..6b4ef69 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-study-desk", - "version": "0.8.0", + "version": "0.8.1", "icon": "./assets/icon.svg", "description": "学习工作台 DSH 插件:Notion 式待办墙与标签过滤 + 间隔重复复习队列(简化 SM-2,带记忆曲线、未来 7 天到期预报与今日建议)+ 按实际复习时长点亮的热力图与周报 + 每日复盘 + Markdown 导出读回 + 跨面板存活的番茄钟 + 独立的不背单词打卡面板(只读本机 App 数据)。只消费主题 token,融入现有主题而不改主题。", "type": "module", diff --git a/test/message-source.test.js b/test/message-source.test.js new file mode 100644 index 0000000..d591edd --- /dev/null +++ b/test/message-source.test.js @@ -0,0 +1,68 @@ +/** + * 消息 source 形状守卫(回归:session-26939e9f 的 `format v4 message requires a producer-owned source kind`)。 + * + * 背景:宿主会话格式 v4 把旧的共享包装 `{ kind: 'plugin', plugin: … }` 判为退役语法。 + * app.asar 里 lib/types/message-sources.js 的 source() 只要看到 kind === 'plugin' 就抛, + * 而插件塞进 `agent.followup(...)` 的消息会被原样写进 `agent/inbox/spliced.data.inserted[]`, + * 于是整条 splice 落盘失败,那个会话从此每一轮都报这行错。 + * + * 所以这里锁两件事: + * 1. 番茄钟提醒的 source 是 `plugin:<包名>` 这种生产者自己命名的 kind; + * 2. 源码里不许再出现裸 `kind: 'plugin'`(静态扫,防回潮)。 + */ + +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { MESSAGE_SOURCE_KIND, name, timerEndSource } from '../index.js' + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') + +/** 复刻宿主的 v4 准入判据(app.asar: lib/types/message-sources.js 的 source())。 */ +function assertProducerOwnedSource(message) { + const value = message && message.source + assert.ok(value && typeof value === 'object' && !Array.isArray(value), 'source 必须是对象') + assert.equal(typeof value.kind, 'string', 'source.kind 必须是字符串') + assert.ok(value.kind.length > 0, 'source.kind 不能是空串') + assert.notEqual(value.kind, 'plugin', 'source.kind 不许是退役写法 plugin') +} + +test('MESSAGE_SOURCE_KIND 是生产者自己命名的 kind', () => { + assert.equal(MESSAGE_SOURCE_KIND, 'plugin:dsh-study-desk') + assert.equal(MESSAGE_SOURCE_KIND, `plugin:${name}`) +}) + +test('番茄钟提醒的 source 能过宿主 v4 准入', () => { + const at = 1770000000000 + const source = timerEndSource(at) + assertProducerOwnedSource({ source }) + assert.deepEqual(source, { + kind: 'plugin:dsh-study-desk', + plugin: 'dsh-study-desk', + event: 'timer-end', + at, + }) +}) + +test('timerEndSource 的 at 默认取当前时间,且不共享同一个对象', () => { + const before = Date.now() + const a = timerEndSource() + const b = timerEndSource() + assert.ok(a.at >= before) + assert.notEqual(a, b, '每次都要新对象:消息 source 会被宿主深冻结') +}) + +test('源码里不许出现裸 kind: \'plugin\'(静态扫,防回潮)', () => { + // 只看宿主半边的源码;client.js 是浏览器 bundle,它的 kind 全是诊断/计时器, + // 但为免以后有人往里面塞会话消息,也一并扫。 + const files = ['index.js', 'desk.js', 'timer.js', 'markdown.js', 'bbdc.js', 'client.js'] + const banned = /kind\s*:\s*(['"`])plugin\1/ + for (const file of files) { + const text = readFileSync(join(ROOT, file), 'utf8') + const hit = banned.exec(text) + assert.equal(hit, null, `${file} 里还有裸 kind 'plugin':${hit ? hit[0] : ''}`) + } +}) From 423848a61e009c5ae250e706eb55e2567cec8b6d Mon Sep 17 00:00:00 2001 From: modusensus Date: Thu, 5 Nov 2026 09:42:33 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E9=9D=99=E6=80=81=E6=89=AB=E5=8A=A0?= =?UTF-8?q?=E6=B3=A8=E9=87=8A=E9=81=AE=E7=BD=A9=EF=BC=9A=E8=AE=B2=E6=97=A7?= =?UTF-8?q?=E5=86=99=E6=B3=95=E7=9A=84=E6=B3=A8=E9=87=8A=E4=B8=8D=E7=AE=97?= =?UTF-8?q?=E8=BF=9D=E8=A7=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原来那条守卫直接对文件原文跑正则,index.js 里解释退役语法的注释 (「kind 一律写 plugin」那几个字)离踩线只差一个冒号,随时会误红。 - test/message-source.test.js: 新增 maskComments(),把块注释/行注释抹成空格 (保留换行与字符串),扫描前先过一遍:注释里讲旧写法放过,代码里写了才抓 - 新增一条「扫描器不空转」自证:注释三种写法不误红、代码三种引号必须抓到、 plugin:dsh-study-desk 与 pluginish 不许误伤 --- AGENTS.md | 4 +-- CHANGELOG.md | 2 +- test/message-source.test.js | 58 ++++++++++++++++++++++++++++++++++--- 3 files changed, 57 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 81c1628..918cac5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ | `test/markdown.test.js` | 4 个单测:导出→读回往返、手写 Markdown 读回、id 命中即更新。 | | `test/bbdc.test.js` | 13 个单测:prefs 解析、按月求和(累计 293 词就是 48 个月的 `learnNum` 相加)、`sid` 编码变体、时长两边取新、本地刷盘滞后标记等。 | | `test/mini-dock.test.js` | 8 个单测:迷你条松手不贴边、仍会被收回视口、吸附实现已移除、声明了 `no-drag`、不许停进宿主标题栏那条拖拽带,以及计划表悬浮件的同款坑位守卫(no-drag / pointer-events / z-index / 默认位不重叠 / 钳制函数不许另起炉灶)。 | -| `test/message-source.test.js` | 4 个单测(会话消息 source 准入):kind 必须是 `plugin:dsh-study-desk`、工厂出的 source 能过宿主 v4 准入、`at` 默认取当前时间且不共享同一对象,外加一条**静态扫**把裸 `kind: 'plugin'` 挡在提交前。 | +| `test/message-source.test.js` | 5 个单测(会话消息 source 准入):kind 必须是 `plugin:dsh-study-desk`、工厂出的 source 能过宿主 v4 准入、`at` 默认取当前时间且不共享同一对象,外加**静态扫**把裸 `kind: 'plugin'` 挡在提交前(先 `maskComments()` 抹掉注释——注释里讲旧写法不算违规),以及一条「扫描器不空转」自证。 | | `test/style.test.js` | 6 个单测(视觉层守卫):CSS 里 hex 必须为 0、饼图 8 色不许退回语义色、不许有零点几像素字号、标题不用衬线、字阶挂在各根上、图表配色归属。 | | `tools/client-loader.mjs` | 把 `client.js` 这个 loader 格式的 bundle 在纯 Node 里载进来、取 `exports.__test`。两个测试文件共用一份,免得各写一套桩。放在 `tools/` 是因为 `node --test` 会把 `test/` 下每个文件都当测试跑。 | | `apply-probe.mjs` | 离线冒烟:用 stub ctx 把两侧 `apply()` 真跑一遍,不启动 DSH 就能抓加载期错误。 | @@ -210,7 +210,7 @@ ctx.set('studyDesk', { ... }) // 抛 Error: cannot set property "studyDesk ## 测试 ```bash -node --test # 77 个单测:数据层 + 番茄钟状态机 + AI 起钟会话标记 + 复习排期 + 遗忘曲线/预报/建议 + Markdown 往返 + 不背单词 prefs 解析与时长取新 + 迷你条/计划表悬浮件落点与拖拽带 + 每日计划表 + 视觉层守卫(hex / 字阶 / 配色归属)+ 消息 source kind 准入(含静态扫防回潮) +node --test # 78 个单测:数据层 + 番茄钟状态机 + AI 起钟会话标记 + 复习排期 + 遗忘曲线/预报/建议 + Markdown 往返 + 不背单词 prefs 解析与时长取新 + 迷你条/计划表悬浮件落点与拖拽带 + 每日计划表 + 视觉层守卫(hex / 字阶 / 配色归属)+ 消息 source kind 准入(含静态扫防回潮) node apply-probe.mjs # 离线冒烟:stub ctx 跑两侧 apply(),检查工具 / 路由 / 槽位注册 / 新 op / 快照新字段 / AI 起钟门禁与会话提醒 / 每日计划表校验 node install.mjs --check # 体检:本目录与某个 profile 的接线是否完整 ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 0817a98..456c895 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ 与 `timerEndSource()` 统一产出,不再手写。 - **回归测试 + 静态扫**:新增 `test/message-source.test.js`(4 条)。除了断言工厂产出的 source 能过宿主同款准入,还静态扫源码禁止裸 `kind: 'plugin'` 回潮;`apply-probe.mjs` 里那条端到端断言 - 同步改成新 kind。单测 73 → 77。 + 同步改成新 kind。单测 73 → 78。 ## v0.8.0 diff --git a/test/message-source.test.js b/test/message-source.test.js index d591edd..9ab8e6c 100644 --- a/test/message-source.test.js +++ b/test/message-source.test.js @@ -55,14 +55,64 @@ test('timerEndSource 的 at 默认取当前时间,且不共享同一个对象' assert.notEqual(a, b, '每次都要新对象:消息 source 会被宿主深冻结') }) -test('源码里不许出现裸 kind: \'plugin\'(静态扫,防回潮)', () => { +/** + * 把注释抹成空格(保留长度与换行),字符串 / 模板原样留着。 + * 静态扫只该抓「代码里写了旧写法」;解释旧写法的注释必须放过 —— + * index.js 里那段退役说明离踩线只差一个冒号。 + */ +function maskComments(text) { + const out = text.split('') + let i = 0 + let quote = null + while (i < text.length) { + const ch = text[i] + if (quote) { + if (ch === '\\') { i += 2; continue } + if (ch === quote) quote = null + i += 1 + continue + } + if (ch === "'" || ch === '"' || ch === '`') { quote = ch; i += 1; continue } + if (ch === '/' && text[i + 1] === '/') { + while (i < text.length && text[i] !== '\n') { out[i] = ' '; i += 1 } + continue + } + if (ch === '/' && text[i + 1] === '*') { + while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) { + if (text[i] !== '\n') out[i] = ' ' + i += 1 + } + if (i < text.length) { out[i] = ' '; out[i + 1] = ' '; i += 2 } + continue + } + i += 1 + } + return out.join('') +} + +const BANNED_KIND = /kind\s*:\s*(['"`])plugin\1/ + +test("源码里不许出现裸 kind: 'plugin'(静态扫,防回潮)", () => { // 只看宿主半边的源码;client.js 是浏览器 bundle,它的 kind 全是诊断/计时器, // 但为免以后有人往里面塞会话消息,也一并扫。 const files = ['index.js', 'desk.js', 'timer.js', 'markdown.js', 'bbdc.js', 'client.js'] - const banned = /kind\s*:\s*(['"`])plugin\1/ for (const file of files) { - const text = readFileSync(join(ROOT, file), 'utf8') - const hit = banned.exec(text) + const code = maskComments(readFileSync(join(ROOT, file), 'utf8')) + const hit = BANNED_KIND.exec(code) assert.equal(hit, null, `${file} 里还有裸 kind 'plugin':${hit ? hit[0] : ''}`) } }) + +test('扫描器不空转:注释里讲旧写法不算违规,代码里写了才算', () => { + // 注释形态必须放过 + assert.equal(BANNED_KIND.test(maskComments("// 旧写法是 kind: 'plugin'")), false) + assert.equal(BANNED_KIND.test(maskComments('/* 也别写成 kind: "plugin" */')), false) + assert.equal(BANNED_KIND.test(maskComments('const a = 1 // kind: `plugin`')), false) + // 代码形态必须抓到 + assert.equal(BANNED_KIND.test(maskComments("const s = { kind: 'plugin' }")), true) + assert.equal(BANNED_KIND.test(maskComments('const s = { kind:"plugin" }')), true) + assert.equal(BANNED_KIND.test(maskComments('const s = { kind : `plugin` }')), true) + // 新写法与相似词不许误伤 + assert.equal(BANNED_KIND.test(maskComments("const s = { kind: 'plugin:dsh-study-desk' }")), false) + assert.equal(BANNED_KIND.test(maskComments("const s = { kind: 'pluginish' }")), false) +})