@adam0white/human-framework 2.13.0 → 2.14.1

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.
@@ -47,7 +47,7 @@
47
47
  */
48
48
  // The bundled catalog is only the default; a host with its own catalog passes it (`World.catalog`).
49
49
  import { DEFAULT_NORMS } from '../conscience/catalog.js';
50
- import { clamp01, dayOf, dpow, hasNumbers, isObj } from '../core/index.js';
50
+ import { clamp01, dayOf, dpow, hasNumbers, isNum, isObj } from '../core/index.js';
51
51
  import { expectedEffect } from '../memory/index.js';
52
52
  import { MINUTES_PER_DAY } from '../types.js';
53
53
  export const AGENDA_DEFAULTS = {
@@ -144,6 +144,20 @@ 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
+ if (isNum(project.peak) && project.peak >= 0 && project.peak <= 1)
156
+ g.project.peak = project.peak;
157
+ }
158
+ else
159
+ delete g.project;
160
+ }
147
161
  const t = g.temporal;
148
162
  if (t !== undefined &&
149
163
  !(isObj(t) &&
@@ -479,11 +493,16 @@ function heldConviction(p, c) {
479
493
  * keeps by the same rule, so a pull never leads to an activity that cannot keep the commitment.
480
494
  */
481
495
  export const spanMeetsWindow = (c, start, end) => start <= c.until && end >= c.from;
496
+ /** Explicit host progress can name either the adopted goal or its perceived prospect. */
497
+ function namesGoal(g, advances) {
498
+ return ((advances?.includes(g.id) ?? false) ||
499
+ (g.project !== undefined && (advances?.includes(g.project.prospectId) ?? false)));
500
+ }
482
501
  function goalAmount(g, aff) {
483
502
  const own = g.advancedBy.find((a) => a.action === aff.action);
484
503
  if (own)
485
504
  return own.amount;
486
- return aff.advances?.includes(g.id) ? AGENDA_DEFAULTS.defaultAdvanceAmount : 0;
505
+ return namesGoal(g, aff.advances) ? AGENDA_DEFAULTS.defaultAdvanceAmount : 0;
487
506
  }
488
507
  function deadlinePressure(g, now) {
489
508
  if (g.deadline === undefined)
@@ -671,9 +690,17 @@ export function onFinished(p, outcome, startedAt = outcome.at, opts = {}) {
671
690
  if (g.status !== 'active')
672
691
  continue;
673
692
  const own = g.advancedBy.find((a) => a.action === outcome.action)?.amount ?? 0;
674
- const amount = own > 0 ? own : outcome.advances?.includes(g.id) ? AGENDA_DEFAULTS.defaultAdvanceAmount : 0;
693
+ const amount = own > 0 ? own : namesGoal(g, outcome.advances) ? AGENDA_DEFAULTS.defaultAdvanceAmount : 0;
675
694
  if (amount <= 0)
676
695
  continue;
696
+ // onFinished runs before the host shows the result: advanced counts a contribution made from
697
+ // at or above the high-water mark, not a confirmed new high. Only the next perception moves
698
+ // the progress clock, importance and pull; the composite does not consume advanced.
699
+ if (g.project?.perceivedProgress) {
700
+ if (g.project.peak === undefined || g.progress >= g.project.peak)
701
+ advanced.push(g);
702
+ continue;
703
+ }
677
704
  g.progress = clamp01(g.progress + amount);
678
705
  g.lastAdvancedAt = outcome.at;
679
706
  if (g.baseImportance !== undefined)
@@ -704,6 +731,25 @@ export function adoptGoal(p, g, now) {
704
731
  p.agenda.goals.push(goal);
705
732
  return goal;
706
733
  }
734
+ /** Attended host progress is authoritative for an active perceived-progress project. @internal */
735
+ export function perceiveProjectProgress(p, prospectId, progress, now) {
736
+ for (const g of p.agenda.goals) {
737
+ if (g.status !== 'active' || !g.project?.perceivedProgress || g.project.prospectId !== prospectId)
738
+ continue;
739
+ if (p.projects?.behaviour === 2 && g.project.peak === undefined)
740
+ g.project.peak = g.progress;
741
+ if (progress > (g.project.peak ?? g.progress)) {
742
+ if (g.project.peak !== undefined)
743
+ g.project.peak = clamp01(progress);
744
+ g.lastAdvancedAt = now;
745
+ if (g.baseImportance !== undefined)
746
+ g.importance = g.baseImportance;
747
+ }
748
+ g.progress = clamp01(progress);
749
+ if (g.progress >= 1)
750
+ g.status = 'achieved';
751
+ }
752
+ }
707
753
  export function abandonGoal(p, id) {
708
754
  const g = p.agenda.goals.find((x) => x.id === id);
709
755
  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))
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Human Framework public surface. */
2
- export declare const FRAMEWORK_VERSION = "2.13.0";
2
+ export declare const FRAMEWORK_VERSION = "2.14.1";
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.13.0';
2
+ export const FRAMEWORK_VERSION = '2.14.1';
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,8 @@
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.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
21
+ * the earliest-ending one. 2.14.0 to 2.14.1 only stamps the version: slices without behaviour markers
22
+ * retain 2.14.0 crediting and an undecaying pull until the host re-enables them. 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
23
  * believed regard stay absent unless a host supplies them;
23
24
  * `regard` becomes a reserved impression cue key. A migrated save continues under the current engine's rules: it restores and runs,
24
25
  * 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 +71,16 @@ export const MIGRATIONS = {
70
71
  note: 'the surroundings field `ambient.now` is renamed `ambient.percept`; per-person retention is optional and starts absent; behaviour unchanged',
71
72
  apply: renameAmbient,
72
73
  },
74
+ '2.14.0': {
75
+ to: '2.14.1',
76
+ note: 'version stamp only; restored projects and persistence retain 2.14.0 behaviour until explicitly re-enabled',
77
+ apply: stamp,
78
+ },
79
+ '2.13.0': {
80
+ to: '2.14.0',
81
+ 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',
82
+ apply: stamp,
83
+ },
73
84
  '2.12.0': {
74
85
  to: '2.13.0',
75
86
  note: 'suggestion reasons (Person.will.suggestionReasons) and firm self-rules (Commitment.firm) are optional and start absent; existing hosts retain exact behavior',
@@ -0,0 +1 @@
1
+ export * from './persistence.ts';
@@ -0,0 +1 @@
1
+ export * from './persistence.js';
@@ -0,0 +1,19 @@
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
+ readonly stallHalfLifeMinutes: 30;
15
+ };
16
+ /** Enable or reconfigure, retaining a current pursuit. Invalid overrides use defaults. */
17
+ export declare function enablePersistence(p: Person, spec?: PersistenceSpec): PursuitState;
18
+ /** A copy of the current pursuit, without pruning, mutation or RNG. */
19
+ export declare function pursuitOf(p: Person): Pursuit | undefined;
@@ -0,0 +1,209 @@
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 keeps one refractory entry, for the most recent release. With defaults
8
+ * (maxMinutes 240, restMinutes 20), two releases cannot fall within one interval; with a shorter
9
+ * maximum, a later release can replace an earlier unexpired interval.
10
+ * These are shapes, not fitted values; all coefficients and time bounds are engineering defaults.
11
+ * This is a bounded bias plus a switching cost, not a planner. It chooses no goals, executes no
12
+ * plans, reorders no steps and changes no agenda valuation. It imports no projects, uses no RNG,
13
+ * LLM or faith lens. Needs and fear gate both terms; commitment options never pay the switch cost.
14
+ * Behaviour 2 halves both terms with time since the pursued goal last advanced (or adoption),
15
+ * including after release and re-formation. Absent behaviour retains the 2.14.0 undecaying pull.
16
+ * Without the slice, it adds no state or terms.
17
+ */
18
+ import { emotionLevel } from '../affect/index.js';
19
+ import { clamp01, cmpStr, dpow, isNum, isObj, round } from '../core/index.js';
20
+ import { MAX_MINUTE, PHYSIOLOGICAL_NEEDS } from '../types.js';
21
+ /** Engineering defaults, relative to cognition's need scale 4, pleasure .6 and curiosity .8/.15.
22
+ * Continuation and switching together outweigh mild relaxed distraction; urgency and fear gate both.
23
+ * A pressing commitment has its full agenda term and pays no switching cost.
24
+ */
25
+ export const PERSISTENCE_DEFAULTS = {
26
+ continueScale: 0.8,
27
+ switchScale: 0.8,
28
+ cap: 0.8,
29
+ switchMargin: 0.15,
30
+ slackMinutes: 20,
31
+ maxMinutes: 240,
32
+ restMinutes: 20,
33
+ stallHalfLifeMinutes: 30,
34
+ };
35
+ const KEYS = [
36
+ 'continueScale',
37
+ 'switchScale',
38
+ 'cap',
39
+ 'switchMargin',
40
+ 'slackMinutes',
41
+ 'maxMinutes',
42
+ 'restMinutes',
43
+ 'stallHalfLifeMinutes',
44
+ ];
45
+ function cleanSpec(input) {
46
+ const out = {};
47
+ if (!isObj(input))
48
+ return out;
49
+ for (const key of KEYS) {
50
+ const x = input[key];
51
+ const duration = key === 'slackMinutes' ||
52
+ key === 'maxMinutes' ||
53
+ key === 'restMinutes' ||
54
+ key === 'stallHalfLifeMinutes';
55
+ const min = key === 'stallHalfLifeMinutes' ? 1 : 0;
56
+ if (isNum(x) && x >= min && x <= (duration ? 1440 : 1))
57
+ out[key] = x;
58
+ }
59
+ return out;
60
+ }
61
+ const setting = (s, key) => s.spec?.[key] ?? PERSISTENCE_DEFAULTS[key];
62
+ const identifier = (x) => typeof x === 'string' && x.length > 0 && x.length <= 512;
63
+ const active = (p, id) => p.agenda.goals.find((g) => g.id === id && g.status === 'active');
64
+ /** Enable or reconfigure, retaining a current pursuit. Invalid overrides use defaults. */
65
+ export function enablePersistence(p, spec = {}) {
66
+ const out = { behaviour: 2 };
67
+ const clean = cleanSpec(spec);
68
+ if (Object.keys(clean).length)
69
+ out.spec = clean;
70
+ if (p.pursuit?.current)
71
+ out.current = { ...p.pursuit.current };
72
+ if (p.pursuit?.rested)
73
+ out.rested = { ...p.pursuit.rested };
74
+ p.pursuit = out;
75
+ return out;
76
+ }
77
+ /** A copy of the current pursuit, without pruning, mutation or RNG. */
78
+ export function pursuitOf(p) {
79
+ return p.pursuit?.current ? { ...p.pursuit.current } : undefined;
80
+ }
81
+ /** Drop finished, absent, neglected or overlong pursuits. Interruptions alone do not release. @internal */
82
+ export function releasePursuit(p) {
83
+ const s = p.pursuit;
84
+ if (!s)
85
+ return;
86
+ if (s.rested && p.now >= s.rested.until)
87
+ delete s.rested;
88
+ const c = s.current;
89
+ if (!c)
90
+ return;
91
+ if (!active(p, c.goalId) || p.now - c.lastServedAt >= setting(s, 'slackMinutes')) {
92
+ delete s.current;
93
+ return;
94
+ }
95
+ if (p.now - c.since >= setting(s, 'maxMinutes')) {
96
+ delete s.current;
97
+ s.rested = { goalId: c.goalId, until: Math.min(MAX_MINUTE, p.now + setting(s, 'restMinutes')) };
98
+ }
99
+ }
100
+ /** Form from the begun option's already-rounded goal terms, including route credit. @internal */
101
+ export function beginPursuit(p, terms) {
102
+ const s = p.pursuit;
103
+ if (!s)
104
+ return;
105
+ releasePursuit(p);
106
+ const candidates = p.agenda.goals
107
+ .filter((g) => g.status === 'active' && identifier(g.id) && g.id !== s.rested?.goalId)
108
+ .map((goal) => ({
109
+ goal,
110
+ value: terms
111
+ .filter((t) => t.source === `goal:${goal.id}` && t.value > 0)
112
+ .reduce((v, t) => v + t.value, 0),
113
+ }))
114
+ .filter((x) => x.value > 0)
115
+ .sort((a, b) => b.value - a.value || cmpStr(a.goal.id, b.goal.id));
116
+ const best = candidates[0];
117
+ if (!best)
118
+ return;
119
+ const current = s.current;
120
+ const currentValue = candidates.find((x) => x.goal.id === current?.goalId)?.value ?? 0;
121
+ if (current &&
122
+ best.goal.id !== current.goalId &&
123
+ round(best.value - currentValue) <= setting(s, 'switchMargin')) {
124
+ if (currentValue > 0)
125
+ current.lastServedAt = p.now;
126
+ return;
127
+ }
128
+ s.current = {
129
+ goalId: best.goal.id,
130
+ since: current?.goalId === best.goal.id ? current.since : p.now,
131
+ lastServedAt: p.now,
132
+ strength: clamp01(best.goal.importance * (0.4 + 0.6 * p.traits.conscientiousness)),
133
+ };
134
+ }
135
+ /** Read already-pushed goal/commitment terms under the actual perceived needs. @internal */
136
+ export function persistenceTerms(p, terms, ctx) {
137
+ const s = p.pursuit;
138
+ const c = s?.current;
139
+ if (!s || !c)
140
+ return [];
141
+ const goal = active(p, c.goalId);
142
+ if (!goal)
143
+ return [];
144
+ const stall = Math.max(0, p.now - (goal.lastAdvancedAt ?? goal.adoptedAt));
145
+ const decay = s.behaviour === 2 ? dpow(0.5, stall / setting(s, 'stallHalfLifeMinutes')) : 1;
146
+ let urgency = 0;
147
+ for (const n of ctx.needs)
148
+ if (n.id === 'safety' || PHYSIOLOGICAL_NEEDS.includes(n.id))
149
+ urgency = Math.max(urgency, n.urgency);
150
+ const gate = (1 - clamp01(urgency)) * (1 - emotionLevel(p, 'fear'));
151
+ if (terms.some((t) => t.source === `goal:${c.goalId}` && t.value > 0))
152
+ return [
153
+ {
154
+ source: `pursuit:${c.goalId}`,
155
+ value: round(Math.min(setting(s, 'cap'), setting(s, 'continueScale') * c.strength * gate) * decay),
156
+ },
157
+ ];
158
+ if (terms.some((t) => t.source.startsWith('commitment:')))
159
+ return [];
160
+ return [
161
+ {
162
+ source: 'pursuit:switch',
163
+ value: -round(Math.min(setting(s, 'cap'), setting(s, 'switchScale') * c.strength * gate) * decay),
164
+ },
165
+ ];
166
+ }
167
+ const minute = (x, now) => isNum(x) && Math.abs(x) <= MAX_MINUTE && x <= now;
168
+ /** Conservative restore: malformed settings or pursuit drop the optional slice. @internal */
169
+ export function sanitizePursuit(input, now) {
170
+ if (!isObj(input) || (input.spec !== undefined && !isObj(input.spec)))
171
+ return undefined;
172
+ if (input.behaviour !== undefined && input.behaviour !== 2)
173
+ return undefined;
174
+ const out = {};
175
+ if (input.behaviour === 2)
176
+ out.behaviour = 2;
177
+ if (input.spec !== undefined) {
178
+ const spec = cleanSpec(input.spec);
179
+ if (Object.keys(spec).length !== Object.keys(input.spec).length)
180
+ return undefined;
181
+ if (Object.keys(spec).length)
182
+ out.spec = spec;
183
+ }
184
+ const c = input.current;
185
+ if (c !== undefined) {
186
+ if (!isObj(c) ||
187
+ !identifier(c.goalId) ||
188
+ !minute(c.since, now) ||
189
+ !minute(c.lastServedAt, now) ||
190
+ c.lastServedAt < c.since ||
191
+ !isNum(c.strength) ||
192
+ c.strength < 0 ||
193
+ c.strength > 1)
194
+ return undefined;
195
+ out.current = { goalId: c.goalId, since: c.since, lastServedAt: c.lastServedAt, strength: c.strength };
196
+ }
197
+ const r = input.rested;
198
+ if (r !== undefined) {
199
+ if (!isObj(r) ||
200
+ !identifier(r.goalId) ||
201
+ !isNum(r.until) ||
202
+ Math.abs(r.until) > MAX_MINUTE ||
203
+ // A later reconfiguration may shorten restMinutes without shortening an existing interval.
204
+ r.until > now + 1440)
205
+ return undefined;
206
+ out.rested = { goalId: r.goalId, until: r.until };
207
+ }
208
+ return out;
209
+ }
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,6 +37,8 @@ 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';
@@ -162,8 +164,12 @@ export function createPerson(spec) {
162
164
  enableFatigueStore(p);
163
165
  if (on?.fullness)
164
166
  enableFullness(p);
167
+ if (on?.projects)
168
+ enableProjects(p, on.projects === true ? {} : on.projects);
165
169
  if (on?.curiosity)
166
170
  enableCuriosity(p, on.curiosity === true ? {} : on.curiosity);
171
+ if (on?.persistence)
172
+ enablePersistence(p, on.persistence === true ? {} : on.persistence);
167
173
  if (on?.planning)
168
174
  enablePlanning(p, on.planning);
169
175
  if (on?.promptings)
@@ -367,11 +373,15 @@ function recordKeptAbstentions(p, kept, now) {
367
373
  * rounding (~1e-9) unless calls also fall on the grid, in which case replay is byte-identical.
368
374
  */
369
375
  export function tick(p, now) {
376
+ if (p.pursuit)
377
+ releasePursuit(p);
370
378
  if (now <= p.now)
371
379
  return;
372
380
  if (!p.body.alive) {
373
381
  p.now = now;
374
382
  pruneConsequences(p);
383
+ if (p.pursuit)
384
+ releasePursuit(p);
375
385
  return;
376
386
  }
377
387
  const seg = PERSON_DEFAULTS.maxSegment;
@@ -386,15 +396,21 @@ export function tick(p, now) {
386
396
  if (!(end > p.now)) {
387
397
  p.now = now;
388
398
  pruneConsequences(p);
399
+ if (p.pursuit)
400
+ releasePursuit(p);
389
401
  return;
390
402
  }
391
403
  advanceSegment(p, end - p.now, load, active && act ? act.affordance : undefined);
392
404
  p.now = end;
393
405
  pruneConsequences(p);
406
+ if (p.pursuit)
407
+ releasePursuit(p);
394
408
  if (!p.body.alive) {
395
409
  p.activity = null;
396
410
  p.now = now;
397
411
  pruneConsequences(p);
412
+ if (p.pursuit)
413
+ releasePursuit(p);
398
414
  return;
399
415
  }
400
416
  // Discrete bookkeeping runs only on grid boundaries, so it happens at the same minutes whatever the
@@ -454,6 +470,10 @@ export function tick(p, now) {
454
470
  }
455
471
  }
456
472
  }
473
+ if (p.projects)
474
+ considerProjects(p, end);
475
+ if (p.pursuit)
476
+ releasePursuit(p);
457
477
  }
458
478
  }
459
479
  /** The composite's side of the hourly crisis check: interrupt and remember an onset, remember a recovery. */
@@ -546,6 +566,8 @@ export function skip(p, to) {
546
566
  skipBody(p, to);
547
567
  p.now = to;
548
568
  pruneConsequences(p);
569
+ if (p.pursuit)
570
+ releasePursuit(p);
549
571
  skipAffect(p, to);
550
572
  advanceAgenda(p, to);
551
573
  // Lasting gists (1.8.0, opt-in): episodes now past the horizon fold into gists.
@@ -565,6 +587,8 @@ function socialKindOf(kind) {
565
587
  }
566
588
  function perceiveOne(p, pc) {
567
589
  receiveConsequences(p, pc);
590
+ if (p.projects && pc.prospect)
591
+ perceiveProspect(p, pc.prospect, pc.at);
568
592
  const at = Math.min(Math.max(pc.at, p.affect.lastUpdated), p.now);
569
593
  const actor = pc.actorId;
570
594
  const target = pc.targetId;
@@ -1021,6 +1045,20 @@ export function begin(p, aff, record, opts = {}) {
1021
1045
  if (!p.body.alive)
1022
1046
  return null;
1023
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
+ }
1024
1062
  const duration = Number.isFinite(aff.duration) ? Math.max(1, Math.round(aff.duration)) : 1;
1025
1063
  const mode = aff.mode ?? 'awake';
1026
1064
  const load = { effort: clamp01(aff.effort), focus: clamp01(aff.focus ?? 0), mode };
@@ -1295,6 +1333,8 @@ function learnActivityVoices(p, act, after, now, felt, broken, breached) {
1295
1333
  * relationships, trust in the advising voice, injuries and illness. Clears the activity.
1296
1334
  */
1297
1335
  export function finish(p, outcome, opts = {}) {
1336
+ if (p.pursuit)
1337
+ releasePursuit(p);
1298
1338
  const act = p.activity;
1299
1339
  if (!act)
1300
1340
  return null;
@@ -1409,6 +1449,10 @@ export function finish(p, outcome, opts = {}) {
1409
1449
  ...(act.necessity ? { necessity: true } : {}),
1410
1450
  ...(opts.catalog ? { catalog: opts.catalog } : {}),
1411
1451
  });
1452
+ if (p.projects)
1453
+ noteProjectStatus(p, now);
1454
+ if (p.pursuit)
1455
+ releasePursuit(p);
1412
1456
  // A completed violating action inside an open abstention's window broke it (the fast, e.g. "no work after the evening prayer").
1413
1457
  if (broken.length > 0) {
1414
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,325 @@
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
+ * Behaviour 2 credits only new perceived highs; event-credited projects cannot observe reversals and stay unchanged.
11
+ * Restore recovers one active project, keeping the newest (id breaks ties) and abandoning extras.
12
+ * No invented prospects, host task interpretation, world search, persistence, shielding, faith lens,
13
+ * LLM or RNG. Without the slice, perception and minute bookkeeping write nothing.
14
+ */
15
+ import { emotionLevel } from '../affect/index.js';
16
+ import { AGENDA_DEFAULTS, abandonGoal, adoptGoal, perceiveProjectProgress } from '../agenda/index.js';
17
+ import { clamp01, cmpStr, isNum, isObj, round } from '../core/index.js';
18
+ import { curiosityOf } from '../curiosity/index.js';
19
+ import { readPerson } from '../person.js';
20
+ import { MAX_MINUTE, PHYSIOLOGICAL_NEEDS } from '../types.js';
21
+ export const PROJECTS_DEFAULTS = {
22
+ maxProspects: 8,
23
+ maxHistory: 8,
24
+ forgetMinutes: 1440,
25
+ calmUrgency: 0.35,
26
+ fearBelow: 0.35,
27
+ adoptEveryMinutes: 30,
28
+ cooldownMinutes: 60,
29
+ adoptAppeal: 0.3,
30
+ halfLifeMinutes: 120,
31
+ staleMinutes: 180,
32
+ };
33
+ const KEYS = [
34
+ 'forgetMinutes',
35
+ 'calmUrgency',
36
+ 'fearBelow',
37
+ 'adoptEveryMinutes',
38
+ 'cooldownMinutes',
39
+ 'adoptAppeal',
40
+ 'halfLifeMinutes',
41
+ 'staleMinutes',
42
+ ];
43
+ function cleanSpec(input) {
44
+ const out = {};
45
+ if (!isObj(input))
46
+ return out;
47
+ for (const key of KEYS) {
48
+ const x = input[key];
49
+ const unit = key === 'calmUrgency' || key === 'fearBelow' || key === 'adoptAppeal';
50
+ if (isNum(x) && x >= (unit ? 0 : 1) && x <= (unit ? 1 : 525600))
51
+ out[key] = x;
52
+ }
53
+ return out;
54
+ }
55
+ const setting = (s, key) => s.spec?.[key] ?? PROJECTS_DEFAULTS[key];
56
+ const str = (x) => typeof x === 'string' && x.length > 0 && x.length <= 512;
57
+ const unit = (x) => isNum(x) && x >= 0 && x <= 1;
58
+ const minute = (x, now) => isNum(x) && Math.abs(x) <= MAX_MINUTE && x <= now;
59
+ function cleanProspect(x) {
60
+ if (!isObj(x) ||
61
+ !str(x.id) ||
62
+ !str(x.label) ||
63
+ !['make', 'arrange', 'gather', 'finish'].includes(x.kind) ||
64
+ (x.placeId !== undefined && !str(x.placeId)) ||
65
+ (x.progress !== undefined && !unit(x.progress)) ||
66
+ (x.materialIds !== undefined &&
67
+ (!Array.isArray(x.materialIds) || x.materialIds.length > 256 || !x.materialIds.every(str))))
68
+ return undefined;
69
+ const p = { id: x.id, label: x.label, kind: x.kind };
70
+ if (x.placeId !== undefined)
71
+ p.placeId = x.placeId;
72
+ if (x.progress !== undefined)
73
+ p.progress = x.progress;
74
+ // A long list is kept, not rejected: the first eight distinct ids in the host's order (host rerun, 2026-10-20).
75
+ if (x.materialIds !== undefined)
76
+ p.materialIds = [...new Set(x.materialIds)].slice(0, 8);
77
+ return p;
78
+ }
79
+ /** Enable or reconfigure without discarding retained prospects and history. */
80
+ export function enableProjects(p, spec = {}) {
81
+ const state = p.projects ?? { prospects: [], history: [] };
82
+ for (const g of p.agenda.goals)
83
+ if (g.status === 'active' && g.project?.perceivedProgress && g.project.peak === undefined)
84
+ g.project.peak = g.progress;
85
+ state.behaviour = 2;
86
+ const clean = cleanSpec(spec);
87
+ delete state.spec;
88
+ if (Object.keys(clean).length)
89
+ state.spec = clean;
90
+ p.projects = state;
91
+ return state;
92
+ }
93
+ function relaxed(p) {
94
+ const needs = readPerson(p).needs;
95
+ return {
96
+ urgency: Math.max(0, ...needs
97
+ .filter((n) => n.id === 'safety' || PHYSIOLOGICAL_NEEDS.includes(n.id))
98
+ .map((n) => n.urgency)),
99
+ fear: emotionLevel(p, 'fear'),
100
+ interest: Math.max(0, ...needs.filter((n) => ['leisure', 'competence', 'autonomy'].includes(n.id)).map((n) => n.urgency)),
101
+ };
102
+ }
103
+ /** Read-only, deterministic appeal. Thresholds close the relaxed gate before the bounded bonus. */
104
+ export function projectAppeal(p, prospect, now = p.now) {
105
+ const s = p.projects;
106
+ if (!s ||
107
+ !cleanProspect(prospect) ||
108
+ prospect.progress === 1 ||
109
+ p.body.asleep ||
110
+ p.activity?.mode === 'sleep' ||
111
+ !p.body.alive)
112
+ return 0;
113
+ const { urgency, fear, interest } = relaxed(p);
114
+ if (urgency >= setting(s, 'calmUrgency') || fear >= setting(s, 'fearBelow'))
115
+ return 0;
116
+ const gate = (1 - urgency) * (1 - fear);
117
+ const fit = 0.25 + 0.75 * (prospect.kind === 'finish' ? p.traits.conscientiousness : p.traits.openness);
118
+ const resume = prospect.progress !== undefined && prospect.progress > 0 && prospect.progress < 1 ? 0.1 : 0;
119
+ const bonus = p.curiosity
120
+ ? 0.05 *
121
+ clamp01(curiosityOf(p, {
122
+ id: prospect.id,
123
+ action: prospect.kind,
124
+ label: prospect.label,
125
+ duration: 1,
126
+ effort: 0,
127
+ advertises: {},
128
+ }, now).drive)
129
+ : 0;
130
+ return round(clamp01(gate * (fit * (0.5 + 0.5 * interest) + resume + bonus)));
131
+ }
132
+ function recordProjectStatus(p, g, now) {
133
+ const s = p.projects;
134
+ if (!s || !g.project || g.status === 'active')
135
+ return;
136
+ if (!s.history.some((h) => h.goalId === g.id))
137
+ s.history.push({
138
+ prospectId: g.project.prospectId,
139
+ goalId: g.id,
140
+ adoptedAt: g.adoptedAt,
141
+ outcome: g.status,
142
+ at: now,
143
+ });
144
+ if (g.status === 'abandoned')
145
+ s.lastAbandonedAt = now;
146
+ else {
147
+ // Completed event-progress projects have no host progress reading: retain the known completion.
148
+ const prospect = s.prospects.find((x) => x.id === g.project?.prospectId);
149
+ if (prospect)
150
+ prospect.progress = 1;
151
+ }
152
+ if (s.history.length > PROJECTS_DEFAULTS.maxHistory)
153
+ s.history.shift();
154
+ if (s.currentGoalId === g.id)
155
+ delete s.currentGoalId;
156
+ }
157
+ /** Record the adopted project's terminal state once, even after history eviction. @internal */
158
+ export function noteProjectStatus(p, now) {
159
+ const s = p.projects;
160
+ if (!s?.currentGoalId)
161
+ return;
162
+ const g = p.agenda.goals.find((g) => g.id === s.currentGoalId);
163
+ if (!g?.project)
164
+ delete s.currentGoalId;
165
+ else
166
+ recordProjectStatus(p, g, now);
167
+ }
168
+ /** Only the composite calls this, after attention. Newest event minute wins; equal-minute input order wins. */
169
+ export function perceiveProspect(p, input, at) {
170
+ const s = p.projects;
171
+ if (!s || !minute(at, p.now))
172
+ return;
173
+ const prospect = cleanProspect(input);
174
+ if (!prospect)
175
+ return;
176
+ const old = s.prospects.find((x) => x.id === prospect.id);
177
+ if (old && old.lastSeenAt > at)
178
+ return;
179
+ if (p.now - at > setting(s, 'forgetMinutes'))
180
+ return;
181
+ const held = { ...prospect, lastSeenAt: at };
182
+ if (old)
183
+ s.prospects[s.prospects.indexOf(old)] = held;
184
+ else {
185
+ if (s.prospects.length >= PROJECTS_DEFAULTS.maxProspects) {
186
+ let oldest = 0;
187
+ for (const [i, entry] of s.prospects.entries())
188
+ if (entry.lastSeenAt < (s.prospects[oldest]?.lastSeenAt ?? Infinity))
189
+ oldest = i;
190
+ s.prospects.splice(oldest, 1);
191
+ }
192
+ s.prospects.push(held);
193
+ }
194
+ if (prospect.progress !== undefined)
195
+ perceiveProjectProgress(p, prospect.id, prospect.progress, p.now);
196
+ noteProjectStatus(p, p.now);
197
+ }
198
+ /** Called on each tick segment, including one-minute begin/tick/finish host loops. No RNG. */
199
+ export function considerProjects(p, now) {
200
+ const s = p.projects;
201
+ if (!s || !minute(now, p.now))
202
+ return;
203
+ s.prospects = s.prospects.filter((x) => now - x.lastSeenAt <= setting(s, 'forgetMinutes'));
204
+ for (const g of p.agenda.goals)
205
+ if (g.project &&
206
+ g.status === 'active' &&
207
+ now - (g.lastAdvancedAt ?? g.adoptedAt) >= setting(s, 'staleMinutes')) {
208
+ abandonGoal(p, g.id);
209
+ recordProjectStatus(p, g, now);
210
+ }
211
+ noteProjectStatus(p, now);
212
+ const active = p.agenda.goals.filter((g) => g.status === 'active');
213
+ if (active.some((g) => g.project) ||
214
+ active.length >= AGENDA_DEFAULTS.maxActiveGoals ||
215
+ p.agenda.commitments.some((c) => c.status === 'pending' && c.from <= now && c.until >= now) ||
216
+ (s.lastAdoptedAt !== undefined && now - s.lastAdoptedAt < setting(s, 'adoptEveryMinutes')) ||
217
+ (s.lastAbandonedAt !== undefined && now - s.lastAbandonedAt < setting(s, 'cooldownMinutes')))
218
+ return;
219
+ const best = s.prospects
220
+ .map((prospect) => ({ prospect, appeal: projectAppeal(p, prospect, now) }))
221
+ .filter((x) => x.appeal > setting(s, 'adoptAppeal'))
222
+ .sort((a, b) => b.appeal - a.appeal || cmpStr(a.prospect.id, b.prospect.id))[0];
223
+ if (!best)
224
+ return;
225
+ const { prospect, appeal } = best;
226
+ const g = adoptGoal(p, {
227
+ label: prospect.label,
228
+ serves: ['competence', 'leisure'],
229
+ advancedBy: [],
230
+ importance: Math.max(0.3, Math.min(0.7, appeal)),
231
+ temporal: { halfLifeMinutes: setting(s, 'halfLifeMinutes') },
232
+ project: { prospectId: prospect.id, perceivedProgress: prospect.progress !== undefined },
233
+ }, now);
234
+ if (prospect.progress !== undefined)
235
+ perceiveProjectProgress(p, prospect.id, prospect.progress, now);
236
+ if (s.behaviour === 2 && g.project)
237
+ g.project.peak = g.progress;
238
+ s.lastAdoptedAt = now;
239
+ s.currentGoalId = g.id;
240
+ }
241
+ /** Conservative restore: bounded fields, finite past timestamps, and no ambiguous duplicate ids. */
242
+ export function sanitizeProjects(input, now) {
243
+ if (!isObj(input) || !Array.isArray(input.prospects) || !Array.isArray(input.history))
244
+ return undefined;
245
+ if (input.behaviour !== undefined && input.behaviour !== 2)
246
+ return undefined;
247
+ const out = { prospects: [], history: [] };
248
+ if (input.behaviour === 2)
249
+ out.behaviour = 2;
250
+ if (input.spec !== undefined) {
251
+ if (!isObj(input.spec))
252
+ return undefined;
253
+ const spec = cleanSpec(input.spec);
254
+ if (Object.keys(spec).length !== Object.keys(input.spec).length)
255
+ return undefined;
256
+ if (Object.keys(spec).length)
257
+ out.spec = spec;
258
+ }
259
+ for (const key of ['lastAdoptedAt', 'lastAbandonedAt']) {
260
+ if (input[key] === undefined)
261
+ continue;
262
+ if (!minute(input[key], now))
263
+ return undefined;
264
+ out[key] = input[key];
265
+ }
266
+ if (input.currentGoalId !== undefined) {
267
+ if (!str(input.currentGoalId))
268
+ return undefined;
269
+ out.currentGoalId = input.currentGoalId;
270
+ }
271
+ const seen = new Set();
272
+ for (const entry of input.prospects.slice(0, PROJECTS_DEFAULTS.maxProspects)) {
273
+ const prospect = cleanProspect(entry);
274
+ if (!prospect || !isObj(entry) || !minute(entry.lastSeenAt, now))
275
+ continue;
276
+ if (seen.has(prospect.id))
277
+ return undefined;
278
+ seen.add(prospect.id);
279
+ if (now - entry.lastSeenAt <= setting(out, 'forgetMinutes'))
280
+ out.prospects.push({ ...prospect, lastSeenAt: entry.lastSeenAt });
281
+ }
282
+ seen.clear();
283
+ for (const h of input.history.slice(-PROJECTS_DEFAULTS.maxHistory)) {
284
+ if (!isObj(h) ||
285
+ !str(h.prospectId) ||
286
+ !str(h.goalId) ||
287
+ !minute(h.at, now) ||
288
+ !minute(h.adoptedAt, h.at) ||
289
+ (h.outcome !== 'achieved' && h.outcome !== 'abandoned'))
290
+ continue;
291
+ if (seen.has(h.goalId))
292
+ return undefined;
293
+ seen.add(h.goalId);
294
+ out.history.push({
295
+ prospectId: h.prospectId,
296
+ goalId: h.goalId,
297
+ adoptedAt: h.adoptedAt,
298
+ outcome: h.outcome,
299
+ at: h.at,
300
+ });
301
+ }
302
+ return out;
303
+ }
304
+ /** Sanitize and reconcile agenda ownership deterministically, without RNG. @internal */
305
+ export function restoreProjects(p) {
306
+ const s = sanitizeProjects(p.projects, p.now);
307
+ if (!s) {
308
+ delete p.projects;
309
+ return;
310
+ }
311
+ p.projects = s;
312
+ noteProjectStatus(p, p.now);
313
+ const active = p.agenda.goals
314
+ .filter((g) => g.project && g.status === 'active')
315
+ .sort((a, b) => b.adoptedAt - a.adoptedAt || cmpStr(a.id, b.id));
316
+ for (const g of active.slice(1)) {
317
+ abandonGoal(p, g.id);
318
+ recordProjectStatus(p, g, p.now);
319
+ }
320
+ const kept = active[0];
321
+ if (kept)
322
+ s.currentGoalId = kept.id;
323
+ else
324
+ delete s.currentGoalId;
325
+ }
@@ -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
@@ -588,6 +588,13 @@ export interface Commitment {
588
588
  recurUntil?: Minute;
589
589
  }
590
590
  export interface Goal {
591
+ /** Prospect identity; perceived projects receive progress only through perception. */
592
+ project?: {
593
+ prospectId: string;
594
+ perceivedProgress: boolean;
595
+ /** Highest perceived progress since adoption; presence opts this goal into net credit. */
596
+ peak?: Unit;
597
+ };
591
598
  id: string;
592
599
  label: string;
593
600
  /** Which need/value the goal serves. */
@@ -889,7 +896,7 @@ export declare const PERSON_SCHEMA = "human/person@1";
889
896
  * and an activity that would cover a closing stretch is reviewed when the stretch begins (`isUnderWay`,
890
897
  * `dutyReviewAt`). Person shape unchanged.
891
898
  */
892
- export declare const ENGINE_VERSION = "2.13.0";
899
+ export declare const ENGINE_VERSION = "2.14.1";
893
900
  /** A pregnancy carried by this person (host-driven; see `family/`). */
894
901
  export interface Pregnancy {
895
902
  fatherId: PersonId;
@@ -1006,6 +1013,8 @@ export interface ReceivedConsequence {
1006
1013
  effort: Unit;
1007
1014
  focus?: Unit;
1008
1015
  materialCost?: number;
1016
+ /** Goal or prospect ids this successor contributes to (bounded to 8). */
1017
+ advances?: string[];
1009
1018
  };
1010
1019
  availableFrom: Minute;
1011
1020
  /** Latest permitted successor completion, inclusive. */
@@ -1071,6 +1080,10 @@ export interface Person {
1071
1080
  promptings?: PromptingsState;
1072
1081
  /** Intrinsic exploration and varied repetition. Owned by `curiosity/`; absent unless enabled. */
1073
1082
  curiosity?: CuriosityState;
1083
+ /** Self-set projects from attended prospects; owned by projects/. */
1084
+ projects?: ProjectsState;
1085
+ /** Keeping a started goal active. Owned by persistence/; absent unless enabled. */
1086
+ pursuit?: PursuitState;
1074
1087
  /** Evening self-review and its resolves (2.7.0, opt-in via `enableReview`). Owned by `review/`. */
1075
1088
  review?: ReviewState;
1076
1089
  /** Practice consolidation (1.8.0, opt-in via `enableSkillRetention`). Owned by `skills/`. */
@@ -1206,6 +1219,8 @@ export interface Affordance {
1206
1219
  }
1207
1220
  /** Something a person perceives. Hosts emit percepts; attention decides which are encoded. */
1208
1221
  export interface Percept {
1222
+ /** Host-shown opportunity, retained only with projects enabled and when attended. */
1223
+ prospect?: Prospect;
1209
1224
  at: Minute;
1210
1225
  /**
1211
1226
  * How it reached the person: seen, heard, told (testimony), felt (bodily), the result of an action
@@ -1529,6 +1544,10 @@ export interface PersonSpec {
1529
1544
  planning?: PlanningSpec;
1530
1545
  /** Evening self-review (2.7.0): the same as `enableReview(p, spec)` after creation. */
1531
1546
  review?: ReviewSpec;
1547
+ /** Self-set projects from what the person attends to. */
1548
+ projects?: boolean | ProjectsSpec;
1549
+ /** Keep started goal pursuits across decisions. */
1550
+ persistence?: boolean | PersistenceSpec;
1532
1551
  };
1533
1552
  }
1534
1553
  /** A known span of exertion after an act (see `Affordance.followedBy`). */
@@ -1957,3 +1976,78 @@ export interface CuriosityReading {
1957
1976
  vary: Unit;
1958
1977
  drive: number;
1959
1978
  }
1979
+ /** A bounded pursuit opportunity explicitly shown by the host. */
1980
+ export interface Prospect {
1981
+ id: string;
1982
+ label: string;
1983
+ kind: 'make' | 'arrange' | 'gather' | 'finish';
1984
+ placeId?: EntityId;
1985
+ /** Up to 256 accepted; the first eight distinct ids are retained. */
1986
+ materialIds?: EntityId[];
1987
+ progress?: Unit;
1988
+ }
1989
+ export interface ProjectsSpec {
1990
+ forgetMinutes?: number;
1991
+ calmUrgency?: Unit;
1992
+ fearBelow?: Unit;
1993
+ adoptEveryMinutes?: number;
1994
+ cooldownMinutes?: number;
1995
+ adoptAppeal?: Unit;
1996
+ halfLifeMinutes?: number;
1997
+ staleMinutes?: number;
1998
+ }
1999
+ export interface ProjectHistory {
2000
+ prospectId: string;
2001
+ goalId: string;
2002
+ adoptedAt: Minute;
2003
+ outcome: 'achieved' | 'abandoned';
2004
+ at: Minute;
2005
+ }
2006
+ /** Optional bounded JSON state owned by projects/. */
2007
+ export interface ProjectsState {
2008
+ /** Absent retains 2.14.0 credit. Re-enabling, including on every load, opts into net credit. */
2009
+ behaviour?: 2;
2010
+ spec?: ProjectsSpec;
2011
+ prospects: (Prospect & {
2012
+ lastSeenAt: Minute;
2013
+ })[];
2014
+ history: ProjectHistory[];
2015
+ lastAdoptedAt?: Minute;
2016
+ lastAbandonedAt?: Minute;
2017
+ /** The adopted goal awaiting its one terminal history entry. */
2018
+ currentGoalId?: string;
2019
+ }
2020
+ /** Bounded engineering overrides for persistence/. Invalid overrides use defaults. */
2021
+ export interface PersistenceSpec {
2022
+ /** Half-life without goal progress, in minutes (default 30; valid 1..1440). */
2023
+ stallHalfLifeMinutes?: number;
2024
+ continueScale?: number;
2025
+ switchScale?: number;
2026
+ cap?: number;
2027
+ switchMargin?: number;
2028
+ slackMinutes?: number;
2029
+ maxMinutes?: number;
2030
+ /** Refractory interval for the same goal after maximum-duration release (default 20 minutes). */
2031
+ restMinutes?: number;
2032
+ }
2033
+ /** A started goal retained across decisions. */
2034
+ export interface Pursuit {
2035
+ goalId: string;
2036
+ since: Minute;
2037
+ lastServedAt: Minute;
2038
+ strength: Unit;
2039
+ }
2040
+ /** Optional JSON state owned by persistence/: one current pursuit and one refractory goal. */
2041
+ export interface PursuitState {
2042
+ /** Absent retains the 2.14.0 pull. Re-enabling, including on every load, opts into decay. */
2043
+ behaviour?: 2;
2044
+ spec?: PersistenceSpec;
2045
+ current?: Pursuit;
2046
+ /** Most recent maximum-duration release, withheld until this minute; agenda terms stay unchanged.
2047
+ * Defaults (maxMinutes 240, restMinutes 20) prevent two releases within one interval.
2048
+ * A shorter maximum can let a later release replace an earlier unexpired interval. */
2049
+ rested?: {
2050
+ goalId: string;
2051
+ until: Minute;
2052
+ };
2053
+ }
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.13.0';
123
+ export const ENGINE_VERSION = '2.14.1';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adam0white/human-framework",
3
- "version": "2.13.0",
3
+ "version": "2.14.1",
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",