FEAT!(voice): Implement intent hydration, dehydration system fixes: #29

This commit is contained in:
2026-07-19 13:13:07 +05:30
parent 84bff92631
commit a4b620502a
38 changed files with 622 additions and 211 deletions

View File

@@ -0,0 +1,13 @@
{
"name": "@omnia/voice",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": "./dist/index.js"
},
"dependencies": {
"@omnia/core": "workspace:*",
"compromise": "^14.14.0"
}
}

View File

@@ -0,0 +1,146 @@
export interface Segment {
text: string;
isQuote: boolean;
}
/**
* Splits text into quote and non-quote segments.
*/
export function splitQuotes(text: string): Segment[] {
const segments: Segment[] = [];
let current = "";
let inQuote = false;
let quoteChar = "";
for (let i = 0; i < text.length; i++) {
const char = text[i];
if ((char === '"' || char === "'") && (!inQuote || char === quoteChar)) {
if (current) {
segments.push({ text: current, isQuote: inQuote });
current = "";
}
inQuote = !inQuote;
quoteChar = inQuote ? char : "";
} else {
current += char;
}
}
if (current) {
segments.push({ text: current, isQuote: inQuote });
}
return segments;
}
/**
* Transforms standard narrative prose from the source actor's perspective
* into a dehydrated canonical form with entity@<id>[original] placeholder tags.
*/
export function dehydrate(
content: string,
sourceId: string,
targetIds: string[],
aliasMap: Record<string, string>,
): string {
if (!content) return "";
const segments = splitQuotes(content);
const processedSegments = segments.map((seg) => {
if (seg.isQuote) {
return `"${seg.text}"`;
}
let text = seg.text;
// 1. Map lowercase aliases/names/IDs to IDs
const nameToId = new Map<string, string>();
// Add target IDs and source ID themselves
nameToId.set(sourceId.toLowerCase(), sourceId);
targetIds.forEach((id) => {
nameToId.set(id.toLowerCase(), id);
});
// Add entries from aliasMap (mapped lowercased)
Object.entries(aliasMap).forEach(([name, id]) => {
nameToId.set(name.toLowerCase(), id);
});
// Sort names by length descending to match longest name first
const sortedNames = Array.from(nameToId.keys()).sort(
(a, b) => b.length - a.length,
);
// Track state of matched target IDs for pronoun lookback
const matchedTargetIds: string[] = [];
// 2. Replace names and aliases with entity@<id>[name]
sortedNames.forEach((name) => {
const id = nameToId.get(name)!;
const escapedName = name.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&");
const regex = new RegExp(`\\b${escapedName}\\b`, "gi");
text = text.replace(regex, (matched) => {
if (id !== sourceId) {
matchedTargetIds.push(id);
}
return `entity@${id}[${matched}]`;
});
});
// 3. Replace first-person pronouns with source actor tag
const firstPersonPronouns = [
{ word: "i" },
{ word: "me" },
{ word: "my" },
{ word: "myself" },
{ word: "mine" },
{ word: "we" },
{ word: "us" },
{ word: "our" },
{ word: "ours" },
{ word: "ourselves" },
];
firstPersonPronouns.forEach(({ word }) => {
const regex = new RegExp(`\\b${word}\\b`, "gi");
text = text.replace(regex, (matched) => {
return `entity@${sourceId}[${matched}]`;
});
});
// 4. Replace third-person pronouns using state lookback
const thirdPersonPronouns = [
"he",
"him",
"his",
"himself",
"she",
"her",
"hers",
"herself",
"they",
"them",
"their",
"theirs",
"themselves",
];
thirdPersonPronouns.forEach((pronoun) => {
const regex = new RegExp(`\\b${pronoun}\\b`, "gi");
text = text.replace(regex, (matched) => {
const lastTargetId =
matchedTargetIds[matchedTargetIds.length - 1] || targetIds[0];
if (lastTargetId) {
return `entity@${lastTargetId}[${matched}]`;
}
return matched;
});
});
return text;
});
return processedSegments.join("");
}

View File

@@ -0,0 +1,236 @@
import nlp from "compromise";
import { Entity, WorldState, resolveAlias } from "@omnia/core";
import { splitQuotes } from "./dehydration.js";
/**
* Hydrates a dehydrated narration text containing entity@<id>[original] symbol tags
* into natural language from a specific viewer's perspective.
*/
export function hydrate(content: string, viewer: Entity): string {
if (!content) return "";
const segments = splitQuotes(content);
const processedSegments = segments.map((seg) => {
if (seg.isQuote) {
return `'${seg.text}'`;
}
// Match entity@<id>[original] and optionally the following space and word
const regex = /entity@([a-zA-Z0-9-]+)\[([^\]]+)\](?:\s+([a-zA-Z]+))?/g;
const firstPersonSet = new Set([
"i",
"me",
"my",
"myself",
"mine",
"we",
"us",
"our",
"ours",
]);
const thirdPersonSet = new Set([
"he",
"him",
"his",
"himself",
"she",
"her",
"hers",
"herself",
"they",
"them",
"their",
"theirs",
"themselves",
]);
return seg.text.replace(regex, (matchStr, id, original, followingWord) => {
const isSelf = id === viewer.id;
const lowerOriginal = original.toLowerCase();
let resolvedSubject = "";
let isThirdPersonSingular = false;
if (isSelf) {
if (["his", "her", "their", "my", "its", "our"].includes(lowerOriginal))
resolvedSubject = "my";
else if (["hers", "theirs", "mine", "ours"].includes(lowerOriginal))
resolvedSubject = "mine";
else if (
[
"himself",
"herself",
"themselves",
"myself",
"itself",
"ourselves",
].includes(lowerOriginal)
)
resolvedSubject = "myself";
else if (["he", "she", "they", "i", "we"].includes(lowerOriginal))
resolvedSubject = "I";
else if (["him", "her", "them", "me", "us"].includes(lowerOriginal))
resolvedSubject = "me";
else {
// Noun/alias mapped to self: check preceding/succeeding context
const matchIdx = seg.text.indexOf(matchStr);
const precedingText = seg.text.slice(0, matchIdx);
const prec = precedingText.trim();
const words = prec.split(/\s+/);
const lastWord = words[words.length - 1]?.toLowerCase() || "";
const prepositions = [
"to",
"with",
"for",
"at",
"by",
"from",
"in",
"on",
"about",
"between",
"of",
"under",
"over",
"behind",
"beside",
"through",
];
if (prepositions.includes(lastWord)) {
resolvedSubject = "me";
} else {
resolvedSubject = "I";
}
}
} else {
const alias = resolveAlias(viewer, id);
if (firstPersonSet.has(lowerOriginal)) {
if (["my", "our"].includes(lowerOriginal))
resolvedSubject = `${alias}'s`;
else if (["mine", "ours"].includes(lowerOriginal))
resolvedSubject = `${alias}'s`;
else if (["myself", "ourselves"].includes(lowerOriginal))
resolvedSubject = "himself";
else {
resolvedSubject = alias;
isThirdPersonSingular = true;
}
} else if (thirdPersonSet.has(lowerOriginal)) {
resolvedSubject = original;
if (["he", "she", "it"].includes(lowerOriginal)) {
isThirdPersonSingular = true;
}
} else {
resolvedSubject = alias;
isThirdPersonSingular = true;
}
}
if (followingWord) {
if (isThirdPersonSingular) {
const conj = nlp(followingWord).verbs().conjugate()[0] as any;
if (conj && conj.Infinitive === followingWord && conj.PresentTense) {
return `${resolvedSubject} ${conj.PresentTense}`;
}
}
return `${resolvedSubject} ${followingWord}`;
}
return resolvedSubject;
});
});
return processedSegments.join("");
}
/**
* Hydrates a dehydrated narration text containing entity@<id>[original] symbol tags
* into natural language from an objective world perspective.
*/
export function hydrateObjective(
content: string,
worldState: WorldState,
): string {
if (!content) return "";
const segments = splitQuotes(content);
const processedSegments = segments.map((seg) => {
if (seg.isQuote) {
return `'${seg.text}'`;
}
// Match entity@<id>[original] and optionally the following space and word
const regex = /entity@([a-zA-Z0-9-]+)\[([^\]]+)\](?:\s+([a-zA-Z]+))?/g;
const firstPersonSet = new Set([
"i",
"me",
"my",
"myself",
"mine",
"we",
"us",
"our",
"ours",
]);
const thirdPersonSet = new Set([
"he",
"him",
"his",
"himself",
"she",
"her",
"hers",
"herself",
"they",
"them",
"their",
"theirs",
"themselves",
]);
return seg.text.replace(regex, (matchStr, id, original, followingWord) => {
const entity = worldState.getEntity(id);
const name = entity?.attributes.get("name")?.getValue() || id;
const lowerOriginal = original.toLowerCase();
let resolvedSubject = "";
let isThirdPersonSingular = false;
if (firstPersonSet.has(lowerOriginal)) {
if (["my", "our"].includes(lowerOriginal))
resolvedSubject = `${name}'s`;
else if (["mine", "ours"].includes(lowerOriginal))
resolvedSubject = `${name}'s`;
else if (["myself", "ourselves"].includes(lowerOriginal))
resolvedSubject = "himself";
else {
resolvedSubject = name;
isThirdPersonSingular = true;
}
} else if (thirdPersonSet.has(lowerOriginal)) {
resolvedSubject = original;
if (["he", "she", "it"].includes(lowerOriginal)) {
isThirdPersonSingular = true;
}
} else {
resolvedSubject = name;
isThirdPersonSingular = true;
}
if (followingWord) {
if (isThirdPersonSingular) {
const conj = nlp(followingWord).verbs().conjugate()[0] as any;
if (conj && conj.Infinitive === followingWord && conj.PresentTense) {
return `${resolvedSubject} ${conj.PresentTense}`;
}
}
return `${resolvedSubject} ${followingWord}`;
}
return resolvedSubject;
});
});
return processedSegments.join("");
}

View File

@@ -0,0 +1,2 @@
export * from "./dehydration.js";
export * from "./hydration.js";

View File

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"],
"references": [{ "path": "../core" }]
}