import crypto from "node:crypto";
import fsSync from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { z } from "zod";
import { withFileLock } from "@kenkaiiii/gg-core";
export const JIWA_SOFT_LIMIT = 60;
export const JIWA_HARD_LIMIT = 90;
export const JIWA_TEXT_LIMIT = 600;
const JIWA_FILE_VERSION = 1;
const DUPLICATE_THRESHOLD = 0.8;
export const JIWA_CATEGORIES = [
    "identity",
    "voice",
    "interaction",
    "boundaries",
    "workflow",
    "other",
];
const categorySet = new Set(JIWA_CATEGORIES);
const duplicateStopWords = new Set(["a", "an", "and", "by", "for", "has", "is", "the", "to"]);
function validDate(value, fallback) {
    return typeof value === "string" && !Number.isNaN(Date.parse(value)) ? value : fallback;
}
function coerceEntry(value, now) {
    if (!value || typeof value !== "object")
        return null;
    const candidate = value;
    const text = typeof candidate.text === "string" ? candidate.text.trim().slice(0, JIWA_TEXT_LIMIT) : "";
    if (!text)
        return null;
    const createdAt = validDate(candidate.createdAt, now);
    return {
        id: typeof candidate.id === "string" && candidate.id ? candidate.id : crypto.randomUUID(),
        text,
        category: typeof candidate.category === "string" && categorySet.has(candidate.category)
            ? candidate.category
            : "other",
        importance: typeof candidate.importance === "number" && Number.isFinite(candidate.importance)
            ? Math.max(1, Math.min(5, Math.round(candidate.importance)))
            : 3,
        createdAt,
        updatedAt: validDate(candidate.updatedAt, createdAt),
    };
}
function tokenSet(text) {
    return new Set((text.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? []).filter((token) => !duplicateStopWords.has(token)));
}
function similarity(left, right) {
    const a = tokenSet(left);
    const b = tokenSet(right);
    if (a.size === 0 && b.size === 0)
        return 1;
    let intersection = 0;
    for (const token of a)
        if (b.has(token))
            intersection += 1;
    const uniqueTokens = a.size + b.size - intersection * 2;
    return intersection / (intersection + uniqueTokens * 3);
}
function findDuplicate(entries, text, excludeId) {
    return entries.find((entry) => entry.id !== excludeId && similarity(entry.text, text) >= DUPLICATE_THRESHOLD);
}
function findExactDuplicate(entries, text, excludeId) {
    return entries.find((entry) => entry.id !== excludeId && similarity(entry.text, text) === 1);
}
function enforceHardLimit(entries) {
    if (entries.length <= JIWA_HARD_LIMIT)
        return entries;
    const removable = entries
        .filter((entry) => entry.category !== "identity")
        .sort((left, right) => left.importance - right.importance ||
        Date.parse(left.updatedAt) - Date.parse(right.updatedAt));
    const removeCount = Math.min(entries.length - JIWA_HARD_LIMIT, removable.length);
    const removedIds = new Set(removable.slice(0, removeCount).map((entry) => entry.id));
    return entries.filter((entry) => !removedIds.has(entry.id));
}
function sortForDisplay(entries) {
    return [...entries].sort((left, right) => right.importance - left.importance ||
        Date.parse(right.updatedAt) - Date.parse(left.updatedAt));
}
export class JiwaStore {
    filePath;
    onChange;
    now;
    constructor(options = {}) {
        this.filePath = options.filePath ?? path.join(os.homedir(), ".gg", "chat-jiwa.json");
        this.onChange = options.onChange;
        this.now = options.now ?? (() => new Date());
    }
    async list() {
        return sortForDisplay((await this.readFile()).jiwa);
    }
    async snapshot() {
        return this.toSnapshot(await this.list());
    }
    async set(content, category = "other", importance = 3) {
        return this.mutate((entries) => {
            const text = this.normalizeText(content);
            const duplicateOf = findDuplicate(entries, text);
            if (duplicateOf)
                return { jiwa: entries, duplicateOf };
            const timestamp = this.now().toISOString();
            const entry = {
                id: crypto.randomUUID(),
                text,
                category,
                importance: this.normalizeImportance(importance),
                createdAt: timestamp,
                updatedAt: timestamp,
            };
            const next = enforceHardLimit([...entries, entry]);
            if (next.length > JIWA_HARD_LIMIT || !next.some((item) => item.id === entry.id)) {
                throw new Error("Jiwa limit is full of protected identity instructions; forget one before adding another.");
            }
            return { jiwa: next, entry };
        });
    }
    async update(id, content, category, importance, forgetIds = []) {
        return this.mutate((entries) => {
            const index = entries.findIndex((entry) => entry.id === id);
            if (index < 0)
                throw new Error(`Jiwa entry not found: ${id}`);
            const text = this.normalizeText(content);
            const forgetSet = new Set(forgetIds.filter((forgetId) => forgetId !== id));
            const remaining = entries.filter((entry) => !forgetSet.has(entry.id));
            const duplicateOf = findExactDuplicate(remaining, text, id);
            if (duplicateOf)
                return { jiwa: entries, duplicateOf };
            const current = entries[index];
            const entry = {
                ...current,
                text,
                category: category ?? current.category,
                importance: importance === undefined ? current.importance : this.normalizeImportance(importance),
                updatedAt: this.now().toISOString(),
            };
            const next = remaining.map((item) => (item.id === id ? entry : item));
            return {
                jiwa: next,
                entry,
                forgotten: entries.length - next.length,
            };
        });
    }
    async forget(id) {
        return this.mutate((entries) => {
            const exists = entries.some((entry) => entry.id === id);
            if (!exists)
                return { jiwa: entries, deleted: false };
            return { jiwa: entries.filter((entry) => entry.id !== id), deleted: true };
        });
    }
    renderForPrompt() {
        const entries = sortForDisplay(this.readFileSync().jiwa);
        if (entries.length === 0) {
            return "# Jiwa\nNo persistent behavior instructions are stored yet. Use set_jiwa only for durable user preferences about how chat agents should act.";
        }
        const lines = [
            "# Jiwa",
            "These user-established instructions define how chat agents should act across sessions. Follow them as active user preferences unless a newer user request or higher-priority instruction conflicts.",
        ];
        for (const category of JIWA_CATEGORIES) {
            const grouped = entries.filter((entry) => entry.category === category);
            if (grouped.length === 0)
                continue;
            lines.push(`\n## ${category}`);
            for (const entry of grouped) {
                lines.push(`- [${entry.id}] (importance ${entry.importance}) ${entry.text}`);
            }
        }
        if (entries.length >= JIWA_SOFT_LIMIT) {
            lines.push(`\nJiwa has reached ${entries.length}/${JIWA_HARD_LIMIT}. Consolidate related instructions with update_jiwa and forget stale or redundant entries with forget_jiwa.`);
        }
        return lines.join("\n");
    }
    async mutate(apply) {
        await fs.mkdir(path.dirname(this.filePath), { recursive: true });
        const { result, changed } = await withFileLock(this.filePath, async () => {
            const current = await this.readFile();
            const mutation = apply(current.jiwa);
            const changed = mutation.jiwa !== current.jiwa;
            if (changed)
                await this.writeFile(mutation.jiwa);
            return {
                result: { ...mutation, jiwa: sortForDisplay(mutation.jiwa) },
                changed,
            };
        });
        if (changed)
            await this.onChange?.(this.toSnapshot(result.jiwa));
        return result;
    }
    normalizeText(content) {
        const text = content.trim();
        if (!text)
            throw new Error("Jiwa instruction cannot be empty.");
        if (text.length > JIWA_TEXT_LIMIT) {
            throw new Error(`Jiwa instruction must be ${JIWA_TEXT_LIMIT} characters or fewer.`);
        }
        return text;
    }
    normalizeImportance(importance) {
        if (!Number.isFinite(importance))
            return 3;
        return Math.max(1, Math.min(5, Math.round(importance)));
    }
    toSnapshot(jiwa) {
        return { jiwa, softLimit: JIWA_SOFT_LIMIT, hardLimit: JIWA_HARD_LIMIT };
    }
    parse(raw) {
        const value = JSON.parse(raw);
        const now = this.now().toISOString();
        const candidates = value && typeof value === "object" && Array.isArray(value.jiwa)
            ? value.jiwa
            : [];
        return {
            version: JIWA_FILE_VERSION,
            jiwa: enforceHardLimit(candidates
                .map((candidate) => coerceEntry(candidate, now))
                .filter((item) => item !== null)),
        };
    }
    async readFile() {
        try {
            return this.parse(await fs.readFile(this.filePath, "utf8"));
        }
        catch (error) {
            if (error.code === "ENOENT")
                return { version: JIWA_FILE_VERSION, jiwa: [] };
            try {
                return this.parse(await fs.readFile(`${this.filePath}.bak`, "utf8"));
            }
            catch {
                return { version: JIWA_FILE_VERSION, jiwa: [] };
            }
        }
    }
    readFileSync() {
        try {
            return this.parse(requireFileSync(this.filePath));
        }
        catch {
            try {
                return this.parse(requireFileSync(`${this.filePath}.bak`));
            }
            catch {
                return { version: JIWA_FILE_VERSION, jiwa: [] };
            }
        }
    }
    async writeFile(jiwa) {
        await fs.mkdir(path.dirname(this.filePath), { recursive: true });
        const tempPath = `${this.filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
        try {
            const existing = await fs.readFile(this.filePath, "utf8").catch(() => null);
            if (existing !== null) {
                try {
                    this.parse(existing);
                    await fs.writeFile(`${this.filePath}.bak`, existing, "utf8");
                }
                catch {
                    // Keep the last known-good backup when the primary is malformed.
                }
            }
            await fs.writeFile(tempPath, `${JSON.stringify({ version: JIWA_FILE_VERSION, jiwa }, null, 2)}\n`, "utf8");
            await fs.rename(tempPath, this.filePath);
        }
        finally {
            await fs.unlink(tempPath).catch(() => { });
        }
    }
}
function requireFileSync(filePath) {
    return fsSync.readFileSync(filePath, "utf8");
}
const setJiwaParameters = z.object({
    content: z
        .string()
        .min(1)
        .max(JIWA_TEXT_LIMIT)
        .describe("One concise, self-contained instruction for how chat agents should act"),
    category: z.enum(JIWA_CATEGORIES).optional().describe("Kind of behavior instruction"),
    importance: z.number().int().min(1).max(5).optional().describe("Ongoing importance from 1 to 5"),
});
const updateJiwaParameters = z.object({
    id: z.string().min(1).describe("Jiwa entry ID shown in the injected Jiwa block"),
    content: z.string().min(1).max(JIWA_TEXT_LIMIT).describe("Replacement behavior instruction"),
    category: z.enum(JIWA_CATEGORIES).optional(),
    importance: z.number().int().min(1).max(5).optional(),
    forget_ids: z
        .array(z.string().min(1))
        .max(JIWA_HARD_LIMIT)
        .optional()
        .describe("Redundant Jiwa entry IDs to delete atomically after merging them into this update"),
});
const forgetJiwaParameters = z.object({
    id: z.string().min(1).describe("Jiwa entry ID shown in the injected Jiwa block"),
});
function jiwaCount(count, verb) {
    return `${count} Jiwa ${count === 1 ? "entry" : "entries"} ${verb}`;
}
export function buildJiwaTools(store) {
    return [
        {
            name: "set_jiwa",
            description: "Save one durable user instruction about how chat agents should act, speak, identify themselves, work, or what they should avoid.",
            parameters: setJiwaParameters,
            executionMode: "sequential",
            async execute(args) {
                const { content, category, importance } = setJiwaParameters.parse(args);
                const result = await store.set(content, category, importance);
                if (result.duplicateOf) {
                    return `Near-duplicate Jiwa entry already exists as ${result.duplicateOf.id}. Use update_jiwa if that instruction changed.`;
                }
                return `Set Jiwa entry ${result.entry.id}. ${jiwaCount(result.jiwa.length, "stored")}.`;
            },
        },
        {
            name: "update_jiwa",
            description: "Replace or correct an existing Jiwa instruction. To consolidate, merge related instructions into content and pass their redundant IDs in forget_ids.",
            parameters: updateJiwaParameters,
            executionMode: "sequential",
            async execute(args) {
                const { id, content, category, importance, forget_ids } = updateJiwaParameters.parse(args);
                const result = await store.update(id, content, category, importance, forget_ids);
                if (result.duplicateOf) {
                    return `That would duplicate Jiwa entry ${result.duplicateOf.id}. Update or forget the redundant entry instead.`;
                }
                const cleanup = result.forgotten
                    ? ` Consolidated ${result.forgotten} redundant ${result.forgotten === 1 ? "entry" : "entries"}.`
                    : "";
                return `Updated Jiwa entry ${id}.${cleanup} ${jiwaCount(result.jiwa.length, "stored")}.`;
            },
        },
        {
            name: "forget_jiwa",
            description: "Delete one stale, wrong, redundant, or explicitly unwanted Jiwa instruction.",
            parameters: forgetJiwaParameters,
            executionMode: "sequential",
            async execute(args) {
                const { id } = forgetJiwaParameters.parse(args);
                const result = await store.forget(id);
                return result.deleted
                    ? `Forgot Jiwa entry ${id}. ${jiwaCount(result.jiwa.length, "remain")}.`
                    : `Jiwa entry ${id} was not found. ${jiwaCount(result.jiwa.length, "remain")}.`;
            },
        },
    ];
}
//# sourceMappingURL=jiwa.js.map