[Bug] Pi profile splits sections inside nested XML blocks — memory block injected twice
PiProfile.splitByPiLabels opens a new top-level section on any Label: line regardless of XML nesting. Pi's real system prompt nests <project_instructions path="..."> inside <project_context>, and the files those blocks carry (AGENTS.md and friends) routinely contain label-shaped lines of their own.
Because PI_SLOT_MAP routes both skills and rules to the Guidelines key, and pipeline.ts applyInjection lands the injected block on every segment matching that key, the memory block is injected twice — and the second copy is misplaced inside the project instructions. The enclosing <project_context> segment is also truncated at the nested label, which moves the task_context anchor.
Version: 0b7bc09 (branch feat/server_team, 2026-09-17). Introduced by #1126.
Root cause
MemoryProxy/src/injection/agents/pi/profile.ts:
const LABEL_RE = /^([A-Z][A-Za-z ]*):\s*$/;
const XML_OPEN_RE = /^<([a-z_]+)>\s*$/;
for (const line of lines) {
const labelMatch = LABEL_RE.exec(line); // no nesting check
const xmlMatch = XML_OPEN_RE.exec(line); // no nesting check
if (labelMatch || xmlMatch) {
flush();
currentKey = labelMatch ? labelMatch[1].trim() : xmlMatch![1];
currentKind = "markdown_section";
}
buffer.push(line);
}The real Pi prompt shape (see the fixture that shipped with #1126):
<project_context>
Project-specific instructions and guidelines:
<project_instructions path="/example/AGENTS.md">
# Example project instructions
- Follow existing patterns in the codebase.
</project_instructions>
</project_context>Note <project_instructions path="..."> carries an attribute, so it does not match XML_OPEN_RE — it is treated as plain body text, while the Guidelines:-style lines inside it do match LABEL_RE and split the section.
The pipeline injects on every match (MemoryProxy/src/injection/pipeline.ts):
if (key && segments.some((s) => s.key === key)) {
const newSegments = profile.applyAnchor(segments, { key, relation: hook.anchor.relation }, text);
sysMsg.blocks = [{ type: "text", content: profile.rebuild(newSegments) }];
return;
}SkillInjector ({ slot: "skills", relation: "before" }) and TdaiProfileMemoryInjector ({ slot: "memory", relation: "inside_append" }) both run through this path.
To Reproduce
Drive the real InjectionPipeline with the Pi profile registered, using a realistic Pi system prompt whose <project_instructions> body contains a Guidelines: line:
cd MemoryProxy
npx tsx repro-pipeline.ts # script attached below / in the linked PREvidence
Measured on 0b7bc09 (unfixed) vs. the fix:
### UNFIXED (base 0b7bc09) ###
=== REAL PIPELINE RESULT ===
system prompt bytes : 671
injection count : 2 (DUPLICATED)
marker positions : 203, 448
nested block starts : 388
marker inside nested: true
### FIXED ###
=== REAL PIPELINE RESULT ===
system prompt bytes : 646
injection count : 1 (correct)
marker positions : 203
nested block starts : 388
marker inside nested: falsemarker inside nested: true on the unfixed build is the misplaced second copy. Segment-level view:
before: segments = [null, "Available tools", "Guidelines", "project_context", "Guidelines"]
after: segments = [null, "Available tools", "Guidelines", "project_context"]The project_context section is also truncated at the nested label on the unfixed build (it ends before </project_instructions>), which is what shifts the task_context anchor.
Expected behavior
A label-shaped line inside an open XML block is body text, not a section opener. The block must be injected exactly once, into the top-level section, and parse → rebuild must stay lossless (the profile's stated safety guarantee).
Impact
Any Pi session whose project instructions contain a label-shaped line — e.g. Guidelines:, Never:, Prefer:, Ownership rules: — gets the skills/rules memory block twice, once in the wrong place. This is the common case, not an edge case: PI-Desktop's own AGENTS.md contains 10 such lines.
Note on test coverage
The fixture that would have caught this (pi/__tests__/fixtures/pi-system-prompt.txt) was deleted in release commit 220af62 (v2.0.2-beta.1), so this path currently has no regression net. A fix PR with regression tests follows.
Source: TencentCloud/TencentDB-Agent-Memory