haansi 0.1.1 → 0.1.2

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 (2) hide show
  1. package/dist/haansi.js +194 -45
  2. package/package.json +1 -1
package/dist/haansi.js CHANGED
@@ -65,8 +65,10 @@ async function validateToken(token, apiUrl) {
65
65
  async function init() {
66
66
  const apiUrl = process.env.HAANSI_API_URL ?? DEFAULT_API_URL;
67
67
  console.log("Haansi CLI \u2014 Setup\n");
68
- console.log(`You can generate an API token at https://app.haansi.co/settings/tokens
69
- `);
68
+ console.log(
69
+ `You can generate an API token at https://app.haansi.co/settings/tokens
70
+ `
71
+ );
70
72
  const token = await prompt("Paste your Haansi API token: ");
71
73
  if (!token) {
72
74
  console.error("Error: No token provided.");
@@ -938,6 +940,43 @@ var init_ingester = __esm({
938
940
  /**
939
941
  * Mark a session as processed and persist state.
940
942
  */
943
+ /**
944
+ * Return the number of lines already uploaded for this session (0 if unknown).
945
+ */
946
+ getUploadedLineCount(sessionId) {
947
+ const state = this.loadState();
948
+ return state.processed[sessionId]?.uploadedLineCount ?? 0;
949
+ }
950
+ /**
951
+ * Return whether this session was previously finalized.
952
+ */
953
+ isFinalized(sessionId) {
954
+ const state = this.loadState();
955
+ return state.processed[sessionId]?.finalized ?? false;
956
+ }
957
+ /**
958
+ * Return all session IDs that are tracked but not yet finalized.
959
+ * These are sessions that were previously uploaded as open and may
960
+ * need finalization if they no longer appear in the scan results.
961
+ */
962
+ getOpenSessionIds() {
963
+ const state = this.loadState();
964
+ return Object.values(state.processed).filter((r) => r.finalized === false).map((r) => r.sessionId);
965
+ }
966
+ /**
967
+ * Mark a session as finalized without re-reading its file.
968
+ * Used when a previously-open session is no longer receiving new lines.
969
+ */
970
+ markFinalized(sessionId) {
971
+ const state = this.loadState();
972
+ const record2 = state.processed[sessionId];
973
+ if (record2) {
974
+ record2.finalized = true;
975
+ record2.processedAt = (/* @__PURE__ */ new Date()).toISOString();
976
+ state.lastRunAt = (/* @__PURE__ */ new Date()).toISOString();
977
+ this.saveState(state);
978
+ }
979
+ }
941
980
  markProcessed(session, status, extras) {
942
981
  const state = this.loadState();
943
982
  state.processed[session.sessionId] = {
@@ -1002,17 +1041,22 @@ function resolveToken() {
1002
1041
  }
1003
1042
  return null;
1004
1043
  }
1005
- async function uploadSession(sessionId, jsonlContent, apiUrl, token) {
1006
- const body = (0, import_zlib.gzipSync)(Buffer.from(jsonlContent, "utf-8"));
1044
+ async function uploadSession(sessionId, jsonlContent, apiUrl, token, isOpen) {
1045
+ const headers = {
1046
+ Authorization: `Bearer ${token}`,
1047
+ "Content-Type": "application/x-ndjson",
1048
+ "x-session-id": sessionId,
1049
+ "x-session-open": isOpen ? "true" : "false"
1050
+ };
1051
+ let body;
1052
+ if (jsonlContent.length > 0) {
1053
+ body = (0, import_zlib.gzipSync)(Buffer.from(jsonlContent, "utf-8"));
1054
+ headers["Content-Encoding"] = "gzip";
1055
+ }
1007
1056
  const response = await fetch(`${apiUrl}/api/v1/sessions/raw`, {
1008
1057
  method: "POST",
1009
- headers: {
1010
- Authorization: `Bearer ${token}`,
1011
- "Content-Type": "application/x-ndjson",
1012
- "Content-Encoding": "gzip",
1013
- "x-session-id": sessionId
1014
- },
1015
- body
1058
+ headers,
1059
+ body: body ? new Uint8Array(body) : void 0
1016
1060
  });
1017
1061
  if (!response.ok) {
1018
1062
  const text = await response.text().catch(() => "");
@@ -1033,39 +1077,97 @@ async function collect(options) {
1033
1077
  projectPath: options.projectPath
1034
1078
  });
1035
1079
  const scanResult = options.projectPath ? await ingester.scan({ forceAll: options.forceAll }) : await ingester.scanAll({ forceAll: options.forceAll });
1036
- const toUpload = [...scanResult.newSessions, ...scanResult.updatedSessions];
1037
- const sessions = options.limit ? toUpload.slice(0, options.limit) : toUpload;
1038
- if (sessions.length === 0) {
1039
- logger3.info("No new sessions to upload");
1040
- return { uploaded: 0, skipped: scanResult.unchangedCount, errors: 0 };
1041
- }
1042
- logger3.info(`Uploading ${sessions.length} session(s)`, {
1043
- apiUrl,
1044
- dryRun: options.dryRun ?? false
1045
- });
1080
+ const activeSessions = [
1081
+ ...scanResult.newSessions,
1082
+ ...scanResult.updatedSessions
1083
+ ];
1084
+ const activeSessionIds = new Set(activeSessions.map((s) => s.sessionId));
1085
+ const sessions = options.limit ? activeSessions.slice(0, options.limit) : activeSessions;
1046
1086
  let uploaded = 0;
1047
1087
  let errors = 0;
1048
- for (const session of sessions) {
1049
- const shortId = session.sessionId.slice(0, 8);
1050
- try {
1051
- const rawContent = (0, import_fs4.readFileSync)(session.filePath, "utf-8");
1052
- const scrubbed = edgeScrub(rawContent);
1053
- if (options.dryRun) {
1088
+ if (sessions.length > 0) {
1089
+ logger3.info(`Uploading ${sessions.length} active session(s)`, {
1090
+ apiUrl,
1091
+ dryRun: options.dryRun ?? false
1092
+ });
1093
+ for (const session of sessions) {
1094
+ const shortId = session.sessionId.slice(0, 8);
1095
+ try {
1096
+ const rawContent = (0, import_fs4.readFileSync)(session.filePath, "utf-8");
1097
+ const allLines = rawContent.split("\n").filter((l) => l.trim().length > 0);
1098
+ const totalLines = allLines.length;
1099
+ let uploadedLineCount = ingester.getUploadedLineCount(
1100
+ session.sessionId
1101
+ );
1102
+ const wasPreviouslyFinalized = ingester.isFinalized(session.sessionId);
1103
+ if (wasPreviouslyFinalized && totalLines > uploadedLineCount) {
1104
+ uploadedLineCount = 0;
1105
+ }
1106
+ if (totalLines < uploadedLineCount) {
1107
+ uploadedLineCount = 0;
1108
+ }
1109
+ const deltaLines = allLines.slice(uploadedLineCount);
1110
+ if (deltaLines.length === 0) {
1111
+ continue;
1112
+ }
1113
+ const deltaContent = edgeScrub(deltaLines.join("\n") + "\n");
1114
+ if (options.dryRun) {
1115
+ logger3.info(
1116
+ `[dry-run] Would upload delta for session ${shortId} (${deltaLines.length} new lines, open)`
1117
+ );
1118
+ uploaded++;
1119
+ continue;
1120
+ }
1121
+ await uploadSession(
1122
+ session.sessionId,
1123
+ deltaContent,
1124
+ apiUrl,
1125
+ token,
1126
+ true
1127
+ );
1128
+ ingester.markProcessed(session, "pending", {
1129
+ uploadedLineCount: totalLines,
1130
+ finalized: false
1131
+ });
1132
+ uploaded++;
1054
1133
  logger3.info(
1055
- `[dry-run] Would upload session ${shortId} (${rawContent.length} bytes)`
1134
+ `delta uploaded session ${shortId} (${deltaLines.length} new lines, open)`
1056
1135
  );
1136
+ } catch (err) {
1137
+ errors++;
1138
+ logger3.error(`Failed to upload session ${shortId}`, {
1139
+ err: err.message
1140
+ });
1141
+ }
1142
+ }
1143
+ }
1144
+ const openSessionIds = ingester.getOpenSessionIds();
1145
+ const toFinalize = openSessionIds.filter((id) => !activeSessionIds.has(id));
1146
+ if (toFinalize.length > 0) {
1147
+ logger3.info(`Finalizing ${toFinalize.length} idle session(s)`);
1148
+ for (const sessionId of toFinalize) {
1149
+ const shortId = sessionId.slice(0, 8);
1150
+ try {
1151
+ if (options.dryRun) {
1152
+ logger3.info(`[dry-run] Would finalize session ${shortId}`);
1153
+ uploaded++;
1154
+ continue;
1155
+ }
1156
+ await uploadSession(sessionId, "", apiUrl, token, false);
1157
+ ingester.markFinalized(sessionId);
1057
1158
  uploaded++;
1058
- continue;
1159
+ logger3.info(`finalized session ${shortId}`);
1160
+ } catch (err) {
1161
+ errors++;
1162
+ logger3.error(`Failed to finalize session ${shortId}`, {
1163
+ err: err.message
1164
+ });
1059
1165
  }
1060
- await uploadSession(session.sessionId, scrubbed, apiUrl, token);
1061
- ingester.markProcessed(session, "pending", {});
1062
- uploaded++;
1063
- logger3.info(`Uploaded session ${shortId}`);
1064
- } catch (err) {
1065
- errors++;
1066
- logger3.error(`Failed to upload session ${shortId}`, { err: err.message });
1067
1166
  }
1068
1167
  }
1168
+ if (sessions.length === 0 && toFinalize.length === 0) {
1169
+ logger3.info("No new sessions to upload");
1170
+ }
1069
1171
  return { uploaded, skipped: scanResult.unchangedCount, errors };
1070
1172
  }
1071
1173
  async function main2() {
@@ -31991,6 +32093,25 @@ function resolveToken2() {
31991
32093
  }
31992
32094
  return null;
31993
32095
  }
32096
+ function triggerCollectInBackground() {
32097
+ if (isCollecting) return;
32098
+ isCollecting = true;
32099
+ collect({}).then(({ uploaded, skipped, errors }) => {
32100
+ console.error(
32101
+ `[mcp-server] Collection done: ${uploaded} uploaded, ${skipped} unchanged, ${errors} errors`
32102
+ );
32103
+ lastCollectTime = Date.now();
32104
+ }).catch((err) => {
32105
+ console.error("[mcp-server] Collection error:", err.message);
32106
+ }).finally(() => {
32107
+ isCollecting = false;
32108
+ });
32109
+ }
32110
+ function maybeCollect() {
32111
+ if (Date.now() - lastCollectTime >= COLLECT_COOLDOWN_MS) {
32112
+ triggerCollectInBackground();
32113
+ }
32114
+ }
31994
32115
  async function apiGet(path) {
31995
32116
  const response = await fetch(`${API_URL}/api/v1${path}`, {
31996
32117
  headers: { Authorization: `Bearer ${TOKEN}` }
@@ -32005,6 +32126,7 @@ async function main4() {
32005
32126
  try {
32006
32127
  await apiGet("/sessions/recent?limit=1");
32007
32128
  console.error("[mcp-server] API connection ok");
32129
+ triggerCollectInBackground();
32008
32130
  } catch (err) {
32009
32131
  console.error(
32010
32132
  "[mcp-server] WARNING: API connection check failed:",
@@ -32015,7 +32137,7 @@ async function main4() {
32015
32137
  await mcpServer.connect(transport);
32016
32138
  console.error("[mcp-server] haansi-solutions MCP server running on stdio");
32017
32139
  }
32018
- var import_dotenv4, import_path5, import_fs5, import_os4, DEFAULT_API_URL3, CREDENTIALS_FILE3, API_URL, TOKEN, mcpServer, server;
32140
+ var import_dotenv4, import_path5, import_fs5, import_os4, DEFAULT_API_URL3, CREDENTIALS_FILE3, API_URL, TOKEN, isCollecting, lastCollectTime, COLLECT_COOLDOWN_MS, mcpServer, server;
32019
32141
  var init_mcp_server2 = __esm({
32020
32142
  "../service-capture/claude-sessions/mcp-server.ts"() {
32021
32143
  "use strict";
@@ -32023,6 +32145,7 @@ var init_mcp_server2 = __esm({
32023
32145
  import_path5 = require("path");
32024
32146
  import_fs5 = require("fs");
32025
32147
  import_os4 = require("os");
32148
+ init_collector();
32026
32149
  init_mcp();
32027
32150
  init_stdio2();
32028
32151
  init_types2();
@@ -32037,6 +32160,9 @@ var init_mcp_server2 = __esm({
32037
32160
  );
32038
32161
  process.exit(1);
32039
32162
  }
32163
+ isCollecting = false;
32164
+ lastCollectTime = 0;
32165
+ COLLECT_COOLDOWN_MS = 5 * 60 * 1e3;
32040
32166
  mcpServer = new McpServer(
32041
32167
  { name: "haansi-solutions", version: "0.2.0" },
32042
32168
  { capabilities: { tools: {} } }
@@ -32079,6 +32205,7 @@ var init_mcp_server2 = __esm({
32079
32205
  ]
32080
32206
  }));
32081
32207
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
32208
+ maybeCollect();
32082
32209
  const { name, arguments: args } = request.params;
32083
32210
  console.error(`[mcp-server] Tool: ${name}`);
32084
32211
  try {
@@ -32150,7 +32277,9 @@ async function setupMcp() {
32150
32277
  try {
32151
32278
  config6 = JSON.parse((0, import_node_fs2.readFileSync)(CLAUDE_JSON, "utf-8"));
32152
32279
  } catch {
32153
- console.error(`Warning: Could not parse ${CLAUDE_JSON} \u2014 will overwrite with new config.`);
32280
+ console.error(
32281
+ `Warning: Could not parse ${CLAUDE_JSON} \u2014 will overwrite with new config.`
32282
+ );
32154
32283
  }
32155
32284
  }
32156
32285
  if (!config6.mcpServers) {
@@ -32162,7 +32291,9 @@ async function setupMcp() {
32162
32291
  };
32163
32292
  (0, import_node_fs2.writeFileSync)(CLAUDE_JSON, JSON.stringify(config6, null, 2) + "\n", "utf-8");
32164
32293
  console.log(`MCP server configured in ${CLAUDE_JSON}`);
32165
- console.log("Restart Claude Code to activate the haansi-solutions MCP server.");
32294
+ console.log(
32295
+ "Restart Claude Code to activate the haansi-solutions MCP server."
32296
+ );
32166
32297
  }
32167
32298
  var import_node_fs2, import_node_path2, import_node_os2, CLAUDE_JSON;
32168
32299
  var init_setup_mcp = __esm({
@@ -32184,7 +32315,9 @@ function findHaansiBin() {
32184
32315
  try {
32185
32316
  return (0, import_node_child_process.execSync)("which haansi", { encoding: "utf-8" }).trim();
32186
32317
  } catch {
32187
- throw new Error("Could not find haansi in PATH. Make sure it is installed globally (npm install -g haansi).");
32318
+ throw new Error(
32319
+ "Could not find haansi in PATH. Make sure it is installed globally (npm install -g haansi)."
32320
+ );
32188
32321
  }
32189
32322
  }
32190
32323
  function buildPlist(haansiPath) {
@@ -32255,7 +32388,12 @@ var init_setup_daemon = __esm({
32255
32388
  import_node_path3 = require("node:path");
32256
32389
  import_node_os3 = require("node:os");
32257
32390
  PLIST_LABEL = "co.haansi.daemon";
32258
- PLIST_PATH = (0, import_node_path3.join)((0, import_node_os3.homedir)(), "Library", "LaunchAgents", `${PLIST_LABEL}.plist`);
32391
+ PLIST_PATH = (0, import_node_path3.join)(
32392
+ (0, import_node_os3.homedir)(),
32393
+ "Library",
32394
+ "LaunchAgents",
32395
+ `${PLIST_LABEL}.plist`
32396
+ );
32259
32397
  LOG_FILE = (0, import_node_path3.join)((0, import_node_os3.homedir)(), ".haansi", "daemon.log");
32260
32398
  }
32261
32399
  });
@@ -32297,7 +32435,11 @@ async function list(options) {
32297
32435
  apiUrl
32298
32436
  );
32299
32437
  } else {
32300
- data = await apiGet2(`/sessions/recent?limit=${options.limit}`, token, apiUrl);
32438
+ data = await apiGet2(
32439
+ `/sessions/recent?limit=${options.limit}`,
32440
+ token,
32441
+ apiUrl
32442
+ );
32301
32443
  }
32302
32444
  if (!data.results || data.results.length === 0) {
32303
32445
  console.log("No solutions found. Run `haansi collect` to upload sessions.");
@@ -32336,9 +32478,16 @@ async function run2() {
32336
32478
  const limitIdx = process.argv.indexOf("--limit");
32337
32479
  const limit = limitIdx !== -1 ? parseInt(process.argv[limitIdx + 1], 10) : void 0;
32338
32480
  const projectPath = process.env.CLAUDE_PROJECT_PATH || void 0;
32339
- const { uploaded, skipped, errors } = await collect2({ forceAll, dryRun, limit, projectPath });
32340
- console.log(`
32341
- Collector done: ${uploaded} uploaded, ${skipped} unchanged, ${errors} errors`);
32481
+ const { uploaded, skipped, errors } = await collect2({
32482
+ forceAll,
32483
+ dryRun,
32484
+ limit,
32485
+ projectPath
32486
+ });
32487
+ console.log(
32488
+ `
32489
+ Collector done: ${uploaded} uploaded, ${skipped} unchanged, ${errors} errors`
32490
+ );
32342
32491
  if (errors > 0) process.exit(1);
32343
32492
  break;
32344
32493
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "haansi",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Haansi CLI - Session collector and MCP server for Claude Code",
5
5
  "bin": {
6
6
  "haansi": "./dist/haansi.js"