pinglet-cli 0.1.3 → 0.1.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.
package/dist/api.js CHANGED
@@ -39,6 +39,7 @@ exports.registerInstallation = registerInstallation;
39
39
  exports.fetchFeed = fetchFeed;
40
40
  exports.uploadEventBatch = uploadEventBatch;
41
41
  exports.sendHeartbeat = sendHeartbeat;
42
+ exports.fetchMessageStats = fetchMessageStats;
42
43
  exports.createMessage = createMessage;
43
44
  exports.linkInstallation = linkInstallation;
44
45
  const os = __importStar(require("os"));
@@ -128,6 +129,15 @@ async function sendHeartbeat(config, record) {
128
129
  body: { clientVersion: config_1.VERSION },
129
130
  });
130
131
  }
132
+ /** GET /messages/:id/stats — 내가 쓴 메시지의 도달 통계 (익명 작성자는 설치 토큰으로). */
133
+ async function fetchMessageStats(config, messageId) {
134
+ const token = config.userToken ?? Object.values(config.installations)[0]?.token;
135
+ if (!token)
136
+ return null;
137
+ return request(config, "GET", `/messages/${messageId}/stats`, {
138
+ token,
139
+ });
140
+ }
131
141
  /**
132
142
  * POST /messages — 메시지 작성 (분당 5개 제한).
133
143
  * 로그인했으면 유저 토큰으로(닉네임 표시), 아니면 설치 토큰으로 익명 작성한다.
package/dist/cache.js CHANGED
@@ -4,6 +4,10 @@ exports.loadFeedMessages = loadFeedMessages;
4
4
  exports.saveFeedMessages = saveFeedMessages;
5
5
  exports.feedAgeMs = feedAgeMs;
6
6
  exports.loadState = loadState;
7
+ exports.loadMyPosts = loadMyPosts;
8
+ exports.saveMyPost = saveMyPost;
9
+ exports.loadMyStats = loadMyStats;
10
+ exports.saveMyStats = saveMyStats;
7
11
  exports.saveState = saveState;
8
12
  const config_1 = require("./config");
9
13
  /**
@@ -38,13 +42,33 @@ function loadState() {
38
42
  }
39
43
  return { seen: {} };
40
44
  }
45
+ function loadMyPosts() {
46
+ return (0, config_1.readJsonFile)(config_1.POSTS_PATH) ?? [];
47
+ }
48
+ function saveMyPost(post) {
49
+ const posts = [post, ...loadMyPosts()].slice(0, 20);
50
+ (0, config_1.writeJsonFile)(config_1.POSTS_PATH, posts);
51
+ }
52
+ function loadMyStats() {
53
+ return (0, config_1.readJsonFile)(config_1.MYSTATS_PATH);
54
+ }
55
+ function saveMyStats(stats) {
56
+ (0, config_1.writeJsonFile)(config_1.MYSTATS_PATH, stats);
57
+ }
41
58
  function saveState(state) {
42
- // seen map이 무한히 자라지 않게 상한을 둔다.
59
+ // seen/delivered map이 무한히 자라지 않게 상한을 둔다.
43
60
  const keys = Object.keys(state.seen);
44
61
  if (keys.length > 500) {
45
62
  for (const key of keys.slice(0, keys.length - 500)) {
46
63
  delete state.seen[key];
47
64
  }
48
65
  }
66
+ const delivered = state.delivered ?? {};
67
+ const deliveredKeys = Object.keys(delivered);
68
+ if (deliveredKeys.length > 2000) {
69
+ for (const key of deliveredKeys.slice(0, deliveredKeys.length - 2000)) {
70
+ delete delivered[key];
71
+ }
72
+ }
49
73
  (0, config_1.writeJsonFile)(config_1.STATE_PATH, state);
50
74
  }
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.runPost = runPost;
4
4
  const config_1 = require("../config");
5
5
  const api_1 = require("../api");
6
+ const cache_1 = require("../cache");
6
7
  const REASON_LABELS = {
7
8
  EMPTY: "빈 메시지는 등록할 수 없습니다",
8
9
  TOO_LONG: "메시지가 너무 깁니다 (기본 100자 제한)",
@@ -54,6 +55,10 @@ async function runPost(args) {
54
55
  process.exitCode = 1;
55
56
  return;
56
57
  }
58
+ // statusline 도달 표시용으로 이 기기에서 쓴 메시지를 기록해 둔다.
59
+ if (result.id && result.status !== "REJECTED") {
60
+ (0, cache_1.saveMyPost)({ id: result.id, text, postedAt: new Date().toISOString() });
61
+ }
57
62
  const reason = result.moderationReason
58
63
  ? REASON_LABELS[result.moderationReason] ?? result.moderationReason
59
64
  : undefined;
@@ -4,6 +4,7 @@ exports.runRefresh = runRefresh;
4
4
  const config_1 = require("../config");
5
5
  const api_1 = require("../api");
6
6
  const cache_1 = require("../cache");
7
+ const queue_1 = require("../queue");
7
8
  const claude_1 = require("../adapters/claude");
8
9
  /**
9
10
  * 서버 feed를 미리 받아 Local Feed Cache를 갱신한다 (아키텍처 §6).
@@ -35,5 +36,38 @@ async function runRefresh() {
35
36
  // 새 feed를 Claude Code spinner verb에도 반영한다 (설정 파일만 다시 쓰면 됨).
36
37
  // feed가 비었으면 spinner를 기본 상태로 되돌린다.
37
38
  (0, claude_1.syncSpinnerVerbs)(config, messages);
39
+ // spinner는 실제 노출 시점을 알 수 없으므로, spinner pool에 실린 시점을
40
+ // "전달(DELIVERED)"로 기록한다. 메시지당 설치 1회만.
41
+ if (config.adapters.claude && config.installations.CLAUDE) {
42
+ const state = (0, cache_1.loadState)();
43
+ state.delivered ??= {};
44
+ let changed = false;
45
+ for (const m of messages) {
46
+ if (state.delivered[m.id])
47
+ continue;
48
+ (0, queue_1.appendEvent)({ agentType: "CLAUDE", type: "DELIVERED", messageId: m.id });
49
+ state.delivered[m.id] = 1;
50
+ changed = true;
51
+ }
52
+ if (changed)
53
+ (0, cache_1.saveState)(state);
54
+ }
55
+ }
56
+ // 이 기기에서 작성한 최근 메시지의 도달 통계를 갱신한다 (statusline 표시용).
57
+ const latest = (0, cache_1.loadMyPosts)()[0];
58
+ if (latest) {
59
+ const stats = await (0, api_1.fetchMessageStats)(config, latest.id);
60
+ if (stats) {
61
+ (0, cache_1.saveMyStats)({
62
+ messageId: latest.id,
63
+ text: latest.text,
64
+ reachedInstallations: stats.reachedInstallations,
65
+ activeInstallations: stats.activeInstallations,
66
+ delivered: stats.delivered,
67
+ qualifiedImpressions: stats.qualifiedImpressions,
68
+ reactions: stats.reactions,
69
+ updatedAt: new Date().toISOString(),
70
+ });
71
+ }
38
72
  }
39
73
  }
@@ -6,34 +6,19 @@ const render_1 = require("../render");
6
6
  const runtime_1 = require("../runtime");
7
7
  /**
8
8
  * Claude Code statusLine hook 진입점.
9
- * Claude Code가 상태 변경마다(최대 300ms 간격) 호출하며, stdout 한 줄이 status 영역에 그려진다.
10
- * 이 경로는 반드시 빨라야 하므로 로컬 파일만 읽고, 네트워크 작업은 detached로 넘긴다.
9
+ * Ping 메시지는 spinner(spinnerVerbs)가 담당하고, statusline에는
10
+ * 이 기기에서 작성한 최근 메시지의 도달 현황을 보여준다.
11
+ * flush/refresh 백그라운드 트리거도 이 경로에서 돈다 (Claude 사용 중 항상 호출되므로).
11
12
  */
12
13
  function runStatusline() {
13
14
  const state = (0, cache_1.loadState)();
14
15
  const now = Date.now();
15
- const current = state.current;
16
- let line;
17
- if (!current || now - current.shownAt >= runtime_1.ROTATE_MS) {
18
- const message = (0, runtime_1.rotateMessage)(state, "CLAUDE", now);
19
- // feed가 없으면 빈 줄 — statusLine에 아무것도 그리지 않는다.
20
- line = message ? (0, render_1.formatPing)(message) : "";
16
+ // 300ms마다 호출되므로 상태 기록/maintenance는 1초 스로틀.
17
+ if (state.lastTickAt === undefined || now - state.lastTickAt > 1_000) {
21
18
  (0, runtime_1.runMaintenance)(state, now);
22
19
  state.lastTickAt = now;
23
20
  (0, cache_1.saveState)(state);
24
21
  }
25
- else {
26
- line = (0, render_1.formatPing)({
27
- id: current.messageId,
28
- text: current.text,
29
- author: current.author,
30
- contentType: "USER",
31
- });
32
- // visibleMs 추정용 tick. 300ms마다 쓰지 않도록 1초 스로틀.
33
- if (state.lastTickAt === undefined || now - state.lastTickAt > 1_000) {
34
- state.lastTickAt = now;
35
- (0, cache_1.saveState)(state);
36
- }
37
- }
38
- process.stdout.write(line);
22
+ const stats = (0, cache_1.loadMyStats)();
23
+ process.stdout.write(stats ? (0, render_1.formatMyReach)(stats) : "");
39
24
  }
package/dist/config.js CHANGED
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.DEFAULT_API_BASE_URL = exports.EVENTS_PATH = exports.STATE_PATH = exports.FEED_PATH = exports.CONFIG_PATH = exports.PINGLET_DIR = exports.VERSION = void 0;
36
+ exports.DEFAULT_API_BASE_URL = exports.MYSTATS_PATH = exports.POSTS_PATH = exports.EVENTS_PATH = exports.STATE_PATH = exports.FEED_PATH = exports.CONFIG_PATH = exports.PINGLET_DIR = exports.VERSION = void 0;
37
37
  exports.ensurePingletDir = ensurePingletDir;
38
38
  exports.readJsonFile = readJsonFile;
39
39
  exports.writeJsonFile = writeJsonFile;
@@ -58,6 +58,10 @@ exports.CONFIG_PATH = path.join(exports.PINGLET_DIR, "config.json");
58
58
  exports.FEED_PATH = path.join(exports.PINGLET_DIR, "feed.json");
59
59
  exports.STATE_PATH = path.join(exports.PINGLET_DIR, "state.json");
60
60
  exports.EVENTS_PATH = path.join(exports.PINGLET_DIR, "events.jsonl");
61
+ /** 이 기기에서 작성한 메시지 기록 (statusline 도달 표시용) */
62
+ exports.POSTS_PATH = path.join(exports.PINGLET_DIR, "posts.json");
63
+ /** 최근 작성 메시지의 도달 통계 캐시 — refresh가 갱신, statusline이 읽는다 */
64
+ exports.MYSTATS_PATH = path.join(exports.PINGLET_DIR, "mystats.json");
61
65
  exports.DEFAULT_API_BASE_URL = process.env.PINGLET_API_URL ?? "https://pinglet.halluci.co.kr";
62
66
  function ensurePingletDir() {
63
67
  fs.mkdirSync(exports.PINGLET_DIR, { recursive: true });
package/dist/render.js CHANGED
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.supportsUnicode = supportsUnicode;
4
4
  exports.formatPing = formatPing;
5
+ exports.formatMyReach = formatMyReach;
5
6
  /** 터미널 독립 원칙: unicode가 불확실하면 plain ASCII로 fallback (아키텍처 §5). */
6
7
  function supportsUnicode() {
7
8
  if (process.platform === "win32")
@@ -9,7 +10,7 @@ function supportsUnicode() {
9
10
  const locale = process.env.LC_ALL || process.env.LC_CTYPE || process.env.LANG || "";
10
11
  return /utf-?8/i.test(locale);
11
12
  }
12
- /** 작성자 닉네임 없이 메시지만 표시한다 (statusline / spinner verb 공용). */
13
+ /** 작성자 닉네임 없이 메시지만 표시한다 (spinner verb용). */
13
14
  function formatPing(message) {
14
15
  const sponsored = message.contentType === "SPONSORED" ? "[AD] " : "";
15
16
  if (supportsUnicode()) {
@@ -17,3 +18,23 @@ function formatPing(message) {
17
18
  }
18
19
  return `[ping] ${sponsored}"${message.text}"`;
19
20
  }
21
+ const REACH_TEXT_MAX = 24;
22
+ /** statusline용: 내가 쓴 최근 메시지의 도달 현황 한 줄 (커버리지 = 도달/활성 설치). */
23
+ function formatMyReach(stats) {
24
+ let text = stats.text;
25
+ if (text.length > REACH_TEXT_MAX)
26
+ text = text.slice(0, REACH_TEXT_MAX - 1) + "…";
27
+ const reached = stats.reachedInstallations;
28
+ const active = stats.activeInstallations ?? 0;
29
+ // 활성 설치 수를 모르면(구서버/구캐시) 도달 수만 표시한다.
30
+ const coverage = active > 0
31
+ ? `도달률 ${Math.round((reached / active) * 100)}%`
32
+ : `터미널 ${reached}곳 도달`;
33
+ if (supportsUnicode()) {
34
+ return `💌 내 Ping "${text}" → ${coverage}`;
35
+ }
36
+ const asciiCoverage = active > 0
37
+ ? `reach ${Math.round((reached / active) * 100)}%`
38
+ : `reached ${reached} terminals`;
39
+ return `[ping] "${text}" -> ${asciiCoverage}`;
40
+ }
package/dist/runtime.js CHANGED
@@ -1,60 +1,14 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.FEED_TTL_MS = exports.FLUSH_COUNT = exports.FLUSH_INTERVAL_MS = exports.QUALIFIED_MS = exports.ROTATE_MS = void 0;
4
- exports.rotateMessage = rotateMessage;
3
+ exports.FEED_TTL_MS = exports.FLUSH_COUNT = exports.FLUSH_INTERVAL_MS = void 0;
5
4
  exports.runMaintenance = runMaintenance;
6
5
  const child_process_1 = require("child_process");
7
6
  const config_1 = require("./config");
8
7
  const cache_1 = require("./cache");
9
- const picker_1 = require("./picker");
10
8
  const queue_1 = require("./queue");
11
- exports.ROTATE_MS = 30_000; // 메시지 교체 주기
12
- exports.QUALIFIED_MS = 3_000; // 정책상 최소 노출 시간 (아키텍처 §7.2)
13
9
  exports.FLUSH_INTERVAL_MS = 60_000; // batch 전송 주기
14
10
  exports.FLUSH_COUNT = 20; // 또는 이벤트 개수 기준
15
11
  exports.FEED_TTL_MS = 5 * 60_000; // feed 캐시 갱신 주기
16
- /** 구버전 시드 메시지 id는 서버에 존재하지 않으므로 이벤트를 만들지 않는다. */
17
- function isServerMessage(messageId) {
18
- return !messageId.startsWith("seed_");
19
- }
20
- /**
21
- * 이전 메시지의 QUALIFIED_IMPRESSION을 정산하고 다음 메시지를 뽑는다.
22
- * 렌더링 path이므로 네트워크 호출 없이 로컬 캐시만 읽는다 (Local-first).
23
- * feed가 비어 있으면 null — 호출부는 아무것도 표시하지 않는다.
24
- */
25
- function rotateMessage(state, agentType, now) {
26
- const prev = state.current;
27
- if (prev && isServerMessage(prev.messageId)) {
28
- // 세션이 중간에 끊겼으면 마지막 tick까지만 보였다고 간주한다.
29
- const sessionGap = state.lastTickAt !== undefined && now - state.lastTickAt > exports.ROTATE_MS * 2;
30
- const visibleEnd = sessionGap ? state.lastTickAt : now;
31
- const visibleMs = Math.max(0, Math.min(visibleEnd - prev.shownAt, exports.ROTATE_MS));
32
- if (visibleMs >= exports.QUALIFIED_MS) {
33
- (0, queue_1.appendEvent)({
34
- agentType,
35
- type: "QUALIFIED_IMPRESSION",
36
- messageId: prev.messageId,
37
- visibleMs,
38
- });
39
- }
40
- }
41
- const message = (0, picker_1.pickMessage)((0, cache_1.loadFeedMessages)(), state);
42
- if (!message) {
43
- delete state.current;
44
- return null;
45
- }
46
- if (isServerMessage(message.id)) {
47
- (0, queue_1.appendEvent)({ agentType, type: "DELIVERED", messageId: message.id });
48
- }
49
- state.seen[message.id] = (state.seen[message.id] ?? 0) + 1;
50
- state.current = {
51
- messageId: message.id,
52
- text: message.text,
53
- author: message.author,
54
- shownAt: now,
55
- };
56
- return message;
57
- }
58
12
  function spawnDetachedCommand(args) {
59
13
  try {
60
14
  const child = (0, child_process_1.spawn)(process.execPath, [(0, config_1.cliPath)(), ...args], {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinglet-cli",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "AI가 생각하는 동안 다른 개발자의 Ping을 만나는 메시지 네트워크 — Claude Code / Codex integration",
5
5
  "license": "MIT",
6
6
  "repository": {