clearai-dsh 0.2.7 → 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.
package/lib/host.js CHANGED
@@ -19,7 +19,8 @@ import { readdirSync, statSync } from 'node:fs'
19
19
  import { join, relative, resolve, sep } from 'node:path'
20
20
  import { z } from 'zod'
21
21
  import { HUMAN_GATE_ACTIONS, HUMAN_GATE_MARK, MUTATION_KIND, STATE_VERSION, applyEvent, applyMutations, derive, emptyState, inspectGraphSelection, renderCard, view } from './fold.js'
22
- import { describeDomainShelf, formatAssertion, validateAssertions, validatePredicate, validateTerm } from './domain-language.js'
22
+ import { describeDomainShelf, formatAssertion, graphProjection, validateAssertions, validatePredicate, validateTerm } from './domain-language.js'
23
+ import { knowledgeView as knowledgeViewOf } from './knowledge-view.js'
23
24
  import { install as installInvariants } from './invariant.js'
24
25
 
25
26
  export const name = 'clearai-host'
@@ -99,9 +100,118 @@ export function apply(ctx) {
99
100
  return lastView
100
101
  }
101
102
 
103
+ /**
104
+ * ═══ 注入服务只走**方法式**,读不到就降级 ═══
105
+ *
106
+ * 属性式读服务(把服务名当属性取)在 Cordis 里走一条 Proxy walk:只要本 fiber
107
+ * (或链路上某个声明了同一条 inject 的祖先 fiber)此刻不是 ACTIVE,它**当场抛**
108
+ * `cannot get required service "…" in inactive context`。
109
+ * 方法式 `ctx.get(名字)` 走的是全局注册表,同一时刻只回 `undefined`——
110
+ * 于是「这一刻读不到」成了一个**可表示**的值:读面降级,而不是把一次跑了几分钟的
111
+ * 评审整个作废。所以宿主半一次都不许出现属性式访问;`inject` 声明保留,
112
+ * 它保证正常路径上的就位顺序。
113
+ *
114
+ * 连方法式都炸(不该发生)时也走同一条降级:读面不许变成失败面。
115
+ *
116
+ * 降级这件事本身也要留痕:落一条**宿主健康事实**——只增不删,只留最近这些条,
117
+ * 随 `state()` / `view()` 读取暴露(字段 `hostHealth`)。它与折法从会话日志折出来的
118
+ * 那份**同形**(`[{ scope, detail, at }]`),所以面板与卡不必为「本地的」和「折出来的」
119
+ * 写两套读法。
120
+ */
121
+ const HOST_HEALTH_MAX = 20
122
+ const hostHealth = []
123
+ /**
124
+ * **内容寻址的 id**:`scope + detail` 一样就是同一条观测。
125
+ *
126
+ * 为什么不用时间戳:`at` 每次都不同,同一条降级会被记成无数条。用内容算 id 之后,
127
+ * ① 本地数组自己按 id 去重;② 内核把观测落成 `host/inactive` 变更时用同一个 id,
128
+ * 折法按 id 幂等 ⇒ **账本上一条、读数上一条**,不会因为"宿主记一次、内核再落一次"变成两条。
129
+ */
130
+ const hostHealthId = (scope, detail) => {
131
+ let h = 0x811c9dc5
132
+ const text = `${scope}\u0000${detail}`
133
+ for (let index = 0; index < text.length; index += 1) {
134
+ h ^= text.charCodeAt(index)
135
+ h = Math.imul(h, 0x01000193) >>> 0
136
+ }
137
+ return `hh-${h.toString(16).padStart(8, '0')}`
138
+ }
139
+ const noteHostHealth = (scope, detail) => {
140
+ const id = hostHealthId(scope, detail)
141
+ if (hostHealth.some((entry) => entry.id === id)) return
142
+ hostHealth.push({ id, scope, detail, at: Date.now() })
143
+ if (hostHealth.length > HOST_HEALTH_MAX) hostHealth.splice(0, hostHealth.length - HOST_HEALTH_MAX)
144
+ }
145
+ /**
146
+ * 与折法折出来的那份**合并**,不是覆盖:折法手里的 `hostHealth` 是账本上的历史,
147
+ * 本地这份是「这一刻读不到」的观测;两者都是读者要知道的事实,谁都不许把谁盖掉。
148
+ * 本地一条都没有时原样返回,正常路径上的返回值与加这条机制之前逐字段相同。
149
+ */
150
+ const withHostHealth = (value) => {
151
+ if (hostHealth.length === 0) return value
152
+ const folded = Array.isArray(value?.hostHealth) ? value.hostHealth : []
153
+ // 账本上已经有同一条(内核把它落成了 `host/inactive`)就不再加本地那份:同一条事实只报一次。
154
+ const known = new Set(folded.map((entry) => entry?.id).filter((id) => id !== undefined && id !== null))
155
+ const fresh = hostHealth.filter((entry) => !known.has(entry.id)).map((entry) => ({ ...entry }))
156
+ if (fresh.length === 0) return { ...value, hostHealth: folded }
157
+ return { ...value, hostHealth: [...folded, ...fresh] }
158
+ }
159
+
160
+ const sessionsOf = () => {
161
+ let sessions
162
+ try {
163
+ sessions = ctx.get('sessions')
164
+ } catch {
165
+ sessions = undefined
166
+ }
167
+ if (sessions === undefined || sessions === null) {
168
+ noteHostHealth('sessions', '宿主半此刻拿不到会话服务:读面退回空态(不是「这个会话不存在」)')
169
+ return undefined
170
+ }
171
+ return sessions
172
+ }
173
+
174
+ /** 投影服务:同上一条降级语义,同一条方法式访问。 */
175
+ const projectionsOf = () => {
176
+ let projections
177
+ try {
178
+ projections = ctx.get('sessionProjections')
179
+ } catch {
180
+ projections = undefined
181
+ }
182
+ if (projections === undefined || projections === null) {
183
+ noteHostHealth('sessionProjections', '宿主半此刻拿不到投影服务:读面退回空态(不是「这个会话没有状态」)')
184
+ return undefined
185
+ }
186
+ return projections
187
+ }
188
+
189
+ /**
190
+ * 本插件 fiber 掉出 ACTIVE 时留一条观测。
191
+ *
192
+ * 读面降级只解释「这一刻读不到」,解释不了「为什么会读不到」——那要看 fiber 生命周期。
193
+ * Cordis 的 `internal/status` 事件带着 (fiber, 旧状态),订阅面就挂在它上面;
194
+ * 事件面是所有 fiber 共用的,所以过滤靠**身份**(`ctx.fiber`)而不是名字。
195
+ * 观测面的失败不许把产品弄坏:订阅抛错也只是少一条证据。
196
+ */
197
+ const FIBER_ACTIVE = 2
198
+ try {
199
+ ctx.on('internal/status', (fiber, oldValue) => {
200
+ if (fiber !== ctx.fiber || oldValue !== FIBER_ACTIVE) return
201
+ const detail = `宿主半 fiber 掉出 ACTIVE(${oldValue} → ${fiber.state}):两个注入服务这一刻都读不到,读面退回空态`
202
+ noteHostHealth('sessions', detail)
203
+ noteHostHealth('sessionProjections', detail)
204
+ ctx.logger?.warn?.(`clearai: ${detail}`)
205
+ })
206
+ } catch (error) {
207
+ ctx.logger?.warn?.(`clearai: fiber 生命周期观测没挂上 ${String(error?.message ?? error).slice(0, 160)}`)
208
+ }
209
+
102
210
  ctx.effect(
103
- () =>
104
- ctx.sessionProjections.register({
211
+ () => {
212
+ const projections = projectionsOf()
213
+ if (projections === undefined || typeof projections.register !== 'function') return () => {}
214
+ const disposer = projections.register({
105
215
  key: 'clearai',
106
216
  stateVersion: STATE_VERSION,
107
217
  stateSchema,
@@ -115,17 +225,32 @@ export function apply(ctx) {
115
225
  return applyEvent(state, event)
116
226
  },
117
227
  wire: { viewSchema, view: memoView },
118
- }),
228
+ })
229
+ return typeof disposer === 'function' ? disposer : () => {}
230
+ },
119
231
  'clearai: session projection unit',
120
232
  )
121
233
 
122
234
  /** 读面:预设侧与运行态卡都走这里,不各自维护一份状态。 */
123
235
  const stateOf = (sessionId) => {
124
- const session = ctx.sessions.get(sessionId)
125
- if (session === undefined) return emptyState()
126
- return ctx.sessionProjections.stateOf(session, 'clearai') ?? emptyState()
236
+ const sessions = sessionsOf()
237
+ if (sessions === undefined || typeof sessions.get !== 'function') return withHostHealth(emptyState())
238
+ const session = sessions.get(sessionId)
239
+ if (session === undefined) return withHostHealth(emptyState())
240
+ const projections = projectionsOf()
241
+ if (projections === undefined || typeof projections.stateOf !== 'function') return withHostHealth(emptyState())
242
+ return withHostHealth(projections.stateOf(session, 'clearai') ?? emptyState())
243
+ }
244
+
245
+ /** 会话服务的读,给路由用:同一条降级语义(拿不到 ⇒ `undefined`)。 */
246
+ const sessionOf = (sessionId) => {
247
+ const sessions = sessionsOf()
248
+ return sessions === undefined || typeof sessions.get !== 'function' ? undefined : sessions.get(sessionId)
127
249
  }
128
250
 
251
+ /** 面板视图:与 `state()` 同一份降级读数(健康事实一起交出去)。 */
252
+ const viewOf = (sessionId) => withHostHealth(view(stateOf(sessionId)))
253
+
129
254
  /**
130
255
  * ═══ 人门通道(D2=B:面板可写,但只写人门动作)═══
131
256
  *
@@ -546,11 +671,13 @@ export function apply(ctx) {
546
671
  */
547
672
  route('/api/clearai/deliverables', ['GET'], (httpRequest) => {
548
673
  const url = new URL(httpRequest.url)
549
- const session = ctx.sessions.get(url.searchParams.get('sessionId') ?? '')
674
+ const session = sessionOf(url.searchParams.get('sessionId') ?? '')
550
675
  const cwd = session?.header?.cwd
551
676
  if (typeof cwd !== 'string' || cwd === '') return reply(404, { ok: false, error: 'no_live_session' })
552
677
  // 只挑叶子字段构造属于我们自己的 JSON:不把投影对象整体搬出去。
553
- const state = ctx.sessionProjections.stateOf(session, 'clearai')
678
+ // 投影这一刻读不到 ⇒ 「声明」那一半给空;盘上「实际」那一半是读盘,不吃投影,照给。
679
+ const projections = projectionsOf()
680
+ const state = projections === undefined || typeof projections.stateOf !== 'function' ? null : projections.stateOf(session, 'clearai')
554
681
  const stages = (state?.plans ?? []).map((plan) => ({
555
682
  plan: plan.id,
556
683
  status: plan.status,
@@ -618,7 +745,7 @@ export function apply(ctx) {
618
745
  */
619
746
  route('/api/clearai/brain', ['GET'], async (httpRequest) => {
620
747
  const url = new URL(httpRequest.url)
621
- const session = ctx.sessions.get(url.searchParams.get('sessionId') ?? '')
748
+ const session = sessionOf(url.searchParams.get('sessionId') ?? '')
622
749
  const cwd = session?.header?.cwd
623
750
  if (typeof cwd !== 'string' || cwd === '') return reply(404, { ok: false, error: 'no_live_session' })
624
751
  const skills = ctx.get('skills')
@@ -670,14 +797,15 @@ export function apply(ctx) {
670
797
  route('/api/clearai/inspector', ['GET'], async (httpRequest) => {
671
798
  const url = new URL(httpRequest.url)
672
799
  const sessionId = url.searchParams.get('sessionId') ?? ''
673
- const session = ctx.sessions.get(sessionId)
800
+ const session = sessionOf(sessionId)
674
801
  if (session === undefined) return reply(404, { ok: false, error: 'no_live_session' })
675
802
  /**
676
803
  * 直接用本模块的折法读状态,不走 `ctx.get('clearai')`:那条门面是同一条 fiber 上
677
804
  * 提供给**预设侧**用的,而这条路由只是把同一个纯函数接到 HTTP 上——
678
805
  * 中间多一跳服务解析,只会多一种「服务没接上」的失败模式。
679
806
  */
680
- const state = ctx.sessionProjections.stateOf(session, 'clearai')
807
+ const projections = projectionsOf()
808
+ const state = projections === undefined || typeof projections.stateOf !== 'function' ? null : projections.stateOf(session, 'clearai')
681
809
  if (state === null || state === undefined) return reply(200, { ok: true, found: false })
682
810
  const found = inspectGraphSelection(state, { kind: url.searchParams.get('kind') ?? '', id: url.searchParams.get('id') ?? '' }, derive(state))
683
811
  /** 找不到不是错误:那个对象可能刚被废止或本来就不在(如实说 `found: false`,不编一份空的)。 */
@@ -694,8 +822,8 @@ export function apply(ctx) {
694
822
  state: stateOf,
695
823
  /** 派生:阶段/完成度/假设状态/世界线阶段,全部现算。 */
696
824
  derive: (sessionId) => derive(stateOf(sessionId)),
697
- /** 面板视图(与 wire 同一份)。 */
698
- view: (sessionId) => view(stateOf(sessionId)),
825
+ /** 面板视图(与 wire 同一份,降级时一并交出席位健康事实)。 */
826
+ view: viewOf,
699
827
  /**
700
828
  * 运行态卡:注给模型的**事实**。
701
829
  *
@@ -736,7 +864,12 @@ export function apply(ctx) {
736
864
  domain: {
737
865
  validateTerm: (sessionId, draft) => validateTerm(stateOf(sessionId).lexicon, draft),
738
866
  validatePredicate: (sessionId, draft) => validatePredicate(stateOf(sessionId).lexicon, draft),
739
- validateAssertions: (sessionId, assertions) => validateAssertions(stateOf(sessionId).lexicon, assertions),
867
+ /**
868
+ * **递整份 state,不只递 lexicon**:断言的主体要能被指认(实例登记过)才算数,
869
+ * 而「登记过哪些实例」住在 `state.entities` 里。只递词汇的话,那条判据永远无从判断,
870
+ * 只能迁移期一律放行——那就等于没有这条判据。
871
+ */
872
+ validateAssertions: (sessionId, assertions, options = {}) => validateAssertions(stateOf(sessionId), assertions, options),
740
873
  /**
741
874
  * 货架正文。带 `mutations` 时按**这一步之后**的样子渲染——
742
875
  * 工具在返回前就把货架写好,读的人不必等下一回合。
@@ -745,10 +878,16 @@ export function apply(ctx) {
745
878
  const state = applyMutations(stateOf(sessionId), Array.isArray(mutations) ? mutations : [])
746
879
  const next = derive(state)
747
880
  // 在途命题也传进去:词汇刚立起来时「引用 0」会让人以为没人用,而断言已经在假设上了。
748
- return describeDomainShelf(state.lexicon, next.factRows, next.hypotheses)
881
+ // `view`:货架的「使用」一节与运行态卡 / 右栏读**同一份**叙述(单一叙述源)。
882
+ return describeDomainShelf(state.lexicon, next.factRows, next.hypotheses, { view: knowledgeViewOf(state) })
749
883
  },
750
884
  /** 一条断言的一行人话(货架 / 卡片 / 查询共用同一句话,免得三处各写一套)。 */
751
885
  format: (sessionId, assertion) => formatAssertion(stateOf(sessionId).lexicon, assertion),
886
+ /**
887
+ * **当前的图投影**(节点 / 边)。给内核用:谓词登记与 `Assert` 都要判
888
+ * 「这个类型是已登记的概念吗」——判据只有一份,就在这张投影里。只读,不落盘。
889
+ */
890
+ graph: (sessionId) => graphProjection(stateOf(sessionId)),
752
891
  },
753
892
  /**
754
893
  * **知识 Inspector**:一个选择 → 它的定义 / 关系 / 断言 / 证据链 / 历史。
package/lib/invariant.js CHANGED
@@ -160,7 +160,13 @@ const install = Object.assign(
160
160
  admitted.set(session, adm)
161
161
  return state
162
162
  }
163
- ctx.sessions.list().forEach(seed)
163
+ /**
164
+ * **方法式取服务**:属性式(`ctx.sessions`)在访问者 fiber 不是 ACTIVE 时会抛
165
+ * `cannot get required service … in inactive context`,而这里只是"把已有会话喂一遍",
166
+ * 读不到就少做一次种子,不该让整个不变量伴生件崩掉。
167
+ */
168
+ const sessions = ctx.get('sessions')
169
+ if (sessions !== undefined && typeof sessions.list === 'function') sessions.list().forEach(seed)
164
170
  ctx.on('session/created', (session) => seed(session), { global: true })
165
171
  ctx.on(
166
172
  'internal/dispatch',