@adam0white/human-framework 2.12.0 → 2.14.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.
@@ -144,6 +144,17 @@ function copyArrays(c) {
144
144
  */
145
145
  /** Copy valid temporal settings and drop invalid ones at every agenda entry point. */
146
146
  function sanitizeGoalTemporal(g) {
147
+ const project = g.project;
148
+ if (project !== undefined) {
149
+ if (isObj(project) &&
150
+ typeof project.prospectId === 'string' &&
151
+ project.prospectId.length > 0 &&
152
+ project.prospectId.length <= 512 &&
153
+ typeof project.perceivedProgress === 'boolean')
154
+ g.project = { prospectId: project.prospectId, perceivedProgress: project.perceivedProgress };
155
+ else
156
+ delete g.project;
157
+ }
147
158
  const t = g.temporal;
148
159
  if (t !== undefined &&
149
160
  !(isObj(t) &&
@@ -154,8 +165,11 @@ function sanitizeGoalTemporal(g) {
154
165
  else if (g.temporal)
155
166
  g.temporal = { halfLifeMinutes: g.temporal.halfLifeMinutes };
156
167
  }
157
- /** Restore-time check of `agenda.lapse` (1.7.0), in place: a malformed one is dropped (absent means none). @internal */
168
+ /** Restore-time check of optional lapse and firm flags; malformed values are dropped. @internal */
158
169
  export function sanitizeAgenda(a) {
170
+ for (const c of a.commitments)
171
+ if (isObj(c) && (c.firm !== true || c.kind !== 'abstain'))
172
+ delete c.firm;
159
173
  for (const g of a.goals)
160
174
  if (isObj(g))
161
175
  sanitizeGoalTemporal(g);
@@ -476,11 +490,16 @@ function heldConviction(p, c) {
476
490
  * keeps by the same rule, so a pull never leads to an activity that cannot keep the commitment.
477
491
  */
478
492
  export const spanMeetsWindow = (c, start, end) => start <= c.until && end >= c.from;
493
+ /** Explicit host progress can name either the adopted goal or its perceived prospect. */
494
+ function namesGoal(g, advances) {
495
+ return ((advances?.includes(g.id) ?? false) ||
496
+ (g.project !== undefined && (advances?.includes(g.project.prospectId) ?? false)));
497
+ }
479
498
  function goalAmount(g, aff) {
480
499
  const own = g.advancedBy.find((a) => a.action === aff.action);
481
500
  if (own)
482
501
  return own.amount;
483
- return aff.advances?.includes(g.id) ? AGENDA_DEFAULTS.defaultAdvanceAmount : 0;
502
+ return namesGoal(g, aff.advances) ? AGENDA_DEFAULTS.defaultAdvanceAmount : 0;
484
503
  }
485
504
  function deadlinePressure(g, now) {
486
505
  if (g.deadline === undefined)
@@ -668,9 +687,14 @@ export function onFinished(p, outcome, startedAt = outcome.at, opts = {}) {
668
687
  if (g.status !== 'active')
669
688
  continue;
670
689
  const own = g.advancedBy.find((a) => a.action === outcome.action)?.amount ?? 0;
671
- const amount = own > 0 ? own : outcome.advances?.includes(g.id) ? AGENDA_DEFAULTS.defaultAdvanceAmount : 0;
690
+ const amount = own > 0 ? own : namesGoal(g, outcome.advances) ? AGENDA_DEFAULTS.defaultAdvanceAmount : 0;
672
691
  if (amount <= 0)
673
692
  continue;
693
+ // Credit the contribution for narration/reward, but only perception sets this project's progress.
694
+ if (g.project?.perceivedProgress) {
695
+ advanced.push(g);
696
+ continue;
697
+ }
674
698
  g.progress = clamp01(g.progress + amount);
675
699
  g.lastAdvancedAt = outcome.at;
676
700
  if (g.baseImportance !== undefined)
@@ -701,6 +725,21 @@ export function adoptGoal(p, g, now) {
701
725
  p.agenda.goals.push(goal);
702
726
  return goal;
703
727
  }
728
+ /** Attended host progress is authoritative for an active perceived-progress project. @internal */
729
+ export function perceiveProjectProgress(p, prospectId, progress, now) {
730
+ for (const g of p.agenda.goals) {
731
+ if (g.status !== 'active' || !g.project?.perceivedProgress || g.project.prospectId !== prospectId)
732
+ continue;
733
+ if (progress > g.progress) {
734
+ g.lastAdvancedAt = now;
735
+ if (g.baseImportance !== undefined)
736
+ g.importance = g.baseImportance;
737
+ }
738
+ g.progress = clamp01(progress);
739
+ if (g.progress >= 1)
740
+ g.status = 'achieved';
741
+ }
742
+ }
704
743
  export function abandonGoal(p, id) {
705
744
  const g = p.agenda.goals.find((x) => x.id === id);
706
745
  if (g?.status !== 'active')
@@ -46,6 +46,7 @@ import { expectedEffect } from '../memory/index.js';
46
46
  import { methodEvidence } from '../methods/index.js';
47
47
  import { levelOf, needThreshold, urgencyOf } from '../needs/index.js';
48
48
  import { partneringTerms } from '../partnering/index.js';
49
+ import { persistenceTerms } from '../persistence/index.js';
49
50
  import { promptIn, promptingTerms } from '../promptings/index.js';
50
51
  import { bodyForecastTerm, receivedGoalRoute } from '../prospection/index.js';
51
52
  import { reviewTerms } from '../review/index.js';
@@ -461,6 +462,9 @@ export function consider(p, aff, ctx) {
461
462
  if (p.curiosity)
462
463
  for (const t of curiosityTerms(p, aff, ctx))
463
464
  push(t.source, t.value);
465
+ if (p.pursuit)
466
+ for (const t of persistenceTerms(p, terms, ctx))
467
+ push(t.source, t.value);
464
468
  // Promptings (2.4.0, opt-in): a recurrence pull under `fault` and a practised turning-away from a noticed prompt.
465
469
  if (p.promptings)
466
470
  for (const t of promptingTerms(p, aff.action, terms, aff.tags, ctx.now, ctx.desperation))
@@ -22,7 +22,10 @@
22
22
  * desperation threshold, and only for acts meeting a bodily need; this is not the fast's own exemption for
23
23
  * illness and travel (2:184-185, applied in the agenda) and not the 2:173 necessity exception for forbidden
24
24
  * food. Below the threshold the abstention acts only as a negative term (agenda), so a weakly convinced person
25
- * can be tempted. It does not model the person's later judgment of a broken fast beyond the recorded breach.
25
+ * can be tempted. An opt-in `firm: true` self abstention without a linked obligatory norm instead vetoes
26
+ * as `rule:<label>` (id fallback). It uses the same open-window, exemption and bodily survival checks,
27
+ * including for self choice and insistence. It models a held rule, not a new religious ruling.
28
+ * It does not model the person's later judgment of a broken fast beyond the recorded breach.
26
29
  */
27
30
  import { clamp01 } from '../core/index.js';
28
31
  import { DEFAULT_NORMS, NORM_SCOPE } from './catalog.js';
@@ -251,11 +254,15 @@ export function abstentionVeto(p, aff, now, capacityLifted = false) {
251
254
  return undefined;
252
255
  const end = now + Math.max(0, aff.duration);
253
256
  for (const c of p.agenda?.commitments ?? []) {
254
- if (c.kind !== 'abstain' || c.status !== 'pending' || c.exempt !== undefined || c.normId === undefined)
257
+ if (c.kind !== 'abstain' || c.status !== 'pending' || c.exempt !== undefined)
255
258
  continue;
256
259
  if (!(c.violatedBy?.includes(aff.action) ?? false) || now >= c.until || end <= c.from)
257
260
  continue;
258
- const held = heldNorm(p, c.normId);
261
+ const held = c.normId === undefined ? undefined : heldNorm(p, c.normId);
262
+ if (c.firm === true &&
263
+ (c.toId === undefined || c.toId === 'self' || c.toId === p.id) &&
264
+ held?.standing !== 'obligatory')
265
+ return { kind: 'willNot', reason: `rule:${c.label ?? c.id}` };
259
266
  if (held?.standing !== 'obligatory' || held.conviction < CONSCIENCE_DEFAULTS.abstainVetoConviction)
260
267
  continue;
261
268
  return { kind: 'willNot', reason: `duty:${c.normId}` };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Human Framework public surface. */
2
- export declare const FRAMEWORK_VERSION = "2.12.0";
2
+ export declare const FRAMEWORK_VERSION = "2.14.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, PrayerCalendar, PrayerSchedule, PrayerTimes, Retimer, } from './agenda/index.ts';
@@ -28,7 +28,9 @@ export { MIGRATIONS, migratableVersions, migrate } from './migrate.ts';
28
28
  export * from './narrate/index.ts';
29
29
  export * from './needs/index.ts';
30
30
  export * from './partnering/index.ts';
31
+ export { enablePersistence, PERSISTENCE_DEFAULTS, pursuitOf } from './persistence/index.ts';
31
32
  export * from './person.ts';
33
+ export { considerProjects, enableProjects, PROJECTS_DEFAULTS, projectAppeal } from './projects/index.ts';
32
34
  export * from './promptings/index.ts';
33
35
  export type { ConsequenceEvidence, PlanRoute, PlanStep } from './prospection/index.ts';
34
36
  export { bodyForecastFor, bodyForecastTerm, consequenceEvidence, enablePlanning, FORECAST_DEFAULTS, forecastLine, forecastThresholds, PROSPECTION_DEFAULTS, seedConsequences, } from './prospection/index.ts';
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Human Framework public surface. */
2
- export const FRAMEWORK_VERSION = '2.12.0';
2
+ export const FRAMEWORK_VERSION = '2.14.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, isUnderWay, MAKRUH_DEFAULTS, makruhWindows, missedExcuse, onFinished, owedMakeUps, PRAYER_IMPORTANCE, PURPOSE_DEFAULTS, prayerWindows, pressureReachedAt, promise, proposeGoals, RAMADAN_DEFAULTS, ramadanFast, ramadanMeals, release as releaseCommitment, retimeCommitments, revisePurposes, scheduleMakeUp, spanMeetsWindow, suhoorWindow, timesFor, violatesAbstention, } from './agenda/index.js';
@@ -25,7 +25,9 @@ export { MIGRATIONS, migratableVersions, migrate } from './migrate.js';
25
25
  export * from './narrate/index.js';
26
26
  export * from './needs/index.js';
27
27
  export * from './partnering/index.js';
28
+ export { enablePersistence, PERSISTENCE_DEFAULTS, pursuitOf } from './persistence/index.js';
28
29
  export * from './person.js';
30
+ export { considerProjects, enableProjects, PROJECTS_DEFAULTS, projectAppeal } from './projects/index.js';
29
31
  export * from './promptings/index.js';
30
32
  export { bodyForecastFor, bodyForecastTerm, consequenceEvidence, enablePlanning, FORECAST_DEFAULTS, forecastLine, forecastThresholds, PROSPECTION_DEFAULTS, seedConsequences, } from './prospection/index.js';
31
33
  export { fingerprint, restore, snapshot } from './restore.js';
package/dist/migrate.js CHANGED
@@ -18,7 +18,7 @@
18
18
  * version; fullness stays absent unless enabled, and alongside partners and reconsideration are opt-in.
19
19
  * 2.8.0 to 2.9.0 stamps the version; prompting cues and practice half-lives stay absent unless supplied,
20
20
  * review terms may carry refs, and an outcome naming several commitments now keeps each rather than only
21
- * the earliest-ending one. 2.11.0 to 2.12.0 stamps the version (planning starts absent). 2.10.0 to 2.11.0 stamps the version (curiosity starts absent). 2.9.0 to 2.10.0 stamps the version; belief provenance, noted contradictions and
21
+ * the earliest-ending one. 2.13.0 to 2.14.0 stamps the version (projects and persistence start absent; goals may carry `project`, received successors may carry `advances`). 2.12.0 to 2.13.0 stamps the version (suggestion reasons and firm rules start absent). 2.11.0 to 2.12.0 stamps the version (planning starts absent). 2.10.0 to 2.11.0 stamps the version (curiosity starts absent). 2.9.0 to 2.10.0 stamps the version; belief provenance, noted contradictions and
22
22
  * believed regard stay absent unless a host supplies them;
23
23
  * `regard` becomes a reserved impression cue key. A migrated save continues under the current engine's rules: it restores and runs,
24
24
  * but it does not reproduce what the old engine would have done next where the rules changed (1.4.0 to 1.5.0
@@ -70,6 +70,16 @@ export const MIGRATIONS = {
70
70
  note: 'the surroundings field `ambient.now` is renamed `ambient.percept`; per-person retention is optional and starts absent; behaviour unchanged',
71
71
  apply: renameAmbient,
72
72
  },
73
+ '2.13.0': {
74
+ to: '2.14.0',
75
+ note: 'projects (Person.projects) and persistence (Person.pursuit) are optional and start absent; Goal.project, Percept.prospect and received successor advances are optional; existing hosts retain exact behavior',
76
+ apply: stamp,
77
+ },
78
+ '2.12.0': {
79
+ to: '2.13.0',
80
+ note: 'suggestion reasons (Person.will.suggestionReasons) and firm self-rules (Commitment.firm) are optional and start absent; existing hosts retain exact behavior',
81
+ apply: stamp,
82
+ },
73
83
  '2.11.0': {
74
84
  to: '2.12.0',
75
85
  note: 'the planning slice (Person.planning) is optional and starts absent; retained consequences may carry channel known only when seeded; existing hosts retain exact behavior',
@@ -0,0 +1 @@
1
+ export * from './persistence.ts';
@@ -0,0 +1 @@
1
+ export * from './persistence.js';
@@ -0,0 +1,18 @@
1
+ import type { PersistenceSpec, Person, Pursuit, PursuitState } from '../types.ts';
2
+ /** Engineering defaults, relative to cognition's need scale 4, pleasure .6 and curiosity .8/.15.
3
+ * Continuation and switching together outweigh mild relaxed distraction; urgency and fear gate both.
4
+ * A pressing commitment has its full agenda term and pays no switching cost.
5
+ */
6
+ export declare const PERSISTENCE_DEFAULTS: {
7
+ readonly continueScale: 0.8;
8
+ readonly switchScale: 0.8;
9
+ readonly cap: 0.8;
10
+ readonly switchMargin: 0.15;
11
+ readonly slackMinutes: 20;
12
+ readonly maxMinutes: 240;
13
+ readonly restMinutes: 20;
14
+ };
15
+ /** Enable or reconfigure, retaining a current pursuit. Invalid overrides use defaults. */
16
+ export declare function enablePersistence(p: Person, spec?: PersistenceSpec): PursuitState;
17
+ /** A copy of the current pursuit, without pruning, mutation or RNG. */
18
+ export declare function pursuitOf(p: Person): Pursuit | undefined;
@@ -0,0 +1,190 @@
1
+ /**
2
+ * SCOPE: opt-in persistence of a started goal, owned only in p.pursuit. Positive goal terms cue
3
+ * continued means (implementation intentions: Gollwitzer 1999; Gollwitzer & Sheeran 2006), while
4
+ * unrelated options pay a switching cost (goal shielding: Shah, Friedman & Kruglanski 2002;
5
+ * task switching: Monsell 2003). Unused activation expires (Altmann & Trafton 2002); short
6
+ * interruptions allow resumption (Ovsiankina 1928). See research/projects-and-persistence.md.
7
+ * Maximum-duration release rests that goal for one bounded interval before pursuit can form again.
8
+ * These are shapes, not fitted values; all coefficients and time bounds are engineering defaults.
9
+ * This is a bounded bias plus a switching cost, not a planner. It chooses no goals, executes no
10
+ * plans, reorders no steps and changes no agenda valuation. It imports no projects, uses no RNG,
11
+ * LLM or faith lens. Needs and fear gate both terms; commitment options never pay the switch cost.
12
+ * Without the slice, it adds no state or terms.
13
+ */
14
+ import { emotionLevel } from '../affect/index.js';
15
+ import { clamp01, cmpStr, isNum, isObj, round } from '../core/index.js';
16
+ import { MAX_MINUTE, PHYSIOLOGICAL_NEEDS } from '../types.js';
17
+ /** Engineering defaults, relative to cognition's need scale 4, pleasure .6 and curiosity .8/.15.
18
+ * Continuation and switching together outweigh mild relaxed distraction; urgency and fear gate both.
19
+ * A pressing commitment has its full agenda term and pays no switching cost.
20
+ */
21
+ export const PERSISTENCE_DEFAULTS = {
22
+ continueScale: 0.8,
23
+ switchScale: 0.8,
24
+ cap: 0.8,
25
+ switchMargin: 0.15,
26
+ slackMinutes: 20,
27
+ maxMinutes: 240,
28
+ restMinutes: 20,
29
+ };
30
+ const KEYS = [
31
+ 'continueScale',
32
+ 'switchScale',
33
+ 'cap',
34
+ 'switchMargin',
35
+ 'slackMinutes',
36
+ 'maxMinutes',
37
+ 'restMinutes',
38
+ ];
39
+ function cleanSpec(input) {
40
+ const out = {};
41
+ if (!isObj(input))
42
+ return out;
43
+ for (const key of KEYS) {
44
+ const x = input[key];
45
+ const max = key === 'slackMinutes' || key === 'maxMinutes' || key === 'restMinutes' ? 1440 : 1;
46
+ if (isNum(x) && x >= 0 && x <= max)
47
+ out[key] = x;
48
+ }
49
+ return out;
50
+ }
51
+ const setting = (s, key) => s.spec?.[key] ?? PERSISTENCE_DEFAULTS[key];
52
+ const identifier = (x) => typeof x === 'string' && x.length > 0 && x.length <= 512;
53
+ const active = (p, id) => p.agenda.goals.find((g) => g.id === id && g.status === 'active');
54
+ /** Enable or reconfigure, retaining a current pursuit. Invalid overrides use defaults. */
55
+ export function enablePersistence(p, spec = {}) {
56
+ const out = {};
57
+ const clean = cleanSpec(spec);
58
+ if (Object.keys(clean).length)
59
+ out.spec = clean;
60
+ if (p.pursuit?.current)
61
+ out.current = { ...p.pursuit.current };
62
+ if (p.pursuit?.rested)
63
+ out.rested = { ...p.pursuit.rested };
64
+ p.pursuit = out;
65
+ return out;
66
+ }
67
+ /** A copy of the current pursuit, without pruning, mutation or RNG. */
68
+ export function pursuitOf(p) {
69
+ return p.pursuit?.current ? { ...p.pursuit.current } : undefined;
70
+ }
71
+ /** Drop finished, absent, neglected or overlong pursuits. Interruptions alone do not release. @internal */
72
+ export function releasePursuit(p) {
73
+ const s = p.pursuit;
74
+ if (!s)
75
+ return;
76
+ if (s.rested && p.now >= s.rested.until)
77
+ delete s.rested;
78
+ const c = s.current;
79
+ if (!c)
80
+ return;
81
+ if (!active(p, c.goalId) || p.now - c.lastServedAt >= setting(s, 'slackMinutes')) {
82
+ delete s.current;
83
+ return;
84
+ }
85
+ if (p.now - c.since >= setting(s, 'maxMinutes')) {
86
+ delete s.current;
87
+ s.rested = { goalId: c.goalId, until: Math.min(MAX_MINUTE, p.now + setting(s, 'restMinutes')) };
88
+ }
89
+ }
90
+ /** Form from the begun option's already-rounded goal terms, including route credit. @internal */
91
+ export function beginPursuit(p, terms) {
92
+ const s = p.pursuit;
93
+ if (!s)
94
+ return;
95
+ releasePursuit(p);
96
+ const candidates = p.agenda.goals
97
+ .filter((g) => g.status === 'active' && identifier(g.id) && g.id !== s.rested?.goalId)
98
+ .map((goal) => ({
99
+ goal,
100
+ value: terms
101
+ .filter((t) => t.source === `goal:${goal.id}` && t.value > 0)
102
+ .reduce((v, t) => v + t.value, 0),
103
+ }))
104
+ .filter((x) => x.value > 0)
105
+ .sort((a, b) => b.value - a.value || cmpStr(a.goal.id, b.goal.id));
106
+ const best = candidates[0];
107
+ if (!best)
108
+ return;
109
+ const current = s.current;
110
+ const currentValue = candidates.find((x) => x.goal.id === current?.goalId)?.value ?? 0;
111
+ if (current &&
112
+ best.goal.id !== current.goalId &&
113
+ round(best.value - currentValue) <= setting(s, 'switchMargin')) {
114
+ if (currentValue > 0)
115
+ current.lastServedAt = p.now;
116
+ return;
117
+ }
118
+ s.current = {
119
+ goalId: best.goal.id,
120
+ since: current?.goalId === best.goal.id ? current.since : p.now,
121
+ lastServedAt: p.now,
122
+ strength: clamp01(best.goal.importance * (0.4 + 0.6 * p.traits.conscientiousness)),
123
+ };
124
+ }
125
+ /** Read already-pushed goal/commitment terms under the actual perceived needs. @internal */
126
+ export function persistenceTerms(p, terms, ctx) {
127
+ const s = p.pursuit;
128
+ const c = s?.current;
129
+ if (!s || !c || !active(p, c.goalId))
130
+ return [];
131
+ let urgency = 0;
132
+ for (const n of ctx.needs)
133
+ if (n.id === 'safety' || PHYSIOLOGICAL_NEEDS.includes(n.id))
134
+ urgency = Math.max(urgency, n.urgency);
135
+ const gate = (1 - clamp01(urgency)) * (1 - emotionLevel(p, 'fear'));
136
+ if (terms.some((t) => t.source === `goal:${c.goalId}` && t.value > 0))
137
+ return [
138
+ {
139
+ source: `pursuit:${c.goalId}`,
140
+ value: round(Math.min(setting(s, 'cap'), setting(s, 'continueScale') * c.strength * gate)),
141
+ },
142
+ ];
143
+ if (terms.some((t) => t.source.startsWith('commitment:')))
144
+ return [];
145
+ return [
146
+ {
147
+ source: 'pursuit:switch',
148
+ value: -round(Math.min(setting(s, 'cap'), setting(s, 'switchScale') * c.strength * gate)),
149
+ },
150
+ ];
151
+ }
152
+ const minute = (x, now) => isNum(x) && Math.abs(x) <= MAX_MINUTE && x <= now;
153
+ /** Conservative restore: malformed settings or pursuit drop the optional slice. @internal */
154
+ export function sanitizePursuit(input, now) {
155
+ if (!isObj(input) || (input.spec !== undefined && !isObj(input.spec)))
156
+ return undefined;
157
+ const out = {};
158
+ if (input.spec !== undefined) {
159
+ const spec = cleanSpec(input.spec);
160
+ if (Object.keys(spec).length !== Object.keys(input.spec).length)
161
+ return undefined;
162
+ if (Object.keys(spec).length)
163
+ out.spec = spec;
164
+ }
165
+ const c = input.current;
166
+ if (c !== undefined) {
167
+ if (!isObj(c) ||
168
+ !identifier(c.goalId) ||
169
+ !minute(c.since, now) ||
170
+ !minute(c.lastServedAt, now) ||
171
+ c.lastServedAt < c.since ||
172
+ !isNum(c.strength) ||
173
+ c.strength < 0 ||
174
+ c.strength > 1)
175
+ return undefined;
176
+ out.current = { goalId: c.goalId, since: c.since, lastServedAt: c.lastServedAt, strength: c.strength };
177
+ }
178
+ const r = input.rested;
179
+ if (r !== undefined) {
180
+ if (!isObj(r) ||
181
+ !identifier(r.goalId) ||
182
+ !isNum(r.until) ||
183
+ Math.abs(r.until) > MAX_MINUTE ||
184
+ // A later reconfiguration may shorten restMinutes without shortening an existing interval.
185
+ r.until > now + 1440)
186
+ return undefined;
187
+ out.rested = { goalId: r.goalId, until: r.until };
188
+ }
189
+ return out;
190
+ }
package/dist/person.js CHANGED
@@ -20,14 +20,14 @@
20
20
  * long-horizon runs. Each hook calls the owning module; nothing here writes another module's slice.
21
21
  */
22
22
  import { actionTendencies, advanceAffect, appraise, CRISIS_DEFAULTS, checkCrisis, createAffect, easeBreak, emotionLevel, feel, readAffect, regulate, release, skipAffect, skipCrisis, strain, tendencyEmotions, } from './affect/index.js';
23
- import { advanceAgenda, createAgenda, onFinished, promise, proposeGoals, revisePurposes, } from './agenda/index.js';
23
+ import { advanceAgenda, agendaTermsWithGoalEstimates, createAgenda, onFinished, promise, proposeGoals, revisePurposes, } from './agenda/index.js';
24
24
  import { advanceBeliefs, applyContradiction, attend, believe, confirm, credence, noteProvenance, trustOf, } from './beliefs/index.js';
25
25
  import { advanceBody, BODY_DEFAULTS, checkDowned, clearDowned, consume, createBody, downedAllows, enableFatigueStore, enableFullness, expose, injure, nextBodyThreshold, readBody, setDowned, sicken, skipBody, } from './body/index.js';
26
26
  import { ageCharacter, enableCharacterChange, noteCharacterDay, noteCharacterSocial, } from './character/index.js';
27
27
  import { closeDay, enableYearbook, endDay, noteAnswer, noteCommitments, noteDecision, noteMood, noteOutcome, trimChronicle, } from './chronicle/index.js';
28
28
  import { decide as cognitionDecide, desperationOf, prospectiveRoute, scoreAll } from './cognition/index.js';
29
29
  import { createConscience, guiltLoad, heldNorms, normVeto, recordDeed, recordRepair, repent, } from './conscience/index.js';
30
- import { clamp01, clampSigned, createRng, dayOf, isObj, minuteOfDay } from './core/index.js';
30
+ import { clamp01, clampSigned, createRng, dayOf, isObj, minuteOfDay, round } from './core/index.js';
31
31
  import { enableCuriosity, noteCuriosity } from './curiosity/index.js';
32
32
  import { ambientBodyParams, ambientModifiers, ambientMood, ambientNeeds } from './environment/index.js';
33
33
  import { aptitudeOf, createFamily, pregnancyModifiers } from './family/index.js';
@@ -37,13 +37,15 @@ import { advanceMemory, createMemory, enableGists, foldGists, learnOutcome, reca
37
37
  import { intentionFor, narrateDecision, voiceLine } from './narrate/index.js';
38
38
  import { advanceNeeds, createNeeds, levelOf, meanSatisfaction, readNeeds, satisfy, urgencyOf, } from './needs/index.js';
39
39
  import { widow } from './partnering/index.js';
40
+ import { beginPursuit, enablePersistence, releasePursuit } from './persistence/index.js';
41
+ import { considerProjects, enableProjects, noteProjectStatus, perceiveProspect } from './projects/index.js';
40
42
  import { enablePromptings, nafsPatterns, notePromptings } from './promptings/index.js';
41
43
  import { bodyForecastFor, consumeConsequences, enablePlanning, forecastLine, forecastThresholds, pruneConsequences, receiveConsequences, } from './prospection/index.js';
42
44
  import { enableReview, reviewIfDue } from './review/index.js';
43
45
  import { enableSkillRetention, learnByWatching, practise, seedSkills, } from './skills/index.js';
44
46
  import { advanceSocial, applyReputationBelief, careFor, closeness, judge, markDeceased, noteKnownBy, relationshipWith, seedRelationships, socialEvent, threatAppraisal, } from './social/index.js';
45
47
  import { ENGINE_VERSION, MAX_MINUTE, PERSON_SCHEMA, PHYSIOLOGICAL_NEEDS, PSYCHOLOGICAL_NEEDS, TRAIT_KEYS, VALUE_KEYS, } from './types.js';
46
- import { advanceWill, answerSuggestion, chargeCommand, commandTargets, createWill, creditedVoices, dischargeAdvice, dutyReviewAt, endCommand, learnFromVoice, noteCommandOutcome, predictResponse, rememberAdvice, resolveCommand, takeCommand, voicesIn, WILL_DEFAULTS, wakeReviewAt, } from './will/index.js';
48
+ import { advanceWill, answerSuggestion, chargeCommand, commandTargets, createWill, creditedVoices, dischargeAdvice, dutyReviewAt, enableSuggestionReasons, endCommand, learnFromVoice, noteCommandOutcome, predictResponse, rememberAdvice, resolveCommand, takeCommand, voicesIn, WILL_DEFAULTS, wakeReviewAt, } from './will/index.js';
47
49
  export const PERSON_DEFAULTS = {
48
50
  /** Body load while idle and awake. */
49
51
  idleLoad: { effort: 0.05, focus: 0.05, mode: 'awake' },
@@ -148,6 +150,8 @@ export function createPerson(spec) {
148
150
  if (family)
149
151
  p.family = family;
150
152
  const on = spec.enable;
153
+ if (on?.suggestionReasons)
154
+ enableSuggestionReasons(p);
151
155
  if (on?.gists)
152
156
  enableGists(p);
153
157
  if (on?.yearbook)
@@ -160,8 +164,12 @@ export function createPerson(spec) {
160
164
  enableFatigueStore(p);
161
165
  if (on?.fullness)
162
166
  enableFullness(p);
167
+ if (on?.projects)
168
+ enableProjects(p, on.projects === true ? {} : on.projects);
163
169
  if (on?.curiosity)
164
170
  enableCuriosity(p, on.curiosity === true ? {} : on.curiosity);
171
+ if (on?.persistence)
172
+ enablePersistence(p, on.persistence === true ? {} : on.persistence);
165
173
  if (on?.planning)
166
174
  enablePlanning(p, on.planning);
167
175
  if (on?.promptings)
@@ -365,11 +373,15 @@ function recordKeptAbstentions(p, kept, now) {
365
373
  * rounding (~1e-9) unless calls also fall on the grid, in which case replay is byte-identical.
366
374
  */
367
375
  export function tick(p, now) {
376
+ if (p.pursuit)
377
+ releasePursuit(p);
368
378
  if (now <= p.now)
369
379
  return;
370
380
  if (!p.body.alive) {
371
381
  p.now = now;
372
382
  pruneConsequences(p);
383
+ if (p.pursuit)
384
+ releasePursuit(p);
373
385
  return;
374
386
  }
375
387
  const seg = PERSON_DEFAULTS.maxSegment;
@@ -384,15 +396,21 @@ export function tick(p, now) {
384
396
  if (!(end > p.now)) {
385
397
  p.now = now;
386
398
  pruneConsequences(p);
399
+ if (p.pursuit)
400
+ releasePursuit(p);
387
401
  return;
388
402
  }
389
403
  advanceSegment(p, end - p.now, load, active && act ? act.affordance : undefined);
390
404
  p.now = end;
391
405
  pruneConsequences(p);
406
+ if (p.pursuit)
407
+ releasePursuit(p);
392
408
  if (!p.body.alive) {
393
409
  p.activity = null;
394
410
  p.now = now;
395
411
  pruneConsequences(p);
412
+ if (p.pursuit)
413
+ releasePursuit(p);
396
414
  return;
397
415
  }
398
416
  // Discrete bookkeeping runs only on grid boundaries, so it happens at the same minutes whatever the
@@ -452,6 +470,10 @@ export function tick(p, now) {
452
470
  }
453
471
  }
454
472
  }
473
+ if (p.projects)
474
+ considerProjects(p, end);
475
+ if (p.pursuit)
476
+ releasePursuit(p);
455
477
  }
456
478
  }
457
479
  /** The composite's side of the hourly crisis check: interrupt and remember an onset, remember a recovery. */
@@ -544,6 +566,8 @@ export function skip(p, to) {
544
566
  skipBody(p, to);
545
567
  p.now = to;
546
568
  pruneConsequences(p);
569
+ if (p.pursuit)
570
+ releasePursuit(p);
547
571
  skipAffect(p, to);
548
572
  advanceAgenda(p, to);
549
573
  // Lasting gists (1.8.0, opt-in): episodes now past the horizon fold into gists.
@@ -563,6 +587,8 @@ function socialKindOf(kind) {
563
587
  }
564
588
  function perceiveOne(p, pc) {
565
589
  receiveConsequences(p, pc);
590
+ if (p.projects && pc.prospect)
591
+ perceiveProspect(p, pc.prospect, pc.at);
566
592
  const at = Math.min(Math.max(pc.at, p.affect.lastUpdated), p.now);
567
593
  const actor = pc.actorId;
568
594
  const target = pc.targetId;
@@ -1019,6 +1045,20 @@ export function begin(p, aff, record, opts = {}) {
1019
1045
  if (!p.body.alive)
1020
1046
  return null;
1021
1047
  const now = p.now;
1048
+ if (p.pursuit) {
1049
+ const considered = record.considered.find((c) => c.affordanceId === aff.id);
1050
+ let terms = considered?.terms;
1051
+ if (!terms) {
1052
+ const { ctx } = decisionInputs(p, [aff], record.id, {});
1053
+ const route = prospectiveRoute(p, aff, ctx);
1054
+ const scale = 0.5 + 0.5 * clamp01(ctx.mods.maturity);
1055
+ terms = agendaTermsWithGoalEstimates(p, aff, now, route?.goals).map((t) => ({
1056
+ source: t.source,
1057
+ value: round(t.value * scale),
1058
+ }));
1059
+ }
1060
+ beginPursuit(p, terms);
1061
+ }
1022
1062
  const duration = Number.isFinite(aff.duration) ? Math.max(1, Math.round(aff.duration)) : 1;
1023
1063
  const mode = aff.mode ?? 'awake';
1024
1064
  const load = { effort: clamp01(aff.effort), focus: clamp01(aff.focus ?? 0), mode };
@@ -1293,6 +1333,8 @@ function learnActivityVoices(p, act, after, now, felt, broken, breached) {
1293
1333
  * relationships, trust in the advising voice, injuries and illness. Clears the activity.
1294
1334
  */
1295
1335
  export function finish(p, outcome, opts = {}) {
1336
+ if (p.pursuit)
1337
+ releasePursuit(p);
1296
1338
  const act = p.activity;
1297
1339
  if (!act)
1298
1340
  return null;
@@ -1407,6 +1449,10 @@ export function finish(p, outcome, opts = {}) {
1407
1449
  ...(act.necessity ? { necessity: true } : {}),
1408
1450
  ...(opts.catalog ? { catalog: opts.catalog } : {}),
1409
1451
  });
1452
+ if (p.projects)
1453
+ noteProjectStatus(p, now);
1454
+ if (p.pursuit)
1455
+ releasePursuit(p);
1410
1456
  // A completed violating action inside an open abstention's window broke it (the fast, e.g. "no work after the evening prayer").
1411
1457
  if (broken.length > 0) {
1412
1458
  recordMissed(p, broken, now, outcome.action);
@@ -0,0 +1 @@
1
+ export * from './projects.ts';
@@ -0,0 +1 @@
1
+ export * from './projects.js';
@@ -0,0 +1,23 @@
1
+ import type { Minute, Person, ProjectsSpec, ProjectsState, Prospect } from '../types.ts';
2
+ export declare const PROJECTS_DEFAULTS: {
3
+ readonly maxProspects: 8;
4
+ readonly maxHistory: 8;
5
+ readonly forgetMinutes: 1440;
6
+ readonly calmUrgency: 0.35;
7
+ readonly fearBelow: 0.35;
8
+ readonly adoptEveryMinutes: 30;
9
+ readonly cooldownMinutes: 60;
10
+ readonly adoptAppeal: 0.3;
11
+ readonly halfLifeMinutes: 120;
12
+ readonly staleMinutes: 180;
13
+ };
14
+ /** Enable or reconfigure without discarding retained prospects and history. */
15
+ export declare function enableProjects(p: Person, spec?: ProjectsSpec): ProjectsState;
16
+ /** Read-only, deterministic appeal. Thresholds close the relaxed gate before the bounded bonus. */
17
+ export declare function projectAppeal(p: Person, prospect: Prospect, now?: Minute): number;
18
+ /** Only the composite calls this, after attention. Newest event minute wins; equal-minute input order wins. */
19
+ export declare function perceiveProspect(p: Person, input: Prospect, at: Minute): void;
20
+ /** Called on each tick segment, including one-minute begin/tick/finish host loops. No RNG. */
21
+ export declare function considerProjects(p: Person, now: Minute): void;
22
+ /** Conservative restore: bounded fields, finite past timestamps, and no ambiguous duplicate ids. */
23
+ export declare function sanitizeProjects(input: unknown, now: Minute): ProjectsState | undefined;
@@ -0,0 +1,314 @@
1
+ /**
2
+ * SCOPE: self-set, bounded pursuits from host-shown, attended prospects. Fit to temperament and
3
+ * relaxed needs chooses one project; perceived progress or completed acts advances it; neglect drops
4
+ * it. Shapes, not fitted values: Little's personal projects (1983), Locke & Latham's goal setting
5
+ * (2002), autonomy/competence in Deci & Ryan and Ryan & Deci (2000), constructive play (Smilansky
6
+ * 1968; Burghardt 2005), and interrupted-task resumption (Ovsiankina 1928). The resumption bonus
7
+ * models a pull to continue, not a Zeigarnik memory advantage. See research/projects-and-persistence.md.
8
+ * All thresholds and coefficients are engineering defaults. This owns only p.projects; agenda owns
9
+ * goals. All kinds serve competence and leisure: making is chosen as a free-time pursuit, not duty.
10
+ * Restore recovers one active project, keeping the newest (id breaks ties) and abandoning extras.
11
+ * No invented prospects, host task interpretation, world search, persistence, shielding, faith lens,
12
+ * LLM or RNG. Without the slice, perception and minute bookkeeping write nothing.
13
+ */
14
+ import { emotionLevel } from '../affect/index.js';
15
+ import { AGENDA_DEFAULTS, abandonGoal, adoptGoal, perceiveProjectProgress } from '../agenda/index.js';
16
+ import { clamp01, cmpStr, isNum, isObj, round } from '../core/index.js';
17
+ import { curiosityOf } from '../curiosity/index.js';
18
+ import { readPerson } from '../person.js';
19
+ import { MAX_MINUTE, PHYSIOLOGICAL_NEEDS } from '../types.js';
20
+ export const PROJECTS_DEFAULTS = {
21
+ maxProspects: 8,
22
+ maxHistory: 8,
23
+ forgetMinutes: 1440,
24
+ calmUrgency: 0.35,
25
+ fearBelow: 0.35,
26
+ adoptEveryMinutes: 30,
27
+ cooldownMinutes: 60,
28
+ adoptAppeal: 0.3,
29
+ halfLifeMinutes: 120,
30
+ staleMinutes: 180,
31
+ };
32
+ const KEYS = [
33
+ 'forgetMinutes',
34
+ 'calmUrgency',
35
+ 'fearBelow',
36
+ 'adoptEveryMinutes',
37
+ 'cooldownMinutes',
38
+ 'adoptAppeal',
39
+ 'halfLifeMinutes',
40
+ 'staleMinutes',
41
+ ];
42
+ function cleanSpec(input) {
43
+ const out = {};
44
+ if (!isObj(input))
45
+ return out;
46
+ for (const key of KEYS) {
47
+ const x = input[key];
48
+ const unit = key === 'calmUrgency' || key === 'fearBelow' || key === 'adoptAppeal';
49
+ if (isNum(x) && x >= (unit ? 0 : 1) && x <= (unit ? 1 : 525600))
50
+ out[key] = x;
51
+ }
52
+ return out;
53
+ }
54
+ const setting = (s, key) => s.spec?.[key] ?? PROJECTS_DEFAULTS[key];
55
+ const str = (x) => typeof x === 'string' && x.length > 0 && x.length <= 512;
56
+ const unit = (x) => isNum(x) && x >= 0 && x <= 1;
57
+ const minute = (x, now) => isNum(x) && Math.abs(x) <= MAX_MINUTE && x <= now;
58
+ function cleanProspect(x) {
59
+ if (!isObj(x) ||
60
+ !str(x.id) ||
61
+ !str(x.label) ||
62
+ !['make', 'arrange', 'gather', 'finish'].includes(x.kind) ||
63
+ (x.placeId !== undefined && !str(x.placeId)) ||
64
+ (x.progress !== undefined && !unit(x.progress)) ||
65
+ (x.materialIds !== undefined &&
66
+ (!Array.isArray(x.materialIds) || x.materialIds.length > 256 || !x.materialIds.every(str))))
67
+ return undefined;
68
+ const p = { id: x.id, label: x.label, kind: x.kind };
69
+ if (x.placeId !== undefined)
70
+ p.placeId = x.placeId;
71
+ if (x.progress !== undefined)
72
+ p.progress = x.progress;
73
+ // A long list is kept, not rejected: the first eight distinct ids in the host's order (island rerun, 2026-10-20).
74
+ if (x.materialIds !== undefined)
75
+ p.materialIds = [...new Set(x.materialIds)].slice(0, 8);
76
+ return p;
77
+ }
78
+ /** Enable or reconfigure without discarding retained prospects and history. */
79
+ export function enableProjects(p, spec = {}) {
80
+ const state = p.projects ?? { prospects: [], history: [] };
81
+ const clean = cleanSpec(spec);
82
+ delete state.spec;
83
+ if (Object.keys(clean).length)
84
+ state.spec = clean;
85
+ p.projects = state;
86
+ return state;
87
+ }
88
+ function relaxed(p) {
89
+ const needs = readPerson(p).needs;
90
+ return {
91
+ urgency: Math.max(0, ...needs
92
+ .filter((n) => n.id === 'safety' || PHYSIOLOGICAL_NEEDS.includes(n.id))
93
+ .map((n) => n.urgency)),
94
+ fear: emotionLevel(p, 'fear'),
95
+ interest: Math.max(0, ...needs.filter((n) => ['leisure', 'competence', 'autonomy'].includes(n.id)).map((n) => n.urgency)),
96
+ };
97
+ }
98
+ /** Read-only, deterministic appeal. Thresholds close the relaxed gate before the bounded bonus. */
99
+ export function projectAppeal(p, prospect, now = p.now) {
100
+ const s = p.projects;
101
+ if (!s ||
102
+ !cleanProspect(prospect) ||
103
+ prospect.progress === 1 ||
104
+ p.body.asleep ||
105
+ p.activity?.mode === 'sleep' ||
106
+ !p.body.alive)
107
+ return 0;
108
+ const { urgency, fear, interest } = relaxed(p);
109
+ if (urgency >= setting(s, 'calmUrgency') || fear >= setting(s, 'fearBelow'))
110
+ return 0;
111
+ const gate = (1 - urgency) * (1 - fear);
112
+ const fit = 0.25 + 0.75 * (prospect.kind === 'finish' ? p.traits.conscientiousness : p.traits.openness);
113
+ const resume = prospect.progress !== undefined && prospect.progress > 0 && prospect.progress < 1 ? 0.1 : 0;
114
+ const bonus = p.curiosity
115
+ ? 0.05 *
116
+ clamp01(curiosityOf(p, {
117
+ id: prospect.id,
118
+ action: prospect.kind,
119
+ label: prospect.label,
120
+ duration: 1,
121
+ effort: 0,
122
+ advertises: {},
123
+ }, now).drive)
124
+ : 0;
125
+ return round(clamp01(gate * (fit * (0.5 + 0.5 * interest) + resume + bonus)));
126
+ }
127
+ function recordProjectStatus(p, g, now) {
128
+ const s = p.projects;
129
+ if (!s || !g.project || g.status === 'active')
130
+ return;
131
+ if (!s.history.some((h) => h.goalId === g.id))
132
+ s.history.push({
133
+ prospectId: g.project.prospectId,
134
+ goalId: g.id,
135
+ adoptedAt: g.adoptedAt,
136
+ outcome: g.status,
137
+ at: now,
138
+ });
139
+ if (g.status === 'abandoned')
140
+ s.lastAbandonedAt = now;
141
+ else {
142
+ // Completed event-progress projects have no host progress reading: retain the known completion.
143
+ const prospect = s.prospects.find((x) => x.id === g.project?.prospectId);
144
+ if (prospect)
145
+ prospect.progress = 1;
146
+ }
147
+ if (s.history.length > PROJECTS_DEFAULTS.maxHistory)
148
+ s.history.shift();
149
+ if (s.currentGoalId === g.id)
150
+ delete s.currentGoalId;
151
+ }
152
+ /** Record the adopted project's terminal state once, even after history eviction. @internal */
153
+ export function noteProjectStatus(p, now) {
154
+ const s = p.projects;
155
+ if (!s?.currentGoalId)
156
+ return;
157
+ const g = p.agenda.goals.find((g) => g.id === s.currentGoalId);
158
+ if (!g?.project)
159
+ delete s.currentGoalId;
160
+ else
161
+ recordProjectStatus(p, g, now);
162
+ }
163
+ /** Only the composite calls this, after attention. Newest event minute wins; equal-minute input order wins. */
164
+ export function perceiveProspect(p, input, at) {
165
+ const s = p.projects;
166
+ if (!s || !minute(at, p.now))
167
+ return;
168
+ const prospect = cleanProspect(input);
169
+ if (!prospect)
170
+ return;
171
+ const old = s.prospects.find((x) => x.id === prospect.id);
172
+ if (old && old.lastSeenAt > at)
173
+ return;
174
+ if (p.now - at > setting(s, 'forgetMinutes'))
175
+ return;
176
+ const held = { ...prospect, lastSeenAt: at };
177
+ if (old)
178
+ s.prospects[s.prospects.indexOf(old)] = held;
179
+ else {
180
+ if (s.prospects.length >= PROJECTS_DEFAULTS.maxProspects) {
181
+ let oldest = 0;
182
+ for (const [i, entry] of s.prospects.entries())
183
+ if (entry.lastSeenAt < (s.prospects[oldest]?.lastSeenAt ?? Infinity))
184
+ oldest = i;
185
+ s.prospects.splice(oldest, 1);
186
+ }
187
+ s.prospects.push(held);
188
+ }
189
+ if (prospect.progress !== undefined)
190
+ perceiveProjectProgress(p, prospect.id, prospect.progress, p.now);
191
+ noteProjectStatus(p, p.now);
192
+ }
193
+ /** Called on each tick segment, including one-minute begin/tick/finish host loops. No RNG. */
194
+ export function considerProjects(p, now) {
195
+ const s = p.projects;
196
+ if (!s || !minute(now, p.now))
197
+ return;
198
+ s.prospects = s.prospects.filter((x) => now - x.lastSeenAt <= setting(s, 'forgetMinutes'));
199
+ for (const g of p.agenda.goals)
200
+ if (g.project &&
201
+ g.status === 'active' &&
202
+ now - (g.lastAdvancedAt ?? g.adoptedAt) >= setting(s, 'staleMinutes')) {
203
+ abandonGoal(p, g.id);
204
+ recordProjectStatus(p, g, now);
205
+ }
206
+ noteProjectStatus(p, now);
207
+ const active = p.agenda.goals.filter((g) => g.status === 'active');
208
+ if (active.some((g) => g.project) ||
209
+ active.length >= AGENDA_DEFAULTS.maxActiveGoals ||
210
+ p.agenda.commitments.some((c) => c.status === 'pending' && c.from <= now && c.until >= now) ||
211
+ (s.lastAdoptedAt !== undefined && now - s.lastAdoptedAt < setting(s, 'adoptEveryMinutes')) ||
212
+ (s.lastAbandonedAt !== undefined && now - s.lastAbandonedAt < setting(s, 'cooldownMinutes')))
213
+ return;
214
+ const best = s.prospects
215
+ .map((prospect) => ({ prospect, appeal: projectAppeal(p, prospect, now) }))
216
+ .filter((x) => x.appeal > setting(s, 'adoptAppeal'))
217
+ .sort((a, b) => b.appeal - a.appeal || cmpStr(a.prospect.id, b.prospect.id))[0];
218
+ if (!best)
219
+ return;
220
+ const { prospect, appeal } = best;
221
+ const g = adoptGoal(p, {
222
+ label: prospect.label,
223
+ serves: ['competence', 'leisure'],
224
+ advancedBy: [],
225
+ importance: Math.max(0.3, Math.min(0.7, appeal)),
226
+ temporal: { halfLifeMinutes: setting(s, 'halfLifeMinutes') },
227
+ project: { prospectId: prospect.id, perceivedProgress: prospect.progress !== undefined },
228
+ }, now);
229
+ if (prospect.progress !== undefined)
230
+ perceiveProjectProgress(p, prospect.id, prospect.progress, now);
231
+ s.lastAdoptedAt = now;
232
+ s.currentGoalId = g.id;
233
+ }
234
+ /** Conservative restore: bounded fields, finite past timestamps, and no ambiguous duplicate ids. */
235
+ export function sanitizeProjects(input, now) {
236
+ if (!isObj(input) || !Array.isArray(input.prospects) || !Array.isArray(input.history))
237
+ return undefined;
238
+ const out = { prospects: [], history: [] };
239
+ if (input.spec !== undefined) {
240
+ if (!isObj(input.spec))
241
+ return undefined;
242
+ const spec = cleanSpec(input.spec);
243
+ if (Object.keys(spec).length !== Object.keys(input.spec).length)
244
+ return undefined;
245
+ if (Object.keys(spec).length)
246
+ out.spec = spec;
247
+ }
248
+ for (const key of ['lastAdoptedAt', 'lastAbandonedAt']) {
249
+ if (input[key] === undefined)
250
+ continue;
251
+ if (!minute(input[key], now))
252
+ return undefined;
253
+ out[key] = input[key];
254
+ }
255
+ if (input.currentGoalId !== undefined) {
256
+ if (!str(input.currentGoalId))
257
+ return undefined;
258
+ out.currentGoalId = input.currentGoalId;
259
+ }
260
+ const seen = new Set();
261
+ for (const entry of input.prospects.slice(0, PROJECTS_DEFAULTS.maxProspects)) {
262
+ const prospect = cleanProspect(entry);
263
+ if (!prospect || !isObj(entry) || !minute(entry.lastSeenAt, now))
264
+ continue;
265
+ if (seen.has(prospect.id))
266
+ return undefined;
267
+ seen.add(prospect.id);
268
+ if (now - entry.lastSeenAt <= setting(out, 'forgetMinutes'))
269
+ out.prospects.push({ ...prospect, lastSeenAt: entry.lastSeenAt });
270
+ }
271
+ seen.clear();
272
+ for (const h of input.history.slice(-PROJECTS_DEFAULTS.maxHistory)) {
273
+ if (!isObj(h) ||
274
+ !str(h.prospectId) ||
275
+ !str(h.goalId) ||
276
+ !minute(h.at, now) ||
277
+ !minute(h.adoptedAt, h.at) ||
278
+ (h.outcome !== 'achieved' && h.outcome !== 'abandoned'))
279
+ continue;
280
+ if (seen.has(h.goalId))
281
+ return undefined;
282
+ seen.add(h.goalId);
283
+ out.history.push({
284
+ prospectId: h.prospectId,
285
+ goalId: h.goalId,
286
+ adoptedAt: h.adoptedAt,
287
+ outcome: h.outcome,
288
+ at: h.at,
289
+ });
290
+ }
291
+ return out;
292
+ }
293
+ /** Sanitize and reconcile agenda ownership deterministically, without RNG. @internal */
294
+ export function restoreProjects(p) {
295
+ const s = sanitizeProjects(p.projects, p.now);
296
+ if (!s) {
297
+ delete p.projects;
298
+ return;
299
+ }
300
+ p.projects = s;
301
+ noteProjectStatus(p, p.now);
302
+ const active = p.agenda.goals
303
+ .filter((g) => g.project && g.status === 'active')
304
+ .sort((a, b) => b.adoptedAt - a.adoptedAt || cmpStr(a.id, b.id));
305
+ for (const g of active.slice(1)) {
306
+ abandonGoal(p, g.id);
307
+ recordProjectStatus(p, g, p.now);
308
+ }
309
+ const kept = active[0];
310
+ if (kept)
311
+ s.currentGoalId = kept.id;
312
+ else
313
+ delete s.currentGoalId;
314
+ }
@@ -88,6 +88,10 @@ function consequence(x, receivedAt) {
88
88
  s.duration < 0 ||
89
89
  s.duration > PROSPECTION_DEFAULTS.horizon ||
90
90
  !unit(s.effort) ||
91
+ (s.advances !== undefined &&
92
+ (!Array.isArray(s.advances) ||
93
+ s.advances.length > 8 ||
94
+ !s.advances.every((x) => typeof x === 'string' && x.length > 0 && x.length <= 512))) ||
91
95
  (s.focus !== undefined && !unit(s.focus)) ||
92
96
  (s.materialCost !== undefined &&
93
97
  (!isNum(s.materialCost) || s.materialCost < 0 || s.materialCost > PROSPECTION_DEFAULTS.maxMaterialCost)))
@@ -110,6 +114,7 @@ function consequence(x, receivedAt) {
110
114
  ...successorAction,
111
115
  duration: s.duration,
112
116
  effort: s.effort,
117
+ ...(s.advances !== undefined ? { advances: [...s.advances] } : {}),
113
118
  ...(s.focus !== undefined ? { focus: s.focus } : {}),
114
119
  ...(s.materialCost !== undefined ? { materialCost: s.materialCost } : {}),
115
120
  },
package/dist/restore.js CHANGED
@@ -21,7 +21,9 @@ import { GIST_DEFAULTS, MEMORY_DEFAULTS, sanitizeGists } from './memory/index.js
21
21
  import { sanitizeMethods } from './methods/index.js';
22
22
  import { migrate } from './migrate.js';
23
23
  import { sanitizeBonds } from './partnering/index.js';
24
+ import { sanitizePursuit } from './persistence/index.js';
24
25
  import { cleanRetention, createPerson, traceLimit } from './person.js';
26
+ import { restoreProjects } from './projects/index.js';
25
27
  import { sanitizePromptings } from './promptings/index.js';
26
28
  import { sanitizePlanning, sanitizeProspection } from './prospection/index.js';
27
29
  import { sanitizeReview } from './review/index.js';
@@ -88,6 +90,8 @@ const OPTIONAL_KEYS = new Set([
88
90
  'planning',
89
91
  'promptings',
90
92
  'curiosity',
93
+ 'projects',
94
+ 'pursuit',
91
95
  'review',
92
96
  ]);
93
97
  /** Kind of a JSON value for the per-field type check in `restore`. */
@@ -205,6 +209,8 @@ export function restore(input) {
205
209
  keepValid(out, 'planning', sanitizePlanning);
206
210
  keepValid(out, 'prospection', (x) => sanitizeProspection(x, out.now, out.id, out.planning !== undefined));
207
211
  keepValid(out, 'curiosity', (x) => sanitizeCuriosity(x, out.now));
212
+ restoreProjects(out);
213
+ keepValid(out, 'pursuit', (x) => sanitizePursuit(x, out.now));
208
214
  keepValid(out, 'promptings', (x) => sanitizePromptings(x, out.now));
209
215
  keepValid(out, 'review', (x) => sanitizeReview(x, out.now));
210
216
  boundLists(out);
package/dist/types.d.ts CHANGED
@@ -563,6 +563,8 @@ export interface Commitment {
563
563
  label?: string;
564
564
  /** For kind 'abstain': action ids whose completion inside the window breaks it. */
565
565
  violatedBy?: string[];
566
+ /** Firm self abstention: a will-not, lifted for bodily survival like a norm abstention. */
567
+ firm?: true;
566
568
  /** For kind 'abstain': catalogued conditions under which the person may not hold it that day. */
567
569
  exemptWhen?: ('illness' | 'travel')[];
568
570
  /**
@@ -586,6 +588,11 @@ export interface Commitment {
586
588
  recurUntil?: Minute;
587
589
  }
588
590
  export interface Goal {
591
+ /** Prospect identity; perceived projects receive progress only through perception. */
592
+ project?: {
593
+ prospectId: string;
594
+ perceivedProgress: boolean;
595
+ };
589
596
  id: string;
590
597
  label: string;
591
598
  /** Which need/value the goal serves. */
@@ -700,6 +707,8 @@ export interface VoiceTrustEvent {
700
707
  * impulse emerges from competing terms, real fatigue costs, habits and precommitments.
701
708
  */
702
709
  export interface WillState {
710
+ /** Opt-in score explanations on suggestion resolutions; absent preserves legacy JSON. */
711
+ suggestionReasons?: true;
703
712
  voices: VoiceRelation[];
704
713
  /**
705
714
  * Precommitments the person made for themselves (e.g. "no work after Isha", "walk instead of eat when bored"):
@@ -885,7 +894,7 @@ export declare const PERSON_SCHEMA = "human/person@1";
885
894
  * and an activity that would cover a closing stretch is reviewed when the stretch begins (`isUnderWay`,
886
895
  * `dutyReviewAt`). Person shape unchanged.
887
896
  */
888
- export declare const ENGINE_VERSION = "2.12.0";
897
+ export declare const ENGINE_VERSION = "2.14.0";
889
898
  /** A pregnancy carried by this person (host-driven; see `family/`). */
890
899
  export interface Pregnancy {
891
900
  fatherId: PersonId;
@@ -1002,6 +1011,8 @@ export interface ReceivedConsequence {
1002
1011
  effort: Unit;
1003
1012
  focus?: Unit;
1004
1013
  materialCost?: number;
1014
+ /** Goal or prospect ids this successor contributes to (bounded to 8). */
1015
+ advances?: string[];
1005
1016
  };
1006
1017
  availableFrom: Minute;
1007
1018
  /** Latest permitted successor completion, inclusive. */
@@ -1067,6 +1078,10 @@ export interface Person {
1067
1078
  promptings?: PromptingsState;
1068
1079
  /** Intrinsic exploration and varied repetition. Owned by `curiosity/`; absent unless enabled. */
1069
1080
  curiosity?: CuriosityState;
1081
+ /** Self-set projects from attended prospects; owned by projects/. */
1082
+ projects?: ProjectsState;
1083
+ /** Keeping a started goal active. Owned by persistence/; absent unless enabled. */
1084
+ pursuit?: PursuitState;
1070
1085
  /** Evening self-review and its resolves (2.7.0, opt-in via `enableReview`). Owned by `review/`. */
1071
1086
  review?: ReviewState;
1072
1087
  /** Practice consolidation (1.8.0, opt-in via `enableSkillRetention`). Owned by `skills/`. */
@@ -1202,6 +1217,8 @@ export interface Affordance {
1202
1217
  }
1203
1218
  /** Something a person perceives. Hosts emit percepts; attention decides which are encoded. */
1204
1219
  export interface Percept {
1220
+ /** Host-shown opportunity, retained only with projects enabled and when attended. */
1221
+ prospect?: Prospect;
1205
1222
  at: Minute;
1206
1223
  /**
1207
1224
  * How it reached the person: seen, heard, told (testimony), felt (bodily), the result of an action
@@ -1374,6 +1391,12 @@ export type SuggestionVerdict = 'assented' | 'complied' | 'deferred' | 'modified
1374
1391
  /** Refusal class for UI: grey = cannot, amber = notNow, red = willNot. */
1375
1392
  export type RefusalKind = 'cannot' | 'notNow' | 'willNot';
1376
1393
  export interface SuggestionResolution {
1394
+ /** Read-only score explanation, only with `enableSuggestionReasons`; margin includes activity inertia. */
1395
+ decidedBy?: {
1396
+ kind: 'inertia' | 'term';
1397
+ source?: string;
1398
+ margin: number;
1399
+ };
1377
1400
  voiceId: EntityId;
1378
1401
  verdict: SuggestionVerdict;
1379
1402
  kind?: RefusalKind;
@@ -1501,6 +1524,8 @@ export interface PersonSpec {
1501
1524
  * `enableCharacterChange` and `enableSkillRetention` right after `createPerson`. Each is off when absent.
1502
1525
  */
1503
1526
  enable?: {
1527
+ /** Read-only suggestion score explanations, as with `enableSuggestionReasons(p)`. */
1528
+ suggestionReasons?: boolean;
1504
1529
  gists?: boolean;
1505
1530
  yearbook?: boolean;
1506
1531
  character?: boolean;
@@ -1517,6 +1542,10 @@ export interface PersonSpec {
1517
1542
  planning?: PlanningSpec;
1518
1543
  /** Evening self-review (2.7.0): the same as `enableReview(p, spec)` after creation. */
1519
1544
  review?: ReviewSpec;
1545
+ /** Self-set projects from what the person attends to. */
1546
+ projects?: boolean | ProjectsSpec;
1547
+ /** Keep started goal pursuits across decisions. */
1548
+ persistence?: boolean | PersistenceSpec;
1520
1549
  };
1521
1550
  }
1522
1551
  /** A known span of exertion after an act (see `Affordance.followedBy`). */
@@ -1945,3 +1974,70 @@ export interface CuriosityReading {
1945
1974
  vary: Unit;
1946
1975
  drive: number;
1947
1976
  }
1977
+ /** A bounded pursuit opportunity explicitly shown by the host. */
1978
+ export interface Prospect {
1979
+ id: string;
1980
+ label: string;
1981
+ kind: 'make' | 'arrange' | 'gather' | 'finish';
1982
+ placeId?: EntityId;
1983
+ /** Up to 256 accepted; the first eight distinct ids are retained. */
1984
+ materialIds?: EntityId[];
1985
+ progress?: Unit;
1986
+ }
1987
+ export interface ProjectsSpec {
1988
+ forgetMinutes?: number;
1989
+ calmUrgency?: Unit;
1990
+ fearBelow?: Unit;
1991
+ adoptEveryMinutes?: number;
1992
+ cooldownMinutes?: number;
1993
+ adoptAppeal?: Unit;
1994
+ halfLifeMinutes?: number;
1995
+ staleMinutes?: number;
1996
+ }
1997
+ export interface ProjectHistory {
1998
+ prospectId: string;
1999
+ goalId: string;
2000
+ adoptedAt: Minute;
2001
+ outcome: 'achieved' | 'abandoned';
2002
+ at: Minute;
2003
+ }
2004
+ /** Optional bounded JSON state owned by projects/. */
2005
+ export interface ProjectsState {
2006
+ spec?: ProjectsSpec;
2007
+ prospects: (Prospect & {
2008
+ lastSeenAt: Minute;
2009
+ })[];
2010
+ history: ProjectHistory[];
2011
+ lastAdoptedAt?: Minute;
2012
+ lastAbandonedAt?: Minute;
2013
+ /** The adopted goal awaiting its one terminal history entry. */
2014
+ currentGoalId?: string;
2015
+ }
2016
+ /** Bounded engineering overrides for persistence/. Invalid overrides use defaults. */
2017
+ export interface PersistenceSpec {
2018
+ continueScale?: number;
2019
+ switchScale?: number;
2020
+ cap?: number;
2021
+ switchMargin?: number;
2022
+ slackMinutes?: number;
2023
+ maxMinutes?: number;
2024
+ /** Refractory interval for the same goal after maximum-duration release (default 20 minutes). */
2025
+ restMinutes?: number;
2026
+ }
2027
+ /** A started goal retained across decisions. */
2028
+ export interface Pursuit {
2029
+ goalId: string;
2030
+ since: Minute;
2031
+ lastServedAt: Minute;
2032
+ strength: Unit;
2033
+ }
2034
+ /** Optional JSON state owned by persistence/: one current pursuit and one refractory goal. */
2035
+ export interface PursuitState {
2036
+ spec?: PersistenceSpec;
2037
+ current?: Pursuit;
2038
+ /** One goal withheld from pursuit formation until this minute; agenda terms remain unchanged. */
2039
+ rested?: {
2040
+ goalId: string;
2041
+ until: Minute;
2042
+ };
2043
+ }
package/dist/types.js CHANGED
@@ -120,4 +120,4 @@ export const PERSON_SCHEMA = 'human/person@1';
120
120
  * and an activity that would cover a closing stretch is reviewed when the stretch begins (`isUnderWay`,
121
121
  * `dutyReviewAt`). Person shape unchanged.
122
122
  */
123
- export const ENGINE_VERSION = '2.12.0';
123
+ export const ENGINE_VERSION = '2.14.0';
@@ -266,6 +266,13 @@ export declare function dominantTerm(c: Considered | undefined): string;
266
266
  * sorted by voice id and bounded by `maxVoices`, so the outcome never depends on the host's argument order.
267
267
  */
268
268
  export declare function voicesIn(single: Suggestion | undefined, list: readonly Suggestion[] | undefined): Suggestion[];
269
+ /**
270
+ * SCOPE (suggestion reasons): opt-in read-only explanations of score-based deferrals and modifications.
271
+ * The gap includes running-activity inertia; source differences aggregate like-labelled terms and tie by
272
+ * source string. This does not change selection, voice wording, counters or RNG, explain vetoes, or claim
273
+ * a score caused a lower-scoring stochastic choice. Legacy resolutions have no additional keys.
274
+ */
275
+ export declare function enableSuggestionReasons(p: Person): void;
269
276
  /** Whether a command names offer `aff` (by affordance id, or by action class when it names no affordance). */
270
277
  export declare const commandTargets: (cmd: Command, aff: Affordance) => boolean;
271
278
  /**
package/dist/will/will.js CHANGED
@@ -36,7 +36,7 @@ import { breakAllows, inBreak } from '../affect/index.js';
36
36
  import { commitmentPressure, isUnderWay, pressureReachedAt } from '../agenda/index.js';
37
37
  import { downedAllows, readCapacities } from '../body/index.js';
38
38
  import { CONSCIENCE_DEFAULTS, normVeto } from '../conscience/index.js';
39
- import { clamp01, cmpStr, decay, dexp, dpow, hasNumbers, isObj, random } from '../core/index.js';
39
+ import { clamp01, cmpStr, decay, dexp, dpow, hasNumbers, isObj, random, round } from '../core/index.js';
40
40
  import { skillLevel } from '../skills/index.js';
41
41
  import { MINUTES_PER_DAY, MINUTES_PER_HOUR } from '../types.js';
42
42
  export const WILL_DEFAULTS = {
@@ -160,6 +160,8 @@ export const WILL_DEFAULTS = {
160
160
  * or `lastCommand` (1.6.0, 1.7.0) are dropped (absent means none). A save the engine wrote is unchanged. @internal
161
161
  */
162
162
  export function sanitizeWill(w) {
163
+ if (w.suggestionReasons !== true)
164
+ delete w.suggestionReasons;
163
165
  if (w.advice !== undefined && !Array.isArray(w.advice))
164
166
  delete w.advice;
165
167
  const cmd = w.command;
@@ -721,6 +723,43 @@ function arbitrateCommand(p, ctx, cmd, suggestions, prepared, selection, ev) {
721
723
  }
722
724
  return false;
723
725
  }
726
+ /**
727
+ * SCOPE (suggestion reasons): opt-in read-only explanations of score-based deferrals and modifications.
728
+ * The gap includes running-activity inertia; source differences aggregate like-labelled terms and tie by
729
+ * source string. This does not change selection, voice wording, counters or RNG, explain vetoes, or claim
730
+ * a score caused a lower-scoring stochastic choice. Legacy resolutions have no additional keys.
731
+ */
732
+ export function enableSuggestionReasons(p) {
733
+ p.will.suggestionReasons = true;
734
+ }
735
+ function scoreReason(selection, live, targetIds) {
736
+ const { winner, ranked, score } = selection;
737
+ if (!winner)
738
+ return undefined;
739
+ // A stochastic draw below the best live score was not decided by the scores.
740
+ if (ranked.some((c) => !c.vetoed && score(c) > score(winner)))
741
+ return undefined;
742
+ // Compare against the closest suggested option by the same adjusted score the winner was chosen on.
743
+ const suggested = [...live].sort((a, b) => score(b) - score(a) || cmpStr(a.affordanceId, b.affordanceId))[0];
744
+ if (!suggested)
745
+ return undefined;
746
+ const gap = score(winner) - score(suggested);
747
+ if (!Number.isFinite(gap) || gap < 0)
748
+ return undefined;
749
+ const margin = round(gap);
750
+ const withoutInertia = [...ranked].sort(byUtilityThenId)[0];
751
+ if (score(winner) > winner.utility && withoutInertia && targetIds.has(withoutInertia.affordanceId))
752
+ return { kind: 'inertia', margin };
753
+ const differences = new Map();
754
+ for (const t of winner.terms)
755
+ differences.set(t.source, (differences.get(t.source) ?? 0) + t.value);
756
+ for (const t of suggested.terms)
757
+ differences.set(t.source, (differences.get(t.source) ?? 0) - t.value);
758
+ const term = [...differences]
759
+ .filter(([, value]) => value > 0)
760
+ .sort((a, b) => b[1] - a[1] || cmpStr(a[0], b[0]))[0];
761
+ return { kind: 'term', ...(term ? { source: term[0] } : {}), margin };
762
+ }
724
763
  /** Judge each voice against the same selected option before resolving competing insistence. */
725
764
  function judgeVoices(p, ctx, suggestions, prepared, selection) {
726
765
  const W = WILL_DEFAULTS;
@@ -829,17 +868,21 @@ function judgeVoices(p, ctx, suggestions, prepared, selection) {
829
868
  }
830
869
  side.pressure = W.pressurePush * clamp01(s.strength);
831
870
  side.refused = 1;
871
+ const decidedBy = p.will.suggestionReasons ? scoreReason(selection, liveSuggested, targetIds) : undefined;
872
+ const explanation = decidedBy ? { decidedBy } : {};
832
873
  const near = winnerAff !== undefined &&
833
874
  suggestedAff !== undefined &&
834
875
  (winnerAff.action === suggestedAff.action ||
835
876
  (mainAim(suggestedAff) !== undefined && mainAim(suggestedAff) === mainAim(winnerAff)));
836
877
  if (near && winnerAff)
837
878
  return out(resolution('modified', reason, {
879
+ ...explanation,
838
880
  kind: 'notNow',
839
881
  insteadAffordanceId: winnerAff.id,
840
882
  counterOffer: { affordanceId: winnerAff.id, label: winnerAff.label },
841
883
  }));
842
884
  return out(resolution('deferred', reason, {
885
+ ...explanation,
843
886
  kind: 'notNow',
844
887
  insteadAffordanceId: winnerAff?.id,
845
888
  counterOffer: winnerAff
@@ -865,6 +908,9 @@ function arbitrateInsistence(p, judged, considered, affById, ev) {
865
908
  forcer = j;
866
909
  }
867
910
  if (forcer?.comply) {
911
+ // The final option is imposed by insistence, not selected by scores.
912
+ for (const j of judged)
913
+ delete j.res.decidedBy;
868
914
  const forced = forcer.comply.target.affordanceId;
869
915
  const forcedAff = affById.get(forced);
870
916
  ev.chosenId = forced;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adam0white/human-framework",
3
- "version": "2.12.0",
3
+ "version": "2.14.0",
4
4
  "description": "Deterministic, explainable simulated people: body, needs, affect, memory, beliefs, skills, habits, relationships, values and a will that can refuse a suggestion",
5
5
  "keywords": [
6
6
  "simulation",