@stelis/say-ur-intent 0.0.3 → 0.0.4

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.
@@ -1,75 +1,88 @@
1
+ const DEFAULT_REACQUIRE_INTERVAL_MS = 3000;
1
2
  function errorCode(error) {
2
3
  return typeof error === "object" && error !== null
3
4
  ? error.code
4
5
  : undefined;
5
6
  }
6
- /**
7
- * Bind the review server to its fixed port, taking the port over from a previous
8
- * instance of our own review server if necessary.
9
- *
10
- * Safety: the holder is positively identified over loopback before any signal is
11
- * sent. A process that is not our review server is never signalled; a same-name
12
- * instance owned by another OS user surfaces a clear error rather than a kill.
13
- * The port is never silently reassigned, so the wallet autoconnect origin stays
14
- * stable.
15
- */
16
- export async function startReviewServerWithTakeover(start, port, deps) {
7
+ // Bind the port: returns the server on success, undefined when the port is already
8
+ // in use, and rethrows any other (non-EADDRINUSE) startup error.
9
+ async function tryStart(start, port) {
17
10
  try {
18
11
  return await start(port);
19
12
  }
20
13
  catch (error) {
21
- if (errorCode(error) !== "EADDRINUSE") {
22
- throw error;
23
- }
24
- const holder = await deps.probeIdentity(port);
25
- if (!holder || holder.service !== deps.serviceName) {
26
- throw new Error(`Review server port ${port} is already in use by a process that is not a ${deps.serviceName} review server. ` +
27
- `Set SAY_UR_INTENT_REVIEW_PORT to a free port. The port is not reassigned automatically so the wallet autoconnect origin stays stable.`);
14
+ if (errorCode(error) === "EADDRINUSE") {
15
+ return undefined;
28
16
  }
29
- if (holder.pid === deps.currentPid) {
30
- // We appear to hold the port ourselves yet cannot bind it. Never signal
31
- // our own process; surface the original bind error.
32
- throw error;
33
- }
34
- deps.logger.info("review port held by a previous say-ur-intent instance; taking it over", {
35
- port,
36
- previousPid: holder.pid
37
- });
38
- const terminated = deps.terminate(holder.pid);
39
- if (!terminated.ok) {
40
- if (terminated.reason === "no_permission") {
41
- throw new Error(`Review server port ${port} is held by a ${deps.serviceName} instance owned by another OS user; cannot take it over. ` +
42
- `Set SAY_UR_INTENT_REVIEW_PORT to a free port for this client.`);
17
+ throw error;
18
+ }
19
+ }
20
+ /**
21
+ * Bind the review server to its fixed port, or defer to a healthy peer already
22
+ * serving it.
23
+ *
24
+ * - Port free → bind and own the single review origin.
25
+ * - Port held by a separate healthy instance of our review server → defer (run no
26
+ * local server; the peer serves the shared database for every client) and watch for
27
+ * the owner to exit, then take the origin over. No process is ever signalled.
28
+ * - Port held by anything else (foreign, no identity answer, or our own pid) → clear
29
+ * error; the origin is never silently reassigned.
30
+ */
31
+ export async function startOrDeferReviewServer(start, port, deps) {
32
+ const owned = await tryStart(start, port);
33
+ if (owned) {
34
+ deps.logger.info("review server bound; owning the review origin", { port });
35
+ return { deferred: false, close: () => owned.close() };
36
+ }
37
+ const holder = await deps.probeIdentity(port);
38
+ if (!holder || holder.service !== deps.serviceName || holder.pid === deps.currentPid) {
39
+ throw new Error(`Review server port ${port} is already in use by a process that is not a separate ${deps.serviceName} review server. ` +
40
+ `Set SAY_UR_INTENT_REVIEW_PORT to a free port. The port is not reassigned automatically so the wallet autoconnect origin stays stable.`);
41
+ }
42
+ deps.logger.info("review port owned by a healthy peer; deferring and watching for takeover", {
43
+ port,
44
+ ownerPid: holder.pid,
45
+ ...(holder.version ? { ownerVersion: holder.version } : {})
46
+ });
47
+ const reacquireIntervalMs = deps.reacquireIntervalMs ?? DEFAULT_REACQUIRE_INTERVAL_MS;
48
+ let stopped = false;
49
+ let acquired;
50
+ const watch = (async () => {
51
+ while (!stopped && !acquired) {
52
+ await deps.delay(reacquireIntervalMs);
53
+ if (stopped || acquired) {
54
+ break;
43
55
  }
44
- if (terminated.reason === "error") {
45
- throw new Error(`Failed to stop the ${deps.serviceName} instance holding review port ${port}: ${terminated.message ?? "unknown error"}.`);
56
+ const next = await tryStart(start, port);
57
+ if (!next) {
58
+ continue; // a peer still owns the port; keep deferring
46
59
  }
47
- // not_found: the holder already exited between probe and signal; fall
48
- // through and rebind.
49
- }
50
- const waitForReleaseMs = deps.waitForReleaseMs ?? 3000;
51
- const pollIntervalMs = deps.pollIntervalMs ?? 100;
52
- const attempts = Math.max(1, Math.ceil(waitForReleaseMs / pollIntervalMs));
53
- for (let attempt = 0; attempt < attempts; attempt++) {
54
- await deps.delay(pollIntervalMs);
55
- try {
56
- return await start(port);
60
+ if (stopped) {
61
+ await next.close();
62
+ break;
57
63
  }
58
- catch (retryError) {
59
- if (errorCode(retryError) !== "EADDRINUSE") {
60
- throw retryError;
61
- }
64
+ acquired = next;
65
+ deps.logger.info("acquired review port after the previous owner exited", { port });
66
+ }
67
+ })();
68
+ watch.catch(() => undefined);
69
+ return {
70
+ deferred: true,
71
+ close: async () => {
72
+ // Stop watching; the loop's stopped-check closes any bind that lands in-flight,
73
+ // so we never await the (possibly mid-delay) watch loop here.
74
+ stopped = true;
75
+ if (acquired) {
76
+ await acquired.close();
62
77
  }
63
78
  }
64
- throw new Error(`Review server port ${port} did not become free after stopping the previous ${deps.serviceName} instance (pid ${holder.pid}). ` +
65
- `Set SAY_UR_INTENT_REVIEW_PORT to a free port.`);
66
- }
79
+ };
67
80
  }
68
81
  /**
69
- * Ask whatever is listening on the loopback review port to identify itself. Only
70
- * our own review server answers `/__identity`; any other process either returns
71
- * something else or does not answer, and we report it as "not ours" so the
72
- * caller never signals it.
82
+ * Ask whatever is listening on the loopback review port to identify itself. Only our
83
+ * own review server answers `/__identity`; any other process either returns something
84
+ * else or does not answer, and we report it as "not ours" so the caller never defers
85
+ * to it.
73
86
  */
74
87
  export async function probeReviewServerIdentity(port, host = "127.0.0.1", timeoutMs = 1000) {
75
88
  try {
@@ -100,29 +113,3 @@ export async function probeReviewServerIdentity(port, host = "127.0.0.1", timeou
100
113
  return null;
101
114
  }
102
115
  }
103
- /**
104
- * Gracefully signal a same-user process. SIGTERM lets the previous instance run
105
- * its own shutdown handler (close the review server and database, then exit).
106
- * Killing another OS user's process fails with EPERM, which we report instead of
107
- * escalating.
108
- */
109
- export function terminateProcessByPid(pid) {
110
- try {
111
- process.kill(pid, "SIGTERM");
112
- return { ok: true };
113
- }
114
- catch (error) {
115
- const code = errorCode(error);
116
- if (code === "ESRCH") {
117
- return { ok: false, reason: "not_found" };
118
- }
119
- if (code === "EPERM") {
120
- return { ok: false, reason: "no_permission" };
121
- }
122
- return {
123
- ok: false,
124
- reason: "error",
125
- message: error instanceof Error ? error.message : String(error)
126
- };
127
- }
128
- }
@@ -9,11 +9,10 @@ import { validateSupportedAdapterLifecycle } from "../adapters/adapterLifecycleV
9
9
  import { SqliteActivityStore } from "../core/activity/sqliteActivityStore.js";
10
10
  import { TransactionActivityService } from "../core/activity/transactionActivityService.js";
11
11
  import { createSuiReadService } from "../core/read/readService.js";
12
- import { InMemorySessionStore } from "../core/session/sessionStore.js";
12
+ import { LocalSessionStore } from "../core/session/sessionStore.js";
13
13
  import { createMcpServer } from "../mcp/server.js";
14
14
  import { TOOL_NAMES } from "../mcp/toolNames.js";
15
15
  import { createReviewHttpServer } from "../review-server/server.js";
16
- import { InMemoryLocalTransactionMaterialStore } from "../core/session/transactionMaterialStore.js";
17
16
  import { buildSupportedReviewAdapters } from "../adapters/reviewAdapters.js";
18
17
  import { createDeepbookSwapTransactionMaterialDigestProducer, createDeepbookSwapTransactionMaterialProducer } from "../adapters/deepbook/deepbookTransactionMaterialProducer.js";
19
18
  import { createDeepbookSwapHumanReadableReviewProducer } from "../adapters/deepbook/deepbookHumanReviewProducer.js";
@@ -121,11 +120,15 @@ async function main() {
121
120
  chainIdentifier,
122
121
  coinMetadataCache: activityStore.createCoinMetadataCache()
123
122
  });
124
- const transactionMaterialStore = new InMemoryLocalTransactionMaterialStore();
125
- const sessions = new InMemorySessionStore({
123
+ const transactionMaterialStore = activityStore.createTransactionMaterialStore();
124
+ const sessions = new LocalSessionStore({
126
125
  activityStore,
127
126
  logger,
128
- validateAdapterLifecycle: validateSupportedAdapterLifecycle
127
+ validateAdapterLifecycle: validateSupportedAdapterLifecycle,
128
+ sessions: activityStore.createSessionRecordStore(),
129
+ artifacts: activityStore.createPrivateReviewArtifactStore(),
130
+ walletIdentityStore: activityStore.createWalletIdentityRecordStore(),
131
+ settingsStore: activityStore.createSettingsRecordStore()
129
132
  });
130
133
  reviewServer = await createReviewHttpServer({
131
134
  host: config.reviewHost,
@@ -15,13 +15,12 @@ import { createSuiReadService } from "../core/read/readService.js";
15
15
  import { createTransactionObjectOwnershipProducer } from "../core/action/transactionObjectOwnershipProducer.js";
16
16
  import { createReviewTimeSimulationProducer } from "../core/action/reviewTimeSimulationEvidence.js";
17
17
  import { producePtbVisualizationArtifact } from "../core/action/ptbVisualizationProducer.js";
18
- import { InMemoryLocalTransactionMaterialStore } from "../core/session/transactionMaterialStore.js";
19
- import { InMemorySessionStore } from "../core/session/sessionStore.js";
18
+ import { LocalSessionStore } from "../core/session/sessionStore.js";
20
19
  import { createMcpServer, startMcp } from "../mcp/server.js";
21
20
  import { SERVER_NAME, SERVER_NETWORK, SERVER_VERSION } from "../mcp/serverInfo.js";
22
21
  import { createReviewHttpServer } from "../review-server/server.js";
23
22
  import { DEFAULT_SUI_GRAPHQL_URL, DEFAULT_SUI_GRPC_URL, composeRuntimeConfig, loadBootConfig } from "./config.js";
24
- import { probeReviewServerIdentity, startReviewServerWithTakeover, terminateProcessByPid } from "./reviewServerAcquire.js";
23
+ import { probeReviewServerIdentity, startOrDeferReviewServer } from "./reviewServerAcquire.js";
25
24
  import { RuntimeLocalSettingsService } from "./localSettingsService.js";
26
25
  import { createStderrLogger } from "./logger.js";
27
26
  import { SuiEndpointError, verifyMainnetGraphqlEndpoint, verifyMainnetGrpcEndpoint } from "./suiEndpoint.js";
@@ -81,12 +80,16 @@ async function main() {
81
80
  });
82
81
  }
83
82
  });
84
- const transactionMaterialStore = new InMemoryLocalTransactionMaterialStore();
85
- const sessions = new InMemorySessionStore({
83
+ const transactionMaterialStore = store.createTransactionMaterialStore();
84
+ const sessions = new LocalSessionStore({
86
85
  activityStore: store,
87
86
  transactionMaterialStore,
88
87
  logger,
89
- validateAdapterLifecycle: validateSupportedAdapterLifecycle
88
+ validateAdapterLifecycle: validateSupportedAdapterLifecycle,
89
+ sessions: store.createSessionRecordStore(),
90
+ artifacts: store.createPrivateReviewArtifactStore(),
91
+ walletIdentityStore: store.createWalletIdentityRecordStore(),
92
+ settingsStore: store.createSettingsRecordStore()
90
93
  });
91
94
  const readService = createSuiReadService({
92
95
  client: suiClient,
@@ -169,22 +172,19 @@ async function main() {
169
172
  network: SERVER_NETWORK
170
173
  }
171
174
  });
172
- // The review origin is a single-port singleton: the newest instance takes
173
- // the fixed port over from a previous instance of our own review server so
174
- // the most recently started client owns the one wallet-autoconnect origin.
175
- const reviewServer = await startReviewServerWithTakeover((port) => reviewServerFactory.start(port), config.reviewPort, {
175
+ // The review origin is a single-port singleton shared through the local database:
176
+ // whichever process owns the fixed port serves every client. A second instance
177
+ // defers to a healthy peer (no signals, no port war) and takes the origin over
178
+ // only if that peer exits.
179
+ const reviewServer = await startOrDeferReviewServer((port) => reviewServerFactory.start(port), config.reviewPort, {
176
180
  probeIdentity: (probePort) => probeReviewServerIdentity(probePort, config.reviewHost),
177
- terminate: terminateProcessByPid,
178
181
  delay: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
179
182
  currentPid: process.pid,
180
183
  serviceName: SERVER_NAME,
181
184
  logger
182
185
  });
183
186
  reviewServerForCleanup = reviewServer;
184
- logger.info("review server started", {
185
- host: reviewServer.host,
186
- port: reviewServer.port
187
- });
187
+ logger.info(reviewServer.deferred ? "review server deferring to a healthy peer on the shared origin" : "review server started", { host: config.reviewHost, port: config.reviewPort, deferred: reviewServer.deferred });
188
188
  let shuttingDown = false;
189
189
  const shutdown = async (signal) => {
190
190
  logger.info("shutdown requested", { signal });
@@ -236,7 +236,7 @@ async function main() {
236
236
  promptSurfaces: ADAPTER_PROMPT_SURFACES,
237
237
  sessions,
238
238
  activityStore: store,
239
- reviewBaseUrl: `http://${reviewServer.host}:${reviewServer.port}`,
239
+ reviewBaseUrl: `http://${config.reviewHost}:${config.reviewPort}`,
240
240
  readService,
241
241
  transactionActivityService: new TransactionActivityService({
242
242
  activityStore: store,
@@ -139,7 +139,12 @@ wallet review contract, a connected wallet whose account equals the reviewed
139
139
  account, and a successful digest-gated handoff. The signing step shows no wallet picker:
140
140
  dapp-kit autoconnect restores the wallet recorded for the active account on the
141
141
  fixed-port origin, and the sign action stays gated on the connected account
142
- matching the reviewed account. While a handoff is outstanding the server locks the session
142
+ matching the reviewed account. When autoconnect cannot establish a connection -
143
+ for example after a reload or in a new tab, or for a hardware signer whose
144
+ device session is not restored automatically - the signing step may offer a
145
+ targeted reconnect for the one recorded wallet. That reconnect is not a wallet
146
+ picker: it resumes the recorded wallet's signer session, and the sign action
147
+ stays gated on the connected account matching the reviewed account. While a handoff is outstanding the server locks the session
143
148
  (state recomputes are refused) and the page shows a signing-in-progress state
144
149
  whose only action is cancel. Other states render:
145
150
 
@@ -210,7 +215,7 @@ Wallet and review screens must be keyboard reachable and screen-reader labeled.
210
215
 
211
216
  Native push notifications are out of scope. A frontend may use low-authority browser affordances such as document title changes for off-tab terminal results, but only after server status changes.
212
217
 
213
- Each session URL represents one independent tab surface. Cross-tab state sharing is out of scope unless a shared local store is explicitly designed.
218
+ Each session URL represents one independent tab surface. Cross-tab state sharing is out of scope unless a shared local store is explicitly designed. That shared local store is now explicitly designed — the shared SQLite database (see docs/LOCAL_DB_ARCHITECTURE.md) — so live review and session state is shared across local clients through it, while the frontend still does not share state directly between browser tabs.
214
219
 
215
220
  ## Out Of Scope
216
221
 
@@ -1,6 +1,6 @@
1
1
  # Local DB Architecture
2
2
 
3
- Say Ur Intent uses a local SQLite database for durable product state that must survive MCP server restarts. The database stores account read context, Say Ur Intent review activity evidence, and user-requested bounded Sui activity facts. It is not a custody store, wallet authorization store, background indexer, complete wallet-history store, or raw transaction archive.
3
+ Say Ur Intent uses a local SQLite database for durable product state that must survive MCP server restarts. The database stores account read context, Say Ur Intent review activity evidence, live review and session state shared across local AI clients, and user-requested bounded Sui activity facts. It is not a custody store, wallet authorization store, background indexer, complete wallet-history store, or raw transaction archive.
4
4
 
5
5
  This document is for maintainers and contributors who change local state, import/export behavior, activity queries, or review evidence storage. Product users normally need only the README and `docs/MCP_SETUP.md`.
6
6
 
@@ -53,6 +53,15 @@ WAL mode can create companion files next to the main database, such as `say-ur-i
53
53
  - `local_settings`: allowlisted local settings. The current key set is `suiGrpcUrl` and `suiGraphqlUrl`, stored as JSON-encoded text and applied after restart.
54
54
  - `coin_metadata_cache`: account-independent positive cache for Sui coin metadata used only to format wallet balance display amounts. Rows are keyed by normalized coin type and verified mainnet chain identifier, expire after 24 hours, and are excluded from local data export/import. Read or write failures for this cache block affected wallet unit reads with `metadata_cache_unavailable`; they are not reported as unavailable token decimals.
55
55
 
56
+ The following live session tables hold runtime session state in the shared database so any one review server can serve a session another client created (see [Shared single-origin review server](#shared-single-origin-review-server)):
57
+
58
+ - `live_review_sessions`: the live review session record — status, bound account, the pending wallet-handoff lock, the plan / review-state / execution-result JSON, and timestamps — keyed by session id.
59
+ - `live_private_review_artifacts`: per-session private review evidence (the transaction-material handle, its digest commitment, and derived evidence), cascade-deleted with its review session.
60
+ - `live_transaction_materials`: locally built unsigned transaction bytes stored as a BLOB behind a redacted handle, with a TTL; deleted on signing, terminal result, or expiry.
61
+ - `live_wallet_identity_sessions` and `live_settings_sessions`: the short-lived wallet-identity and settings capture sessions, stored as validated JSON keyed by session id.
62
+
63
+ These live tables are distinct from the append-only review evidence tables above: the evidence tables remain the durable activity/audit record, while the live tables hold the in-flight session state that the review server serves.
64
+
56
65
  Database columns use snake_case. MCP and HTTP JSON fields use camelCase, so the database `review_session_id` column stores the same review-session identity exposed as `reviewSessionId` in API responses.
57
66
 
58
67
  Table relationships:
@@ -72,6 +81,20 @@ Logical local data reset is the local settings page action that clears stored pr
72
81
 
73
82
  Non-terminal review session expiry is recorded lazily when the session is read or mutated after its TTL. There is no background expiry worker.
74
83
 
84
+ ## Shared single-origin review server
85
+
86
+ All live session state lives in this one shared database, so multiple local AI clients (for example Claude and Codex running at the same time) share it. Exactly one process binds the fixed review port and serves every client's review pages from the shared database. A second client's MCP does not take the port over; it defers to that healthy peer and takes the origin over only if the owner exits. Because whichever process owns the port reads and writes the same session rows, a review created by one client is servable by the review server of another, and the single fixed origin keeps the browser wallet autoconnect stable.
87
+
88
+ Cross-process writes rely on WAL plus `PRAGMA busy_timeout`. The one write that must be atomic across processes — the wallet handoff lock — is a single conditional `UPDATE` on `live_review_sessions.pending_handoff_digest`, so two processes cannot hand off two different transactions for the same session.
89
+
90
+ ### Unsigned transaction material on disk
91
+
92
+ `live_transaction_materials` stores locally built unsigned transaction bytes so a review can be signed by whichever process owns the port, not only the process that built it. Because these bytes are now on disk in the shared database rather than only in one process's memory, the data directory is created `0700` and the database file is set `0600` so other operating-system users cannot read them; the bytes carry a short TTL and are deleted on signing, terminal result, or expiry. This store is separate from the review evidence path: the MCP tool layer still does not return transaction bytes, and the activity-store evidence inputs still reject transaction bytes, signatures, and signing material before write.
93
+
94
+ ### Schema versioning across shared clients
95
+
96
+ The live session tables are added without changing `user_version`. A runtime that does not know a live table simply ignores it, so a newer client can introduce live tables while an older client keeps opening the same shared database. Only a change that an older runtime cannot safely read should raise `user_version`, and such a change requires updating every client that shares the database, because a runtime does not open a database whose `user_version` is newer than it supports.
97
+
75
98
  ## Boundaries
76
99
 
77
100
  The active account is a read context only. It is not signing authorization, login, authentication for transactions, custody, permission for transactions, or proof of ownership. It stores at most one address per database file; setting a new active account replaces the previous one without revoking anything onchain.
package/docs/SDK_API.md CHANGED
@@ -8,7 +8,7 @@ This document records the pinned SDK APIs used by the current runtime. The sourc
8
8
  - `@mysten/deepbook-v3`: `1.3.6`
9
9
  - `@mysten/dapp-kit-core`: `1.3.2`
10
10
  - `@flowx-finance/sdk`: `2.1.0`
11
- - `@stelis/agent-q-provider-sui`: `0.0.5`
11
+ - `@stelis/agent-q-provider-sui`: `0.0.7`
12
12
  - `@zktx.io/ptb-model`: `0.5.0`
13
13
  - `mermaid`: `11.12.0`
14
14
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stelis/say-ur-intent",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "mcpName": "io.github.stelis-dev/say-ur-intent",
5
5
  "description": "Say Ur Intent is a local-first toolkit for helping AI applications and users inspect Sui DeFi actions before execution.",
6
6
  "license": "MIT",
@@ -54,7 +54,7 @@
54
54
  "@mysten/deepbook-v3": "1.3.6",
55
55
  "@mysten/sui": "2.17.0",
56
56
  "@scure/bip39": "2.2.0",
57
- "@stelis/agent-q-provider-sui": "0.0.5",
57
+ "@stelis/agent-q-provider-sui": "0.0.7",
58
58
  "@zktx.io/ptb-model": "0.5.0",
59
59
  "better-sqlite3": "12.9.0",
60
60
  "mermaid": "11.12.0",