feat: add HoloLake personal channel module runtime

This commit is contained in:
冰朔 2026-08-09 14:14:07 +08:00
commit a106b23f9b
21 changed files with 786 additions and 31 deletions

View file

@ -0,0 +1,35 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyChannelPatch, defaultChannelState, KNOWLEDGE_MODULE_ID, normalizeChannelState } from './channel-runtime.js';
test('module removal unmounts the module but preserves a reversible before state', () => {
const receipt = applyChannelPatch(defaultChannelState('2026-08-09T00:00:00.000Z'), {
operation: 'set_module_state',
moduleId: KNOWLEDGE_MODULE_ID,
installed: false,
}, '2026-08-09T00:01:00.000Z');
assert.equal(receipt.after.modules[0].installed, false);
assert.equal(receipt.after.modules[0].mounted, false);
assert.equal(receipt.before.modules[0].installed, true);
assert.equal(receipt.after.revision, 1);
});
test('unknown modules fail closed instead of becoming UI-only entries', () => {
assert.throws(() => applyChannelPatch(defaultChannelState(), {
operation: 'set_module_state',
moduleId: 'UNREGISTERED-MODULE',
installed: true,
}), /未在光湖注册表登记/);
});
test('stored state is normalized against the current official registry', () => {
const state = normalizeChannelState({
schema: 'hololake.personal-channel-state/v1',
channelId: 'CHANNEL-ICE-001',
revision: 3,
updatedAt: '2026-08-09T00:00:00.000Z',
modules: [{ id: KNOWLEDGE_MODULE_ID, installed: false, mounted: true, order: 0 }],
});
assert.equal(state.modules[0].installed, false);
assert.equal(state.modules[0].mounted, false);
});

View file

@ -0,0 +1,126 @@
import crypto from 'crypto';
export const KNOWLEDGE_MODULE_ID = 'HL-MOD-KNOWLEDGE-001';
export interface ModuleManifest {
id: string;
name: string;
version: string;
kind: 'native-application';
state: 'LIVE';
description: string;
capabilities: string[];
dataPolicy: string;
}
export interface ChannelModuleState {
id: string;
installed: boolean;
mounted: boolean;
order: number;
}
export interface ChannelState {
schema: 'hololake.personal-channel-state/v1';
channelId: string;
revision: number;
updatedAt: string;
modules: ChannelModuleState[];
}
export interface ChannelPatch {
operation: 'set_module_state';
moduleId: string;
installed?: boolean;
mounted?: boolean;
}
export interface ChannelReceipt {
schema: 'hololake.channel-receipt/v1';
id: string;
operation: ChannelPatch;
before: ChannelState;
after: ChannelState;
createdAt: string;
reversible: true;
}
export const MODULE_REGISTRY: ModuleManifest[] = [{
id: KNOWLEDGE_MODULE_ID,
name: '知识库',
version: '0.8.0',
kind: 'native-application',
state: 'LIVE',
description: 'Git 驱动的个人知识空间,支持导入、编辑、检索、历史与 HoloLake 语言操作。',
capabilities: [
'knowledge.import',
'knowledge.read',
'knowledge.search',
'knowledge.edit',
'knowledge.history',
],
dataPolicy: '移除模块只卸载频道入口,不删除用户文档、历史或服务器绑定。',
}];
export function defaultChannelState(now = new Date().toISOString()): ChannelState {
return {
schema: 'hololake.personal-channel-state/v1',
channelId: 'CHANNEL-PERSONAL-DEFAULT',
revision: 0,
updatedAt: now,
modules: [{ id: KNOWLEDGE_MODULE_ID, installed: true, mounted: true, order: 0 }],
};
}
export function normalizeChannelState(input: unknown): ChannelState {
const fallback = defaultChannelState();
if (!input || typeof input !== 'object') return fallback;
const candidate = input as Partial<ChannelState>;
const known = new Set(MODULE_REGISTRY.map(module => module.id));
const modules = Array.isArray(candidate.modules)
? candidate.modules
.filter(module => module && known.has(module.id))
.map((module, index) => ({
id: module.id,
installed: module.installed !== false,
mounted: module.installed !== false && module.mounted !== false,
order: Number.isFinite(module.order) ? Number(module.order) : index,
}))
: fallback.modules;
for (const manifest of MODULE_REGISTRY) {
if (!modules.some(module => module.id === manifest.id)) {
modules.push({ id: manifest.id, installed: false, mounted: false, order: modules.length });
}
}
return {
schema: 'hololake.personal-channel-state/v1',
channelId: typeof candidate.channelId === 'string' && candidate.channelId ? candidate.channelId : fallback.channelId,
revision: Number.isInteger(candidate.revision) && Number(candidate.revision) >= 0 ? Number(candidate.revision) : 0,
updatedAt: typeof candidate.updatedAt === 'string' ? candidate.updatedAt : fallback.updatedAt,
modules: modules.sort((a, b) => a.order - b.order),
};
}
export function applyChannelPatch(currentInput: ChannelState, patch: ChannelPatch, now = new Date().toISOString()): ChannelReceipt {
const before = normalizeChannelState(currentInput);
if (patch.operation !== 'set_module_state') throw new Error('不支持的频道动作');
if (!MODULE_REGISTRY.some(module => module.id === patch.moduleId)) throw new Error('模块未在光湖注册表登记');
if (typeof patch.installed !== 'boolean' && typeof patch.mounted !== 'boolean') throw new Error('频道动作没有状态变化');
const modules = before.modules.map(module => {
if (module.id !== patch.moduleId) return module;
const installed = typeof patch.installed === 'boolean' ? patch.installed : module.installed;
const mounted = installed && (typeof patch.mounted === 'boolean' ? patch.mounted : module.mounted);
return { ...module, installed, mounted };
});
const after = { ...before, revision: before.revision + 1, updatedAt: now, modules };
return {
schema: 'hololake.channel-receipt/v1',
id: `HL-CHANNEL-RCPT-${crypto.randomUUID()}`,
operation: patch,
before,
after,
createdAt: now,
reversible: true,
};
}

View file

@ -10,8 +10,17 @@ import simpleGit, { SimpleGit, LogResult } from 'simple-git';
import matter from 'gray-matter';
import { diffLines } from 'diff';
import path from 'path';
import crypto from 'crypto';
import fs from 'fs/promises';
import fsSync from 'fs';
import {
applyChannelPatch as compileChannelPatch,
defaultChannelState,
normalizeChannelState,
type ChannelPatch,
type ChannelReceipt,
type ChannelState,
} from './channel-runtime.js';
// ─── 类型定义 ───
@ -74,15 +83,66 @@ export class GitEngine {
private git: SimpleGit;
private docsDir: string;
private repoPath: string;
private channelStatePath: string;
private channelReceiptsDir: string;
constructor(repoPath: string) {
this.repoPath = repoPath;
this.docsDir = path.join(repoPath, 'docs');
this.channelStatePath = path.join(repoPath, '.hololake', 'channel-state.json');
this.channelReceiptsDir = path.join(repoPath, '.hololake', 'receipts');
// simple-git 要求目录先存在,先同步创建
fsSync.mkdirSync(repoPath, { recursive: true });
this.git = simpleGit(repoPath);
}
async getChannelState(): Promise<ChannelState> {
try {
return normalizeChannelState(JSON.parse(await fs.readFile(this.channelStatePath, 'utf8')));
} catch {
return defaultChannelState();
}
}
async applyChannelPatch(patch: ChannelPatch): Promise<ChannelReceipt> {
const receipt = compileChannelPatch(await this.getChannelState(), patch);
await this.persistChannelReceipt(receipt, `channel: ${patch.operation} ${patch.moduleId}`);
return receipt;
}
async undoChannelPatch(receiptId: string): Promise<ChannelReceipt> {
if (!/^HL-CHANNEL-RCPT-[0-9a-f-]+$/i.test(receiptId)) throw new Error('无效的频道回执编号');
const receiptPath = path.join(this.channelReceiptsDir, `${receiptId}.json`);
const original = JSON.parse(await fs.readFile(receiptPath, 'utf8')) as ChannelReceipt;
const current = await this.getChannelState();
if (current.revision !== original.after.revision) throw new Error('频道状态已经继续变化,不能静默覆盖;请按当前状态重新操作');
const restored = {
...normalizeChannelState(original.before),
revision: current.revision + 1,
updatedAt: new Date().toISOString(),
};
const undoReceipt: ChannelReceipt = {
schema: 'hololake.channel-receipt/v1',
id: `HL-CHANNEL-RCPT-${crypto.randomUUID()}`,
operation: original.operation,
before: current,
after: restored,
createdAt: restored.updatedAt,
reversible: true,
};
await this.persistChannelReceipt(undoReceipt, `channel: undo ${receiptId}`);
return undoReceipt;
}
private async persistChannelReceipt(receipt: ChannelReceipt, message: string): Promise<void> {
await fs.mkdir(this.channelReceiptsDir, { recursive: true });
const receiptPath = path.join(this.channelReceiptsDir, `${receipt.id}.json`);
await fs.writeFile(this.channelStatePath, `${JSON.stringify(receipt.after, null, 2)}\n`, { mode: 0o600 });
await fs.writeFile(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`, { mode: 0o600 });
await this.git.add([this.channelStatePath, receiptPath]);
await this.git.commit(message, [this.channelStatePath, receiptPath]);
}
/** 初始化仓库(如果不存在则创建) */
async init(): Promise<void> {
const repoRoot = path.dirname(this.docsDir);

View file

@ -13,6 +13,7 @@ import { projectHumanKnowledgeTree } from './language-protocol-kernel.js';
import path from 'path';
import fs from 'fs';
import crypto from 'crypto';
import { MODULE_REGISTRY } from './channel-runtime.js';
// __dirname 兼容 CJS 和 ESM
const _dirname = typeof __dirname !== 'undefined' ? __dirname : process.cwd();
@ -44,7 +45,7 @@ const p = (v: unknown): string => Array.isArray(v) ? v.join('/') : String(v);
app.get('/api/health', (_req, res) => {
res.json({
module: 'guanghu-knowledge-base',
version: '0.1.0',
version: '0.8.0',
engine: 'git',
repo: REPO_PATH,
status: 'online',
@ -52,6 +53,38 @@ app.get('/api/health', (_req, res) => {
});
});
// ─── 初始化频道与模块运行时 ───
app.get('/api/modules', (_req, res) => {
res.json({ ok: true, registry: MODULE_REGISTRY });
});
app.get('/api/channel', async (_req, res) => {
try {
res.json({ ok: true, channel: await engine.getChannelState() });
} catch (err: any) {
res.status(500).json({ ok: false, error: err.message });
}
});
app.post('/api/channel/patch', async (req, res) => {
try {
const receipt = await engine.applyChannelPatch(req.body);
res.json({ ok: true, channel: receipt.after, receipt });
} catch (err: any) {
res.status(400).json({ ok: false, error: err.message });
}
});
app.post('/api/channel/undo/:receiptId', async (req, res) => {
try {
const receipt = await engine.undoChannelPatch(req.params.receiptId);
res.json({ ok: true, channel: receipt.after, receipt });
} catch (err: any) {
res.status(409).json({ ok: false, error: err.message });
}
});
// ─── 文档树 ───
app.get('/api/tree', async (_req, res) => {

View file

@ -32,6 +32,8 @@ function toolCall(id: string, name: string, args: Record<string, unknown> = {}):
function fakeGit(overrides: Record<string, unknown> = {}) {
return {
getRepositoryStatus: async () => ({}),
getChannelState: async () => ({ revision: 0, modules: [{ id: 'HL-MOD-KNOWLEDGE-001', installed: true, mounted: true, order: 0 }] }),
applyChannelPatch: async () => ({ id: 'HL-CHANNEL-RCPT-TEST', after: { revision: 1 } }),
getDoc: async (path: string) => ({
meta: { id: path, title: path, updatedAt: '2026-08-09T00:00:00Z' },
body: '正文',
@ -115,6 +117,29 @@ test('confirmed write receipt never leaks an orphan tool message into the next t
});
});
test('channel module changes use the same confirmation gate as document writes', { concurrency: false }, async () => {
let executed = 0;
await withMockModel([
toolCall('call-channel', 'set_channel_module_state', { moduleId: 'HL-MOD-KNOWLEDGE-001', mounted: false }),
{ content: '频道动作等待确认。' },
], async () => {
const agent = createDefaultPersona(fakeGit({
applyChannelPatch: async () => {
executed += 1;
return { id: 'HL-CHANNEL-RCPT-TEST', after: { revision: 1 } };
},
}));
const staged = await agent.chat('收起知识库');
assert.equal(staged.activities[0]?.status, 'pending');
assert.equal(executed, 0);
const pending = agent.getPendingActions()[0];
assert.equal(pending.target, 'HL-MOD-KNOWLEDGE-001');
const receipt = await agent.confirmAction(pending.id);
assert.equal(executed, 1);
assert.match(receipt.output, /HL-CHANNEL-RCPT-TEST/);
});
});
test('persona wake route is resolved and read dynamically without a fixed step count', { concurrency: false }, async () => {
const routePath = 'personas/TCS-0002/WAKE-CURRENT.md';
await withMockModel([

View file

@ -185,6 +185,34 @@ export class PersonaAgent {
execute: async () => JSON.stringify(await this.git.getRepositoryStatus(), null, 2),
});
this.tools.register({
name: 'inspect_channel',
description: '读取当前个人初始化频道的模块安装与挂载状态(只读)',
parameters: {},
effect: 'read',
execute: async () => JSON.stringify(await this.git.getChannelState(), null, 2),
});
this.tools.register({
name: 'set_channel_module_state',
description: '安装、打开、收起或移除已在光湖注册表登记的频道模块;执行前必须由人类确认',
parameters: {
moduleId: { type: 'string', description: '光湖稳定模块编号', required: true },
installed: { type: 'boolean', description: '是否安装;不改变时可以省略', required: false },
mounted: { type: 'boolean', description: '是否在当前频道展开;不改变时可以省略', required: false },
},
effect: 'write',
execute: async (params) => {
const receipt = await this.git.applyChannelPatch({
operation: 'set_module_state',
moduleId: String(params.moduleId || ''),
installed: typeof params.installed === 'boolean' ? params.installed : undefined,
mounted: typeof params.mounted === 'boolean' ? params.mounted : undefined,
});
return `频道状态已更新;回执 ${receipt.id};当前修订 ${receipt.after.revision}`;
},
});
// 读取知识库文档
this.tools.register({
name: 'read_document',
@ -465,7 +493,7 @@ ${toolsList}
if (!tool || tool.effect === 'read') {
return { id: call.id, name: call.name, output: '', error: `动作不可登记: ${call.name}` };
}
const target = String(call.arguments.path || call.arguments.title || '当前知识库');
const target = String(call.arguments.path || call.arguments.moduleId || call.arguments.title || '当前知识库');
const actionId = `action-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
const action: PendingAction = {
id: actionId,
@ -641,6 +669,8 @@ export function createDefaultPersona(git: GitEngine): PersonaAgent {
systemPromptBase: '你是 HoloLake 官方语言操作入口的当前模型计算实例。语言协议、权限边界、路径校验和可核验回执高于模型自由发挥。',
tools: [
'inspect_repository',
'inspect_channel',
'set_channel_module_state',
'read_document',
'create_document',
'update_document',