diff --git a/docs/KNOWLEDGE_BASE.md b/docs/KNOWLEDGE_BASE.md index e4cda44..b15f53b 100644 --- a/docs/KNOWLEDGE_BASE.md +++ b/docs/KNOWLEDGE_BASE.md @@ -42,7 +42,7 @@ Authoritative packaging prose lives in the workspace contract notes (`插件` / | Skills from pack | Registered when `configured` **and** `hasSkills` **and** the inPreset/public toggle for that plane | | Local FS tools | When `configured` + `paths.local` + same KB toggles: `netx__kbWriteRef` / `WriteMemory` / `WriteDraft` / `WriteSuggestion` / `UpdateLocal` / `DeleteLocal` / `ListLocal` — jail under `refs\|memories\|drafts\|suggestions`. HQ pack is read-only; evolve site product knowledge with `kbWriteRef` (per-`host_name` PROFILE) and capture experience with `kbWriteMemory` as it appears. | -RPC (channel `/netxops`): `kb.status` (saved snapshot), `kb.resolve` with `{ path }` (preview unsaved paths). +RPC (channel `/netxops`): `kb.status` (published snapshot), `kb.reload` (re-resolve live `kbRoot` + republish), `kb.resolve` with `{ path }` (preview unsaved paths). ## Pack skill registration diff --git a/lib/client.js b/lib/client.js index 44e2215..436e37b 100644 --- a/lib/client.js +++ b/lib/client.js @@ -591,6 +591,7 @@ function unconfiguredKbSnapshot() { // src/client/kb-status-view.ts var NETXOPS_RPC_CHANNEL2 = "/netxops"; var KB_STATUS_ENDPOINT = "kb.status"; +var KB_RELOAD_ENDPOINT = "kb.reload"; var KB_RESOLVE_ENDPOINT = "kb.resolve"; function asKbSnapshot(value) { const empty = unconfiguredKbSnapshot(); @@ -624,22 +625,44 @@ function asKbSnapshot(value) { errorMessage: typeof row.errorMessage === "string" ? row.errorMessage : "" }; } +function asRpcOk(result) { + return result !== null && typeof result === "object" && result.ok === true; +} async function fetchKbStatus(call, signal) { const result = await call(NETXOPS_RPC_CHANNEL2, KB_STATUS_ENDPOINT, {}, signal); - if (result !== null && typeof result === "object" && result.ok === true) { + if (asRpcOk(result)) { return asKbSnapshot(result.value); } - return unconfiguredKbSnapshot(); + if (result !== null && typeof result === "object" && result.ok === false) { + const message = String(result.error?.message ?? "kb.status rpc failed"); + return { ...unconfiguredKbSnapshot(), status: "error", errorMessage: message }; + } + return { + ...unconfiguredKbSnapshot(), + status: "error", + errorMessage: "kb.status unavailable" + }; +} +async function reloadKbStatus(call, signal) { + const result = await call(NETXOPS_RPC_CHANNEL2, KB_RELOAD_ENDPOINT, {}, signal); + if (asRpcOk(result)) { + return asKbSnapshot(result.value); + } + return fetchKbStatus(call, signal); } async function resolveKbPath(call, path, signal) { let result = await call(NETXOPS_RPC_CHANNEL2, KB_RESOLVE_ENDPOINT, { path }, signal); - if (!(result !== null && typeof result === "object" && result.ok === true)) { + if (!asRpcOk(result)) { result = await call(NETXOPS_RPC_CHANNEL2, KB_RESOLVE_ENDPOINT, { args: { path } }, signal); } - if (result !== null && typeof result === "object" && result.ok === true) { + if (asRpcOk(result)) { return asKbSnapshot(result.value); } - return unconfiguredKbSnapshot(); + return { + ...unconfiguredKbSnapshot(), + status: "error", + errorMessage: "kb.resolve unavailable" + }; } function kbStatusTone(status) { if (status === "configured") @@ -1886,7 +1909,7 @@ class CardForm { } async store(field, value) { await this.scope.set(field, value); - return this.userLayer()?.[field] === value; + return this.sectionValue(field) === value || this.userLayer()?.[field] === value; } stage(field, edit) { this.staged.set(field, edit); @@ -1945,7 +1968,9 @@ class NetxopsCardController { kbDirectoryPicker; kbBrowseInFlight = false; kbStatusInFlight = false; + kbReloadInFlight = false; kbUiError = null; + lastSyncedKbRoot = undefined; imDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG }; sessionsExportStatus = null; sessionsExportBusy = false; @@ -1983,6 +2008,7 @@ class NetxopsCardController { scope.subscribe(() => { this.readCredential(); this.refreshKbPreview(); + this.syncKbRootFromSettings(); }); this.readCredential(); } @@ -2026,7 +2052,7 @@ class NetxopsCardController { } this.startStatusPoll(); this.refreshAlarmPushStatus(); - this.refreshKbStatus(); + this.reloadKbFromHost(); this.refreshKbPreview(); this.refreshImDeliveryCatalog(); this.refreshSessionsExportStatus(); @@ -2114,15 +2140,42 @@ class NetxopsCardController { this.kbStatusInFlight = true; try { const next = await fetchKbStatus(call); - const prev = this.kbStatus; - if (prev && prev.status === next.status && prev.realRoot === next.realRoot && prev.operatorName === next.operatorName && prev.country === next.country && prev.version === next.version && prev.errorMessage === next.errorMessage) - return; - this.kbStatus = next; - this.store.set(this.projection()); + this.applyKbSnapshot(next); } catch {} finally { this.kbStatusInFlight = false; } } + async reloadKbFromHost() { + const call = this.rpcCall; + if (call === undefined || this.kbReloadInFlight) + return; + this.kbReloadInFlight = true; + try { + const next = await reloadKbStatus(call); + this.applyKbSnapshot(next); + this.lastSyncedKbRoot = (this.scope.getSnapshot().value?.kbRoot ?? "").trim(); + } catch { + this.refreshKbStatus(); + } finally { + this.kbReloadInFlight = false; + } + } + applyKbSnapshot(next) { + const prev = this.kbStatus; + if (prev && prev.status === next.status && prev.realRoot === next.realRoot && prev.operatorName === next.operatorName && prev.country === next.country && prev.version === next.version && prev.errorMessage === next.errorMessage) + return; + this.kbStatus = next; + this.store.set(this.projection()); + } + async syncKbRootFromSettings() { + if (this.rpcCall === undefined) + return; + const saved = (this.scope.getSnapshot().value?.kbRoot ?? "").trim(); + if (saved === this.lastSyncedKbRoot) + return; + this.lastSyncedKbRoot = saved; + await this.reloadKbFromHost(); + } async refreshKbPreview() { const call = this.rpcCall; if (call === undefined) @@ -2145,6 +2198,30 @@ class NetxopsCardController { this.store.set(this.projection()); } catch {} } + async commitKbRoot(path) { + const trimmed = path.trim(); + this.kbUiError = null; + this.store.set(this.projection()); + await this.form.save(); + const shell = this.form.shell(); + const saved = (this.scope.getSnapshot().value?.kbRoot ?? "").trim(); + if (shell.failed || saved !== trimmed) { + this.kbUiError = saved === trimmed ? "kbRoot save failed" : `kbRoot save did not land (saved="${saved || "(empty)"}")`; + this.store.set(this.projection()); + const call = this.rpcCall; + if (call !== undefined && trimmed !== "") { + try { + this.kbPreview = await resolveKbPath(call, trimmed); + this.store.set(this.projection()); + } catch {} + } + return; + } + this.kbPreview = null; + this.lastSyncedKbRoot = undefined; + await this.reloadKbFromHost(); + this.refreshKbPreview(); + } browseKbRoot() { if (this.kbBrowseInFlight) return; @@ -2153,11 +2230,7 @@ class NetxopsCardController { this.store.set(this.projection()); const applyPath = (path) => { this.form.actions().edit("kbRoot", path); - this.refreshKbPreview(); - this.form.save().then(() => { - this.refreshKbStatus(); - this.refreshKbPreview(); - }); + this.commitKbRoot(path); }; const fail = (error) => { this.kbUiError = error instanceof Error ? error.message : String(error); @@ -2406,7 +2479,7 @@ var en = { kbBrowse: "Browse…", kbBrowseUnavailable: "Directory picker unavailable — paste an absolute folder path, then Save.", kbBrowseFailed: "Browse failed: {detail}", - kbSaveHint: "Browse writes and saves automatically. Manual paste still needs Save.", + kbSaveHint: "Browse writes and saves automatically, then reloads Host KB status. Manual paste still needs Save.", kbStatusConfigured: "Knowledge base: {operator} ({country}) v{version}", kbStatusUnconfigured: "Knowledge base: not configured (pure netx)", kbStatusError: "Knowledge base: error — {detail}", @@ -2483,7 +2556,7 @@ var zh = { kbBrowse: "浏览…", kbBrowseUnavailable: "目录选择器不可用 — 请粘贴绝对目录路径后点保存。", kbBrowseFailed: "浏览失败:{detail}", - kbSaveHint: "点「浏览」选目录后会自动写入并保存;若是手粘路径仍需点保存。", + kbSaveHint: "点「浏览」选目录后会自动写入、保存,并刷新宿主知识库状态;若是手粘路径仍需点保存。", kbStatusConfigured: "知识库: {operator}({country}) v{version}", kbStatusUnconfigured: "知识库: 未配置(纯 netx)", kbStatusError: "知识库: 错误 — {detail}", diff --git a/lib/index.js b/lib/index.js index 89f35d9..ab46a11 100644 --- a/lib/index.js +++ b/lib/index.js @@ -4878,6 +4878,20 @@ function apply(ctx, config = Config({})) { if (endpoint === "kb.status") { return { ok: true, value: getKbContext() }; } + if (endpoint === "kb.reload") { + const current = source(); + const kb = resolveKbRoot(current.kbRoot ?? ""); + publishKbContext(kb); + applyKbEnv(kb); + if (kb.status === "configured") { + connCtx.logger.info("netxops: kb.reload → %s (%s / %s v%s)", kb.realRoot, kb.operatorName, kb.country, kb.version); + } else if (kb.status === "error") { + connCtx.logger.warn("netxops: kb.reload error — %s", kb.errorMessage); + } else { + connCtx.logger.info("netxops: kb.reload → unconfigured (kbRoot empty)"); + } + return { ok: true, value: kb }; + } if (endpoint === "kb.resolve") { const path = extractKbResolvePath(payload); return { ok: true, value: resolveKbRoot(path) }; diff --git a/package.json b/package.json index 093a859..5e286e4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-netxops", - "version": "0.1.37", + "version": "0.1.38", "description": "DeepSeek Harness Netx Ops: ops/topology + IM delivery + session export + knowledge-base MANIFEST/_skills + localSkills", "license": "MIT", "type": "module", diff --git a/src/client/card-form.ts b/src/client/card-form.ts index 8cff4cb..8da547a 100644 --- a/src/client/card-form.ts +++ b/src/client/card-form.ts @@ -218,7 +218,9 @@ export class CardForm { private async store(field: string, value: unknown): Promise { await this.scope.set(field, value) - return this.userLayer()?.[field] === value + // `settings.mutate` can settle without throwing on a refused write; confirm + // via the resolved section value (user layer alone can lag or omit keys). + return this.sectionValue(field) === value || this.userLayer()?.[field] === value } private stage(field: string, edit: StagedEdit): void { diff --git a/src/client/controller.ts b/src/client/controller.ts index 813fc9f..edb9ad3 100644 --- a/src/client/controller.ts +++ b/src/client/controller.ts @@ -17,6 +17,7 @@ import { } from './alarm-push-status-view.ts' import { fetchKbStatus, + reloadKbStatus, resolveKbPath, type KbSnapshot, } from './kb-status-view.ts' @@ -155,7 +156,9 @@ export class NetxopsCardController { private kbDirectoryPicker: DirectoryPickerRemote | undefined private kbBrowseInFlight = false private kbStatusInFlight = false + private kbReloadInFlight = false private kbUiError: string | null = null + private lastSyncedKbRoot: string | undefined = undefined private imDeliveryCatalog: ImDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG } private sessionsExportStatus: SessionsExportStatus | null = null private sessionsExportBusy = false @@ -199,6 +202,7 @@ export class NetxopsCardController { scope.subscribe(() => { void this.readCredential() void this.refreshKbPreview() + void this.syncKbRootFromSettings() }) void this.readCredential() } @@ -246,7 +250,7 @@ export class NetxopsCardController { } this.startStatusPoll() void this.refreshAlarmPushStatus() - void this.refreshKbStatus() + void this.reloadKbFromHost() void this.refreshKbPreview() void this.refreshImDeliveryCatalog() void this.refreshSessionsExportStatus() @@ -364,18 +368,7 @@ export class NetxopsCardController { this.kbStatusInFlight = true try { const next = await fetchKbStatus(call) - const prev = this.kbStatus - if ( - prev - && prev.status === next.status - && prev.realRoot === next.realRoot - && prev.operatorName === next.operatorName - && prev.country === next.country - && prev.version === next.version - && prev.errorMessage === next.errorMessage - ) return - this.kbStatus = next - this.store.set(this.projection()) + this.applyKbSnapshot(next) } catch { // Keep last good snapshot; next poll retries. } finally { @@ -383,6 +376,50 @@ export class NetxopsCardController { } } + /** + * Ask Host to re-resolve kbRoot from live settings and republish. + * Prefer this after browse/save so the badge cannot stick on a stale snapshot. + */ + private async reloadKbFromHost(): Promise { + const call = this.rpcCall + if (call === undefined || this.kbReloadInFlight) return + this.kbReloadInFlight = true + try { + const next = await reloadKbStatus(call) + this.applyKbSnapshot(next) + this.lastSyncedKbRoot = (this.scope.getSnapshot().value?.kbRoot ?? '').trim() + } catch { + // Fall back to published status poll. + void this.refreshKbStatus() + } finally { + this.kbReloadInFlight = false + } + } + + private applyKbSnapshot(next: KbSnapshot): void { + const prev = this.kbStatus + if ( + prev + && prev.status === next.status + && prev.realRoot === next.realRoot + && prev.operatorName === next.operatorName + && prev.country === next.country + && prev.version === next.version + && prev.errorMessage === next.errorMessage + ) return + this.kbStatus = next + this.store.set(this.projection()) + } + + /** When settings document kbRoot changes, force Host reload (covers footer Save / Reset). */ + private async syncKbRootFromSettings(): Promise { + if (this.rpcCall === undefined) return + const saved = (this.scope.getSnapshot().value?.kbRoot ?? '').trim() + if (saved === this.lastSyncedKbRoot) return + this.lastSyncedKbRoot = saved + await this.reloadKbFromHost() + } + private async refreshKbPreview(): Promise { const call = this.rpcCall if (call === undefined) return @@ -416,6 +453,38 @@ export class NetxopsCardController { } } + /** + * Persist kbRoot then force Host reload so the badge matches the bound package. + */ + private async commitKbRoot(path: string): Promise { + const trimmed = path.trim() + this.kbUiError = null + this.store.set(this.projection()) + await this.form.save() + const shell = this.form.shell() + const saved = (this.scope.getSnapshot().value?.kbRoot ?? '').trim() + if (shell.failed || saved !== trimmed) { + this.kbUiError = saved === trimmed + ? 'kbRoot save failed' + : `kbRoot save did not land (saved="${saved || '(empty)'}")` + this.store.set(this.projection()) + const call = this.rpcCall + if (call !== undefined && trimmed !== '') { + try { + this.kbPreview = await resolveKbPath(call, trimmed) + this.store.set(this.projection()) + } catch { + // keep error banner + } + } + return + } + this.kbPreview = null + this.lastSyncedKbRoot = undefined + await this.reloadKbFromHost() + void this.refreshKbPreview() + } + /** * Open the Host OS directory chooser (or /api directoryPicker/pick fallback) * and write the path into kbRoot. @@ -428,12 +497,7 @@ export class NetxopsCardController { const applyPath = (path: string): void => { this.form.actions().edit('kbRoot', path) - void this.refreshKbPreview() - // Selecting a folder should land immediately — don't require a separate Save. - void this.form.save().then(() => { - void this.refreshKbStatus() - void this.refreshKbPreview() - }) + void this.commitKbRoot(path) } const fail = (error: unknown): void => { diff --git a/src/client/kb-status-view.ts b/src/client/kb-status-view.ts index 68a9c29..205990e 100644 --- a/src/client/kb-status-view.ts +++ b/src/client/kb-status-view.ts @@ -10,6 +10,7 @@ import type { AlarmPushRpcCall } from './alarm-push-status-view.ts' export const NETXOPS_RPC_CHANNEL = '/netxops' export const KB_STATUS_ENDPOINT = 'kb.status' +export const KB_RELOAD_ENDPOINT = 'kb.reload' export const KB_RESOLVE_ENDPOINT = 'kb.resolve' export type { KbSnapshot, KbStatus } @@ -50,6 +51,10 @@ export function asKbSnapshot(value: unknown): KbSnapshot { } } +function asRpcOk(result: unknown): result is { ok: true; value?: unknown } { + return result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === true +} + /** * Fetch the published KB snapshot from the Host. */ @@ -58,10 +63,37 @@ export async function fetchKbStatus( signal?: AbortSignal, ): Promise { const result = await call(NETXOPS_RPC_CHANNEL, KB_STATUS_ENDPOINT, {}, signal) - if (result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === true) { - return asKbSnapshot((result as { value?: unknown }).value) + if (asRpcOk(result)) { + return asKbSnapshot(result.value) } - return unconfiguredKbSnapshot() + // Do not pretend "unconfigured" on transport/shape miss — keep empty identity + // but mark error so the badge is not stuck on the soft "pure netx" copy. + if (result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === false) { + const message = String( + (result as { error?: { message?: string } }).error?.message ?? 'kb.status rpc failed', + ) + return { ...unconfiguredKbSnapshot(), status: 'error', errorMessage: message } + } + return { + ...unconfiguredKbSnapshot(), + status: 'error', + errorMessage: 'kb.status unavailable', + } +} + +/** + * Force Host to re-resolve `kbRoot` from live settings and republish. + */ +export async function reloadKbStatus( + call: AlarmPushRpcCall, + signal?: AbortSignal, +): Promise { + const result = await call(NETXOPS_RPC_CHANNEL, KB_RELOAD_ENDPOINT, {}, signal) + if (asRpcOk(result)) { + return asKbSnapshot(result.value) + } + // Older hosts without kb.reload — fall back to status / resolve. + return fetchKbStatus(call, signal) } /** @@ -74,13 +106,17 @@ export async function resolveKbPath( ): Promise { // Prefer flat `{ path }` (netxops channel); also try `{ args: { path } }`. let result = await call(NETXOPS_RPC_CHANNEL, KB_RESOLVE_ENDPOINT, { path }, signal) - if (!(result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === true)) { + if (!asRpcOk(result)) { result = await call(NETXOPS_RPC_CHANNEL, KB_RESOLVE_ENDPOINT, { args: { path } }, signal) } - if (result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === true) { - return asKbSnapshot((result as { value?: unknown }).value) + if (asRpcOk(result)) { + return asKbSnapshot(result.value) + } + return { + ...unconfiguredKbSnapshot(), + status: 'error', + errorMessage: 'kb.resolve unavailable', } - return unconfiguredKbSnapshot() } /** Badge tone for the settings card. */ diff --git a/src/client/locales.ts b/src/client/locales.ts index 9bafc3d..472c6e7 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -106,7 +106,7 @@ export const en: Record = { kbBrowse: 'Browse…', kbBrowseUnavailable: 'Directory picker unavailable — paste an absolute folder path, then Save.', kbBrowseFailed: 'Browse failed: {detail}', - kbSaveHint: 'Browse writes and saves automatically. Manual paste still needs Save.', + kbSaveHint: 'Browse writes and saves automatically, then reloads Host KB status. Manual paste still needs Save.', kbStatusConfigured: 'Knowledge base: {operator} ({country}) v{version}', kbStatusUnconfigured: 'Knowledge base: not configured (pure netx)', kbStatusError: 'Knowledge base: error — {detail}', @@ -184,7 +184,7 @@ export const zh: Record = { kbBrowse: '浏览…', kbBrowseUnavailable: '目录选择器不可用 — 请粘贴绝对目录路径后点保存。', kbBrowseFailed: '浏览失败:{detail}', - kbSaveHint: '点「浏览」选目录后会自动写入并保存;若是手粘路径仍需点保存。', + kbSaveHint: '点「浏览」选目录后会自动写入、保存,并刷新宿主知识库状态;若是手粘路径仍需点保存。', kbStatusConfigured: '知识库: {operator}({country}) v{version}', kbStatusUnconfigured: '知识库: 未配置(纯 netx)', kbStatusError: '知识库: 错误 — {detail}', diff --git a/src/index.ts b/src/index.ts index a91e974..6848ab1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -624,6 +624,28 @@ export function apply(ctx: Context, config: Config = Config({})): void { if (endpoint === 'kb.status') { return { ok: true, value: getKbContext() } } + if (endpoint === 'kb.reload') { + // Re-read live settings and publish — used by the settings card after + // browse/save so the badge does not stick on a stale unconfigured snapshot. + const current = source() + const kb = resolveKbRoot(current.kbRoot ?? '') + publishKbContext(kb) + applyKbEnv(kb) + if (kb.status === 'configured') { + connCtx.logger.info( + 'netxops: kb.reload → %s (%s / %s v%s)', + kb.realRoot, + kb.operatorName, + kb.country, + kb.version, + ) + } else if (kb.status === 'error') { + connCtx.logger.warn('netxops: kb.reload error — %s', kb.errorMessage) + } else { + connCtx.logger.info('netxops: kb.reload → unconfigured (kbRoot empty)') + } + return { ok: true, value: kb } + } if (endpoint === 'kb.resolve') { const path = extractKbResolvePath(payload) return { ok: true, value: resolveKbRoot(path) }