mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-11 21:36:11 +00:00
226 lines
6.9 KiB
TypeScript
226 lines
6.9 KiB
TypeScript
/**
|
|
* IDENTITY.md parsing and writing support.
|
|
* The parser accepts human-authored markdown, while the writer only updates
|
|
* stable rich identity fields.
|
|
*/
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
|
|
import { DEFAULT_IDENTITY_FILENAME } from "./workspace.js";
|
|
|
|
/** Parsed rich identity values from a workspace `IDENTITY.md` file. */
|
|
export type AgentIdentityFile = {
|
|
name?: string;
|
|
emoji?: string;
|
|
theme?: string;
|
|
creature?: string;
|
|
vibe?: string;
|
|
avatar?: string;
|
|
};
|
|
|
|
const WRITABLE_IDENTITY_FIELDS = [
|
|
["name", "Name"],
|
|
["theme", "Theme"],
|
|
["emoji", "Emoji"],
|
|
["avatar", "Avatar"],
|
|
] as const satisfies ReadonlyArray<readonly [keyof AgentIdentityFile, string]>;
|
|
|
|
const RICH_IDENTITY_LABELS = new Set(["name", "creature", "vibe", "theme", "emoji", "avatar"]);
|
|
|
|
const IDENTITY_PLACEHOLDER_VALUES = new Set([
|
|
"pick something you like",
|
|
"ai? robot? familiar? ghost in the machine? something weirder?",
|
|
"how do you come across? sharp? warm? chaotic? calm?",
|
|
"your signature - pick one that feels right",
|
|
"workspace-relative path, http(s) url, or data uri",
|
|
]);
|
|
|
|
function normalizeIdentityValue(value: string): string {
|
|
// Normalize markdown decoration and punctuation so generated template
|
|
// placeholders do not accidentally become real identity values.
|
|
let normalized = value.trim();
|
|
normalized = normalized.replace(/^[*_`\s]+|[*_`\s]+$/g, "").trim();
|
|
if (normalized.startsWith("(") && normalized.endsWith(")")) {
|
|
normalized = normalized.slice(1, -1).trim();
|
|
}
|
|
normalized = normalized.replace(/[\u2013\u2014]/g, "-");
|
|
return normalizeLowercaseStringOrEmpty(normalized.replace(/\s+/g, " "));
|
|
}
|
|
|
|
function normalizeIdentityLabel(label: string): string {
|
|
return normalizeLowercaseStringOrEmpty(label.replace(/[*_`]/g, ""));
|
|
}
|
|
|
|
function isIdentityPlaceholder(value: string): boolean {
|
|
const normalized = normalizeIdentityValue(value);
|
|
return IDENTITY_PLACEHOLDER_VALUES.has(normalized);
|
|
}
|
|
|
|
/** Parse rich identity fields from human-authored markdown content. */
|
|
export function parseIdentityMarkdown(content: string): AgentIdentityFile {
|
|
const identity: AgentIdentityFile = {};
|
|
const lines = content.split(/\r?\n/);
|
|
for (const line of lines) {
|
|
const cleaned = line.trim().replace(/^\s*-\s*/, "");
|
|
const colonIndex = cleaned.indexOf(":");
|
|
if (colonIndex === -1) {
|
|
continue;
|
|
}
|
|
const label = normalizeIdentityLabel(cleaned.slice(0, colonIndex));
|
|
const value = cleaned
|
|
.slice(colonIndex + 1)
|
|
.replace(/^[*_`\s]+|[*_`\s]+$/g, "")
|
|
.trim();
|
|
if (!value) {
|
|
continue;
|
|
}
|
|
if (isIdentityPlaceholder(value)) {
|
|
continue;
|
|
}
|
|
if (label === "name") {
|
|
identity.name = value;
|
|
}
|
|
if (label === "emoji") {
|
|
identity.emoji = value;
|
|
}
|
|
if (label === "creature") {
|
|
identity.creature = value;
|
|
}
|
|
if (label === "vibe") {
|
|
identity.vibe = value;
|
|
}
|
|
if (label === "theme") {
|
|
identity.theme = value;
|
|
}
|
|
if (label === "avatar") {
|
|
identity.avatar = value;
|
|
}
|
|
}
|
|
return identity;
|
|
}
|
|
|
|
/** Return true when the parsed identity has any meaningful user-supplied value. */
|
|
export function identityHasValues(identity: AgentIdentityFile): boolean {
|
|
return Boolean(
|
|
identity.name ||
|
|
identity.emoji ||
|
|
identity.theme ||
|
|
identity.creature ||
|
|
identity.vibe ||
|
|
identity.avatar,
|
|
);
|
|
}
|
|
|
|
function buildIdentityLine(label: string, value: string): string {
|
|
return `- ${label}: ${value}`;
|
|
}
|
|
|
|
function matchesIdentityLabel(line: string, label: string): boolean {
|
|
const trimmed = line.trim();
|
|
if (!trimmed.startsWith("-")) {
|
|
return false;
|
|
}
|
|
const cleaned = trimmed.replace(/^\s*-\s*/, "");
|
|
const colonIndex = cleaned.indexOf(":");
|
|
if (colonIndex === -1) {
|
|
return false;
|
|
}
|
|
return normalizeIdentityLabel(cleaned.slice(0, colonIndex)) === normalizeIdentityLabel(label);
|
|
}
|
|
|
|
function normalizeIdentityContent(content: string | undefined): string[] {
|
|
if (!content) {
|
|
return [];
|
|
}
|
|
return content.replace(/\r\n/g, "\n").split("\n");
|
|
}
|
|
|
|
function resolveIdentityInsertIndex(lines: string[]): number {
|
|
// New fields stay grouped with existing rich identity fields; otherwise place
|
|
// them directly after the title block so legacy prose remains intact.
|
|
let lastIdentityIndex = -1;
|
|
for (const [index, line] of lines.entries()) {
|
|
const cleaned = line.trim().replace(/^\s*-\s*/, "");
|
|
const colonIndex = cleaned.indexOf(":");
|
|
if (colonIndex === -1) {
|
|
continue;
|
|
}
|
|
const label = normalizeIdentityLabel(cleaned.slice(0, colonIndex));
|
|
if (RICH_IDENTITY_LABELS.has(label)) {
|
|
lastIdentityIndex = index;
|
|
}
|
|
}
|
|
if (lastIdentityIndex >= 0) {
|
|
return lastIdentityIndex + 1;
|
|
}
|
|
|
|
const headingIndex = lines.findIndex((line) => line.trim().startsWith("#"));
|
|
if (headingIndex === -1) {
|
|
return 0;
|
|
}
|
|
let insertIndex = headingIndex + 1;
|
|
while (insertIndex < lines.length && lines[insertIndex]?.trim() === "") {
|
|
insertIndex += 1;
|
|
}
|
|
return insertIndex;
|
|
}
|
|
|
|
/**
|
|
* Merge writable identity fields into existing IDENTITY.md content, replacing
|
|
* duplicate labels and preserving unrelated markdown.
|
|
*/
|
|
export function mergeIdentityMarkdownContent(
|
|
content: string | undefined,
|
|
identity: Pick<AgentIdentityFile, "name" | "theme" | "emoji" | "avatar">,
|
|
): string {
|
|
const lines = normalizeIdentityContent(content);
|
|
const nextLines = lines.length > 0 ? [...lines] : ["# IDENTITY.md - Agent Identity", ""];
|
|
|
|
for (const [field, label] of WRITABLE_IDENTITY_FIELDS) {
|
|
const value = identity[field]?.trim();
|
|
if (!value) {
|
|
continue;
|
|
}
|
|
|
|
const matchingIndexes = nextLines.reduce<number[]>((indexes, line, index) => {
|
|
if (matchesIdentityLabel(line, label)) {
|
|
indexes.push(index);
|
|
}
|
|
return indexes;
|
|
}, []);
|
|
|
|
if (matchingIndexes.length > 0) {
|
|
const [firstIndex, ...duplicateIndexes] = matchingIndexes;
|
|
nextLines[firstIndex] = buildIdentityLine(label, value);
|
|
for (const duplicateIndex of duplicateIndexes.toReversed()) {
|
|
nextLines.splice(duplicateIndex, 1);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const insertIndex = resolveIdentityInsertIndex(nextLines);
|
|
nextLines.splice(insertIndex, 0, buildIdentityLine(label, value));
|
|
}
|
|
|
|
return nextLines.join("\n").replace(/\n*$/, "\n");
|
|
}
|
|
|
|
function loadIdentityFromFile(identityPath: string): AgentIdentityFile | null {
|
|
try {
|
|
const content = fs.readFileSync(identityPath, "utf-8");
|
|
const parsed = parseIdentityMarkdown(content);
|
|
if (!identityHasValues(parsed)) {
|
|
return null;
|
|
}
|
|
return parsed;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Load the workspace identity file when it exists and contains real values. */
|
|
export function loadAgentIdentityFromWorkspace(workspace: string): AgentIdentityFile | null {
|
|
const identityPath = path.join(workspace, DEFAULT_IDENTITY_FILENAME);
|
|
return loadIdentityFromFile(identityPath);
|
|
}
|