@zhushanwen/session-core 0.11.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/README.md ADDED
@@ -0,0 +1,24 @@
1
+ # @zhushanwen/session-core
2
+
3
+ Coding-agent session 的 canonical 模型与纯 JSONL 原语基座。**零运行时依赖**(import 图不可达任何驱动/外部包),可被 node 侧 tsup 与 extension 侧 esbuild 安全内联,可独立 npm 发布。
4
+
5
+ ## 包定位
6
+
7
+ 「把某个 coding-agent 的 session 读成统一结构」的唯一落点:session-reader(pi 会话读取)、runtime 导入、未来的 coding-agent source 包共用本基座,取代各自为政的字节层副本(首行读取、entry 解析、id 归一化)。
8
+
9
+ ## 导出面
10
+
11
+ | 导出 | 说明 |
12
+ |------|------|
13
+ | `Entry` / `ParseResult` / `SessionHeader` / `NormalizedSession` | canonical 模型(D1):严格 Entry 树,坏行丢弃不占位 |
14
+ | `parseSessionContent(content)` | JSONL 文本 → `ParseResult`(坏行计 `skippedLines`,末行半行记 `lastLinePartial`) |
15
+ | `parseSessionFile(path)` | `parseSessionContent` 的 async 文件版(ENOENT 等按 fs 原生错误抛出) |
16
+ | `serializeSession(entries)` | `Entry[]` → JSONL,与 pi 产物逐字节兼容(`JSON.stringify(entry) + '\n'`) |
17
+ | `readFirstJsonlLine(path)` / `readFirstJsonlLineSync(path)` | 首行字节原语(async/sync 双形态):只读首行、处理 CRLF/LF、跨块多字节安全;空文件/纯空白 → `undefined`;IO 错误上抛由消费方分流 |
18
+ | `sessionIdFromFileName(fileName)` | 文件名不变量:剥 `.jsonl` 后最后一个 `_` 之后的尾段;提取不出 → `undefined` |
19
+ | `normalizeZcodeRowId(rowId)` | zcode 行 id → 8 位零填充小写十六进制(canonical entry id 域) |
20
+
21
+ ## 显式不进基座
22
+
23
+ - **header 合法性谓词**:reader 仅要求 `id`、runtime 要求 `id`+`cwd` 非空——语义差异是不可统一的行为契约,各消费侧保留薄包装(≤5 行,共享本基座的 `readFirstJsonlLine`)。
24
+ - **形式化 source SPI**:source 契约 = 「产出 `NormalizedSession`」的约定,形式接口在第二个 source 落地时才抽取。
@@ -0,0 +1,184 @@
1
+ /**
2
+ * canonical session 模型(设计 D1):「与 pi 结构一致」的严格定义。
3
+ *
4
+ * 数据语义(冻结接口,消费方 session-reader / runtime 导入 / zcode source 共享):
5
+ * - type / id / parentId:树结构字段。root(session header)在文件里无 parentId,
6
+ * 归一化为 null。
7
+ * - message:仅 type=message(role/content/toolCalls)。toolName/toolCallId:仅
8
+ * role=toolResult 的 message 透出(probe 实测 515/515 带,用于精确关联同 turn 内
9
+ * toolCall 取参数)。
10
+ * - customType / data:仅 type=custom。
11
+ * - parentSession / cwd:仅 type=session;parentSession 是 fork 文件指向来源的路径指针。
12
+ * - summary:仅 type=compaction。
13
+ *
14
+ * 冻结接口未列的 per-type 附加字段不在此暴露;消费方需要时先扩展本接口——解析层
15
+ * 不私自保留未知字段,保证「读到的就是模型声明的」。
16
+ */
17
+ /** pi message entry 的 role 域(窄联合:解析时值守卫收窄,非法 role 计坏行)。 */
18
+ type SessionMessageRole = 'user' | 'assistant' | 'toolResult';
19
+ interface Entry {
20
+ type: string;
21
+ id: string;
22
+ parentId: string | null;
23
+ timestamp?: string;
24
+ message?: {
25
+ role: SessionMessageRole;
26
+ content: unknown;
27
+ toolCalls?: unknown[];
28
+ /** toolResult 的工具名(仅 role=toolResult 时存在) */
29
+ toolName?: string;
30
+ /** toolResult 关联的 toolCall.id(仅 role=toolResult 时存在) */
31
+ toolCallId?: string;
32
+ };
33
+ customType?: string;
34
+ data?: unknown;
35
+ parentSession?: string;
36
+ cwd?: string;
37
+ summary?: unknown;
38
+ }
39
+ interface ParseResult {
40
+ entries: Entry[];
41
+ /** JSON 解析失败(含缺必填结构字段)的行数 */
42
+ skippedLines: number;
43
+ totalBytes: number;
44
+ /** 最后一行疑似半行(活跃 session 写入中),区别于中间坏行 */
45
+ lastLinePartial: boolean;
46
+ }
47
+ /**
48
+ * session header(首行 type='session' entry 的模型)。
49
+ *
50
+ * runtime / reader 两版消费形态的并集:reader 侧仅消费 id(cwd 可缺),
51
+ * runtime 侧要求 id+cwd 非空(乐观约定 timestamp 必有)。「缺 cwd 的 header 算不算
52
+ * 合法」是两侧不同的行为契约,由各消费侧谓词裁决(基座不导出 header 谓词),
53
+ * 本接口只声明字段形状:除 id 外全部可选。
54
+ */
55
+ interface SessionHeader {
56
+ id: string;
57
+ cwd?: string;
58
+ timestamp?: string;
59
+ /** 父 session 血缘键(fork 出的 session header 指回源文件/源 sessionId)。 */
60
+ parentSession?: string;
61
+ /** fork 锚点 entry id(截断点)。 */
62
+ forkEntryId?: string;
63
+ }
64
+ /**
65
+ * 转换降级记录(zcode→canonical 转换的信息损失按性质分档)。
66
+ *
67
+ * 与 `@taiji/shared` 的 wire 契约类型 `ImportDegradation` 为结构等价声明:基座是零
68
+ * 依赖包(设计负面清单),不 import shared——两处定义字段逐一同步(code 五值闭集 /
69
+ * kind / source / count / sample / zcodeSchemaVersion),漂移由消费侧(runtime 导入
70
+ * 薄包装把本类型赋给 wire 契约类型)的类型检查拦截。档位语义:
71
+ * - `dropped_redundant`(L1):内容已由 tool 通道保留的运行时注入丢弃,无损
72
+ * - `dropped_transient`(L2):无对话语义的瞬态/注入形态丢弃 + part 级诊断登记
73
+ * - `truncated_output` / `compaction_unlinked`(L3):保真损失
74
+ * - `unclassified`(L4):超出闭集无法分类,丢弃 + 独立告警
75
+ */
76
+ interface ImportDegradation {
77
+ /** 降级码(五值闭集) */
78
+ code: 'dropped_redundant' | 'dropped_transient' | 'truncated_output' | 'compaction_unlinked' | 'unclassified';
79
+ /** zcode semantics.kind 原值(按可得性携带的聚合维度) */
80
+ kind?: string;
81
+ /** metadata.source / source 原值(按可得性携带的聚合维度) */
82
+ source?: string;
83
+ /** 该 (code, kind, source) 维度的聚合计数 */
84
+ count: number;
85
+ /** 定位样本(unclassified 必带;part 级诊断可携带便于日志定位) */
86
+ sample?: {
87
+ messageId: string;
88
+ preview: string;
89
+ };
90
+ /** zcode schema_migration.app_version(回归定位锚,按可得性携带) */
91
+ zcodeSchemaVersion?: string;
92
+ }
93
+ /**
94
+ * source 包读取链的统一返回类型:`readZcodeSession(dbPath, sessionId)` 等具体读取
95
+ * 函数产出本形状。
96
+ * - header:canonical 化后的会话头(pi 侧 = 直读 JSONL 首行的 SessionHeader;zcode 侧
97
+ * 由 converter 从库内 session 行构造——zcode 无 cwd 概念,缺省留空不伪造)。
98
+ * - entries:严格 Entry 树(parentId 链完整,坏行/未知 part 不占位)。
99
+ * - degradations:结构化降级明细(pi 恒为 []——JSONL 直读无转换损失;zcode 侧由
100
+ * converter 在降级点登记,不静默丢弃)。
101
+ */
102
+ interface NormalizedSession {
103
+ header: SessionHeader;
104
+ entries: Entry[];
105
+ degradations: ImportDegradation[];
106
+ }
107
+
108
+ /**
109
+ * JSONL 解析原语:session JSONL 文本/文件 → canonical entries。
110
+ *
111
+ * 坏行语义(严格模型,设计 D1):逐行 JSON.parse,语法错误或缺必填结构字段计入
112
+ * skippedLines 并丢弃,不占位、不中断整体解析。宽松「坏行保留占位」形态是
113
+ * core/session-trace 的私有语义(设计 D1 被否项),不进本基座。
114
+ */
115
+
116
+ /**
117
+ * 解析 session JSONL 文本为 entries。
118
+ *
119
+ * 末尾换行产生的空行忽略(不计 skipped、不计 partial);中间空行容错跳过
120
+ * (正常 JSONL 无空行,防御文件损坏)。最后一行 parse 失败时 lastLinePartial=true
121
+ * (活跃 session 写到一半的半行),区别于中间坏行。
122
+ */
123
+ declare function parseSessionContent(content: string): ParseResult;
124
+ /** 读取 session 文件并解析。文件不存在按 Node fs 原生错误抛出(ENOENT)。 */
125
+ declare function parseSessionFile(filePath: string): Promise<ParseResult>;
126
+
127
+ /**
128
+ * JSONL 序列化原语:canonical entries → session JSONL 文本。
129
+ *
130
+ * 字节契约(P-serializer,与 pi 产物逐字节兼容):每行 = `JSON.stringify(entry) + '\n'`
131
+ * (pi session-manager `_persist` 同款);键序责任在 Entry 对象构造方——pi appendMessage
132
+ * 与 converter emitEntry 均按 `{type, id, parentId, timestamp, ...payload}` 顺序构造,
133
+ * 本函数不重排键、不丢接口外字段。空值处理由 JSON.stringify 原生承担:undefined 值
134
+ * 字段不落盘,`parentId: null` 落盘为 `"parentId":null`(root entry 语义,pi 同款)。
135
+ */
136
+
137
+ declare function serializeSession(entries: readonly Entry[]): string;
138
+
139
+ /**
140
+ * 首行字节原语(设计 G2 的 SSOT):只读 session JSONL 首行,收敛仓内 4 份漂移副本
141
+ * (runtime sync session-file-utils / runtime async session-file-external-scan /
142
+ * runtime async import-source-external-file / session-reader session-header)。
143
+ *
144
+ * 语义裁决:
145
+ * - 超长首行:块续读直至换行或 EOF(等价「回退全量读首行」,超长首行可解析);
146
+ * 块大小只影响 syscall 次数不影响结果。
147
+ * - 跨块解码:换行先在原始 Buffer 上定位,命中前内容以 Buffer 累积、最终整体
148
+ * toString——逐块 toString 会把跨块多字节 UTF-8 字符(CJK)拆出 U+FFFD。
149
+ * - 行尾 CRLF 剥 `\r`(副本既有实现不处理,Windows 侧编辑过的文件首行会带裸 CR
150
+ * 致 JSON.parse 失败;本原语统一处理)。
151
+ * - 空文件 / 首行剥 CR 后纯空白 → undefined。
152
+ * - IO 错误(不存在/权限等)上抛:错误分类信息不丢失(STANDARDS §11.2 把 IO 故障
153
+ * 伪装成 not-found 会误导排障);「任何失败都视为无 header」的分流归消费侧薄包装。
154
+ */
155
+ /** 首行读取(async 形态)。文件不存在/打开失败按 fs 原生错误抛出。 */
156
+ declare function readFirstJsonlLine(filePath: string): Promise<string | undefined>;
157
+ /** 首行读取(sync 形态)。文件不存在/打开失败按 fs 原生错误抛出。 */
158
+ declare function readFirstJsonlLineSync(filePath: string): string | undefined;
159
+
160
+ /**
161
+ * session 文件名不变量原语:全仓约定「session 文件名(剥 .jsonl 后)最后一个 `_`
162
+ * 之后的尾段 == header.id」——image-cache 孤儿判定/删除级联、session-reader 文件名
163
+ * 提取、parent-session 兜底匹配、短名展示等消费点共享该不变量。
164
+ *
165
+ * 无 `_` 时返回剥后缀全串(对齐 session-reader extractSessionIdFromFilename 既有
166
+ * 行为——uuid 直接作文件名无时间戳前缀的形态仍可提取)。uuid 特征校验是 reader 侧
167
+ * 策略(非 uuid 特征按无 id 处理),不进基座:基座只承诺命名不变量本身。
168
+ */
169
+ declare function sessionIdFromFileName(fileName: string): string | undefined;
170
+
171
+ /**
172
+ * zcode 行 id 值域归一化:非负安全整数 → 8 位小写十六进制(10 → '0000000a';与
173
+ * pi 自身 id 形态 randomUUID().slice(0,8) 同为 8-hex,pi 侧对 entry id 仅作
174
+ * opaque map key 消费、无 parseInt/形态校验)。
175
+ *
176
+ * canonical entry id 由 source 包 converter 的产序计数器经本函数格式化生成,与
177
+ * zcode 库行 id 无对应关系;parentId 顺序链(每条 entry 指向前一条)同样由
178
+ * converter 维护,本函数只负责单点 id 值域归一化。
179
+ */
180
+ /** 归一化产物的固定十六进制位数(8-hex,'00000001' 起)。 */
181
+ declare const ZCODE_ROW_ID_WIDTH = 8;
182
+ declare function normalizeZcodeRowId(rowId: number): string;
183
+
184
+ export { type Entry, type ImportDegradation, type NormalizedSession, type ParseResult, type SessionHeader, type SessionMessageRole, ZCODE_ROW_ID_WIDTH, normalizeZcodeRowId, parseSessionContent, parseSessionFile, readFirstJsonlLine, readFirstJsonlLineSync, serializeSession, sessionIdFromFileName };
package/dist/index.js ADDED
@@ -0,0 +1,181 @@
1
+ // src/parse.ts
2
+ import { readFile } from "fs/promises";
3
+ function isMessageRole(v) {
4
+ return v === "user" || v === "assistant" || v === "toolResult";
5
+ }
6
+ function parseEntryMessage(raw) {
7
+ if (raw === null || typeof raw !== "object") return void 0;
8
+ const m = raw;
9
+ if (!isMessageRole(m.role)) return void 0;
10
+ const message = { role: m.role, content: m.content };
11
+ if (Array.isArray(m.toolCalls)) message.toolCalls = m.toolCalls;
12
+ if (typeof m.toolName === "string") message.toolName = m.toolName;
13
+ if (typeof m.toolCallId === "string") message.toolCallId = m.toolCallId;
14
+ return message;
15
+ }
16
+ function assignOptionalFields(obj, entry) {
17
+ if (typeof obj.customType === "string") entry.customType = obj.customType;
18
+ if (obj.data !== void 0) entry.data = obj.data;
19
+ if (typeof obj.parentSession === "string") entry.parentSession = obj.parentSession;
20
+ if (typeof obj.cwd === "string") entry.cwd = obj.cwd;
21
+ if (obj.summary !== void 0) entry.summary = obj.summary;
22
+ }
23
+ function toEntry(raw) {
24
+ if (typeof raw !== "object" || raw === null) return void 0;
25
+ const obj = raw;
26
+ if (typeof obj.type !== "string") return void 0;
27
+ const id = obj.id;
28
+ if (typeof id !== "string") return void 0;
29
+ const entry = {
30
+ type: obj.type,
31
+ id,
32
+ parentId: typeof obj.parentId === "string" ? obj.parentId : null
33
+ };
34
+ if (typeof obj.timestamp === "string") entry.timestamp = obj.timestamp;
35
+ const message = parseEntryMessage(obj.message);
36
+ if (message !== void 0) entry.message = message;
37
+ assignOptionalFields(obj, entry);
38
+ return entry;
39
+ }
40
+ function parseSessionContent(content) {
41
+ const entries = [];
42
+ let skippedLines = 0;
43
+ let lastLinePartial = false;
44
+ const lines = content.split("\n");
45
+ while (lines.length > 0 && lines[lines.length - 1] === "") {
46
+ lines.pop();
47
+ }
48
+ for (let i = 0; i < lines.length; i++) {
49
+ const line = lines[i];
50
+ const isLast = i === lines.length - 1;
51
+ if (line.trim() === "") continue;
52
+ let raw;
53
+ try {
54
+ raw = JSON.parse(line);
55
+ } catch {
56
+ skippedLines++;
57
+ if (isLast) lastLinePartial = true;
58
+ continue;
59
+ }
60
+ const entry = toEntry(raw);
61
+ if (entry === void 0) {
62
+ skippedLines++;
63
+ if (isLast) lastLinePartial = true;
64
+ continue;
65
+ }
66
+ entries.push(entry);
67
+ }
68
+ return {
69
+ entries,
70
+ skippedLines,
71
+ totalBytes: Buffer.byteLength(content, "utf8"),
72
+ lastLinePartial
73
+ };
74
+ }
75
+ async function parseSessionFile(filePath) {
76
+ const content = await readFile(filePath, "utf8");
77
+ return parseSessionContent(content);
78
+ }
79
+
80
+ // src/serialize.ts
81
+ function serializeSession(entries) {
82
+ let out = "";
83
+ for (const entry of entries) {
84
+ out += `${JSON.stringify(entry)}
85
+ `;
86
+ }
87
+ return out;
88
+ }
89
+
90
+ // src/first-line.ts
91
+ import { open } from "fs/promises";
92
+ import { closeSync, openSync, readSync } from "fs";
93
+ var NEWLINE_BYTE = 10;
94
+ var CHUNK_BYTES = 8192;
95
+ function decodeLineBytes(chunks) {
96
+ if (chunks.length === 0) return void 0;
97
+ const line = Buffer.concat(chunks).toString("utf8");
98
+ const withoutCr = line.endsWith("\r") ? line.slice(0, -1) : line;
99
+ return withoutCr.trim() === "" ? void 0 : withoutCr;
100
+ }
101
+ async function readFirstLineViaHandle(fh) {
102
+ const buffer = Buffer.alloc(CHUNK_BYTES);
103
+ const chunks = [];
104
+ for (; ; ) {
105
+ const { bytesRead } = await fh.read(buffer, 0, CHUNK_BYTES, null);
106
+ if (bytesRead === 0) {
107
+ return decodeLineBytes(chunks);
108
+ }
109
+ const nl = buffer.subarray(0, bytesRead).indexOf(NEWLINE_BYTE);
110
+ if (nl >= 0) {
111
+ chunks.push(Buffer.from(buffer.subarray(0, nl)));
112
+ return decodeLineBytes(chunks);
113
+ }
114
+ chunks.push(Buffer.from(buffer.subarray(0, bytesRead)));
115
+ }
116
+ }
117
+ async function readFirstJsonlLine(filePath) {
118
+ const fh = await open(filePath, "r");
119
+ try {
120
+ return await readFirstLineViaHandle(fh);
121
+ } finally {
122
+ try {
123
+ await fh.close();
124
+ } catch (err) {
125
+ console.debug("first-line read: close failed after successful read (best-effort)", err);
126
+ }
127
+ }
128
+ }
129
+ function readFirstJsonlLineSync(filePath) {
130
+ const fd = openSync(filePath, "r");
131
+ try {
132
+ const buffer = Buffer.alloc(CHUNK_BYTES);
133
+ const chunks = [];
134
+ for (; ; ) {
135
+ const bytesRead = readSync(fd, buffer, 0, CHUNK_BYTES, null);
136
+ if (bytesRead === 0) {
137
+ return decodeLineBytes(chunks);
138
+ }
139
+ const nl = buffer.subarray(0, bytesRead).indexOf(NEWLINE_BYTE);
140
+ if (nl >= 0) {
141
+ chunks.push(Buffer.from(buffer.subarray(0, nl)));
142
+ return decodeLineBytes(chunks);
143
+ }
144
+ chunks.push(Buffer.from(buffer.subarray(0, bytesRead)));
145
+ }
146
+ } finally {
147
+ try {
148
+ closeSync(fd);
149
+ } catch (err) {
150
+ console.debug("first-line read: closeSync failed after successful read (best-effort)", err);
151
+ }
152
+ }
153
+ }
154
+
155
+ // src/session-id.ts
156
+ function sessionIdFromFileName(fileName) {
157
+ const noExt = fileName.replace(/\.jsonl.*$/, "");
158
+ const idx = noExt.lastIndexOf("_");
159
+ const candidate = idx >= 0 ? noExt.slice(idx + 1) : noExt;
160
+ return candidate.length > 0 ? candidate : void 0;
161
+ }
162
+
163
+ // src/zcode-id.ts
164
+ var ZCODE_ROW_ID_WIDTH = 8;
165
+ var HEX_RADIX = 16;
166
+ function normalizeZcodeRowId(rowId) {
167
+ if (!Number.isSafeInteger(rowId) || rowId < 0) {
168
+ throw new RangeError(`zcode row id must be a non-negative safe integer, got: ${rowId}`);
169
+ }
170
+ return rowId.toString(HEX_RADIX).padStart(ZCODE_ROW_ID_WIDTH, "0");
171
+ }
172
+ export {
173
+ ZCODE_ROW_ID_WIDTH,
174
+ normalizeZcodeRowId,
175
+ parseSessionContent,
176
+ parseSessionFile,
177
+ readFirstJsonlLine,
178
+ readFirstJsonlLineSync,
179
+ serializeSession,
180
+ sessionIdFromFileName
181
+ };
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@zhushanwen/session-core",
3
+ "version": "0.11.0",
4
+ "type": "module",
5
+ "description": "Canonical session model and pure JSONL primitives shared by session readers and importers: Entry/ParseResult/SessionHeader/NormalizedSession, parse/serialize, first-line byte primitive, session id invariants. Zero runtime dependencies.",
6
+ "main": "dist/index.mjs",
7
+ "module": "dist/index.mjs",
8
+ "types": "dist/index.d.mts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.mts",
12
+ "import": "./dist/index.mjs"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "README.md"
18
+ ],
19
+ "license": "MIT",
20
+ "devDependencies": {
21
+ "@vitest/coverage-v8": "^4.1.9",
22
+ "tsup": "^8.5.1",
23
+ "typescript": "^5.7.0",
24
+ "vitest": "^4.1.6"
25
+ },
26
+ "scripts": {
27
+ "build": "tsup",
28
+ "typecheck": "tsc --noEmit",
29
+ "test": "vitest run"
30
+ }
31
+ }