Skip to content
Merged
6 changes: 6 additions & 0 deletions .changeset/client-module-splits.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"dsh-mnemon": patch
"dsh-mnemon-source-memory-spaces": patch
---

Internal restructuring; nothing you see, store or send changes. The Memory System page's client code and Memory Spaces' pages are split into smaller modules, each page and section in its own file, and the workspace keeps its status, Source catalog and navigation in three hooks. The pages look and work as before, read the same data in the same order, and the published entries export the same names.
1 change: 1 addition & 0 deletions docs/pr-assets/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ For new evaluations, commit the reusable harness and workload generator to [`scr

| Record / 记录 | Scope / 范围 |
|---|---|
| [Client modules split along their seams](./client-module-splits/README.md) / [中文](./client-module-splits/README.zh-CN.md) | The Memory System workbench, the Git sync section and Memory Spaces' pages split into a module per page, section and dialog, and the workbench's status, Source catalog and navigation into three hooks; the built bundles hold the same statements and run the same effects in the same order, and every public entry exports the same names / 记忆系统工作台、Git 同步区块与记忆空间页面拆成每个页面、区块与对话框各自的模块,工作台的状态、Source 目录与导航移入三个 hook;构建产物包含同样的语句、按同样的顺序运行同样的 effect,每个公开入口导出同样的名称 |
| [The v0.5.32 WebUI gallery, captured on DSH 0.2.1](./webui-gallery-v0532/README.md) / [中文](./webui-gallery-v0532/README.zh-CN.md) | The documentation's screens and recordings captured again with the live model on DSH 0.2.1-rc.2 as webui-v0.5.32, the current documentation pointed at them, and three behaviors found while capturing added to the plan as items 12 to 14 / 在 DSH 0.2.1-rc.2 上由真实模型重新采集文档的截图与录制(webui-v0.5.32),当前文档改用新图集,采集中发现的三个行为作为第 12 至 14 项加入计划 |
| [Small cleanups](./small-cleanups/README.md) / [中文](./small-cleanups/README.zh-CN.md) | One module for the shipped components and one accessor for the main Strategy id; Memory Spaces exports the Native Provider id and a space's name and description limits, and its code, its client and the Starter's client use them; the retired `toolviews` preference leaves the resolved config; a compatibility register lists each piece of compatibility code and when it can go; nothing users or the model see changes / 随附组件由一个模块列出,主策略 id 通过一个入口读取;记忆空间导出 Native Provider id 与空间名称、描述的上限,记忆空间自身、其客户端与 Starter 的客户端都改用它们;已弃用的 `toolviews` 偏好不再出现在解析后的配置中;兼容登记表列出每项兼容代码及其移除条件;用户和模型看到的内容不变 |
| [Two terms in the extension guide, and two more roadmap items](./extensions-terms-roadmap/README.md) / [中文](./extensions-terms-roadmap/README.zh-CN.md) | The extension guide uses the settled hub name and "runtime memory"; two behaviors found while Memory Spaces' settings moved into it join the roadmap / 扩展指南改用统一的文档中心名称与“运行时记忆”;把记忆空间的设置移入记忆空间时发现的两种行为加入路线图 |
Expand Down
123 changes: 123 additions & 0 deletions docs/pr-assets/client-module-splits/README.md

Large diffs are not rendered by default.

123 changes: 123 additions & 0 deletions docs/pr-assets/client-module-splits/README.zh-CN.md

Large diffs are not rendered by default.

88 changes: 88 additions & 0 deletions docs/pr-assets/client-module-splits/harness/bundle-compare.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// bundle-compare.mjs <repo> <before.js> <after.js>: compare two builds of a DSH client bundle with its module boundaries
// normalized. The bundle is one factory whose body is the scope-hoisted statements of every module, between
// `//#region <module>` comments. Region comments are left out, and the top-level statements are compared as multisets:
// a statement that only moved to another module's region counts as the same. Statements that run code when the bundle
// loads (anything but a declaration of a function, a class or a constant initialized without a call) must also keep
// their order, since that order is what moving a module could change.
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { join, resolve } from 'node:path'

const [repo, beforePath, afterPath] = process.argv.slice(2)
const ts = createRequire(resolve(repo, 'package.json'))('typescript')

// A virtual CSS module's region names its absolute path; keep it from the repository's src/ or plugins/ on, so two
// checkouts in different directories compare.
function relativeRegion(name) {
const colon = name.indexOf(':/')
if (colon < 0) return name
const path = name.slice(colon + 1)
const start = Math.max(path.lastIndexOf('/src/'), path.lastIndexOf('/plugins/'))
return name.slice(0, colon + 1) + (start < 0 ? path : path.slice(start + 1))
}

function statements(path) {
const text = readFileSync(path, 'utf8')
const source = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS)
let body
const find = node => {
if (body === undefined && ts.isPropertyAssignment(node) && node.name.getText(source) === 'factory' && ts.isArrowFunction(node.initializer)) body = node.initializer.body
ts.forEachChild(node, find)
}
find(source)
const regions = []
const list = body.statements.map(statement => {
const leading = text.slice(statement.getFullStart(), statement.getStart(source))
for (const match of leading.matchAll(/\/\/#region (.*)/g)) regions.push(relativeRegion(match[1]))
return { text: statement.getText(source), node: statement, region: regions.at(-1) ?? '(top)' }
})
return { list, regions }
}

// A load-time effect: anything that runs code when the factory runs, beyond binding names.
function effectful(node) {
if (ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) return false
if (ts.isVariableStatement(node)) {
return node.declarationList.declarations.some(declaration => declaration.initializer !== undefined && callsAnything(declaration.initializer))
}
return true
}
function callsAnything(node) {
if (ts.isArrowFunction(node) || ts.isFunctionExpression(node) || ts.isClassExpression(node)) return false
if (ts.isCallExpression(node) || ts.isNewExpression(node) || ts.isTaggedTemplateExpression(node)) return true
return ts.forEachChild(node, callsAnything) === true
}

const before = statements(beforePath)
const after = statements(afterPath)
const count = list => list.reduce((map, item) => map.set(item.text, (map.get(item.text) ?? 0) + 1), new Map())
const beforeCounts = count(before.list)
const afterCounts = count(after.list)
const onlyBefore = before.list.filter(item => { const left = afterCounts.get(item.text) ?? 0; if (left > 0) { afterCounts.set(item.text, left - 1); return false } return true })
const onlyAfter = after.list.filter(item => { const left = beforeCounts.get(item.text) ?? 0; if (left > 0) { beforeCounts.set(item.text, left - 1); return false } return true })

const effects = list => list.filter(item => effectful(item.node)).map(item => item.text)
const beforeEffects = effects(before.list)
const afterEffects = effects(after.list)
const sameEffects = beforeEffects.length === afterEffects.length && beforeEffects.every((text, index) => text === afterEffects[index])

const newRegions = after.regions.filter(region => !before.regions.includes(region))
const goneRegions = before.regions.filter(region => !after.regions.includes(region))
const order = regions => regions.filter(region => before.regions.includes(region) && after.regions.includes(region))
const sameRegionOrder = order(before.regions).join('\n') === order(after.regions).join('\n')

console.log(`bytes: before ${readFileSync(beforePath).length}, after ${readFileSync(afterPath).length}`)
console.log(`top-level statements: before ${before.list.length}, after ${after.list.length}; only before ${onlyBefore.length}, only after ${onlyAfter.length}`)
console.log(`load-time effects: before ${beforeEffects.length}, after ${afterEffects.length}, same order: ${sameEffects}`)
console.log(`regions: before ${before.regions.length}, after ${after.regions.length}; new ${JSON.stringify(newRegions)}; gone ${JSON.stringify(goneRegions)}; regions in both keep their order: ${sameRegionOrder}`)
// Style sheets: each CSS module's region holds the injected sheet and the class map the code reads; both must match.
const cssRegions = side => side.list.filter(item => /css/u.test(item.region) && !item.region.endsWith('.tsx') && !item.region.endsWith('.ts'))
.reduce((map, item) => map.set(item.region, (map.get(item.region) ?? '') + item.text + '\n'), new Map())
const cssBefore = cssRegions(before)
const cssAfter = cssRegions(after)
const cssSame = [...cssBefore].every(([region, text]) => cssAfter.get(region) === text) && cssBefore.size === cssAfter.size
console.log(`style sheets: ${cssBefore.size} CSS modules before, ${cssAfter.size} after; sheets and class maps identical: ${cssSame}`)
const show = (label, items) => { for (const item of items) console.log(`${label} [${item.region}] ${item.text.split('\n')[0].slice(0, 160)}${item.text.includes('\n') ? ` ... (${item.text.split('\n').length} lines)` : ''}`) }
show('-', onlyBefore)
show('+', onlyAfter)
if (process.env.SHOW_FULL) for (const [label, items] of [['-', onlyBefore], ['+', onlyAfter]]) for (const item of items) console.log(`\n${label}${label}${label} [${item.region}]\n${item.text}`)
32 changes: 32 additions & 0 deletions docs/pr-assets/client-module-splits/harness/bundle-exports.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// bundle-exports.mjs <repo> <bundle.js>...: evaluate each built client bundle the way DSH's module loader does (a
// factory given `require`) and print the names on its module.exports, with each value's type. React comes from the
// repository; every other external is a stand-in that accepts any use, since only the export names are compared.
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { join, resolve } from 'node:path'
import vm from 'node:vm'

const [repo, ...bundles] = process.argv.slice(2)
const require = createRequire(resolve(repo, 'package.json'))
const real = { react: require('react'), 'react/jsx-runtime': require('react/jsx-runtime'), 'react-dom': require('react-dom') }
const stand = () => {
const target = function () {}
const proxy = new Proxy(target, {
get: (_, key) => key === Symbol.toPrimitive ? () => '' : key === '__esModule' ? true : proxy,
apply: () => proxy,
construct: () => proxy,
})
return proxy
}
for (const bundle of bundles) {
let loaded
const context = vm.createContext({
window: { __ModuleLoader__: { load: ({ id, factory }) => { loaded = { id, exports: factory(name => real[name] ?? stand()) } } } },
console, setTimeout, clearTimeout, globalThis: undefined,
})
context.globalThis = context
vm.runInContext(readFileSync(bundle, 'utf8'), context, { filename: bundle })
const names = Object.keys(loaded.exports).sort()
console.log(`# ${loaded.id} (${names.length} names)`)
for (const name of names) console.log(`${name}\t${typeof loaded.exports[name]}`)
}
88 changes: 88 additions & 0 deletions docs/pr-assets/client-module-splits/harness/hooks-compare.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// hooks-compare.mjs <repo> <before client.js> <after client.js>: compare the built MnemonWorkspace of main with the
// MnemonWorkspace and the three hooks it became (useWorkspaceStatus, useSourceCatalog, useWorkspaceNavigation).
//
// 1. Statements: the body statements of the old function against the bodies of the four new ones, as multisets with
// indentation normalized. The build returns some values inline (`return { x: useMemo(...) }` for the source's
// `const x = useMemo(...)`); each such value must be the expression the old function gave that const.
// 2. Effects: every hook call in the order the component makes it, a hook's own calls expanded where it is called.
// React runs a commit's layout effects before its passive ones, so each phase must keep its order: useLayoutEffect
// in the layout phase, useEffect and useSyncExternalStore's subscription in the passive phase.
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { join, resolve } from 'node:path'

const [repo, beforePath, afterPath] = process.argv.slice(2)
const ts = createRequire(resolve(repo, 'package.json'))('typescript')
const HOOKS = ['useWorkspaceStatus', 'useSourceCatalog', 'useWorkspaceNavigation']

function functions(path) {
const source = ts.createSourceFile(path, readFileSync(path, 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.JS)
const found = new Map()
const visit = node => {
if (ts.isFunctionDeclaration(node) && node.name !== undefined && node.body !== undefined) found.set(node.name.text, { node, source })
ts.forEachChild(node, visit)
}
visit(source)
return found
}
const normalize = text => text.split('\n').map(line => line.trim()).join('\n')
const short = text => normalize(text).replaceAll('\n', ' ').slice(0, 160)
const before = functions(beforePath).get('MnemonWorkspace')
const afterFunctions = functions(afterPath)
const after = [...HOOKS, 'MnemonWorkspace'].map(name => afterFunctions.get(name))
const statements = ({ node, source }) => node.body.statements.map(statement => ({ statement, source, text: normalize(statement.getText(source)) }))

// 1. Statements.
const left = statements(before)
const right = after.flatMap(statements)
const take = (list, other) => {
const counts = other.reduce((map, item) => map.set(item.text, (map.get(item.text) ?? 0) + 1), new Map())
return list.filter(item => { const n = counts.get(item.text) ?? 0; if (n > 0) { counts.set(item.text, n - 1); return false } return true })
}
const onlyBefore = take(left, right)
const onlyAfter = take(right, left)
const initializers = new Map(onlyBefore.flatMap(({ statement, source }) => ts.isVariableStatement(statement)
? statement.declarationList.declarations.filter(d => ts.isIdentifier(d.name) && d.initializer).map(d => [d.name.text, normalize(d.initializer.getText(source))]) : []))
const inlined = new Set()
for (const { statement, source } of onlyAfter) {
if (!ts.isReturnStatement(statement) || statement.expression === undefined || !ts.isObjectLiteralExpression(statement.expression)) continue
for (const property of statement.expression.properties) {
if (!ts.isPropertyAssignment(property)) continue
const name = property.name.getText(source)
if (initializers.get(name) === normalize(property.initializer.getText(source))) inlined.add(name)
}
}
const explained = onlyBefore.filter(({ statement }) => !(ts.isVariableStatement(statement) && statement.declarationList.declarations.every(d => inlined.has(d.name.getText(before.source)))))
console.log(`statements: MnemonWorkspace before ${left.length}; ${[...HOOKS, 'MnemonWorkspace'].join(' + ')} after ${right.length}`)
console.log(` before only, returned inline by a hook with the same expression: ${[...inlined].join(', ') || 'none'}`)
console.log(` before only, otherwise: ${explained.length}`)
for (const item of explained) console.log(` - ${short(item.text)}`)
console.log(` after only: ${onlyAfter.length}`)
for (const item of onlyAfter) console.log(` + ${short(item.text)}`)

// 2. Effects.
function hookCalls(fn) {
const out = []
const visit = node => {
if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) return
if (ts.isCallExpression(node)) {
ts.forEachChild(node, visit)
const name = node.expression.getText(fn.source).replace(/^\(0, /u, '').replace(/\)$/u, '').split('.').at(-1)
if (HOOKS.includes(name)) out.push(...hookCalls(afterFunctions.get(name)))
else if (/^use[A-Z]/u.test(name)) out.push({ name, text: short(node.arguments[0]?.getText(fn.source) ?? '').slice(0, 90) })
return
}
ts.forEachChild(node, visit)
}
for (const statement of fn.node.body.statements) visit(statement)
return out
}
const beforeCalls = hookCalls(before)
const afterCalls = hookCalls(afterFunctions.get('MnemonWorkspace'))
console.log(`hook calls: before ${beforeCalls.length}, after ${afterCalls.length}`)
for (const [phase, names] of [['layout', ['useLayoutEffect']], ['passive', ['useEffect', 'useSyncExternalStore']]]) {
const effects = calls => calls.filter(call => names.includes(call.name)).map(call => `${call.name} ${call.text}`)
const [l, r] = [effects(beforeCalls), effects(afterCalls)]
console.log(`${phase} effects: before ${l.length}, after ${r.length}, same order: ${l.length === r.length && l.every((text, index) => text === r[index])}`)
for (const text of r) console.log(` ${text}`)
}
58 changes: 58 additions & 0 deletions docs/pr-assets/client-module-splits/harness/moved-lines.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// moved-lines.mjs [--indent] --before <file|rev:file>... --after <file|rev:file>...: compare the code lines of a set of
// files before and after a move, as multisets. Import statements and blank lines are left out, and a line that only
// gained `export` counts as moved. With --indent, leading whitespace is ignored too, for code that moved into or out of
// a function body. A `rev:path` argument reads the file at that revision with git; run it from the repository root.
//
// Prints every line that is only before or only after, so a reader sees each line that is not a verbatim move.
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'

const args = process.argv.slice(2)
const indent = args.includes('--indent')
const rest = args.filter(arg => arg !== '--indent')
const split = rest.indexOf('--after')
if (rest[0] !== '--before' || split < 0) throw new Error('usage: moved-lines.mjs [--indent] --before <files...> --after <files...>')
const read = spec => /^[0-9a-f]{7,40}:/u.test(spec) ? execFileSync('git', ['show', spec], { encoding: 'utf8', maxBuffer: 64 << 20 }) : readFileSync(spec, 'utf8')

function codeLines(specs) {
const lines = new Map()
const origin = new Map()
for (const spec of specs) {
let inImport = false
for (const raw of read(spec).split('\n')) {
const line = raw.trimEnd()
const stripped = line.trim()
if (inImport) {
if (/from ['"][^'"]+['"]$/u.test(stripped)) inImport = false
continue
}
if (/^(import|export) .* from ['"][^'"]+['"]$/u.test(stripped) || /^import ['"]/u.test(stripped)) continue
if (/^(import|export)( type)? \{$/u.test(stripped)) { inImport = true; continue }
if (stripped === '') continue
const key = indent ? stripped : line
lines.set(key, (lines.get(key) ?? 0) + 1)
if (!origin.has(key)) origin.set(key, spec.split('/').at(-1))
}
}
return { lines, origin }
}

const before = codeLines(rest.slice(1, split))
const after = codeLines(rest.slice(split + 1))
const subtract = (left, right) => new Map([...left].map(([line, count]) => [line, count - (right.get(line) ?? 0)]).filter(([, count]) => count > 0))
const removed = subtract(before.lines, after.lines)
const added = subtract(after.lines, before.lines)
const exported = []
for (const [line, count] of [...added]) {
const bare = line.replace(/^(\s*)export /u, '$1')
if (bare === line || !removed.has(bare)) continue
const moved = Math.min(count, removed.get(bare))
exported.push(bare)
if (count === moved) added.delete(line); else added.set(line, count - moved)
if (removed.get(bare) === moved) removed.delete(bare); else removed.set(bare, removed.get(bare) - moved)
}
const total = map => [...map.values()].reduce((sum, count) => sum + count, 0)
console.log(`code lines before ${total(before.lines)}, after ${total(after.lines)}; gained export ${exported.length}; only before ${total(removed)}; only after ${total(added)}`)
for (const line of exported) console.log(` export ${line.trim().slice(0, 150)}`)
for (const [line, count] of removed) console.log(`- ${count}x ${line.trim().slice(0, 200)} [${before.origin.get(line)}]`)
for (const [line, count] of added) console.log(`+ ${count}x ${line.trim().slice(0, 200)} [${after.origin.get(line)}]`)
Loading
Loading