@zhushanwen/pi-session-path 0.1.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/package.json +26 -0
- package/src/__tests__/active-path.test.ts +100 -0
- package/src/index.ts +91 -0
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zhushanwen/pi-session-path",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Active-path clipping for pi session entries — single shared implementation for state-rebuilding extensions (todo/plan/goal/scheduler), so revoked subtrees stay out of rebuild inputs (shared library, not a Pi extension, zero dependencies).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"pi-package",
|
|
9
|
+
"pi",
|
|
10
|
+
"session",
|
|
11
|
+
"active-path",
|
|
12
|
+
"shared"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"files": [
|
|
16
|
+
"src/"
|
|
17
|
+
],
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@vitest/coverage-v8": "^4.1.9",
|
|
20
|
+
"vitest": "^4.1.8"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"typecheck": "npx tsc --noEmit",
|
|
24
|
+
"test": "vitest run"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* filterActivePath 单测——四包(todo/plan/goal/scheduler)收编后的单一实现。
|
|
3
|
+
* 覆盖:分支裁剪 / 无分支回归 / leafId 防御回退 / 统一后的回退取值口径(无 id
|
|
4
|
+
* 尾条目)/ legacy 线性 fixture / 环状 parentId 防挂死。
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
|
|
8
|
+
import { filterActivePath, type ActivePathSessionView } from "../index";
|
|
9
|
+
|
|
10
|
+
interface BranchEntry { // oe-exempt:20261001:test:测试 fixture 形状契约(分支树 entry 最小形状,断言面消费)
|
|
11
|
+
type: string;
|
|
12
|
+
id: string;
|
|
13
|
+
parentId: string | null;
|
|
14
|
+
customType?: string;
|
|
15
|
+
data?: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function entry(id: string, parentId: string | null, customType = "x"): BranchEntry {
|
|
19
|
+
return { type: "custom", id, parentId, customType, data: {} };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 撤回后真实形态:label entry 落文件尾(parentId = 回退后叶子) */
|
|
23
|
+
function labelEntry(id: string, parentId: string | null): BranchEntry {
|
|
24
|
+
return { type: "label", id, parentId, targetId: "u-x", label: "taiji:revoked" };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function makeView(
|
|
28
|
+
entries: Array<Record<string, unknown>>,
|
|
29
|
+
leafId?: string | null,
|
|
30
|
+
): ActivePathSessionView<Record<string, unknown>> {
|
|
31
|
+
return {
|
|
32
|
+
getEntries: () => entries.slice(),
|
|
33
|
+
getLeafId: leafId === undefined ? undefined : () => leafId,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const idsOf = (entries: Array<Record<string, unknown>>): unknown[] =>
|
|
38
|
+
entries.map((e) => e["id"]);
|
|
39
|
+
|
|
40
|
+
describe("filterActivePath(活跃路径裁剪单一实现)", () => {
|
|
41
|
+
it("有分支 fixture:被撤子树不进输出——活跃路径 = leafId 沿 parentId 回溯", () => {
|
|
42
|
+
// 树:e1 → e2(active);e1 → e3(revoked,物理后写);撤回落 label 锚 L(parentId=e2)
|
|
43
|
+
const view = makeView([
|
|
44
|
+
entry("e1", null),
|
|
45
|
+
entry("e2", "e1", "active"),
|
|
46
|
+
entry("e3", "e1", "revoked"),
|
|
47
|
+
labelEntry("L", "e2"),
|
|
48
|
+
], "L");
|
|
49
|
+
|
|
50
|
+
expect(idsOf(filterActivePath(view))).toEqual(["e1", "e2", "L"]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("无分支回归:leafId = 文件尾 → 全量保留(现行为不变)", () => {
|
|
54
|
+
const view = makeView([entry("e1", null), entry("e2", "e1")], "e2");
|
|
55
|
+
|
|
56
|
+
expect(idsOf(filterActivePath(view))).toEqual(["e1", "e2"]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("leafId 防御:null / 指向不存在 entry / 视图不携带 getLeafId → 回退文件尾", () => {
|
|
60
|
+
const entries = [entry("e1", null), entry("e2", "e1")];
|
|
61
|
+
for (const leafId of [null, "missing-id", undefined]) {
|
|
62
|
+
const clipped = filterActivePath(makeView(entries, leafId));
|
|
63
|
+
expect(idsOf(clipped)).toEqual(["e1", "e2"]);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("统一回退取值口径:尾条目无 string id → 回退叶子 = 最后一条带 id 的条目,无 id 尾条目保留在输出", () => {
|
|
68
|
+
// 收编前分叉点:plan/todo 取数组尾(无 id 条目)、goal/scheduler 取最后一条
|
|
69
|
+
// 带 id 条目。统一为后者——回溯需要 id 锚点;无 id 条目按线性文件语义保留。
|
|
70
|
+
const view = makeView([
|
|
71
|
+
entry("e1", null, "id-having"),
|
|
72
|
+
{ type: "custom", customType: "id-less-tail", data: {} },
|
|
73
|
+
], null);
|
|
74
|
+
|
|
75
|
+
const clipped = filterActivePath(view);
|
|
76
|
+
expect(clipped).toHaveLength(2);
|
|
77
|
+
expect(clipped[1]).toMatchObject({ customType: "id-less-tail" });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("legacy fixture:全文件无 string id(线性最小形状)→ 不过滤,保持裁剪前行为", () => {
|
|
81
|
+
const view: ActivePathSessionView<Record<string, unknown>> = {
|
|
82
|
+
getEntries: () => [
|
|
83
|
+
{ type: "custom", customType: "a", data: {} },
|
|
84
|
+
{ type: "custom", customType: "b", data: {} },
|
|
85
|
+
],
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
expect(filterActivePath(view)).toHaveLength(2);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("环状 parentId(a↔b,leafId 指向环内)→ 回溯终止不挂死", () => {
|
|
92
|
+
const view = makeView([entry("a", "b"), entry("b", "a")], "a");
|
|
93
|
+
|
|
94
|
+
expect(new Set(idsOf(filterActivePath(view))).size).toBeGreaterThan(0);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("空 entries → 原样返回空数组", () => {
|
|
98
|
+
expect(filterActivePath(makeView([], "x"))).toEqual([]);
|
|
99
|
+
});
|
|
100
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
//
|
|
3
|
+
// @zhushanwen/pi-session-path —— pi session 活跃路径裁剪共享库(不是 Pi extension,
|
|
4
|
+
// 零依赖纯函数,无 pi SDK peerDep——供任意 @zhushanwen/pi-* 包消费)。
|
|
5
|
+
//
|
|
6
|
+
// 收敛背景:todo/plan/goal/scheduler 四包曾各持一份同构 filterActivePath(extension
|
|
7
|
+
// 不能 import runtime 包,四包间却可互享),靠注释纪律保持同步——回退取值口径已实际
|
|
8
|
+
// 分叉(plan/todo 取数组尾条目 vs goal/scheduler 取最后一条带 string id 的条目,无
|
|
9
|
+
// id 尾条目上行为可分叉)。语义同构 = 假差异,收敛为单一实现;读者普查登记见
|
|
10
|
+
// docs/architecture/session-readers-census.md(§3 各包行 + §4.1 防御语义异源登记)。
|
|
11
|
+
// runtime 侧(entry-tree-builder 重建链 / readEntries 投影的 filterEntriesToActivePath)
|
|
12
|
+
// 与 plugin 侧不经本包、保持独立——跨面异源是有意设计,非漏接。
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* entry 的树结构字段视图(duck-typed 收窄,无断言)。含 type?: unknown 只为通过
|
|
16
|
+
* weak-type 检查(全 optional 目标须与源共享属性名——各消费包的最小 entry 形状
|
|
17
|
+
* SessionEntryLike/SchedulerEntryLike 只声明 type);真实 pi SessionEntry 恒有
|
|
18
|
+
* id/parentId(SessionEntryBase),缺失时按线性文件语义处理(见 filterActivePath)。
|
|
19
|
+
*/
|
|
20
|
+
interface TreeEntryFields { // oe-exempt:20261001:framework:duck-typed 树字段收窄视图(weak-type 检查要求的独立 interface 形态)——共享实现内部结构,非待变体抽象
|
|
21
|
+
type?: unknown;
|
|
22
|
+
id?: unknown;
|
|
23
|
+
parentId?: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 活跃路径回溯所需的最小 session 视图。getLeafId 可选:消费包的最小视图(单测
|
|
28
|
+
* fixture / SessionPort 委托)不携带时按文件尾回退;pi ReadonlySessionManager 恒
|
|
29
|
+
* 携带(结构兼容,多余成员不受限)。
|
|
30
|
+
*/
|
|
31
|
+
export interface ActivePathSessionView<TEntry extends TreeEntryFields> { // oe-exempt:20261001:framework:消费包 session 视图的 ports 契约——goal ActivePathSessionView / pi ReadonlySessionManager / SchedulerBackendCtx.sessionManager 以结构兼容形状传入,类型契约先行、单实现常态
|
|
32
|
+
getEntries(): TEntry[];
|
|
33
|
+
getLeafId?(): string | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 活跃路径裁剪:从 leafId 沿 parentId 回溯得活跃路径 id 集合,按文件序过滤 entries。
|
|
38
|
+
* 撤回(navigateTree 树回退)后被撤子树的 entry 不再进入状态重建输入——被撤内容
|
|
39
|
+
* 不得经压缩摘要 / 面板 / 逐轮注入回到模型上下文。
|
|
40
|
+
*
|
|
41
|
+
* 防御语义(pi buildSessionPath 同构):leafId 缺失/失效时回退文件尾——取**最后一条
|
|
42
|
+
* 带 string id 的 entry**(正常文件尾即它;回溯需要 id 锚点,无 id 的尾条目当不了
|
|
43
|
+
* 叶子,按线性文件语义保留在输出中)。此为四包收编时统一的回退取值口径:真实 pi
|
|
44
|
+
* SessionEntry 恒有 id,两口径仅在无 id 的 duck-typed fixture 上可分叉,生产行为
|
|
45
|
+
* 逐字节不变。
|
|
46
|
+
*
|
|
47
|
+
* 边界形态:
|
|
48
|
+
* - 无任何树信息(legacy 线性 fixture,全文件无 string id)→ 不过滤,保持裁剪前行为;
|
|
49
|
+
* - 无 id 的 entry → 保留(线性文件语义,不因缺字段丢行);
|
|
50
|
+
* - 环状 parentId → activeIds 已含即终止,不挂死。
|
|
51
|
+
*/
|
|
52
|
+
export function filterActivePath<TEntry extends TreeEntryFields>(
|
|
53
|
+
view: ActivePathSessionView<TEntry>,
|
|
54
|
+
): TEntry[] {
|
|
55
|
+
const entries = view.getEntries();
|
|
56
|
+
if (entries.length === 0) return entries;
|
|
57
|
+
|
|
58
|
+
const byId = new Map<string, { id: string; parentId: string | null }>();
|
|
59
|
+
for (const entry of entries) {
|
|
60
|
+
const tree: TreeEntryFields = entry;
|
|
61
|
+
if (typeof tree.id === "string") {
|
|
62
|
+
byId.set(tree.id, {
|
|
63
|
+
id: tree.id,
|
|
64
|
+
parentId: typeof tree.parentId === "string" ? tree.parentId : null,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// 无任何树信息(legacy 线性 fixture)→ 不过滤,保持裁剪前行为
|
|
69
|
+
if (byId.size === 0) return entries;
|
|
70
|
+
|
|
71
|
+
const leafId = view.getLeafId?.();
|
|
72
|
+
let current =
|
|
73
|
+
(leafId ? byId.get(leafId) : undefined) ?? lastOfMapValues(byId);
|
|
74
|
+
const activeIds = new Set<string>();
|
|
75
|
+
while (current && !activeIds.has(current.id)) {
|
|
76
|
+
activeIds.add(current.id);
|
|
77
|
+
current = current.parentId ? byId.get(current.parentId) : undefined;
|
|
78
|
+
}
|
|
79
|
+
return entries.filter((entry) => {
|
|
80
|
+
const tree: TreeEntryFields = entry;
|
|
81
|
+
return typeof tree.id !== "string" || activeIds.has(tree.id);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function lastOfMapValues(
|
|
86
|
+
map: Map<string, { id: string; parentId: string | null }>,
|
|
87
|
+
): { id: string; parentId: string | null } | undefined {
|
|
88
|
+
let last: { id: string; parentId: string | null } | undefined;
|
|
89
|
+
for (const value of map.values()) last = value;
|
|
90
|
+
return last;
|
|
91
|
+
}
|