Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions platform/core/src/__tests__/memory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, expect, test } from "bun:test";
import { generateMemory, parseMemory } from "../memory";

describe("parseMemory", () => {
test("parses the generated template as empty structured fields", () => {
const markdown = generateMemory();

expect(parseMemory(markdown)).toEqual({
userProfile: "",
keyFacts: "",
ongoingContext: "",
manualNotes: "",
raw: markdown,
});
});

test("extracts populated sections and manual notes without cross-contamination", () => {
const markdown = `# Memory

## User Profile

Prefers terse updates.

## Key Facts

- Uses Signet locally.

## Ongoing Context

Reviewing parser behavior.

<!-- MANUAL:START -->
Keep this hand-written note.
<!-- MANUAL:END -->
`;

expect(parseMemory(markdown)).toEqual({
userProfile: "Prefers terse updates.",
keyFacts: "- Uses Signet locally.",
ongoingContext: "Reviewing parser behavior.",
manualNotes: "Keep this hand-written note.",
raw: markdown,
});
});

test("ignores markdown headings inside manual notes when parsing structured sections", () => {
const markdown = `# Memory

## User Profile

Real profile.

## Key Facts

Real fact.

## Ongoing Context

Real context.

<!-- MANUAL:START -->
## User Profile

Manual note profile heading.

## Key Facts

Manual note fact heading.
<!-- MANUAL:END -->
`;

expect(parseMemory(markdown)).toEqual({
userProfile: "Real profile.",
keyFacts: "Real fact.",
ongoingContext: "Real context.",
manualNotes: "## User Profile\n\nManual note profile heading.\n\n## Key Facts\n\nManual note fact heading.",
raw: markdown,
});
});

test("preserves raw markdown for round trips", () => {
const markdown = "# Memory\n\n## Key Facts\n\n- one\n";

expect(parseMemory(markdown).raw).toBe(markdown);
});
});
2 changes: 1 addition & 1 deletion platform/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ export {
export type { ModelCatalogProvider, PipelineModelPreset } from "./llm-model-catalog";
export { parseManifest, generateManifest } from "./manifest";
export { parseSoul, generateSoul } from "./soul";
export { parseMemory, generateMemory } from "./memory";
export { parseMemory, generateMemory, type ParsedMemory } from "./memory";
export {
NETWORK_MODES,
normalizeNetworkMode,
Expand Down
92 changes: 89 additions & 3 deletions platform/core/src/memory.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,92 @@
export function parseMemory(markdown: string): Record<string, any> {
// TODO: Parse memory markdown into structured data
return { raw: markdown };
export interface ParsedMemory {
/** Content under the "## User Profile" section. */
userProfile: string;
/** Content under the "## Key Facts" section. */
keyFacts: string;
/** Content under the "## Ongoing Context" section. */
ongoingContext: string;
/** Content between MANUAL:START and MANUAL:END markers. */
manualNotes: string;
/** The full raw markdown input, preserved for round-tripping. */
raw: string;
}

const TEMPLATE_PLACEHOLDERS: Readonly<Record<string, string>> = {
"User Profile": "*No user profile configured yet.*",
"Key Facts": "*No facts stored yet.*",
"Ongoing Context": "*No ongoing context.*",
};
const MANUAL_NOTES_PLACEHOLDER = "<!-- Add your own notes here - they will be preserved -->";

function normalizeSection(sectionName: string, content: string): string {
const trimmed = content.trim();
return trimmed === TEMPLATE_PLACEHOLDERS[sectionName] ? "" : trimmed;
}

function normalizeManualNotes(content: string): string {
const trimmed = content.trim();
return trimmed === MANUAL_NOTES_PLACEHOLDER ? "" : trimmed;
}

/**
* Parse a Signet memory markdown file into structured sections.
*
* Extracts content from well-known `## ` headings and the
* `<!-- MANUAL:START -->` / `<!-- MANUAL:END -->` block. Any content
* outside recognized sections is ignored — the `raw` field always
* contains the original markdown for lossless round-tripping.
*/
export function parseMemory(markdown: string): ParsedMemory {
const sections: Record<string, string> = {};
let currentSection: string | null = null;
let inManualBlock = false;
const sectionLines: string[] = [];

const flushSection = (): void => {
if (currentSection === null) {
return;
}
sections[currentSection] = normalizeSection(currentSection, sectionLines.join("\n"));
};

const lines = markdown.split("\n");
for (const line of lines) {
if (/^<!--\s*MANUAL:START\s*-->$/.test(line)) {
flushSection();
inManualBlock = true;
currentSection = null;
sectionLines.length = 0;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Manual-note content is still parsed for ## headings after MANUAL:START. Because currentSection is set to null but there is no inManualBlock guard until MANUAL:END, a user note like ## User Profile\nmanual text inside the manual block will be captured into sections["User Profile"] and can overwrite the real generated section. That violates the stated separation between manualNotes and the structured fields, and it can corrupt parsed memory data for perfectly reasonable markdown notes. The parser should skip all section parsing while inside the manual block.

continue;
}
if (/^<!--\s*MANUAL:END\s*-->$/.test(line)) {
inManualBlock = false;
continue;
}
if (inManualBlock) {
continue;
}

const headingMatch = line.match(/^##\s+(.+)/);
if (headingMatch) {
flushSection();
currentSection = headingMatch[1].trim();
sectionLines.length = 0;
} else if (currentSection !== null) {
sectionLines.push(line);
}
}
flushSection();

const manualMatch = markdown.match(/<!--\s*MANUAL:START\s*-->([\s\S]*?)<!--\s*MANUAL:END\s*-->/);
const manualNotes = manualMatch ? normalizeManualNotes(manualMatch[1]) : "";

return {
userProfile: sections["User Profile"] ?? "",
keyFacts: sections["Key Facts"] ?? "",
ongoingContext: sections["Ongoing Context"] ?? "",
manualNotes,
raw: markdown,
};
}

export function generateMemory(): string {
Expand Down
Loading