@rsconcept/rstool 0.10.3 → 1.0.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.
Files changed (116) hide show
  1. package/README.md +61 -33
  2. package/dist/agent-workflow-Gk0Vfnv1.d.ts +64 -0
  3. package/dist/analysis-LLnPhmGa.d.ts +23 -0
  4. package/dist/{common-DxLg3eXX.d.ts → common-DHJalS-Q.d.ts} +6 -1
  5. package/dist/constituenta-DnGR6bnM.d.ts +54 -0
  6. package/dist/diagnostic-D9yl_mEL.d.ts +19 -0
  7. package/dist/evaluation-Cns8BFm4.d.ts +31 -0
  8. package/dist/index.d.ts +11 -11
  9. package/dist/index.js +1 -2
  10. package/dist/mappers/model-adapter.d.ts +3 -3
  11. package/dist/mappers/schema-adapter.d.ts +4 -4
  12. package/dist/mappers/types.d.ts +6 -2
  13. package/dist/mappers/types.js +2 -0
  14. package/dist/mappers/types.js.map +1 -1
  15. package/dist/{model-value-SFAVj0dw.d.ts → model-value-BbonPzMz.d.ts} +14 -3
  16. package/dist/models/agent-workflow.d.ts +2 -0
  17. package/dist/models/agent-workflow.js +1 -0
  18. package/dist/models/analysis.d.ts +1 -1
  19. package/dist/models/common.d.ts +1 -1
  20. package/dist/models/constituenta.d.ts +2 -2
  21. package/dist/models/diagnostic.d.ts +1 -1
  22. package/dist/models/evaluation.d.ts +2 -2
  23. package/dist/models/index.d.ts +11 -11
  24. package/dist/models/index.js +2 -2
  25. package/dist/models/model-value.d.ts +2 -2
  26. package/dist/models/rstool-agent.d.ts +1 -1
  27. package/dist/models/rstool-agent.js +1 -1
  28. package/dist/models/session.d.ts +1 -1
  29. package/dist/models/tool-contract.d.ts +2 -2
  30. package/dist/models/tool-contract.js +2 -1
  31. package/dist/models/tool-contract.js.map +1 -1
  32. package/dist/models-Bw6Uum8i.js +685 -0
  33. package/dist/models-Bw6Uum8i.js.map +1 -0
  34. package/dist/rstool-agent-D2cQze_b.d.ts +71 -0
  35. package/dist/session/session-store.d.ts +18 -5
  36. package/dist/session/session-store.js +1 -64
  37. package/dist/{session-BPgsE80c.d.ts → session-ChexW8i7.d.ts} +11 -8
  38. package/dist/tool-contract-0uRGhEfW.d.ts +164 -0
  39. package/dist/wrapper/client.d.ts +23 -0
  40. package/dist/wrapper/client.js +17 -0
  41. package/dist/wrapper/client.js.map +1 -1
  42. package/dist/wrapper/stdio-wrapper.js +75 -63
  43. package/dist/wrapper/stdio-wrapper.js.map +1 -1
  44. package/docs/CONSTITUENTA.md +2 -2
  45. package/docs/DIAGNOSTICS.md +6 -5
  46. package/docs/MODEL-TESTING.md +3 -3
  47. package/docs/PORTAL-API.md +24 -18
  48. package/examples/README.md +1 -1
  49. package/examples/agent-client.ts +11 -41
  50. package/examples/build-chocolate-nim-rsform.ts +21 -70
  51. package/examples/chocolate-nim/build-rsform.ts +23 -18
  52. package/examples/chocolate-nim/build-rsmodel.ts +10 -12
  53. package/examples/chocolate-nim/rsform-session.json +290 -290
  54. package/examples/chocolate-nim/rsmodel-session.json +291 -291
  55. package/examples/expression-bank/bank-constituents.ts +304 -53
  56. package/examples/expression-bank/build-rsform.ts +19 -16
  57. package/examples/expression-bank/rsform-session.json +1551 -1551
  58. package/examples/kinship/build-rsform.ts +23 -18
  59. package/examples/kinship/build-rsmodel.ts +16 -16
  60. package/examples/kinship/rsform-session.json +219 -219
  61. package/examples/kinship/rsmodel-session.json +221 -221
  62. package/examples/kinship/session.ts +19 -21
  63. package/examples/movd/build-rsform.ts +23 -18
  64. package/examples/movd/build-rsmodel.ts +18 -20
  65. package/examples/movd/rsform-session.json +262 -262
  66. package/examples/movd/rsmodel-session.json +264 -264
  67. package/examples/sample/build-rsform.ts +18 -51
  68. package/examples/sample/build-rsmodel.ts +25 -44
  69. package/examples/sample/rsform-session.json +10 -7
  70. package/examples/sample/rsmodel-session.json +36 -33
  71. package/examples/template-apply/build-rsform.ts +27 -24
  72. package/examples/template-apply/rsform-session.json +48 -48
  73. package/package.json +4 -2
  74. package/skills/rstool-helper/EXAMPLES.md +44 -116
  75. package/skills/rstool-helper/GUIDE.md +40 -25
  76. package/skills/rstool-helper/REFERENCE.md +40 -177
  77. package/src/index.ts +24 -17
  78. package/src/mappers/portal-adapter.ts +49 -0
  79. package/src/mappers/types.ts +4 -0
  80. package/src/models/agent-workflow.ts +66 -0
  81. package/src/models/analysis.ts +7 -0
  82. package/src/models/common.ts +7 -0
  83. package/src/models/constituenta.ts +24 -6
  84. package/src/models/diagnostic.ts +4 -0
  85. package/src/models/evaluation.ts +11 -0
  86. package/src/models/import-detect.test.ts +66 -0
  87. package/src/models/import-detect.ts +42 -0
  88. package/src/models/import-export.ts +24 -0
  89. package/src/models/index.ts +22 -14
  90. package/src/models/model-value.ts +12 -0
  91. package/src/models/portal-json.test.ts +38 -0
  92. package/src/models/portal-json.ts +54 -1
  93. package/src/models/rstool-agent.test.ts +698 -146
  94. package/src/models/rstool-agent.ts +392 -92
  95. package/src/models/session.ts +8 -5
  96. package/src/models/tool-contract.ts +81 -42
  97. package/src/session/batch-apply.test.ts +123 -0
  98. package/src/session/batch-apply.ts +82 -0
  99. package/src/session/persistence.test.ts +63 -0
  100. package/src/session/persistence.ts +69 -0
  101. package/src/session/session-store.ts +76 -6
  102. package/src/wrapper/client.test.ts +58 -0
  103. package/src/wrapper/client.ts +23 -0
  104. package/src/wrapper/stdio-handler.test.ts +101 -0
  105. package/src/wrapper/stdio-handler.ts +195 -0
  106. package/src/wrapper/stdio-wrapper.ts +4 -187
  107. package/dist/analysis-JiwOYDKx.d.ts +0 -16
  108. package/dist/constituenta-Dnd6iToB.d.ts +0 -36
  109. package/dist/diagnostic-BMYvciz8.d.ts +0 -15
  110. package/dist/evaluation-CCVYH0wA.d.ts +0 -21
  111. package/dist/index-uhkmwruf.d.ts +0 -46
  112. package/dist/rstool-agent-BZi5jO1y.js +0 -158
  113. package/dist/rstool-agent-BZi5jO1y.js.map +0 -1
  114. package/dist/rstool-agent-pRaPnZay.d.ts +0 -35
  115. package/dist/session/session-store.js.map +0 -1
  116. package/dist/tool-contract-n1ghUOrK.d.ts +0 -32
@@ -1,49 +1,88 @@
1
- import { type AnalysisResult, type AnalyzeExpressionInput } from './analysis';
2
- import {
3
- type AddOrUpdateConstituentaInput,
4
- type AddOrUpdateConstituentaResult
5
- } from './constituenta';
6
- import {
7
- type DiagnosticRecord,
8
- type ListDiagnosticsFilters
9
- } from './diagnostic';
10
1
  import {
11
- type EvaluateConstituentaInput,
12
- type EvaluateExpressionInput,
13
- type EvaluationResult
14
- } from './evaluation';
15
- import {
16
- type ClearConstituentaValuesInput,
17
- type RecalculateModelResult,
18
- type SessionModelState,
19
- type SetConstituentaValueInput,
20
- type SetConstituentaValuesInput
21
- } from './model-value';
22
- import {
23
- type SessionHandle,
24
- type SessionRevision,
25
- type SessionState
26
- } from './session';
2
+ type ApplySchemaPatchInput,
3
+ type ApplySchemaPatchResult,
4
+ type SessionStateDetail,
5
+ type SessionStateResult
6
+ } from './agent-workflow';
7
+ import { type AnalysisResult, type AnalyzeExpressionInput } from './analysis';
8
+ import { type DiagnosticRecord, type ListDiagnosticsFilters } from './diagnostic';
9
+ import { type EvaluateInput, type EvaluationResult } from './evaluation';
10
+ import { type ExportPortalInput, type ExportPortalResult, type ImportDataKind } from './import-export';
11
+ import { type RecalculateModelResult, type SessionModelState, type SetModelValuesInput } from './model-value';
12
+ import { type SessionHandle, type SessionRevision, type SessionState } from './session';
27
13
 
28
- export const CONTRACT_VERSION = '1.4.0';
14
+ /** Agent-visible contract version; bump on breaking API changes. */
15
+ export const CONTRACT_VERSION = '2.0.0';
29
16
 
17
+ /** Options for constructing an {@link RSToolAgent}. */
18
+ export interface RSToolAgentOptions {
19
+ /** When set, sessions are persisted to this directory and survive process restarts. */
20
+ persistenceDir?: string;
21
+ }
22
+
23
+ /**
24
+ * Public method surface of {@link RSToolAgent}.
25
+ *
26
+ * Each method accepts an optional `sessionId`; when omitted, the current session is used
27
+ * (or a new one is created where noted).
28
+ */
30
29
  export interface RSToolAgentContract {
30
+ /** Current contract version string (same as {@link CONTRACT_VERSION}). */
31
31
  readonly contractVersion: string;
32
+
33
+ /** Return the current session, or create one with optional `initial` metadata. */
34
+ ensureSession(initial?: Partial<SessionState>): SessionHandle;
35
+
36
+ /** Create a new session and make it current. */
32
37
  createSession(initial?: Partial<SessionState>): SessionHandle;
33
- addOrUpdateConstituenta(sessionId: string, input: AddOrUpdateConstituentaInput): AddOrUpdateConstituentaResult;
34
- analyzeExpression(sessionId: string, input: AnalyzeExpressionInput): AnalysisResult;
35
- getFormState(sessionId: string): SessionState;
36
- listDiagnostics(sessionId: string, filters?: ListDiagnosticsFilters): DiagnosticRecord[];
37
- commitStep(sessionId: string, message?: string): SessionRevision;
38
- exportSession(sessionId: string): string;
39
- exportPortalSchema(sessionId: string): string;
40
- exportPortalModel(sessionId: string): string;
41
- importSession(payload: string): SessionHandle;
42
- setConstituentaValue(sessionId: string, input: SetConstituentaValueInput): Promise<SessionModelState>;
43
- setConstituentaValues(sessionId: string, input: SetConstituentaValuesInput): Promise<SessionModelState>;
44
- clearConstituentaValues(sessionId: string, input: ClearConstituentaValuesInput): Promise<SessionModelState>;
45
- getModelState(sessionId: string): SessionModelState;
46
- evaluateExpression(sessionId: string, input: EvaluateExpressionInput): EvaluationResult;
47
- evaluateConstituenta(sessionId: string, input: EvaluateConstituentaInput): EvaluationResult;
48
- recalculateModel(sessionId: string): RecalculateModelResult;
38
+
39
+ /** Return the current session handle, or `null` when none is active. */
40
+ getCurrentSession(): SessionHandle | null;
41
+
42
+ /** Switch the active session; throws when `sessionId` is unknown. */
43
+ setCurrentSession(sessionId: string): SessionHandle;
44
+
45
+ /** Apply constituent patches to the schema (analyze, merge, optionally commit). */
46
+ applySchemaPatch(input: ApplySchemaPatchInput, sessionId?: string): ApplySchemaPatchResult;
47
+
48
+ /** Return session summary (default) or full cloned state when `detail` is `'full'`. */
49
+ getSessionState(detail?: SessionStateDetail, sessionId?: string): SessionStateResult;
50
+
51
+ /** List diagnostics for the session, optionally filtered by constituent. */
52
+ listDiagnostics(filters?: ListDiagnosticsFilters, sessionId?: string): DiagnosticRecord[];
53
+
54
+ /** Parse and type-check an RSLang expression against the current schema context. */
55
+ analyzeExpression(input: AnalyzeExpressionInput, sessionId?: string): AnalysisResult;
56
+
57
+ /** Record a revision checkpoint with an optional message. */
58
+ commitStep(message?: string, sessionId?: string): SessionRevision;
59
+
60
+ /** Export the session as a JSON string (state + diagnostics). */
61
+ exportSession(sessionId?: string): string;
62
+
63
+ /** Export schema or model payload in Portal JSON format. */
64
+ exportPortal(input: ExportPortalInput, sessionId?: string): ExportPortalResult;
65
+
66
+ /**
67
+ * Import a session export or Portal JSON payload and make the new session current.
68
+ *
69
+ * When `kind` is `'auto'`, the payload shape is detected automatically.
70
+ */
71
+ importData(payload: string | object, kind?: ImportDataKind): SessionHandle;
72
+
73
+ /** Set or clear model values for constituents; returns the updated model state. */
74
+ setModelValues(input: SetModelValuesInput, sessionId?: string): Promise<SessionModelState>;
75
+
76
+ /** Return a deep clone of the session model state. */
77
+ getModelState(sessionId?: string): SessionModelState;
78
+
79
+ /**
80
+ * Evaluate a stored constituent or a scratch expression.
81
+ *
82
+ * Provide `constituentId`, or both `expression` and `cstType`.
83
+ */
84
+ evaluate(input: EvaluateInput, sessionId?: string): EvaluationResult;
85
+
86
+ /** Recompute derived model values for all constituents. */
87
+ recalculateModel(sessionId?: string): RecalculateModelResult;
49
88
  }
@@ -0,0 +1,123 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { CstType } from '../models';
4
+ import { orderDrafts, reorderSessionItemsByDrafts } from './batch-apply';
5
+
6
+ const baseItem = {
7
+ id: 1,
8
+ alias: 'X1',
9
+ cstType: CstType.BASE,
10
+ definitionFormal: '',
11
+ term: '',
12
+ definitionText: '',
13
+ convention: '',
14
+ analysis: { success: true, type: null, valueClass: 'value' as const, diagnostics: [] }
15
+ };
16
+
17
+ describe('orderDrafts', () => {
18
+ it('orders dependents after suppliers', () => {
19
+ const ordered = orderDrafts(
20
+ [baseItem],
21
+ [
22
+ { id: 3, alias: 'D1', cstType: CstType.TERM, definitionFormal: 'Pr1(S1)' },
23
+ { id: 2, alias: 'S1', cstType: CstType.STRUCTURED, definitionFormal: 'ℬ(X1×X1)' }
24
+ ]
25
+ );
26
+ expect(ordered.map(draft => draft.alias)).toEqual(['S1', 'D1']);
27
+ });
28
+
29
+ it('orders multi-hop chains X1 → S1 → D1', () => {
30
+ const ordered = orderDrafts(
31
+ [baseItem],
32
+ [
33
+ { id: 4, alias: 'D1', cstType: CstType.TERM, definitionFormal: 'Pr1(S1)' },
34
+ { id: 3, alias: 'S1', cstType: CstType.STRUCTURED, definitionFormal: 'ℬ(X1×X1)' },
35
+ { id: 2, alias: 'C1', cstType: CstType.CONSTANT, definitionFormal: '' }
36
+ ]
37
+ );
38
+ expect(ordered.map(draft => draft.alias)).toEqual(['C1', 'S1', 'D1']);
39
+ });
40
+
41
+ it('orders draft that only references existing session items', () => {
42
+ const ordered = orderDrafts(
43
+ [
44
+ baseItem,
45
+ {
46
+ id: 2,
47
+ alias: 'S1',
48
+ cstType: CstType.STRUCTURED,
49
+ definitionFormal: 'ℬ(X1×X1)',
50
+ term: '',
51
+ definitionText: '',
52
+ convention: '',
53
+ analysis: { success: true, type: null, valueClass: 'value', diagnostics: [] }
54
+ }
55
+ ],
56
+ [{ id: 3, alias: 'D1', cstType: CstType.TERM, definitionFormal: 'Pr1(S1)' }]
57
+ );
58
+ expect(ordered.map(draft => draft.alias)).toEqual(['D1']);
59
+ });
60
+
61
+ it('appends cyclic drafts after topological ids', () => {
62
+ const ordered = orderDrafts(
63
+ [],
64
+ [
65
+ { id: 1, alias: 'D1', cstType: CstType.TERM, definitionFormal: 'D2' },
66
+ { id: 2, alias: 'D2', cstType: CstType.TERM, definitionFormal: 'D1' }
67
+ ]
68
+ );
69
+ expect(ordered.map(draft => draft.alias).sort()).toEqual(['D1', 'D2']);
70
+ expect(ordered).toHaveLength(2);
71
+ });
72
+
73
+ it('returns all independent drafts', () => {
74
+ const ordered = orderDrafts(
75
+ [],
76
+ [
77
+ { id: 1, alias: 'C1', cstType: CstType.CONSTANT, definitionFormal: '' },
78
+ { id: 2, alias: 'C2', cstType: CstType.CONSTANT, definitionFormal: '' }
79
+ ]
80
+ );
81
+ expect(ordered.map(draft => draft.alias).sort()).toEqual(['C1', 'C2']);
82
+ expect(ordered).toHaveLength(2);
83
+ });
84
+ });
85
+
86
+ describe('reorderSessionItemsByDrafts', () => {
87
+ const mk = (id: number, alias: string) => ({
88
+ id,
89
+ alias,
90
+ cstType: CstType.BASE,
91
+ definitionFormal: '',
92
+ term: '',
93
+ definitionText: '',
94
+ convention: '',
95
+ analysis: { success: true, type: null, valueClass: 'value' as const, diagnostics: [] }
96
+ });
97
+
98
+ it('restores full-batch declaration order', () => {
99
+ const items = [mk(4, 'X1'), mk(3, 'C1'), mk(2, 'S1'), mk(1, 'D1')];
100
+ reorderSessionItemsByDrafts(items, [
101
+ { id: 1, alias: 'D1', cstType: CstType.TERM, definitionFormal: 'Pr1(S1)' },
102
+ { id: 2, alias: 'S1', cstType: CstType.STRUCTURED, definitionFormal: 'ℬ(X1×X1)' },
103
+ { id: 3, alias: 'C1', cstType: CstType.CONSTANT, definitionFormal: '' },
104
+ { id: 4, alias: 'X1', cstType: CstType.BASE, definitionFormal: '' }
105
+ ]);
106
+ expect(items.map(item => item.alias)).toEqual(['D1', 'S1', 'C1', 'X1']);
107
+ });
108
+
109
+ it('appends only new items in draft order', () => {
110
+ const items = [mk(1, 'X1'), mk(3, 'S1'), mk(2, 'C1')];
111
+ reorderSessionItemsByDrafts(items, [
112
+ { id: 2, alias: 'C1', cstType: CstType.CONSTANT, definitionFormal: '' },
113
+ { id: 3, alias: 'S1', cstType: CstType.STRUCTURED, definitionFormal: 'ℬ(X1×X1)' }
114
+ ]);
115
+ expect(items.map(item => item.alias)).toEqual(['X1', 'C1', 'S1']);
116
+ });
117
+
118
+ it('does not move existing items on update-only patch', () => {
119
+ const items = [mk(1, 'X1'), mk(2, 'D1')];
120
+ reorderSessionItemsByDrafts(items, [{ id: 2, alias: 'D1', cstType: CstType.TERM, definitionFormal: '1+2' }]);
121
+ expect(items.map(item => item.alias)).toEqual(['X1', 'D1']);
122
+ });
123
+ });
@@ -0,0 +1,82 @@
1
+ import { Graph } from '@rsconcept/domain/graph/graph';
2
+ import { extractGlobals } from '@rsconcept/domain/rslang/api';
3
+
4
+ import { type ConstituentaDraft, type ConstituentaState } from '../models';
5
+
6
+ /** Order drafts so suppliers are applied before dependents. */
7
+ export function orderDrafts(sessionItems: ConstituentaState[], drafts: ConstituentaDraft[]): ConstituentaDraft[] {
8
+ const merged = new Map<number, ConstituentaDraft>();
9
+ for (const item of sessionItems) {
10
+ merged.set(item.id, {
11
+ id: item.id,
12
+ alias: item.alias,
13
+ cstType: item.cstType,
14
+ definitionFormal: item.definitionFormal
15
+ });
16
+ }
17
+ for (const draft of drafts) {
18
+ merged.set(draft.id, draft);
19
+ }
20
+
21
+ const graph = new Graph<number>();
22
+ const aliasToId = new Map<string, number>();
23
+ for (const [id, draft] of merged) {
24
+ graph.addNode(id);
25
+ aliasToId.set(draft.alias, id);
26
+ }
27
+
28
+ for (const [id, draft] of merged) {
29
+ if (!draft.definitionFormal) {
30
+ continue;
31
+ }
32
+ for (const alias of extractGlobals(draft.definitionFormal)) {
33
+ const depId = aliasToId.get(alias);
34
+ if (depId !== undefined && depId !== id) {
35
+ graph.addEdge(depId, id);
36
+ }
37
+ }
38
+ }
39
+
40
+ const draftIds = new Set(drafts.map(draft => draft.id));
41
+ const topoIds = graph.topologicalOrder().filter(id => draftIds.has(id));
42
+ const seen = new Set(topoIds);
43
+ const missing = drafts.filter(draft => !seen.has(draft.id)).map(draft => draft.id);
44
+
45
+ const orderedIds = [...topoIds, ...missing];
46
+ return orderedIds.map(id => drafts.find(draft => draft.id === id)!).filter(Boolean);
47
+ }
48
+
49
+ /**
50
+ * Restore declaration order in session items after a batch apply.
51
+ * Topological apply order is only needed for analysis; Portal JSON uses array order.
52
+ */
53
+ export function reorderSessionItemsByDrafts(items: ConstituentaState[], drafts: ConstituentaDraft[]): void {
54
+ if (drafts.length === 0 || items.length === 0) {
55
+ return;
56
+ }
57
+
58
+ const draftIds = drafts.map(draft => draft.id);
59
+ const draftIdSet = new Set(draftIds);
60
+ const mentioned = items.filter(item => draftIdSet.has(item.id));
61
+ if (mentioned.length === 0) {
62
+ return;
63
+ }
64
+
65
+ const unmentioned = items.filter(item => !draftIdSet.has(item.id));
66
+ if (unmentioned.length === 0) {
67
+ const byId = new Map(items.map(item => [item.id, item]));
68
+ items.splice(0, items.length, ...draftIds.map(id => byId.get(id)!));
69
+ return;
70
+ }
71
+
72
+ const existingIds = new Set(unmentioned.map(item => item.id));
73
+ const newDrafts = drafts.filter(draft => !existingIds.has(draft.id));
74
+ if (newDrafts.length === 0) {
75
+ return;
76
+ }
77
+
78
+ const newIds = new Set(newDrafts.map(draft => draft.id));
79
+ const kept = items.filter(item => !newIds.has(item.id));
80
+ const byId = new Map(items.map(item => [item.id, item]));
81
+ items.splice(0, items.length, ...kept, ...newDrafts.map(draft => byId.get(draft.id)!));
82
+ }
@@ -0,0 +1,63 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ import { afterEach, describe, expect, it } from 'vitest';
6
+
7
+ import { SessionPersistence, type PersistedSessionEnvelope } from './persistence';
8
+
9
+ const envelope: PersistedSessionEnvelope = {
10
+ state: {
11
+ sessionId: 'safe-id',
12
+ alias: '',
13
+ title: '',
14
+ comment: '',
15
+ createdAt: '2026-01-01T00:00:00.000Z',
16
+ updatedAt: '2026-01-01T00:00:00.000Z',
17
+ revisions: [],
18
+ items: [],
19
+ model: { items: [] }
20
+ },
21
+ diagnostics: []
22
+ };
23
+
24
+ describe('SessionPersistence', () => {
25
+ const dirs: string[] = [];
26
+
27
+ afterEach(() => {
28
+ for (const dir of dirs.splice(0)) {
29
+ fs.rmSync(dir, { recursive: true, force: true });
30
+ }
31
+ });
32
+
33
+ function createPersistence(): SessionPersistence {
34
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rstool-session-'));
35
+ dirs.push(dir);
36
+ return new SessionPersistence(dir);
37
+ }
38
+
39
+ it('persists a safe session id under the configured directory', () => {
40
+ const persistence = createPersistence();
41
+ const sessionId = 'a03eec4c-6519-4088-9d8c-0a9c11611583';
42
+
43
+ persistence.save(sessionId, envelope);
44
+
45
+ expect(persistence.load(sessionId)).toEqual(envelope);
46
+ persistence.delete(sessionId);
47
+ expect(persistence.load(sessionId)).toBeNull();
48
+ });
49
+
50
+ it.each(['../escape', 'foo/bar', 'foo\\bar', '..', 'safe/../escape'])(
51
+ 'rejects unsafe session id %j on save',
52
+ unsafeId => {
53
+ const persistence = createPersistence();
54
+ expect(() => persistence.save(unsafeId, envelope)).toThrow(/Invalid session ID/);
55
+ }
56
+ );
57
+
58
+ it.each(['../escape', 'foo/bar', 'foo\\bar', '..'])('rejects unsafe session id %j on load and delete', unsafeId => {
59
+ const persistence = createPersistence();
60
+ expect(() => persistence.load(unsafeId)).toThrow(/Invalid session ID/);
61
+ expect(() => persistence.delete(unsafeId)).toThrow(/Invalid session ID/);
62
+ });
63
+ });
@@ -0,0 +1,69 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import { type DiagnosticRecord, type SessionState } from '../models';
5
+
6
+ export interface PersistedSessionEnvelope {
7
+ state: SessionState;
8
+ diagnostics: DiagnosticRecord[];
9
+ }
10
+
11
+ const CURRENT_SESSION_FILE = '_current.json';
12
+ const UNSAFE_SESSION_ID = /[/\\]|\.\./;
13
+
14
+ function assertSafeSessionId(sessionId: string): void {
15
+ if (!sessionId || UNSAFE_SESSION_ID.test(sessionId)) {
16
+ throw new Error(`Invalid session ID: ${sessionId}`);
17
+ }
18
+ }
19
+
20
+ export class SessionPersistence {
21
+ private readonly dir: string;
22
+
23
+ public constructor(dir: string) {
24
+ this.dir = dir;
25
+ fs.mkdirSync(dir, { recursive: true });
26
+ }
27
+
28
+ public save(sessionId: string, envelope: PersistedSessionEnvelope): void {
29
+ fs.writeFileSync(this.filePath(sessionId), JSON.stringify(envelope, null, 2), 'utf-8');
30
+ }
31
+
32
+ public load(sessionId: string): PersistedSessionEnvelope | null {
33
+ const file = this.filePath(sessionId);
34
+ if (!fs.existsSync(file)) {
35
+ return null;
36
+ }
37
+ return JSON.parse(fs.readFileSync(file, 'utf-8')) as PersistedSessionEnvelope;
38
+ }
39
+
40
+ public delete(sessionId: string): void {
41
+ const file = this.filePath(sessionId);
42
+ if (fs.existsSync(file)) {
43
+ fs.unlinkSync(file);
44
+ }
45
+ }
46
+
47
+ public saveCurrentSessionId(sessionId: string | null): void {
48
+ fs.writeFileSync(path.join(this.dir, CURRENT_SESSION_FILE), JSON.stringify({ sessionId }, null, 2), 'utf-8');
49
+ }
50
+
51
+ public loadCurrentSessionId(): string | null {
52
+ const file = path.join(this.dir, CURRENT_SESSION_FILE);
53
+ if (!fs.existsSync(file)) {
54
+ return null;
55
+ }
56
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf-8')) as { sessionId?: string | null };
57
+ return parsed.sessionId ?? null;
58
+ }
59
+
60
+ private filePath(sessionId: string): string {
61
+ assertSafeSessionId(sessionId);
62
+ const file = path.resolve(this.dir, `${sessionId}.json`);
63
+ const relative = path.relative(path.resolve(this.dir), file);
64
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
65
+ throw new Error(`Invalid session ID: ${sessionId}`);
66
+ }
67
+ return file;
68
+ }
69
+ }
@@ -1,12 +1,18 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
 
3
3
  import {
4
+ CONTRACT_VERSION,
4
5
  type DiagnosticRecord,
5
6
  type ListDiagnosticsFilters,
6
7
  type SessionHandle,
7
8
  type SessionRevision,
8
9
  type SessionState
9
10
  } from '../models';
11
+ import { SessionPersistence, type PersistedSessionEnvelope } from './persistence';
12
+
13
+ export interface SessionStoreOptions {
14
+ persistenceDir?: string;
15
+ }
10
16
 
11
17
  interface SessionEnvelope {
12
18
  state: SessionState;
@@ -15,6 +21,11 @@ interface SessionEnvelope {
15
21
 
16
22
  export class SessionStore {
17
23
  private sessions = new Map<string, SessionEnvelope>();
24
+ private readonly persistence: SessionPersistence | null;
25
+
26
+ public constructor(options: SessionStoreOptions = {}) {
27
+ this.persistence = options.persistenceDir ? new SessionPersistence(options.persistenceDir) : null;
28
+ }
18
29
 
19
30
  public create(initial?: Partial<SessionState>, contractVersion?: string): SessionHandle {
20
31
  const now = new Date().toISOString();
@@ -30,30 +41,43 @@ export class SessionStore {
30
41
  items: initial?.items ?? [],
31
42
  model: initial?.model ?? { items: [] }
32
43
  };
33
- this.sessions.set(sessionId, {
44
+ const envelope: SessionEnvelope = {
34
45
  state,
35
46
  diagnostics: []
36
- });
47
+ };
48
+ this.sessions.set(sessionId, envelope);
49
+ this.persist(sessionId, envelope);
37
50
  return {
38
51
  sessionId,
39
- contractVersion: contractVersion ?? '1.0.0'
52
+ contractVersion: contractVersion ?? CONTRACT_VERSION
40
53
  };
41
54
  }
42
55
 
43
56
  public get(sessionId: string): SessionEnvelope {
44
- const found = this.sessions.get(sessionId);
57
+ const found = this.sessions.get(sessionId) ?? this.loadFromDisk(sessionId);
45
58
  if (!found) {
46
59
  throw new Error(`Unknown session: ${sessionId}`);
47
60
  }
48
61
  return found;
49
62
  }
50
63
 
64
+ public has(sessionId: string): boolean {
65
+ if (this.sessions.has(sessionId)) {
66
+ return true;
67
+ }
68
+ if (!this.persistence) {
69
+ return false;
70
+ }
71
+ return this.persistence.load(sessionId) !== null;
72
+ }
73
+
51
74
  public replaceState(sessionId: string, nextState: SessionState): void {
52
75
  const found = this.get(sessionId);
53
76
  found.state = {
54
77
  ...nextState,
55
78
  updatedAt: new Date().toISOString()
56
79
  };
80
+ this.persist(sessionId, found);
57
81
  }
58
82
 
59
83
  public addRevision(sessionId: string, message?: string): SessionRevision {
@@ -65,20 +89,66 @@ export class SessionStore {
65
89
  };
66
90
  found.state.revisions.push(revision);
67
91
  found.state.updatedAt = revision.at;
92
+ this.persist(sessionId, found);
68
93
  return revision;
69
94
  }
70
95
 
71
- public appendDiagnostics(sessionId: string, records: DiagnosticRecord[]): void {
96
+ /** Replace active diagnostics for one constituent (or scratch when constituentId is undefined). */
97
+ public replaceDiagnosticsForConstituent(
98
+ sessionId: string,
99
+ constituentId: number | undefined,
100
+ records: DiagnosticRecord[]
101
+ ): void {
72
102
  const found = this.get(sessionId);
103
+ found.diagnostics = found.diagnostics.filter(record => record.constituentId !== constituentId);
73
104
  found.diagnostics.push(...records);
74
105
  found.state.updatedAt = new Date().toISOString();
106
+ this.persist(sessionId, found);
107
+ }
108
+
109
+ public setDiagnostics(sessionId: string, records: DiagnosticRecord[]): void {
110
+ const found = this.get(sessionId);
111
+ found.diagnostics = [...records];
112
+ found.state.updatedAt = new Date().toISOString();
113
+ this.persist(sessionId, found);
75
114
  }
76
115
 
77
116
  public listDiagnostics(sessionId: string, filters?: ListDiagnosticsFilters): DiagnosticRecord[] {
78
117
  const found = this.get(sessionId);
79
- if (!filters?.constituentId) {
118
+ if (filters?.constituentId === undefined) {
80
119
  return [...found.diagnostics];
81
120
  }
82
121
  return found.diagnostics.filter(record => record.constituentId === filters.constituentId);
83
122
  }
123
+
124
+ public snapshot(sessionId: string): SessionEnvelope {
125
+ const found = this.get(sessionId);
126
+ return structuredClone(found);
127
+ }
128
+
129
+ public restore(sessionId: string, snapshot: SessionEnvelope): void {
130
+ this.sessions.set(sessionId, structuredClone(snapshot));
131
+ this.persist(sessionId, this.sessions.get(sessionId)!);
132
+ }
133
+
134
+ public saveCurrentSessionId(sessionId: string | null): void {
135
+ this.persistence?.saveCurrentSessionId(sessionId);
136
+ }
137
+
138
+ public loadCurrentSessionId(): string | null {
139
+ return this.persistence?.loadCurrentSessionId() ?? null;
140
+ }
141
+
142
+ private loadFromDisk(sessionId: string): SessionEnvelope | null {
143
+ const loaded = this.persistence?.load(sessionId);
144
+ if (!loaded) {
145
+ return null;
146
+ }
147
+ this.sessions.set(sessionId, loaded);
148
+ return loaded;
149
+ }
150
+
151
+ private persist(sessionId: string, envelope: SessionEnvelope): void {
152
+ this.persistence?.save(sessionId, envelope as PersistedSessionEnvelope);
153
+ }
84
154
  }
@@ -0,0 +1,58 @@
1
+ import { dirname, resolve } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ import { CstType, EvalStatus, RSToolWrapperClient } from '../index';
6
+
7
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
8
+
9
+ describe('RSToolWrapperClient integration', () => {
10
+ it('runs createSession, patch, model, and evaluate over stdio', async () => {
11
+ const client = new RSToolWrapperClient({
12
+ command: 'npx',
13
+ args: ['tsx', resolve(packageRoot, 'src/wrapper/stdio-wrapper.ts')],
14
+ cwd: packageRoot,
15
+ shell: true
16
+ });
17
+
18
+ try {
19
+ await client.waitUntilReady();
20
+
21
+ const methods = await client.call<string[]>('methods');
22
+ expect(methods).toContain('applySchemaPatch');
23
+
24
+ const session = await client.call<{ sessionId: string; contractVersion: string }>('createSession', {
25
+ initial: { title: 'Wrapper test' }
26
+ });
27
+ expect(session.sessionId).toBeTruthy();
28
+
29
+ await client.call('applySchemaPatch', {
30
+ sessionId: session.sessionId,
31
+ items: [{ alias: 'X1' }, { alias: 'D1', definitionFormal: '1+2' }]
32
+ });
33
+
34
+ await client.call('setModelValues', {
35
+ sessionId: session.sessionId,
36
+ set: [{ target: 1, value: { 0: 'zero' } }]
37
+ });
38
+
39
+ const evalResult = await client.call<{ success: boolean; value: number; status: number }>('evaluate', {
40
+ sessionId: session.sessionId,
41
+ constituentId: 2
42
+ });
43
+ expect(evalResult.success).toBe(true);
44
+ expect(evalResult.value).toBe(3);
45
+ expect(evalResult.status).toBe(EvalStatus.HAS_DATA);
46
+
47
+ const analysis = await client.call<{ success: boolean; diagnostics: unknown[] }>('analyzeExpression', {
48
+ sessionId: session.sessionId,
49
+ expression: '(',
50
+ cstType: CstType.TERM
51
+ });
52
+ expect(analysis.success).toBe(false);
53
+ expect(analysis.diagnostics.length).toBeGreaterThan(0);
54
+ } finally {
55
+ await client.close();
56
+ }
57
+ }, 30_000);
58
+ });