@adam0white/human-framework 2.17.0 → 2.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -476,3 +476,56 @@ Narration remains unchanged unless requested. Set `person.lexicon.variety`,
476
476
  `perceive` adds a third `{ reputationTraits }` argument. See the architecture's
477
477
  [narration and social host options](../../docs/framework.md#narration-and-social-host-options-opt-in)
478
478
  for cause-aware reviews and actor-addressed recall. These options change no default continuation.
479
+
480
+ ### Bounded chapter clock (2.18.0, opt-in)
481
+
482
+ `createChapterClock(seed, nowMs)` and `advanceChapterClock(clock, nowMs)` are pure helpers for hosts that author coarse between-chapter life. They do not step a `Person`. The clock quantizes a supplied integer millisecond value into real-hour slots. A return processes at most the newest24 slots and reports the omitted count. Its watermark never moves backward. Repeating the same return produces no new slots. Each retained slot's draw depends only on its absolute slot and pinned seed.
483
+
484
+ ```ts
485
+ const clock = createChapterClock(23, 0);
486
+ const returnInput = advanceChapterClock(clock, 48 * 3_600_000);
487
+ // returnInput.slots.length === 24; returnInput.skipped === 24
488
+ // Pin clock, returned draws and the admitted initial host state in the replay.
489
+ ```
490
+
491
+ The host owns elapsed-time pacing, narrative interpretation, storage, consent and persona admission. A slot is an opportunity for coarse fiction. It is not proof of a lived day, settled duty, completed project or recovered body. Existing actor state and engine2.17.0 continuation remain unchanged unless the host explicitly uses the helper.
492
+
493
+
494
+ ## Plain English (2.19.0)
495
+
496
+ Set `lexicon: { plainEnglish: true }` in `PersonSpec` for new people. Change an
497
+ existing person's choice only through an explicit recorded host action. Do not
498
+ overwrite a loaded run's saved choice. Built-in decision narration,
499
+ spoken replies, intentions and person summaries then use complete, literal English.
500
+ Keep this choice in the saved person and replay setup. It changes recorded prose.
501
+ Without the option, legacy wording and continuation stay exact. Engine2.17.0 remains
502
+ unchanged: this option changes presentation, not decisions or save structure.
503
+
504
+ Host `lines`, `extend`, names and action forms still apply. A host phrase pack owns
505
+ its own grammar. HF does not translate arbitrary host labels or memory summaries.
506
+ Short words used inside templates remain phrases, so they compose grammatically.
507
+ Pass the same lexicon to renderers without a Person, including chronicle renderers
508
+ and label/reason readers.
509
+ Use authored `actions` forms for domain actions that English cannot inflect from an id.
510
+
511
+ Run `node packages/human/examples/plain-narration-control.ts` to compare legacy and
512
+ plain replies without advancing the person's state or RNG.
513
+
514
+ `labelLine(label, lexicon)` and `commitmentLabel(commitment, lexicon)` explain built-in
515
+ prayer and purpose labels without changing stored identity keys. `normLabel(norm, lexicon)`
516
+ explains selected built-in norm labels. `reasonLine(code, lexicon)` gives a sentence
517
+ for a known reason; an unknown plain reason returns an empty string so a host can
518
+ supply its own explanation. Without the option, each reader returns the legacy text.
519
+
520
+ `promptingLine(..., { plainEnglish: true })` supports direct calls.
521
+ `promptingsReading`, `reviewLine` and `bodyForecast` read the saved person's choice.
522
+ A person’s held interpretation of a thought stays distinct from an observed response.
523
+ Neither wording nor a missed-duty explanation changes obligations or excuses.
524
+
525
+ For domain-specific conversation facts, supply `converse`'s `phrase` callback.
526
+ HF cannot infer the English meaning of an opaque proposition id. Without authored
527
+ wording, the generic fact sentence still includes that id so the claim is not hidden.
528
+
529
+ Stock village outcomes, birth/marriage memories, routine fallbacks and generated
530
+ community log details use the same per-person option. World-supplied outcome
531
+ summaries and routine summaries remain exact.
@@ -1,3 +1,50 @@
1
+ /**
2
+ * SCOPE: The agenda holds a person's commitments (promises, duties, appointments, worship windows, jobs)
3
+ * and goals, turns them into utility terms, closes them when kept or broken, recurs periodic ones, and
4
+ * proposes spontaneous purposes when a psychological need goes unserved. Shapes borrowed: deadline
5
+ * pressure rising toward the end of a window (temporal-motivation style, monotone and saturating, not
6
+ * hyperbolic-discounting fitted values), SDT-style needs (belonging, competence, meaning) as the sources
7
+ * of spontaneous goals. Breaking a commitment is reported to the composite, which routes any linked norm
8
+ * through conscience; the agenda itself does not judge. It does not plan multi-step routes, model
9
+ * implementation intentions, or claim calibrated procrastination curves.
10
+ *
11
+ * SCOPE (abstention, lane agenda+conscience): an `abstain` commitment (a fast, e.g. "no work after the evening prayer") is kept by
12
+ * not completing any `violatedBy` action whose span overlaps the window, and broken by the first one that does.
13
+ * While open it pushes against violating options with a constant negative term (importance x held conviction);
14
+ * it does not rise with deadline pressure, because the cost of breaking does not grow toward the end. Shape:
15
+ * self-imposed restraint as a standing cost on the tempting option (precommitment, cf. Ariely & Wertenbroch 2002
16
+ * for the qualitative effect only; no fitted values). Interrupted outcomes do not break it (hosts report a
17
+ * partial meal as completed if it should). Exemptions (illness, travel) come from the norm catalog's
18
+ * `exemptions` with their provenance, release that day's instance and record an owed make-up; they are not the
19
+ * necessity exception of Qur'an 2:173. The illness threshold is an engineering stand-in for the person's own
20
+ * judgment that they are ill; the fidya provision of 2:184 and any expiation (kaffara) for deliberate breaking are
21
+ * not modelled (kaffara is disputed: research/fasting-sources.md §1). A deliberately broken obligatory fast is a
22
+ * breach and also leaves a make-up owed (1.7.0; research/decisions.md, fasting-sources.md §1: the day is made up).
23
+ * A break under necessity is excused with a make-up owed (see the necessity-break SCOPE below).
24
+ *
25
+ * SCOPE (missed duties and make-up debt, engine 1.7.0; research/decisions.md, research/capacity-and-excuse-sources.md):
26
+ * the sources keep blame (a breach) apart from debt (qada) (capacity-and-excuse-sources.md §0). When a `worship`
27
+ * window linked to a norm the person holds as obligatory closes unkept, or an obligatory `abstain` is broken, an
28
+ * `OwedMakeUp` is recorded in `agenda.owed` whatever the cause; what the cause decides is the blame:
29
+ * - Asleep throughout (§4: sleep lifts blame, not the debt): the window opened while the person was already
30
+ * asleep and they woke no earlier than `wakeGrace` minutes before it closed (read from `body.since` and
31
+ * `body.lastSleep`). Sleep begun inside the open window is not excused: whether the sleeper expected to wake is
32
+ * not represented, so the framework takes the stricter reading.
33
+ * - Downed when it closed (§2, unconsciousness; `body.downed`, `body.lastDowned`): blame lifted, debt owed, but
34
+ * once more than `lapseWaiver` (5) windows have closed within one downing, that stretch's debt drops and no more
35
+ * is added (the Hanafi count by prayer times, kept as the default by decisions.md; Maliki and Shafi'i drop it
36
+ * sooner, Hanbali never). Downing is involuntary, so going down after the window opened still excuses it.
37
+ * - Excused instances close `released` with `exempt.reason` 'sleep' or 'unconscious', so the composite books no
38
+ * breach, distress or esteem cost; the chain recurs as for any exemption.
39
+ * - A mental break (`affect/crisis.ts`, reason intact) is not an excuse (§6: the test is retained capacity, and
40
+ * motivation is irrelevant): the window closes broken, with its breach, and the debt is owed (reason 'missed').
41
+ * - Forgetting lifts blame in the sources (§4) but the framework has no state for having forgotten a duty, and no
42
+ * state for loss of reason (junun, §1) or intoxication (§3); none of them is detected. Coercion and commands are
43
+ * not excuses here (§7).
44
+ * A make-up commitment (`makeUpOf`, see `scheduleMakeUp`) that closes unkept, or is broken, carries no blame: qada
45
+ * has no fixed time in the sources, so it closes `released` and its debt is open again for the host to reschedule.
46
+ * The framework never computes whether a prayer or a make-up is accepted.
47
+ */
1
48
  import type { Affordance, AgendaState, Commitment, Episode, Goal, Minute, MissedExcuse, NeedId, NeedReading, NormDefinition, Outcome, OwedMakeUp, Person, PersonId, PsychologicalNeed, Term } from '../types.ts';
2
49
  export interface GoalTemplate {
3
50
  label: string;
@@ -49,6 +49,7 @@
49
49
  import { DEFAULT_NORMS } from '../conscience/catalog.js';
50
50
  import { clamp01, dayOf, dpow, hasNumbers, isNum, isObj } from '../core/index.js';
51
51
  import { expectedEffect } from '../memory/index.js';
52
+ import { labelLine } from '../presentation.js';
52
53
  import { diminishesProject } from '../projects/index.js';
53
54
  import { MINUTES_PER_DAY } from '../types.js';
54
55
  export const AGENDA_DEFAULTS = {
@@ -1015,7 +1016,9 @@ export function revisePurposes(p, now) {
1015
1016
  kind: 'abandoned',
1016
1017
  actorId: p.id,
1017
1018
  valence: -0.3 * g.baseImportance,
1018
- summary: `gave up on ${g.label}`,
1019
+ summary: p.lexicon?.plainEnglish
1020
+ ? `I stopped trying to reach this goal: ${labelLine(g.label, p.lexicon)}.`
1021
+ : `gave up on ${g.label}`,
1019
1022
  tags: ['abandoned', 'goal', g.label, ...g.serves],
1020
1023
  });
1021
1024
  }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Scope: pure, bounded host chapter scheduling from a supplied real-time clock. A slot is a coarse
3
+ * narrative opportunity, not a lived day. This helper touches no Person, resolves no obligations,
4
+ * and performs no body recovery. Hosts own fiction, persistence, admission and consent.
5
+ */
6
+ export declare const CHAPTER_CLOCK: {
7
+ readonly version: 'hf-chapter-clock/1';
8
+ readonly quantumMs: 3600000;
9
+ readonly maxSlots: 24;
10
+ };
11
+ export interface ChapterClock {
12
+ version: 'hf-chapter-clock/1';
13
+ watermark: number;
14
+ seed: number;
15
+ }
16
+ export interface ChapterAdvance {
17
+ clock: ChapterClock;
18
+ slots: {
19
+ at: number;
20
+ draw: number;
21
+ }[];
22
+ skipped: number;
23
+ }
24
+ export declare function createChapterClock(seed: number, nowMs: number): ChapterClock;
25
+ /** Stateless seeded draw per absolute slot: splitting returns does not alter a retained slot's result. */
26
+ export declare function advanceChapterClock(clock: ChapterClock, nowMs: number): ChapterAdvance;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Scope: pure, bounded host chapter scheduling from a supplied real-time clock. A slot is a coarse
3
+ * narrative opportunity, not a lived day. This helper touches no Person, resolves no obligations,
4
+ * and performs no body recovery. Hosts own fiction, persistence, admission and consent.
5
+ */
6
+ export const CHAPTER_CLOCK = { version: 'hf-chapter-clock/1', quantumMs: 3_600_000, maxSlots: 24 };
7
+ function slot(nowMs) {
8
+ if (!Number.isSafeInteger(nowMs) || nowMs < 0)
9
+ throw new Error('Invalid chapter clock.');
10
+ return Math.floor(nowMs / CHAPTER_CLOCK.quantumMs);
11
+ }
12
+ export function createChapterClock(seed, nowMs) {
13
+ if (!Number.isInteger(seed) || seed < 0 || seed > 0xffffffff)
14
+ throw new Error('Invalid chapter seed.');
15
+ return { version: CHAPTER_CLOCK.version, watermark: slot(nowMs), seed };
16
+ }
17
+ /** Stateless seeded draw per absolute slot: splitting returns does not alter a retained slot's result. */
18
+ export function advanceChapterClock(clock, nowMs) {
19
+ if (clock.version !== CHAPTER_CLOCK.version ||
20
+ !Number.isSafeInteger(clock.watermark) ||
21
+ clock.watermark < 0 ||
22
+ !Number.isInteger(clock.seed) ||
23
+ clock.seed < 0 ||
24
+ clock.seed > 0xffffffff)
25
+ throw new Error('Unsupported chapter clock.');
26
+ const end = Math.max(clock.watermark, slot(nowMs));
27
+ const count = Math.min(CHAPTER_CLOCK.maxSlots, end - clock.watermark);
28
+ const slots = Array.from({ length: count }, (_, i) => {
29
+ const at = end - count + i + 1;
30
+ let h = (clock.seed ^ (at >>> 0) ^ Math.floor(at / 0x100000000)) >>> 0;
31
+ h = Math.imul(h ^ (h >>> 16), 0x21f0aaad) >>> 0;
32
+ h = Math.imul(h ^ (h >>> 15), 0x735a2d97) >>> 0;
33
+ return { at, draw: ((h ^ (h >>> 15)) >>> 0) / 0x100000000 };
34
+ });
35
+ return { clock: { ...clock, watermark: end }, slots, skipped: end - clock.watermark - count };
36
+ }
@@ -8,6 +8,7 @@
8
8
  * activity. Lines report behaviour and stated relationships; they never grade devotion, worth or acceptance.
9
9
  */
10
10
  import { actionForms, capitalize, displayName, nameOf, phraseLine } from '../narrate/lexicon.js';
11
+ import { labelLine } from '../presentation.js';
11
12
  /** Thresholds for `diffChronicle` (share of days). */
12
13
  export const STORY_DEFAULTS = {
13
14
  /** An item done unprompted on at least this share of days counts as done "without being told". */
@@ -97,7 +98,7 @@ function worshipActions(records) {
97
98
  }
98
99
  function formsOf(item, lex) {
99
100
  if (item.startsWith('prayer:')) {
100
- const label = item.slice('prayer:'.length);
101
+ const label = labelLine(item.slice('prayer:'.length), lex);
101
102
  return {
102
103
  base: phraseLine('prayer.base', { label }, lex),
103
104
  past: phraseLine('prayer.past', { label }, lex),
@@ -138,7 +139,7 @@ export function narrateChronicle(chronicle, opts = {}) {
138
139
  ? 'chronicle.prayer.one'
139
140
  : 'chronicle.prayer.all'
140
141
  : 'chronicle.prayer';
141
- raw.push({ key, vars: { label, n, days } });
142
+ raw.push({ key, vars: { label: labelLine(label, lex), n, days } });
142
143
  }
143
144
  // Commitments kept by kind (worship is covered above).
144
145
  const kept = {};
@@ -244,9 +244,13 @@ export function converse(speaker, listener, ctx) {
244
244
  actorId: speaker.id,
245
245
  salience: round(0.3 + 0.4 * c.confidence),
246
246
  claims: [{ prop: c.prop, value: c.value, confidence: c.confidence }],
247
- summary: c.about !== undefined
248
- ? `${nameOf(speaker)} says ${c.about} is ${c.value ? '' : 'not '}${c.prop.split(':')[1] ?? ''}`
249
- : `${nameOf(speaker)} says ${c.prop} is ${c.value ? 'so' : 'not so'}`,
247
+ summary: speaker.lexicon?.plainEnglish
248
+ ? c.about !== undefined
249
+ ? `${nameOf(speaker)} says ${speaker.lexicon.names?.[c.about] ?? c.about} ${c.prop.split(':')[1] === 'owes' ? `${c.value ? 'owes' : 'does not owe'} money to ${speaker.lexicon.names?.[c.prop.split(':').slice(2).join(':')] ?? c.prop.split(':').slice(2).join(':')}` : `is ${c.value ? '' : 'not '}${c.prop.split(':')[1] ?? ''}`}.`
250
+ : `${nameOf(speaker)} says this is ${c.value ? 'true' : 'false'}: ${c.prop}.`
251
+ : c.about !== undefined
252
+ ? `${nameOf(speaker)} says ${c.about} is ${c.value ? '' : 'not '}${c.prop.split(':')[1] ?? ''}`
253
+ : `${nameOf(speaker)} says ${c.prop} is ${c.value ? 'so' : 'not so'}`,
250
254
  };
251
255
  if (ctx.phrase)
252
256
  pc.summary = ctx.phrase(Object.freeze(structuredClone(c)), pc.summary ?? '');
@@ -293,7 +297,9 @@ export function converse(speaker, listener, ctx) {
293
297
  valence: 0.1,
294
298
  salience: round(0.4 + 0.3 * a.strength),
295
299
  claims: [{ prop: `advice:${a.action}`, value: true, confidence: round(a.cred) }],
296
- summary: `${nameOf(speaker)} says I should ${a.action}`,
300
+ summary: speaker.lexicon?.plainEnglish
301
+ ? `${nameOf(speaker)} suggests this action: ${a.action.replace(/[-_]/g, ' ')}.`
302
+ : `${nameOf(speaker)} says I should ${a.action}`,
297
303
  };
298
304
  if (ctx.provenance) {
299
305
  const meta = beliefs.find((b) => b.prop === `${prefix}${a.action}`)?.provenance;
@@ -215,7 +215,7 @@ export function deliver(mother, child, at = mother.now) {
215
215
  targetId: child.id,
216
216
  valence: 0.9,
217
217
  salience: 0.95,
218
- summary: 'gave birth',
218
+ summary: mother.lexicon?.plainEnglish ? 'I gave birth.' : 'gave birth',
219
219
  tags: ['birth', 'family'],
220
220
  });
221
221
  return { id: child.id, name: child.name ?? child.id, seed: pg.seed, bornAt: at, fatherId: pg.fatherId };
package/dist/index.d.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  /** Human Framework public surface. */
2
- export declare const FRAMEWORK_VERSION = "2.17.0";
2
+ export declare const FRAMEWORK_VERSION = "2.19.0";
3
3
  export type { CrisisEvent, PracticeKind } from './affect/index.ts';
4
4
  export { AFFECT_DEFAULTS, actionTendencies, advanceAffect, appraise, breakAllows, breakBehaviour, breakHazard, CRISIS_DEFAULTS, checkCrisis, createAffect, easeBreak, emotionLevel, enableBreaks, feel, inBreak, readAffect, regulate, release as releaseEmotion, skipCrisis, strain, tendencyEmotions, } from './affect/index.ts';
5
5
  export type { GoalTemplate, MakeUpReceipt, PrayerCalendar, PrayerSchedule, PrayerTimes, Retimer, } from './agenda/index.ts';
6
6
  export { AGENDA_DEFAULTS, abandonGoal, adoptGoal, advanceAgenda, agendaTerms, applyExemptions, CARE_DEFAULTS, calendarRetimer, careDuty, commitmentPressure, createAgenda, DEFAULT_PRAYER_TIMES, EID_PRAYER_IMPORTANCE, EXEMPTION_DEFAULTS, eidPrayer, eidWindow, fastWindow, iftarWindow, inMakruhTime, isContinuingCommitment, isUnderWay, MAKRUH_DEFAULTS, makruhWindows, missedExcuse, onFinished, owedMakeUps, PRAYER_IMPORTANCE, PURPOSE_DEFAULTS, prayerWindows, pressureReachedAt, promise, proposeGoals, RAMADAN_DEFAULTS, ramadanFast, ramadanMeals, release as releaseCommitment, retimeCommitments, retirePaidMakeUps, revisePurposes, scheduleMakeUp, spanMeetsWindow, suhoorWindow, timesFor, violatesAbstention, } from './agenda/index.ts';
7
7
  export * from './beliefs/index.ts';
8
8
  export * from './body/index.ts';
9
+ export * from './chapter-clock.ts';
9
10
  export * from './character/index.ts';
10
11
  export * from './chronicle/index.ts';
11
12
  export type { ConsiderContext, DecideContext, Decision, SocialContext } from './cognition/index.ts';
@@ -32,6 +33,7 @@ export { enablePersistence, PERSISTENCE_DEFAULTS, pursuitOf } from './persistenc
32
33
  export * from './person.ts';
33
34
  export * from './persona.ts';
34
35
  export * from './persona-calendar.ts';
36
+ export * from './presentation.ts';
35
37
  export { adoptProjectTask, considerProjects, enableProjects, PROJECTS_DEFAULTS, projectAppeal, } from './projects/index.ts';
36
38
  export * from './promptings/index.ts';
37
39
  export type { ConsequenceEvidence, PlanRoute, PlanStep } from './prospection/index.ts';
package/dist/index.js CHANGED
@@ -1,10 +1,11 @@
1
1
  /** Human Framework public surface. */
2
- export const FRAMEWORK_VERSION = '2.17.0';
2
+ export const FRAMEWORK_VERSION = '2.19.0';
3
3
  export { AFFECT_DEFAULTS, actionTendencies, advanceAffect, appraise, breakAllows, breakBehaviour, breakHazard, CRISIS_DEFAULTS, checkCrisis, createAffect, easeBreak, emotionLevel, enableBreaks, feel, inBreak, readAffect, regulate, release as releaseEmotion, skipCrisis, strain, tendencyEmotions, } from './affect/index.js';
4
4
  // `agenda.release` collides with `affect.release`; the agenda one is re-exported under a clearer name.
5
5
  export { AGENDA_DEFAULTS, abandonGoal, adoptGoal, advanceAgenda, agendaTerms, applyExemptions, CARE_DEFAULTS, calendarRetimer, careDuty, commitmentPressure, createAgenda, DEFAULT_PRAYER_TIMES, EID_PRAYER_IMPORTANCE, EXEMPTION_DEFAULTS, eidPrayer, eidWindow, fastWindow, iftarWindow, inMakruhTime, isContinuingCommitment, isUnderWay, MAKRUH_DEFAULTS, makruhWindows, missedExcuse, onFinished, owedMakeUps, PRAYER_IMPORTANCE, PURPOSE_DEFAULTS, prayerWindows, pressureReachedAt, promise, proposeGoals, RAMADAN_DEFAULTS, ramadanFast, ramadanMeals, release as releaseCommitment, retimeCommitments, retirePaidMakeUps, revisePurposes, scheduleMakeUp, spanMeetsWindow, suhoorWindow, timesFor, violatesAbstention, } from './agenda/index.js';
6
6
  export * from './beliefs/index.js';
7
7
  export * from './body/index.js';
8
+ export * from './chapter-clock.js';
8
9
  export * from './character/index.js';
9
10
  export * from './chronicle/index.js';
10
11
  // `cognition.decide` is the raw scorer; the composite's `decide` (person.ts) is the one hosts call.
@@ -29,6 +30,7 @@ export { enablePersistence, PERSISTENCE_DEFAULTS, pursuitOf } from './persistenc
29
30
  export * from './person.js';
30
31
  export * from './persona.js';
31
32
  export * from './persona-calendar.js';
33
+ export * from './presentation.js';
32
34
  export { adoptProjectTask, considerProjects, enableProjects, PROJECTS_DEFAULTS, projectAppeal, } from './projects/index.js';
33
35
  export * from './promptings/index.js';
34
36
  export { bodyForecastFor, bodyForecastTerm, consequenceEvidence, enablePlanning, FORECAST_DEFAULTS, forecastLine, forecastThresholds, PROSPECTION_DEFAULTS, seedConsequences, } from './prospection/index.js';
package/dist/longrun.js CHANGED
@@ -108,7 +108,7 @@ export function routineDay(p, routine, opts = {}) {
108
108
  action: a.action,
109
109
  actorId: p.id,
110
110
  valence: clampSigned(a.valence ?? 0),
111
- summary: a.summary ?? a.action,
111
+ summary: a.summary ?? (p.lexicon?.plainEnglish ? `I completed the activity “${a.action}”.` : a.action),
112
112
  tags: [...(a.tags ?? []), 'routine'],
113
113
  };
114
114
  if (companions[0] !== undefined)
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * SCOPE: keyed narration templates, host phrase packs and name/role lookup (N6). Every English sentence the
3
- * framework narrates lives in `EN_LINES` under a stable key; a host `Lexicon` replaces (`lines`) or extends
3
+ * framework narrates lives in `EN_LINES` under a stable key; `Lexicon.plainEnglish` selects the matching
4
+ * literal English pack while preserving default templates and host overrides. A host `Lexicon` replaces (`lines`) or extends
4
5
  * (`extend`) any key, names entities ("Ada") and maps relationship roles to nouns ("daughter") so narration
5
6
  * can say "my daughter" or "his daughter". Selection stays deterministic: a template is picked by an FNV hash of
6
7
  * a caller key (a decision id, a day index), never by the person's RNG. This is presentation only. It is
@@ -1,3 +1,4 @@
1
+ import { PLAIN_EN_LINES } from './plain.js';
1
2
  /** FNV-1a 32-bit hash for deterministic template selection. */
2
3
  export function hashString(s) {
3
4
  let h = 0x811c9dc5;
@@ -264,7 +265,7 @@ export const EN_LINES = {
264
265
  };
265
266
  /** Templates for a key after the lexicon's replacements and extensions; empty when unknown. */
266
267
  export function linesFor(key, lex) {
267
- const base = lex?.lines?.[key] ?? EN_LINES[key] ?? [];
268
+ const base = lex?.lines?.[key] ?? (lex?.plainEnglish ? PLAIN_EN_LINES[key] : undefined) ?? EN_LINES[key] ?? [];
268
269
  const more = lex?.extend?.[key];
269
270
  return more && more.length > 0 ? [...base, ...more] : base;
270
271
  }
@@ -1,5 +1,18 @@
1
+ /**
2
+ * SCOPE: templated first-person language for decisions and suggestion verdicts, plus a short summary of a
3
+ * person for UI. Sentences are selected from keyed template sets (`EN_LINES`, overridable per key by a host
4
+ * `Lexicon`, N6) by a hash of the decision id, so the same record always narrates the same way without
5
+ * consuming the person's RNG. When memory shaped an option (the record's `recalled` episodes), the narration
6
+ * cites the episode summary. People are named through `nameOf` (lexicon name, role phrase, or id). This is
7
+ * presentation only: nothing here reads back into the simulation, and no claim is made that the templates
8
+ * reflect how people actually explain themselves. `Lexicon.plainEnglish` opts into sentence intentions
9
+ * and summaries; arbitrary host prose is not translated or grammatically inferred.
10
+ */
1
11
  import type { DecisionRecord, Episode, Lexicon, Person, SuggestionResolution } from '../types.ts';
2
- /** Intention string for a decision: 'for Allah', 'to feed myself', 'to keep my promise to X', ... */
12
+ /**
13
+ * A plain intention is a sentence; legacy intentions remain phrases for existing hosts.
14
+ * Host-authored sentences pass through. This renderer never changes the record or RNG.
15
+ */
3
16
  export declare function intentionFor(p: Person, record: DecisionRecord, lexicon?: Lexicon): string;
4
17
  /** First-person narration of a decision record. Deterministic by the decision id. */
5
18
  export declare function narrateDecision(p: Person, record: DecisionRecord, lexicon?: Lexicon): string;
@@ -5,23 +5,23 @@
5
5
  * consuming the person's RNG. When memory shaped an option (the record's `recalled` episodes), the narration
6
6
  * cites the episode summary. People are named through `nameOf` (lexicon name, role phrase, or id). This is
7
7
  * presentation only: nothing here reads back into the simulation, and no claim is made that the templates
8
- * reflect how people actually explain themselves.
8
+ * reflect how people actually explain themselves. `Lexicon.plainEnglish` opts into sentence intentions
9
+ * and summaries; arbitrary host prose is not translated or grammatically inferred.
9
10
  */
10
11
  import { NORM_SCOPE } from '../conscience/index.js';
12
+ import { labelLine } from '../presentation.js';
11
13
  import { capitalize, linesFor, lowerFirst, nameOf, phraseLine, pickLine } from './lexicon.js';
12
14
  /**
13
15
  * Devotional intention: 'for Allah' for norms the built-in catalog scopes as religious, else 'for God'. The phrase
14
16
  * follows the norm, not the person (review 2026-10-03): a host whose people differ in faith overrides
15
17
  * `devotion.allah` / `devotion.god` per person through `Person.lexicon` (from `PersonSpec.lexicon`).
16
18
  */
17
- function devotion(p, normId, lex) {
19
+ function devotion(p, normId, lex, say = phraseLine) {
18
20
  if (p.values.tradition < 0.6)
19
- return normId === undefined
20
- ? phraseLine('devotion.keep', {}, lex)
21
- : phraseLine('intention.right', {}, lex);
21
+ return normId === undefined ? say('devotion.keep', {}, lex) : say('intention.right', {}, lex);
22
22
  if (normId !== undefined && NORM_SCOPE[normId] === 'religious')
23
- return phraseLine('devotion.allah', {}, lex);
24
- return phraseLine('devotion.god', {}, lex);
23
+ return say('devotion.allah', {}, lex);
24
+ return say('devotion.god', {}, lex);
25
25
  }
26
26
  const topTerm = (c) => {
27
27
  let best;
@@ -33,77 +33,108 @@ const topTerm = (c) => {
33
33
  /** Voices the narration addresses as "you" (the host's own voice); other voices are named when a lexicon names them. */
34
34
  const PLAYER_VOICES = new Set(['player', 'you']);
35
35
  /** Intention string for a decision: 'for Allah', 'to feed myself', 'to keep my promise to X', ... */
36
- export function intentionFor(p, record, lexicon) {
36
+ function intentionPhrase(p, record, lexicon, selected) {
37
37
  const lex = lexicon ?? p.lexicon;
38
+ const say = (key, vars = {}, lex) => {
39
+ if (selected)
40
+ selected.key = key;
41
+ return phraseLine(key, vars, lex);
42
+ };
38
43
  const chosen = record.considered.find((c) => c.affordanceId === record.chosenAffordanceId);
39
44
  if (!chosen)
40
- return phraseLine('intention.none', {}, lex);
45
+ return say('intention.none', {}, lex);
41
46
  if (record.suggestion?.verdict === 'complied')
42
- return phraseLine('intention.complied', {}, lex);
47
+ return say('intention.complied', {}, lex);
43
48
  if (record.suggestion?.verdict === 'commanded')
44
- return phraseLine('intention.commanded', {}, lex);
49
+ return say('intention.commanded', {}, lex);
45
50
  const top = topTerm(chosen);
46
51
  if (!top)
47
- return phraseLine('intention.default', {}, lex);
52
+ return say('intention.default', {}, lex);
48
53
  const [kind, rest] = [top.source.split(':')[0] ?? '', top.source.slice(top.source.indexOf(':') + 1)];
49
54
  switch (kind) {
50
55
  case 'need':
51
56
  return linesFor(`intention:${rest}`, lex).length > 0
52
- ? phraseLine(`intention:${rest}`, {}, lex)
53
- : phraseLine('intention.need', { need: rest }, lex);
57
+ ? say(`intention:${rest}`, {}, lex)
58
+ : say('intention.need', { need: rest }, lex);
54
59
  case 'norm': {
55
60
  const tracked = p.agenda.commitments.some((c) => c.normId === rest && c.kind === 'worship');
56
61
  if (NORM_SCOPE[rest] === 'religious' || tracked)
57
- return devotion(p, rest, lex);
58
- return phraseLine('intention.right', {}, lex);
62
+ return devotion(p, rest, lex, say);
63
+ return say('intention.right', {}, lex);
59
64
  }
60
65
  case 'conscience':
61
- return phraseLine(rest === 'repair' ? 'intention.repair' : 'intention.repent', {}, lex);
66
+ return say(rest === 'repair' ? 'intention.repair' : 'intention.repent', {}, lex);
62
67
  case 'commitment': {
63
68
  const c = p.agenda.commitments.find((x) => x.id === rest);
64
69
  if (c?.kind === 'worship')
65
70
  return c.normId === undefined || p.values.tradition < 0.6
66
- ? devotion(p, undefined, lex)
67
- : devotion(p, c.normId, lex);
71
+ ? devotion(p, undefined, lex, say)
72
+ : devotion(p, c.normId, lex, say);
68
73
  if (c?.toId && c.toId !== 'self')
69
- return phraseLine('intention.promiseTo', { who: nameOf(p, c.toId, lex) }, lex);
74
+ return say('intention.promiseTo', { who: nameOf(p, c.toId, lex) }, lex);
70
75
  // A host may phrase a labelled commitment its own way ('intention.label:suhoor' → "for suhoor").
71
76
  if (c?.label !== undefined && linesFor(`intention.label:${c.label}`, lex).length > 0)
72
- return phraseLine(`intention.label:${c.label}`, {}, lex);
73
- return phraseLine('intention.word', {}, lex);
77
+ return say(`intention.label:${c.label}`, {}, lex);
78
+ return say('intention.word', {}, lex);
74
79
  }
75
80
  case 'goal': {
76
81
  const g = p.agenda.goals.find((x) => x.id === rest);
77
82
  return g
78
- ? phraseLine('intention.goal', { goal: g.label.replace(/-/g, ' ') }, lex)
79
- : phraseLine('intention.goalFallback', {}, lex);
83
+ ? say('intention.goal', { goal: lex?.plainEnglish ? labelLine(g.label, lex) : g.label.replace(/-/g, ' ') }, lex)
84
+ : say('intention.goalFallback', {}, lex);
80
85
  }
81
86
  case 'social':
82
87
  return rest === 'group'
83
- ? phraseLine('intention.group', {}, lex)
84
- : phraseLine('intention.for', { who: nameOf(p, rest, lex) }, lex);
88
+ ? say('intention.group', {}, lex)
89
+ : say('intention.for', { who: nameOf(p, rest, lex) }, lex);
85
90
  case 'suggestion': {
86
91
  const voice = rest.startsWith('remembered:') ? rest.slice('remembered:'.length) : rest;
87
92
  if (lex?.names?.[voice] !== undefined && !PLAYER_VOICES.has(voice))
88
- return phraseLine('intention.suggestionBy', { who: nameOf(p, voice, lex) }, lex);
89
- return phraseLine('intention.suggestion', {}, lex);
93
+ return say('intention.suggestionBy', { who: nameOf(p, voice, lex) }, lex);
94
+ return say('intention.suggestion', {}, lex);
90
95
  }
91
96
  case 'habit':
92
- return phraseLine('intention.habit', {}, lex);
97
+ return say('intention.habit', {}, lex);
93
98
  case 'emotion':
94
- return phraseLine('intention.emotion', {}, lex);
99
+ return say('intention.emotion', {}, lex);
95
100
  case 'material':
96
101
  return lex?.materialMeaning !== undefined
97
- ? phraseLine('intention.material.named', { material: lex.materialMeaning }, lex)
98
- : phraseLine('intention.material', {}, lex);
102
+ ? say('intention.material.named', { material: lex.materialMeaning }, lex)
103
+ : say('intention.material', {}, lex);
99
104
  case 'precommit':
100
- return phraseLine('intention.precommit', {}, lex);
105
+ return say('intention.precommit', {}, lex);
101
106
  case 'expectation':
102
- return phraseLine('intention.expectation', {}, lex);
107
+ return say('intention.expectation', {}, lex);
103
108
  default:
104
- return phraseLine('intention.default', {}, lex);
109
+ return say('intention.default', {}, lex);
105
110
  }
106
111
  }
112
+ /**
113
+ * A plain intention is a sentence; legacy intentions remain phrases for existing hosts.
114
+ * Host-authored sentences pass through. This renderer never changes the record or RNG.
115
+ */
116
+ export function intentionFor(p, record, lexicon) {
117
+ const lex = lexicon ?? p.lexicon;
118
+ const selected = {};
119
+ const phrase = intentionPhrase(p, record, lex, selected);
120
+ if (selected.key !== undefined && lex?.lines?.[selected.key] !== undefined)
121
+ return phrase;
122
+ if (!lex?.plainEnglish || /[.!?]$/.test(phrase))
123
+ return phrase;
124
+ if (phrase === 'nothing to do')
125
+ return 'There is nothing for me to do.';
126
+ if (phrase === 'to earn')
127
+ return 'I want to earn money.';
128
+ if (phrase.startsWith('to '))
129
+ return `I want ${phrase}.`;
130
+ if (phrase.startsWith('because '))
131
+ return `I am doing this ${phrase}.`;
132
+ if (phrase.startsWith('for '))
133
+ return `I am doing this ${phrase}.`;
134
+ if (phrase === 'out of habit')
135
+ return 'I am doing this out of habit.';
136
+ return `My reason is ${phrase}.`;
137
+ }
107
138
  function citeEpisode(p, ids, key, lex) {
108
139
  if (!ids || ids.length === 0)
109
140
  return undefined;
@@ -111,7 +142,7 @@ function citeEpisode(p, ids, key, lex) {
111
142
  if (!ep)
112
143
  return undefined;
113
144
  const summary = ep.summary.endsWith('.') ? ep.summary : `${ep.summary}.`;
114
- return pickLine(ep.valence < 0 ? 'cite.bad' : 'cite.good', key, { summary: lowerFirst(summary) }, lex);
145
+ return pickLine(ep.valence < 0 ? 'cite.bad' : 'cite.good', key, { summary: lex?.plainEnglish ? summary : lowerFirst(summary) }, lex);
115
146
  }
116
147
  /** A rival within this fraction of the chosen utility makes the choice "a close call". */
117
148
  const CLOSE_CALL_FRACTION = 0.2;
@@ -212,13 +243,17 @@ export function voiceLine(p, res, key = res.reason, lexicon) {
212
243
  }
213
244
  case 'modified': {
214
245
  const alt = res.counterOffer?.label ?? phraseLine('voice.modified.default', {}, lex);
215
- return pickLine('voice.modified', key, { alt: lowerFirst(alt) }, lex);
246
+ return pickLine('voice.modified', key, { alt: lex?.plainEnglish ? alt : lowerFirst(alt) }, lex);
216
247
  }
217
248
  case 'refused': {
218
249
  if (res.reason === 'distrust') {
219
250
  const ep = res.episodeId ? p.memory.episodes.find((e) => e.id === res.episodeId) : undefined;
220
251
  if (ep)
221
- return pickLine('voice.distrust.episode', key, { what: lowerFirst(ep.summary.replace(/\.$/, '')) }, lex);
252
+ return pickLine('voice.distrust.episode', key, {
253
+ what: lex?.plainEnglish
254
+ ? ep.summary.replace(/[.!?]$/, '')
255
+ : lowerFirst(ep.summary.replace(/\.$/, '')),
256
+ }, lex);
222
257
  return pickLine('voice.distrust', key, {}, lex);
223
258
  }
224
259
  if (res.kind === 'willNot' && res.commitmentId !== undefined) {
@@ -228,7 +263,7 @@ export function voiceLine(p, res, key = res.reason, lexicon) {
228
263
  : duty?.label
229
264
  ? duty.label
230
265
  : phraseLine('voice.omission.default', {}, lex);
231
- return pickLine('voice.omission', key, { what }, lex);
266
+ return pickLine('voice.omission', key, { what: labelLine(what, lex) }, lex);
232
267
  }
233
268
  if (res.kind === 'willNot' && res.reason.startsWith('duty:')) {
234
269
  // An abstention held as a duty (the fast): the veto names what is being kept, not a forbidden act.
@@ -296,6 +331,13 @@ export function describePerson(p, lexicon) {
296
331
  const act = p.activity
297
332
  ? `${p.activity.action}${p.activity.targetId ? ` (${p.activity.targetId})` : ''}`
298
333
  : phraseLine('describe.idle', {}, lex);
334
+ if (lex?.plainEnglish) {
335
+ const state = bits.length > 0 ? bits.join(', ') : phraseLine('describe.well', {}, lex);
336
+ const activity = p.activity
337
+ ? `${p.name} is doing this: ${p.activity.affordance.label}${p.activity.targetId ? ` (${nameOf(p, p.activity.targetId, lex)})` : ''}.`
338
+ : `${p.name} is ${phraseLine('describe.idle', {}, lex)}.`;
339
+ return phraseLine('describe.line', { name: p.name, act: lex.lines?.['describe.line'] ? act : activity, state }, lex);
340
+ }
299
341
  const state = bits.length > 0 ? bits.join(', ') : phraseLine('describe.well', {}, lex);
300
342
  return phraseLine('describe.line', { name: p.name, act, state }, lex);
301
343
  }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * SCOPE: literal English narration for hosts that opt in to plain English. Keys, slot names,
3
+ * template counts and ordering match the legacy English pack, preserving deterministic selection.
4
+ * Internal word and intention slots remain phrases for composition. This pack changes presentation
5
+ * only; it does not change simulation state, choose templates, or rewrite host-authored text.
6
+ */
7
+ export declare const PLAIN_EN_LINES: Readonly<Record<string, readonly string[]>>;