dsh-synapse 0.5.0 → 0.5.2
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/app.js +28 -7
- package/index.js +109 -4
- package/package.json +1 -1
package/app.js
CHANGED
|
@@ -1116,16 +1116,35 @@ function threadMessage(thread, message) {
|
|
|
1116
1116
|
return `<article class="message message-${message.kind}${message.pending ? ' message-pending' : ''}${collapsible ? ' message-collapsible' : ''}${expanded ? ' expanded' : ''}" data-message-seq="${Number.isInteger(message.sourceSeq) ? message.sourceSeq : ''}"><header>${avatar}<span class="message-role">${label}</span><time>${formatTime(message.at)}</time>${branch}${fold}</header><div class="message-body">${body}</div></article>`
|
|
1117
1117
|
}
|
|
1118
1118
|
|
|
1119
|
+
/**
|
|
1120
|
+
* Stored process entries carry a `status` (`pending` / `done` / `failed`);
|
|
1121
|
+
* records folded in from a v3-era message still carry `error` / `result`. Read
|
|
1122
|
+
* both shapes, otherwise a finished tool renders as a failure only because the
|
|
1123
|
+
* newer shape has no `error` key at all (`undefined !== null`).
|
|
1124
|
+
*/
|
|
1125
|
+
function processState(entry) {
|
|
1126
|
+
if (entry.status === 'failed') return 'failed'
|
|
1127
|
+
if (entry.status === 'pending') return 'pending'
|
|
1128
|
+
if (entry.status === 'done') return 'done'
|
|
1129
|
+
if (typeof entry.error === 'string') return entry.error === '' ? (entry.result === null || entry.result === undefined ? 'pending' : 'done') : 'failed'
|
|
1130
|
+
if (entry.error !== null && entry.error !== undefined) return 'failed'
|
|
1131
|
+
return entry.result === null || entry.result === undefined ? 'pending' : 'done'
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1119
1134
|
function processRecords(process, messageId) {
|
|
1120
1135
|
const key = `${messageId}:process`
|
|
1121
1136
|
const expanded = state.expandedMessageIds.has(key)
|
|
1122
1137
|
const entries = process.map((entry, index) => {
|
|
1123
1138
|
const entryKey = `${key}:${index}`
|
|
1124
1139
|
const entryExpanded = state.expandedMessageIds.has(entryKey)
|
|
1125
|
-
const
|
|
1126
|
-
const
|
|
1127
|
-
const
|
|
1128
|
-
|
|
1140
|
+
const state_ = processState(entry)
|
|
1141
|
+
const status = state_ === 'failed' ? '失败' : state_ === 'pending' ? '等待结果' : '完成'
|
|
1142
|
+
const statusClass = state_ === 'failed' ? ' process-status-error' : state_ === 'pending' ? ' process-status-pending' : ' process-status-done'
|
|
1143
|
+
const argumentsHtml = typeof entry.arguments === 'string' && entry.arguments !== '' ? `<pre class="process-args">${escapeHtml(entry.arguments)}</pre>` : ''
|
|
1144
|
+
const outcomeHtml = state_ === 'failed' && typeof entry.error === 'string' && entry.error !== ''
|
|
1145
|
+
? `<pre class="process-error">${escapeHtml(entry.error)}</pre>`
|
|
1146
|
+
: typeof entry.result === 'string' && entry.result !== '' ? `<pre class="process-result">${escapeHtml(entry.result)}</pre>` : ''
|
|
1147
|
+
return `<div class="process-entry${entryExpanded ? ' expanded' : ''}"><button class="process-entry-fold" data-action="toggle-message" data-message="${escapeHtml(entryKey)}"><span class="process-entry-name">${escapeHtml(entry.name)}</span><span class="process-status${statusClass}">${status}</span></button>${entryExpanded ? `<div class="process-entry-body">${argumentsHtml}${outcomeHtml}</div>` : ''}</div>`
|
|
1129
1148
|
}).join('')
|
|
1130
1149
|
return `<section class="process-records${expanded ? ' expanded' : ''}"><button class="process-records-fold" data-action="toggle-message" data-message="${escapeHtml(key)}"><span>${expanded ? '收起过程记录' : '过程记录'}</span><span class="process-count">${process.length}</span></button>${expanded ? entries : ''}</section>`
|
|
1131
1150
|
}
|
|
@@ -1157,13 +1176,15 @@ function inspectorProcessEntries(messages) {
|
|
|
1157
1176
|
continue
|
|
1158
1177
|
}
|
|
1159
1178
|
if (message.kind === 'tool') {
|
|
1160
|
-
entries.push({ name: processSummary(message.text), arguments: message.text,
|
|
1179
|
+
entries.push({ name: processSummary(message.text), arguments: message.text, status: 'pending' })
|
|
1161
1180
|
continue
|
|
1162
1181
|
}
|
|
1163
1182
|
if (message.kind === 'tool-result') {
|
|
1164
1183
|
const previous = entries.at(-1)
|
|
1165
|
-
if (previous !== undefined && previous
|
|
1166
|
-
|
|
1184
|
+
if (previous !== undefined && processState(previous) === 'pending') {
|
|
1185
|
+
previous.status = 'done'
|
|
1186
|
+
previous.result = message.text
|
|
1187
|
+
} else entries.push({ name: '工具结果', arguments: null, status: 'done', result: message.text })
|
|
1167
1188
|
}
|
|
1168
1189
|
}
|
|
1169
1190
|
return entries
|
package/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { copyFile, mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
|
|
1
|
+
import { copyFile, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from 'node:fs/promises'
|
|
2
2
|
import { randomUUID } from 'node:crypto'
|
|
3
|
-
import { dirname } from 'node:path'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
import { zstdDecompressSync } from 'node:zlib'
|
|
4
5
|
|
|
5
6
|
export const name = 'synapse'
|
|
6
7
|
export const inject = ['webServer', 'sessions']
|
|
@@ -805,6 +806,101 @@ export function bodiesFromEvents(thread, events, seqs) {
|
|
|
805
806
|
return messages
|
|
806
807
|
}
|
|
807
808
|
|
|
809
|
+
// ---------------------------------------------------------------------------
|
|
810
|
+
// Session logs on disk
|
|
811
|
+
//
|
|
812
|
+
// `ctx.sessions` only exposes sessions this process currently holds, which is
|
|
813
|
+
// one or two out of a workspace's history. Everything else is read from the DSH
|
|
814
|
+
// session log: a run of concatenated zstd frames holding JSONL events.
|
|
815
|
+
// ---------------------------------------------------------------------------
|
|
816
|
+
|
|
817
|
+
const SESSION_LOG_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd])
|
|
818
|
+
const SESSION_LOG_PATTERN = /^session(?:\.[\w-]+)*\.jsonl\.zstd$/
|
|
819
|
+
|
|
820
|
+
/** Decode the concatenated zstd frames of one session log into JSONL text. */
|
|
821
|
+
export function decodeSessionLog(buffer) {
|
|
822
|
+
if (typeof zstdDecompressSync !== 'function') return null
|
|
823
|
+
const offsets = []
|
|
824
|
+
let index = buffer.indexOf(SESSION_LOG_MAGIC, 0)
|
|
825
|
+
while (index !== -1) {
|
|
826
|
+
offsets.push(index)
|
|
827
|
+
index = buffer.indexOf(SESSION_LOG_MAGIC, index + 4)
|
|
828
|
+
}
|
|
829
|
+
if (offsets.length === 0) return null
|
|
830
|
+
const parts = []
|
|
831
|
+
for (let i = 0; i < offsets.length; i += 1) {
|
|
832
|
+
const end = i + 1 < offsets.length ? offsets[i + 1] : buffer.length
|
|
833
|
+
try {
|
|
834
|
+
parts.push(zstdDecompressSync(buffer.subarray(offsets[i], end)))
|
|
835
|
+
} catch {
|
|
836
|
+
// A log that is being appended to can end mid-frame; decode what is whole.
|
|
837
|
+
break
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
if (parts.length === 0) return null
|
|
841
|
+
return Buffer.concat(parts).toString('utf8')
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/** Reads session events from `$DSH_HOME/sessions`, caching by file mtime. */
|
|
845
|
+
export class SessionLogReader {
|
|
846
|
+
constructor(directory) {
|
|
847
|
+
this.directory = directory
|
|
848
|
+
this.cache = new Map()
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/** Events of one session, or null when its log is missing or unreadable. */
|
|
852
|
+
async events(sessionId) {
|
|
853
|
+
if (typeof this.directory !== 'string' || this.directory === '' || typeof sessionId !== 'string' || sessionId === '') return null
|
|
854
|
+
const file = await this.findLog(sessionId)
|
|
855
|
+
if (file === null) {
|
|
856
|
+
this.cache.delete(sessionId)
|
|
857
|
+
return null
|
|
858
|
+
}
|
|
859
|
+
const info = await stat(file).catch(() => null)
|
|
860
|
+
if (info === null) return null
|
|
861
|
+
const cached = this.cache.get(sessionId)
|
|
862
|
+
if (cached !== undefined && cached.mtimeMs === info.mtimeMs && cached.size === info.size) return cached.events
|
|
863
|
+
const text = decodeSessionLog(await readFile(file))
|
|
864
|
+
if (text === null) return null
|
|
865
|
+
const events = []
|
|
866
|
+
for (const line of text.split('\n')) {
|
|
867
|
+
if (line.trim() === '') continue
|
|
868
|
+
try {
|
|
869
|
+
const event = JSON.parse(line)
|
|
870
|
+
if (typeof event?.seq === 'number') events.push(event)
|
|
871
|
+
} catch {
|
|
872
|
+
// Trailing partial line while the session is still being written.
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
// Keep a few decoded logs; one screenful of cards touches few threads.
|
|
876
|
+
if (this.cache.size >= 4) this.cache.delete(this.cache.keys().next().value)
|
|
877
|
+
this.cache.set(sessionId, { mtimeMs: info.mtimeMs, size: info.size, events })
|
|
878
|
+
return events
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
async findLog(sessionId) {
|
|
882
|
+
let workspaces
|
|
883
|
+
try {
|
|
884
|
+
workspaces = await readdir(this.directory, { withFileTypes: true })
|
|
885
|
+
} catch {
|
|
886
|
+
return null
|
|
887
|
+
}
|
|
888
|
+
for (const workspace of workspaces) {
|
|
889
|
+
if (!workspace.isDirectory()) continue
|
|
890
|
+
const sessionDirectory = join(this.directory, workspace.name, sessionId)
|
|
891
|
+
let files
|
|
892
|
+
try {
|
|
893
|
+
files = await readdir(sessionDirectory)
|
|
894
|
+
} catch {
|
|
895
|
+
continue
|
|
896
|
+
}
|
|
897
|
+
const log = files.find(name => SESSION_LOG_PATTERN.test(name))
|
|
898
|
+
if (log !== undefined) return join(sessionDirectory, log)
|
|
899
|
+
}
|
|
900
|
+
return null
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
808
904
|
function errorText(value) {
|
|
809
905
|
if (typeof value === 'string') return value.trim() || null
|
|
810
906
|
if (value === null || value === undefined || typeof value !== 'object') return null
|
|
@@ -915,6 +1011,12 @@ function page() {
|
|
|
915
1011
|
/** Mount Synapse routes on the existing DSH Web Server. */
|
|
916
1012
|
export function apply(ctx, config) {
|
|
917
1013
|
const store = new WorkspaceStore(config?.dataFile)
|
|
1014
|
+
// A profile can point the plugin at a different home; by default the session
|
|
1015
|
+
// logs sit beside the workspace file ($DSH_HOME/sessions).
|
|
1016
|
+
const sessionsDirectory = typeof config?.sessionsDir === 'string' && config.sessionsDir !== ''
|
|
1017
|
+
? config.sessionsDir
|
|
1018
|
+
: join(dirname(dirname(config?.dataFile ?? '')), 'sessions')
|
|
1019
|
+
const sessionLog = new SessionLogReader(sessionsDirectory)
|
|
918
1020
|
const autoProjection = config?.autoProjection !== false
|
|
919
1021
|
const projectionWorkspaceTitle = typeof config?.projectionWorkspaceTitle === 'string' && config.projectionWorkspaceTitle.trim() !== ''
|
|
920
1022
|
? config.projectionWorkspaceTitle.trim().slice(0, MAX_TITLE_LENGTH)
|
|
@@ -974,8 +1076,11 @@ export function apply(ctx, config) {
|
|
|
974
1076
|
return { messages }
|
|
975
1077
|
}
|
|
976
1078
|
const session = ctx.sessions.list().find(candidate => candidate.id === thread.dshSessionId)
|
|
977
|
-
if (session
|
|
978
|
-
|
|
1079
|
+
if (session !== undefined) return { messages: bodiesFromEvents(thread, session.snapshotEvents(), seqs) }
|
|
1080
|
+
// Closed session: read its log from disk, which is where the body lives.
|
|
1081
|
+
const events = await sessionLog.events(thread.dshSessionId)
|
|
1082
|
+
if (events === null) return { messages: {}, unavailable: true }
|
|
1083
|
+
return { messages: bodiesFromEvents(thread, events, seqs) }
|
|
979
1084
|
}
|
|
980
1085
|
// The DSH /api browser-trust fence does not cover /synapse routes, so this
|
|
981
1086
|
// handler checks the Host header itself: localhost is allowed by default and
|