Bladeren bron

Oasis release 0.7.4

psy 9 uur geleden
bovenliggende
commit
546a15a767
42 gewijzigde bestanden met toevoegingen van 1955 en 197 verwijderingen
  1. 1 0
      README.md
  2. 11 0
      docs/CHANGELOG.md
  3. 4 3
      scripts/build-deb.sh
  4. 20 5
      src/AI/ai_service.mjs
  5. 110 26
      src/backend/backend.js
  6. 206 0
      src/backend/logsPdf.js
  7. 31 0
      src/client/assets/styles/style.css
  8. 56 4
      src/client/assets/translations/oasis_ar.js
  9. 56 4
      src/client/assets/translations/oasis_de.js
  10. 56 4
      src/client/assets/translations/oasis_en.js
  11. 56 4
      src/client/assets/translations/oasis_es.js
  12. 56 4
      src/client/assets/translations/oasis_eu.js
  13. 56 4
      src/client/assets/translations/oasis_fr.js
  14. 56 4
      src/client/assets/translations/oasis_hi.js
  15. 56 4
      src/client/assets/translations/oasis_it.js
  16. 56 4
      src/client/assets/translations/oasis_pt.js
  17. 56 4
      src/client/assets/translations/oasis_ru.js
  18. 56 4
      src/client/assets/translations/oasis_zh.js
  19. 3 0
      src/configs/config-manager.js
  20. 1 0
      src/configs/oasis-config.json
  21. 10 3
      src/games/labyrinth/index.html
  22. 1 1
      src/models/activity_model.js
  23. 40 39
      src/models/banking_model.js
  24. 21 3
      src/models/blockchain_model.js
  25. 32 11
      src/models/chats_model.js
  26. 373 0
      src/models/logs_model.js
  27. 119 35
      src/models/pads_model.js
  28. 8 4
      src/models/search_model.js
  29. 1 1
      src/models/stats_model.js
  30. 47 0
      src/models/tribes_model.js
  31. 8 1
      src/server/SSB_server.js
  32. 2 2
      src/server/package-lock.json
  33. 1 1
      src/server/package.json
  34. 4 3
      src/server/ssb_metadata.js
  35. 3 5
      src/views/banking_views.js
  36. 4 2
      src/views/blockchain_view.js
  37. 246 0
      src/views/logs_view.js
  38. 20 4
      src/views/main_views.js
  39. 3 2
      src/views/modules_view.js
  40. 2 1
      src/views/search_view.js
  41. 6 0
      src/views/stats_view.js
  42. 1 1
      src/views/tribes_view.js

+ 1 - 0
README.md

@@ -85,6 +85,7 @@ Oasis is TRULY MODULAR. Here's a list of what comes deployed with the "core".
  + Jobs: Module to discover and manage jobs.	
  + Legacy: Module to manage your secret (private key) quickly and securely.	
  + Latest: Module to receive the most recent posts and discussions.
+ + Logs: Module to record (via AI assistant) your experiences.
  + Maps: Module to manage and share offline maps.
  + Market: Module to exchange goods or services.
  + Multiverse: Module to receive content from other federated peers.

+ 11 - 0
docs/CHANGELOG.md

@@ -13,6 +13,17 @@ All notable changes to this project will be documented in this file.
 ### Security
 -->
 
+## v0.7.4 - 2026-04-25
+
+### Added
+
+- Logs module: create, manage and share records about your experiences (Logs plugin).
+
+### Fixed
+
+- Tribes strict mode ACLs (Tribes plugin).
+- Labyrinth game scoring (Games plugin).
+
 ## v0.7.3 - 2026-04-20
 
 ### Added

+ 4 - 3
scripts/build-deb.sh

@@ -2,7 +2,7 @@
 
 set -e
 
-VERSION="0.6.9"
+VERSION="0.7.4"
 PKG_NAME="oasis"
 ARCH=$(dpkg --print-architecture)
 SRC_DIR="$(cd "$(dirname "$0")/.." && pwd)"
@@ -50,8 +50,9 @@ cp -r "${SRC_DIR}/src/models/"*.js "${DEB_ROOT}${INSTALL_DIR}/src/models/"
 cp -r "${SRC_DIR}/src/client" "${DEB_ROOT}${INSTALL_DIR}/src/"
 find "${DEB_ROOT}${INSTALL_DIR}/src/client" -name "*.py" -delete 2>/dev/null
 find "${DEB_ROOT}${INSTALL_DIR}/src/client" -name ".ruff_cache" -type d -exec rm -rf {} + 2>/dev/null || true
-cp -r "${SRC_DIR}/src/configs/oasis-config.json" "${DEB_ROOT}${INSTALL_DIR}/src/configs/"
-cp -r "${SRC_DIR}/src/configs/shared-state.js" "${DEB_ROOT}${INSTALL_DIR}/src/configs/" 2>/dev/null || true
+for f in oasis-config.json server-config.json snh-invite-code.json config-manager.js shared-state.js; do
+    cp "${SRC_DIR}/src/configs/${f}" "${DEB_ROOT}${INSTALL_DIR}/src/configs/"
+done
 cp -r "${SRC_DIR}/scripts" "${DEB_ROOT}${INSTALL_DIR}/"
 cp "${SRC_DIR}/oasis.sh" "${DEB_ROOT}${INSTALL_DIR}/"
 cp "${SRC_DIR}/LICENSE" "${DEB_ROOT}${INSTALL_DIR}/"

+ 20 - 5
src/AI/ai_service.mjs

@@ -3,7 +3,7 @@ import fs from 'fs';
 import { fileURLToPath } from 'url';
 import express from '../server/node_modules/express/index.js';
 import cors from '../server/node_modules/cors/lib/index.js';
-import { getLlama, LlamaChatSession } from '../server/node_modules/node-llama-cpp/dist/index.js';
+import { getLlama, LlamaChatSession, LlamaCompletion } from '../server/node_modules/node-llama-cpp/dist/index.js';
 
 let getConfig, buildAIContext;
 try {
@@ -23,6 +23,7 @@ const __filename = fileURLToPath(import.meta.url);
 const __dirname = path.dirname(__filename);
 
 let llamaInstance, model, context, session;
+let rawContext, rawCompletion;
 let ready = false;
 let lastError = null;
 
@@ -39,9 +40,22 @@ async function initModel() {
   ready = true;
 }
 
+async function initRaw() {
+  if (rawCompletion) return;
+  if (!model) await initModel();
+  rawContext = await model.createContext();
+  rawCompletion = new LlamaCompletion({ contextSequence: rawContext.getSequence() });
+}
+
 app.post('/ai', async (req, res) => {
   try {
-    const userInput = String(req.body.input || '').trim();
+    const sanitize = (s) => String(s || '').replace(/[<>"'`]/g, '').replace(/\b(ignore|disregard|forget|system|instruction|prompt)\b/gi, '[$1]').trim();
+    const userInput = sanitize(String(req.body.input || ''));
+    if (req.body.raw === true) {
+      await initRaw();
+      const answer = await rawCompletion.generateCompletion(userInput, { maxTokens: 120 });
+      return res.json({ answer: String(answer || '').trim(), snippets: [] });
+    }
     await initModel();
 
     let userContext = '';
@@ -49,6 +63,7 @@ app.post('/ai', async (req, res) => {
     try {
       userContext = await (buildAIContext ? buildAIContext(120) : '');
       if (userContext) {
+        userContext = userContext.split('\n').map(l => sanitize(l)).join('\n');
         snippets = userContext.split('\n').slice(0, 50);
       }
     } catch {}
@@ -58,9 +73,9 @@ app.post('/ai', async (req, res) => {
     const userPrompt = [baseContext, config.ai?.prompt?.trim() || 'Provide an informative and precise response.'].join('\n');
 
     const prompt = [
-      userContext ? `User Data:\n${userContext}` : '',
-      `Query: "${userInput}"`,
-      userPrompt
+      userPrompt,
+      userContext ? `--- USER DATA START ---\n${userContext}\n--- USER DATA END ---` : '',
+      `--- QUERY START ---\n${userInput}\n--- QUERY END ---`
     ].filter(Boolean).join('\n\n');
     const answer = await session.prompt(prompt);
     res.json({ answer: String(answer || '').trim(), snippets });

+ 110 - 26
src/backend/backend.js

@@ -262,7 +262,6 @@ const ssbConfig = require('../server/ssb_config');
 const tribeCrypto = require('../models/tribe_crypto')(ssbConfig.path);
 const reportsModel = require('../models/reports_model')({ cooler, isPublic: config.public });
 const transfersModel = require('../models/transfers_model')({ cooler, isPublic: config.public });
-const padsModel = require('../models/pads_model')({ cooler, cipherModel });
 const calendarsModel = require('../models/calendars_model')({ cooler, pmModel });
 const cvModel = require('../models/cv_model')({ cooler, isPublic: config.public });
 const inhabitantsModel = require('../models/inhabitants_model')({ cooler, isPublic: config.public });
@@ -276,6 +275,7 @@ const agendaModel = require("../models/agenda_model")({ cooler, isPublic: config
 const trendingModel = require('../models/trending_model')({ cooler, isPublic: config.public });
 const statsModel = require('../models/stats_model')({ cooler, isPublic: config.public });
 const tribesModel = require('../models/tribes_model')({ cooler, isPublic: config.public, tribeCrypto });
+const padsModel = require('../models/pads_model')({ cooler, cipherModel, tribeCrypto, tribesModel });
 const tagsModel = require('../models/tags_model')({ cooler, isPublic: config.public, padsModel, tribesModel });
 const tribesContentModel = require('../models/tribes_content_model')({ cooler, isPublic: config.public, tribeCrypto, tribesModel });
 const searchModel = require('../models/search_model')({ cooler, isPublic: config.public, padsModel });
@@ -286,18 +286,13 @@ const forumModel = require('../models/forum_model')({ cooler, isPublic: config.p
 const blockchainModel = require('../models/blockchain_model')({ cooler, isPublic: config.public });
 const jobsModel = require('../models/jobs_model')({ cooler, isPublic: config.public, tribeCrypto });
 const shopsModel = require('../models/shops_model')({ cooler, isPublic: config.public, tribeCrypto });
-const chatsModel = require('../models/chats_model')({ cooler, tribeCrypto });
+const chatsModel = require('../models/chats_model')({ cooler, tribeCrypto, tribesModel });
 const projectsModel = require("../models/projects_model")({ cooler, isPublic: config.public });
 const mapsModel = require("../models/maps_model")({ cooler, isPublic: config.public });
 const gamesModel = require('../models/games_model')({ cooler });
 const bankingModel = require("../models/banking_model")({ services: { cooler }, isPublic: config.public });
-let pubBalanceTimer = null;
-if (bankingModel.isPubNode()) {
-  const tick = () => { bankingModel.publishPubBalance().catch(() => {}); };
-  setTimeout(tick, 30 * 1000);
-  pubBalanceTimer = setInterval(tick, 24 * 60 * 60 * 1000);
-}
 const favoritesModel = require("../models/favorites_model")({ services: { cooler }, audiosModel, bookmarksModel, documentsModel, imagesModel, videosModel, mapsModel, padsModel, chatsModel, calendarsModel, torrentsModel });
+const logsModel = require("../models/logs_model")({ cooler });
 const parliamentModel = require('../models/parliament_model')({ cooler, services: { tribes: tribesModel, votes: votesModel, inhabitants: inhabitantsModel, banking: bankingModel } });
 const { renderGovernance: renderTribeGovernance } = require('../views/tribes_view');
 const viewerFilters = require('../models/viewer_filters');
@@ -453,7 +448,15 @@ const applyListFilters = async (items, ctx, opts = {}) => {
   return out;
 };
 const courtsModel = require('../models/courts_model')({ cooler, services: { votes: votesModel, inhabitants: inhabitantsModel, tribes: tribesModel, banking: bankingModel }, tribeCrypto });
-tribesModel.processIncomingKeys().catch(err => {
+tribesModel.processIncomingKeys().then(async () => {
+  try {
+    const viewerId = getViewerId();
+    const mine = (await tribesModel.listAll()).filter(t => t.author === viewerId);
+    for (const t of mine) {
+      await tribesModel.ensureTribeKeyDistribution(t.id).catch(() => {});
+    }
+  } catch (_) {}
+}).catch(err => {
   if (config.debug) console.error('tribe-keys scan error:', err.message);
 });
 courtsModel.processIncomingCourtsKeys().catch(err => {
@@ -862,6 +865,8 @@ const { calendarsView, singleCalendarView } = require("../views/calendars_view")
 const { projectsView, singleProjectView } = require("../views/projects_view")
 const { renderBankingView, renderSingleAllocationView, renderEpochView } = require("../views/banking_views")
 const { favoritesView } = require("../views/favorites_view");
+const { logsView } = require("../views/logs_view");
+const { buildLogsPdf } = require("./logsPdf");
 const { parliamentView } = require("../views/parliament_view");
 const { courtsView, courtsCaseView } = require('../views/courts_view');
 let sharp;
@@ -931,6 +936,7 @@ router
       myAddress: myAddress || null,
       totalAddresses: Array.isArray(addrRows) ? addrRows.length : 0
     };
+    try { stats.logsCount = await logsModel.countLogs(); } catch { stats.logsCount = 0; }
     const totalMB = parseSizeMB(stats.statsBlobsSize) + parseSizeMB(stats.statsBlockchainSize);
     const hcT = parseFloat((totalMB * 0.0002 * 475).toFixed(2));
     const inhabitants = stats.usersKPIs?.totalInhabitants || stats.inhabitants || 1;
@@ -1018,7 +1024,7 @@ router
     let filter = query.filter || 'recent';
     if (searchActive && String(filter).toLowerCase() === 'recent') filter = 'all';
     const blockId = ctx.params.id;
-    const block = await blockchainModel.getBlockById(blockId);
+    const block = await blockchainModel.getBlockById(blockId, userId);
     const viewMode = query.view || 'block';
     let restricted = false;
     if (block) {
@@ -1568,6 +1574,7 @@ router
   .get('/tribe/:tribeId', async ctx => {
     if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
     await tribesModel.processIncomingKeys().catch(() => {});
+    await tribesModel.ensureTribeKeyDistribution(ctx.params.tribeId).catch(() => {});
     const listByTribeAllChain = async (tribeId, contentType) => {
       const chainIds = await tribesModel.getChainIds(tribeId).catch(() => [tribeId]);
       const results = await Promise.all(chainIds.map(id => tribesContentModel.listByTribe(id, contentType).catch(() => [])));
@@ -1740,7 +1747,7 @@ router
       if (action.type === 'pad') {
         const c = action.value?.content || action.content || {};
         const rootId = action.id || action.key || '';
-        const decrypted = padsModel.decryptContent(c, rootId);
+        const decrypted = await padsModel.decryptContent(c, rootId);
         if (decrypted.title) {
           if (action.value?.content) { action.value.content.title = decrypted.title; action.value.content.deadline = decrypted.deadline; }
           else if (action.content) { action.content.title = decrypted.title; action.content.deadline = decrypted.deadline; }
@@ -2195,13 +2202,15 @@ router
     if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; }
     const { filter = 'all', q = '' } = ctx.query;
     const uid = getViewerId();
-    const chat = await chatsModel.getChatById(ctx.params.chatId);
+    let chat = await chatsModel.getChatById(ctx.params.chatId);
     if (!chat) { ctx.redirect('/chats'); return; }
     let parentTribe = null;
     if (chat.tribeId) {
       try {
         parentTribe = await tribesModel.getTribeById(chat.tribeId);
         if (!parentTribe.members.includes(uid)) { ctx.body = tribeAccessDeniedView(parentTribe); return; }
+        await tribesModel.processIncomingKeys().catch(() => {});
+        chat = await chatsModel.getChatById(ctx.params.chatId);
       } catch { ctx.redirect('/tribes'); return; }
     }
     if (String(chat.status || '').toUpperCase() === 'INVITE-ONLY' && chat.author !== uid) {
@@ -2228,21 +2237,22 @@ router
     const tribeId = ctx.query.tribeId || "";
     const pads = await padsModel.listAll({ filter, viewerId: uid });
     const fav = await mediaFavorites.getFavoriteSet('pads');
-    const myTribeIds = await getUserTribeIds(uid);
-    let enriched = pads.filter(p => !p.tribeId || myTribeIds.has(p.tribeId)).map(p => ({ ...p, isFavorite: fav.has(String(p.rootId)) }));
+    let enriched = pads.filter(p => !p.tribeId).map(p => ({ ...p, isFavorite: fav.has(String(p.rootId)) }));
     enriched = await applyListFilters(enriched, ctx);
     ctx.body = await padsView(enriched, filter, null, { q, ...(tribeId ? { tribeId } : {}) });
   })
   .get("/pads/:padId", async (ctx) => {
     if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; }
     const uid = getViewerId();
-    const pad = await padsModel.getPadById(ctx.params.padId);
+    let pad = await padsModel.getPadById(ctx.params.padId);
     if (!pad) { ctx.redirect('/pads'); return; }
     let parentTribe = null;
     if (pad.tribeId) {
       try {
         parentTribe = await tribesModel.getTribeById(pad.tribeId);
         if (!parentTribe.members.includes(uid)) { ctx.body = tribeAccessDeniedView(parentTribe); return; }
+        await tribesModel.processIncomingKeys().catch(() => {});
+        pad = await padsModel.getPadById(ctx.params.padId);
       } catch { ctx.redirect('/tribes'); return; }
     }
     if (String(pad.status || '').toUpperCase() === 'INVITE-ONLY' && pad.author !== uid) {
@@ -2342,12 +2352,9 @@ router
     const q = (query.q || '').trim();
     const msg = (query.msg || '').trim();
     await bankingModel.ensureSelfAddressPublished();
-    if (bankingModel.isPubNode()) {
-      try { await bankingModel.publishPubBalance(); } catch (_) {}
-      if (filter === 'overview') {
-        try { await bankingModel.executeEpoch({}); } catch (_) {}
-        try { await bankingModel.processPendingClaims(); } catch (_) {}
-      }
+    if (bankingModel.isPubNode() && filter === 'overview') {
+      try { await bankingModel.executeEpoch({}); } catch (_) {}
+      try { await bankingModel.processPendingClaims(); } catch (_) {}
     }
     const data = await bankingModel.listBanking(filter, userId);
     data.isPub = bankingModel.isPubNode();
@@ -2390,6 +2397,81 @@ router
     const filter = qf(ctx), data = await favoritesModel.listAll({ filter });
     ctx.body = await favoritesView(data.items, filter, data.counts);
   })
+  .get("/logs", async (ctx) => {
+    if (!checkMod(ctx, 'logsMod')) { ctx.redirect('/modules'); return; }
+    const view = String(ctx.query.view || 'list');
+    const aiModOn = logsModel.isAImodOn();
+    if (view === 'create') {
+      const mode = ctx.query.mode === 'ai' ? 'ai' : 'manual';
+      ctx.body = logsView([], 'today', mode, { view: 'create', aiModOn });
+      return;
+    }
+    if (view === 'edit') {
+      const id = String(ctx.query.id || '');
+      const entry = id ? await logsModel.getLogById(id) : null;
+      if (!entry) { ctx.redirect('/logs'); return; }
+      ctx.body = logsView([], 'today', entry.mode, { view: 'edit', aiModOn, entry });
+      return;
+    }
+    const filter = qf(ctx, 'today');
+    const q = String(ctx.query.q || '').trim().toLowerCase();
+    const typeQ = String(ctx.query.type || '').trim().toLowerCase();
+    const dateQ = String(ctx.query.date || '').trim();
+    let items = await logsModel.listLogs(filter);
+    if (q) items = items.filter(i => String(i.text || '').toLowerCase().includes(q) || String(i.label || '').toLowerCase().includes(q));
+    if (typeQ === 'ai' || typeQ === 'manual') items = items.filter(i => (i.mode === 'ai' ? 'ai' : 'manual') === typeQ);
+    if (/^\d{4}-\d{2}-\d{2}$/.test(dateQ)) {
+      const start = new Date(dateQ + 'T00:00:00').getTime();
+      const end = start + 24 * 60 * 60 * 1000;
+      items = items.filter(i => i.ts >= start && i.ts < end);
+    }
+    ctx.body = logsView(items, filter, null, { view: 'list', aiModOn, search: { q: ctx.query.q || '', type: typeQ, date: dateQ } });
+  })
+  .get("/logs/view/:id", async (ctx) => {
+    if (!checkMod(ctx, 'logsMod')) { ctx.redirect('/modules'); return; }
+    const entry = await logsModel.getLogById(ctx.params.id);
+    if (!entry) { ctx.redirect('/logs'); return; }
+    const aiModOn = logsModel.isAImodOn();
+    ctx.body = logsView([], 'today', entry.mode, { view: 'detail', aiModOn, entry });
+  })
+  .post("/logs/create", koaBody(), async (ctx) => {
+    if (!checkMod(ctx, 'logsMod')) { ctx.redirect('/modules'); return; }
+    const b = ctx.request.body || {};
+    const mode = b.mode === 'ai' ? 'ai' : 'manual';
+    try {
+      if (mode === 'ai') { startAI(); await logsModel.createAI(); }
+      else await logsModel.createManual(b.label || '', b.text || '');
+    } catch (_) {}
+    ctx.redirect('/logs');
+  })
+  .post("/logs/edit/:id", koaBody(), async (ctx) => {
+    if (!checkMod(ctx, 'logsMod')) { ctx.redirect('/modules'); return; }
+    const b = ctx.request.body || {};
+    try { await logsModel.updateLog(ctx.params.id, { label: b.label || '', text: b.text || '' }); } catch (_) {}
+    ctx.redirect('/logs');
+  })
+  .post("/logs/delete/:id", koaBody(), async (ctx) => {
+    if (!checkMod(ctx, 'logsMod')) { ctx.redirect('/modules'); return; }
+    try { await logsModel.deleteLog(ctx.params.id); } catch (_) {}
+    ctx.redirect('/logs');
+  })
+  .get("/logs/export", async (ctx) => {
+    if (!checkMod(ctx, 'logsMod')) { ctx.redirect('/modules'); return; }
+    const items = await logsModel.listLogs('always');
+    const pdf = await buildLogsPdf(items, getViewerId());
+    ctx.set('Content-Type', 'application/pdf');
+    ctx.set('Content-Disposition', `attachment; filename="oasis-logs-${Date.now()}.pdf"`);
+    ctx.body = pdf;
+  })
+  .get("/logs/export/:id", async (ctx) => {
+    if (!checkMod(ctx, 'logsMod')) { ctx.redirect('/modules'); return; }
+    const entry = await logsModel.getLogById(ctx.params.id);
+    if (!entry) { ctx.redirect('/logs'); return; }
+    const pdf = await buildLogsPdf([entry], getViewerId());
+    ctx.set('Content-Type', 'application/pdf');
+    ctx.set('Content-Disposition', `attachment; filename="oasis-log-${Date.now()}.pdf"`);
+    ctx.body = pdf;
+  })
   .get('/cipher', async (ctx) => {
     if (!checkMod(ctx, 'cipherMod')) { ctx.redirect('/modules'); return; }
     try {
@@ -3800,6 +3882,7 @@ router
     const b = ctx.request.body;
     const tribeId = b.tribeId || null;
     const imageBlob = ctx.request.files?.image ? extractBlobId(await handleBlobUpload(ctx, 'image')) : null;
+    if (tribeId) await tribesModel.ensureTribeKeyDistribution(tribeId).catch(() => {});
     await chatsModel.createChat(stripDangerousTags(b.title), stripDangerousTags(b.description), imageBlob, b.category, b.status, b.tags, tribeId);
     ctx.redirect(tribeId ? `/tribe/${encodeURIComponent(tribeId)}?section=chats` : safeReturnTo(ctx, '/chats?filter=mine', ['/chats']));
   })
@@ -3874,6 +3957,7 @@ router
     if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; }
     const b = ctx.request.body || {};
     const tribeId = b.tribeId || null;
+    if (tribeId) await tribesModel.ensureTribeKeyDistribution(tribeId).catch(() => {});
     const msg = await padsModel.createPad(
       stripDangerousTags(b.title || ""),
       b.status || "OPEN",
@@ -4355,11 +4439,11 @@ router
   })
   .post("/settings/rebuild", async ctx => { meta.rebuild(); ctx.redirect("/settings"); })
   .post("/modules/preset", koaBody(), async (ctx) => {
-    const ALL_MODULES = ['popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'invites', 'wallet', 'legacy', 'cipher', 'bookmarks', 'calendars', 'chats', 'videos', 'docs', 'audios', 'tags', 'images', 'trending', 'events', 'tasks', 'market', 'tribes', 'votes', 'reports', 'opinions', 'pads', 'transfers', 'feed', 'pixelia', 'agenda', 'favorites', 'ai', 'forum', 'games', 'jobs', 'projects', 'shops', 'banking', 'parliament', 'courts'];
+    const ALL_MODULES = ['popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'invites', 'wallet', 'legacy', 'cipher', 'bookmarks', 'calendars', 'chats', 'videos', 'docs', 'audios', 'tags', 'images', 'maps', 'trending', 'events', 'tasks', 'market', 'tribes', 'votes', 'reports', 'opinions', 'pads', 'transfers', 'feed', 'pixelia', 'agenda', 'favorites', 'ai', 'forum', 'games', 'jobs', 'projects', 'shops', 'banking', 'parliament', 'courts', 'logs', 'torrents'];
     const PRESETS = {
       minimal: ['feed', 'forum', 'games', 'images', 'videos', 'audios', 'bookmarks', 'tags', 'trending', 'popular', 'latest', 'threads', 'opinions', 'cipher', 'legacy'],
-      social: ['agenda', 'audios', 'bookmarks', 'chats', 'cipher', 'courts', 'docs', 'events', 'favorites', 'feed', 'forum', 'games', 'images', 'invites', 'legacy', 'multiverse', 'opinions', 'parliament', 'pixelia', 'projects', 'reports', 'tags', 'tasks', 'threads', 'trending', 'tribes', 'videos', 'votes'],
-      economy: ['agenda', 'audios', 'bookmarks', 'chats', 'cipher', 'courts', 'docs', 'events', 'favorites', 'feed', 'forum', 'games', 'images', 'invites', 'legacy', 'multiverse', 'opinions', 'parliament', 'pixelia', 'projects', 'reports', 'tags', 'tasks', 'threads', 'trending', 'tribes', 'videos', 'votes', 'banking', 'wallet', 'transfers', 'market', 'jobs', 'shops'],
+      social: ['agenda', 'audios', 'bookmarks', 'calendars', 'chats', 'cipher', 'courts', 'docs', 'events', 'favorites', 'feed', 'forum', 'games', 'images', 'invites', 'legacy', 'logs', 'maps', 'multiverse', 'opinions', 'pads', 'parliament', 'pixelia', 'projects', 'reports', 'tags', 'tasks', 'threads', 'trending', 'tribes', 'videos', 'votes'],
+      economy: ['agenda', 'audios', 'bookmarks', 'calendars', 'chats', 'cipher', 'courts', 'docs', 'events', 'favorites', 'feed', 'forum', 'games', 'images', 'invites', 'legacy', 'logs', 'maps', 'multiverse', 'opinions', 'pads', 'parliament', 'pixelia', 'projects', 'reports', 'tags', 'tasks', 'threads', 'trending', 'tribes', 'videos', 'votes', 'banking', 'wallet', 'transfers', 'market', 'jobs', 'shops'],
       full: ALL_MODULES
     };
     const preset = String(ctx.request.body.preset || '');
@@ -4371,7 +4455,7 @@ router
     ctx.redirect('/modules');
   })
   .post("/save-modules", koaBody(), async (ctx) => {
-    const modules = ['popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'invites', 'wallet', 'legacy', 'cipher', 'bookmarks', 'calendars', 'chats', 'videos', 'docs', 'audios', 'tags', 'images', 'maps', 'trending', 'events', 'tasks', 'market', 'tribes', 'votes', 'reports', 'opinions', 'pads', 'transfers', 'feed', 'pixelia', 'agenda', 'favorites', 'ai', 'forum', 'games', 'jobs', 'projects', 'shops', 'banking', 'parliament', 'courts'];
+    const modules = ['popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'invites', 'wallet', 'legacy', 'cipher', 'bookmarks', 'calendars', 'chats', 'videos', 'docs', 'audios', 'tags', 'images', 'maps', 'trending', 'events', 'tasks', 'market', 'tribes', 'votes', 'reports', 'opinions', 'pads', 'transfers', 'feed', 'pixelia', 'agenda', 'favorites', 'ai', 'forum', 'games', 'jobs', 'projects', 'shops', 'banking', 'parliament', 'courts', 'logs', 'torrents'];
     const cfg = getConfig();
     modules.forEach(mod => cfg.modules[`${mod}Mod`] = ctx.request.body[`${mod}Form`] === 'on' ? 'on' : 'off');
     saveConfig(cfg);
@@ -4496,6 +4580,6 @@ const middleware = [
   routes,
 ];
 const app = http({ host, port, middleware, allowHost: config.allowHost });
-app._close = () => { nameWarmup.close(); cooler.close(); if (pubBalanceTimer) clearInterval(pubBalanceTimer); };
+app._close = () => { nameWarmup.close(); cooler.close(); };
 module.exports = app;
 if (config.open === true) open(url);

+ 206 - 0
src/backend/logsPdf.js

@@ -0,0 +1,206 @@
+const fs = require('fs');
+const path = require('path');
+
+const LOGO_PATH = path.join(__dirname, '..', 'client', 'assets', 'images', 'snh-oasis.jpg');
+
+const escapePdf = s => String(s || '').replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
+
+const linkPattern = /(?:https?:\/\/[^\s]+|www\.[^\s]+|@[A-Za-z0-9+/=.\-]+\.ed25519|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})/g;
+
+const splitSegments = (line) => {
+  const segs = [];
+  let last = 0;
+  const re = new RegExp(linkPattern.source, 'g');
+  let m;
+  while ((m = re.exec(line)) !== null) {
+    if (m.index > last) segs.push({ t: line.slice(last, m.index), l: false });
+    segs.push({ t: m[0], l: true });
+    last = m.index + m[0].length;
+  }
+  if (last < line.length) segs.push({ t: line.slice(last), l: false });
+  return segs;
+};
+
+const wrap = (txt, max = 82) => {
+  const out = [];
+  for (const raw of String(txt || '').split('\n')) {
+    let line = raw;
+    while (line.length > max) {
+      let cut = line.lastIndexOf(' ', max);
+      if (cut <= 0) cut = max;
+      out.push(line.slice(0, cut));
+      line = line.slice(cut).replace(/^\s+/, '');
+    }
+    out.push(line);
+  }
+  return out;
+};
+
+const readJpegDims = (buf) => {
+  let i = 2;
+  while (i < buf.length) {
+    if (buf[i] !== 0xFF) return null;
+    const marker = buf[i + 1];
+    if (marker === 0xD8 || marker === 0xD9) { i += 2; continue; }
+    const len = buf.readUInt16BE(i + 2);
+    if (marker >= 0xC0 && marker <= 0xCF && marker !== 0xC4 && marker !== 0xC8 && marker !== 0xCC) {
+      const h = buf.readUInt16BE(i + 5);
+      const w = buf.readUInt16BE(i + 7);
+      const c = buf[i + 9];
+      return { w, h, c };
+    }
+    i += 2 + len;
+  }
+  return null;
+};
+
+function buildLogsPdf(entries, oasisId, opts = {}) {
+  const pageW = 612;
+  const pageH = 792;
+  const marginX = 50;
+  const headerH = 90;
+  const footerH = 40;
+  const bodyTop = pageH - headerH - 22;
+  const bodyBottom = footerH + 10;
+  const lineH = 12;
+  const maxBodyLines = Math.floor((bodyTop - bodyBottom) / lineH);
+
+  let logoBuf = null;
+  let logoDims = null;
+  try {
+    logoBuf = fs.readFileSync(LOGO_PATH);
+    logoDims = readJpegDims(logoBuf);
+  } catch (_) {}
+
+  const allLines = [];
+  for (const e of entries) {
+    const ts = new Date(e.ts);
+    const when = ts.toISOString().replace('T', ' ').slice(0, 19);
+    allLines.push({ kind: 'header', text: `[${when}]:` });
+    allLines.push({ kind: 'blank', text: '' });
+    for (const l of wrap(e.text, 82)) allLines.push({ kind: 'text', text: l });
+    allLines.push({ kind: 'blank', text: '' });
+  }
+  if (!allLines.length) allLines.push({ kind: 'text', text: '(no entries)' });
+
+  const pages = [];
+  for (let i = 0; i < allLines.length; i += maxBodyLines) {
+    pages.push(allLines.slice(i, i + maxBodyLines));
+  }
+  if (!pages.length) pages.push([{ kind: 'text', text: '(no entries)' }]);
+
+  const objects = [];
+  const addObj = body => { objects.push(body); return objects.length; };
+
+  const catalogId = addObj(null);
+  const pagesId = addObj(null);
+  const fontId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>');
+  const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold >>');
+
+  let logoXObjId = null;
+  if (logoBuf && logoDims) {
+    const colorSpace = logoDims.c === 1 ? '/DeviceGray' : '/DeviceRGB';
+    const dict = `<< /Type /XObject /Subtype /Image /Width ${logoDims.w} /Height ${logoDims.h} /ColorSpace ${colorSpace} /BitsPerComponent 8 /Filter /DCTDecode /Length ${logoBuf.length} >>`;
+    logoXObjId = addObj({ dict, stream: logoBuf });
+  }
+
+  const exportDate = new Date().toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
+  const footerLeft = `Generated: ${exportDate}`;
+
+  const pageIds = [];
+  const contentIds = [];
+
+  pages.forEach((pg, pgIdx) => {
+    const parts = [];
+
+    if (logoXObjId) {
+      const logoH = 60;
+      const logoW = Math.round((logoDims.w / logoDims.h) * logoH);
+      const logoX = marginX;
+      const logoY = pageH - headerH + 15;
+      parts.push(`q\n${logoW} 0 0 ${logoH} ${logoX} ${logoY} cm\n/Logo Do\nQ`);
+    }
+
+    const titleX = (logoXObjId ? marginX + 80 : marginX);
+    const titleY = pageH - 45;
+    parts.push(`BT\n/F2 16 Tf\n${titleX} ${titleY} Td\n(${escapePdf('OASIS - Experience logs')}) Tj\nET`);
+    const inhabitantPrefix = 'Inhabitant: ';
+    const inhabitantPrefixW = inhabitantPrefix.length * 5.4;
+    parts.push(`BT\n/F1 9 Tf\n${titleX} ${titleY - 16} Td\n(${escapePdf(inhabitantPrefix)}) Tj\nET`);
+    parts.push(`BT\n/F2 9 Tf\n${titleX + inhabitantPrefixW} ${titleY - 16} Td\n(${escapePdf(String(oasisId || ''))}) Tj\nET`);
+
+    parts.push(`q\n0.6 0.6 0.6 RG\n0.5 w\n${marginX} ${pageH - headerH} m\n${pageW - marginX} ${pageH - headerH} l\nS\nQ`);
+
+    let y = bodyTop;
+    const charW = 6;
+    for (const ln of pg) {
+      if (ln.kind === 'header') {
+        parts.push(`BT\n/F2 10 Tf\n1 0.647 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
+      } else if (ln.text) {
+        const segs = splitSegments(ln.text);
+        let x = marginX;
+        for (const s of segs) {
+          if (!s.t) continue;
+          const color = s.l ? '0 0 1 rg' : '0 0 0 rg';
+          parts.push(`BT\n/F1 10 Tf\n${color}\n${x} ${y} Td\n(${escapePdf(s.t)}) Tj\nET`);
+          x += s.t.length * charW;
+        }
+      }
+      y -= lineH;
+    }
+
+    parts.push(`q\n0.6 0.6 0.6 RG\n0.5 w\n${marginX} ${footerH + 5} m\n${pageW - marginX} ${footerH + 5} l\nS\nQ`);
+    parts.push(`BT\n/F1 8 Tf\n${marginX} ${footerH - 10} Td\n(${escapePdf(footerLeft)}) Tj\nET`);
+    const pageLabel = `Page ${pgIdx + 1} of ${pages.length}`;
+    const pageLabelW = pageLabel.length * 4.8;
+    parts.push(`BT\n/F1 8 Tf\n${pageW - marginX - pageLabelW} ${footerH - 10} Td\n(${escapePdf(pageLabel)}) Tj\nET`);
+
+    const content = parts.join('\n');
+    const stream = `<< /Length ${Buffer.byteLength(content)} >>\nstream\n${content}\nendstream`;
+    const cid = addObj(stream);
+    contentIds.push(cid);
+    const pid = addObj(null);
+    pageIds.push(pid);
+  });
+
+  for (let i = 0; i < pageIds.length; i++) {
+    const resources = logoXObjId
+      ? `<< /Font << /F1 ${fontId} 0 R /F2 ${fontBoldId} 0 R >> /XObject << /Logo ${logoXObjId} 0 R >> >>`
+      : `<< /Font << /F1 ${fontId} 0 R /F2 ${fontBoldId} 0 R >> >>`;
+    objects[pageIds[i] - 1] = `<< /Type /Page /Parent ${pagesId} 0 R /MediaBox [0 0 ${pageW} ${pageH}] /Contents ${contentIds[i]} 0 R /Resources ${resources} >>`;
+  }
+
+  objects[catalogId - 1] = `<< /Type /Catalog /Pages ${pagesId} 0 R >>`;
+  objects[pagesId - 1] = `<< /Type /Pages /Kids [${pageIds.map(id => `${id} 0 R`).join(' ')}] /Count ${pageIds.length} >>`;
+
+  const chunks = [];
+  const offsets = [0];
+  let byteLen = 0;
+  const push = (buf) => { chunks.push(buf); byteLen += buf.length; };
+
+  push(Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n', 'binary'));
+
+  for (let i = 0; i < objects.length; i++) {
+    offsets.push(byteLen);
+    const obj = objects[i];
+    if (obj && typeof obj === 'object' && obj.dict && obj.stream) {
+      push(Buffer.from(`${i + 1} 0 obj\n${obj.dict}\nstream\n`, 'binary'));
+      push(obj.stream);
+      push(Buffer.from('\nendstream\nendobj\n', 'binary'));
+    } else {
+      push(Buffer.from(`${i + 1} 0 obj\n${obj}\nendobj\n`, 'binary'));
+    }
+  }
+
+  const xrefStart = byteLen;
+  let xref = `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
+  for (let i = 1; i <= objects.length; i++) {
+    xref += `${String(offsets[i]).padStart(10, '0')} 00000 n \n`;
+  }
+  xref += `trailer\n<< /Size ${objects.length + 1} /Root ${catalogId} 0 R >>\nstartxref\n${xrefStart}\n%%EOF`;
+  push(Buffer.from(xref, 'binary'));
+
+  return Buffer.concat(chunks);
+}
+
+module.exports = { buildLogsPdf };

+ 31 - 0
src/client/assets/styles/style.css

@@ -5026,3 +5026,34 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 .torrent-table th{background:#1a1a1a;color:#FFA500;font-weight:bold}
 .torrent-download{margin:12px 0}
 .torrent-download .filter-btn{border:none}
+
+.logs-table{width:100%;border-collapse:collapse;margin-top:12px}
+.logs-table th,.logs-table td{padding:8px 12px;text-align:left;vertical-align:middle;border-bottom:1px solid #222}
+.logs-col-actions{vertical-align:middle;text-align:center;white-space:nowrap;background:transparent}
+.logs-col-actions form{margin:0;display:inline-block}
+.logs-col-actions .filter-btn{min-width:110px}
+.logs-table th{background:#1a1a1a;color:#FFA500;font-weight:bold}
+.logs-col-date{white-space:nowrap;text-align:center;font-family:monospace;background:transparent}
+.logs-date-day{color:#FFA500;font-weight:bold;font-size:0.9rem}
+.logs-date-time{color:#ddd;font-size:0.85rem}
+.logs-col-log{max-width:560px;vertical-align:middle;background:transparent;padding:8px 12px}
+.logs-col-log>*{background:transparent;border:0;padding:0;margin:0}
+.logs-entry-label{color:#FFA500;font-weight:bold;margin-bottom:4px !important;background:transparent;border:0}
+.logs-entry-meta{color:#888;font-size:0.85rem;margin-bottom:6px}
+.logs-entry-mode{color:#FFA500;font-weight:bold}
+.logs-entry-text{white-space:pre-wrap;word-break:break-word;color:#ddd;background:transparent;border:0;padding:0;margin:0;display:block}
+.logs-toolbar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin:12px 0}
+.logs-toolbar-inline{display:inline-flex;gap:10px;margin:0}
+.logs-mode-form{margin:12px 0}
+.logs-mode-group{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
+.logs-ai-disabled{color:#e74c3c;font-style:italic;margin:8px 0}
+.logs-col-type{white-space:nowrap;text-align:center;font-family:monospace;background:transparent}
+.logs-type-text{font-weight:bold;letter-spacing:0.5px;text-transform:uppercase;font-family:monospace;font-size:0.85rem}
+.logs-type-ai{color:#00b4ff}
+.logs-type-manual{color:#FFA500}
+.logs-toolbar-wrap{display:flex;flex-direction:column;gap:12px;margin:12px 0}
+.logs-search{margin:0}
+.logs-detail{padding:16px}
+.logs-detail-header{color:#aaa;font-family:monospace;margin-bottom:12px;word-break:break-all}
+.logs-detail-text{white-space:pre-wrap;word-break:break-word;color:#ddd;margin:12px 0}
+.logs-detail-actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:16px}

+ 56 - 4
src/client/assets/translations/oasis_ar.js

@@ -2110,7 +2110,6 @@ module.exports = {
     bankStatusExpired: 'Expired',
     bankPubOnly: 'PUB-only operation',
     bankNoPendingUBI: 'لا توجد تخصيصات UBI معلقة لهذه الحقبة.',
-    bankPubBalance: 'رصيد PUB',
     bankEpoch: 'الحقبة',
     bankPool: 'المجمع (هذه الحقبة)',
     bankWeightsSum: 'مجموع الأوزان',
@@ -2164,9 +2163,9 @@ module.exports = {
     pubIdLabel: "PUB ID",
     pubIdSave: "Save configuration",
     pubIdPlaceholder: "@PUB_ID.ed25519",
-    bankUbiAvailability: "توفر الدخل الأساسي",
-    bankUbiAvailableOk: "متاح",
     bankUbiAvailableNo: "لا توجد أموال!",
+    bankUbiAvailableOk: "متوفر!",
+    bankUbiAvailability: "توفر UBI",
     bankAlreadyClaimedThisMonth: "تم المطالبة بالفعل هذا الشهر",
     bankUbiThisMonth: "الدخل الأساسي (هذا الشهر)",
     bankUbiLastClaimed: "الدخل الأساسي (آخر مطالبة)",
@@ -3137,6 +3136,59 @@ module.exports = {
     tribeGovernanceAddRule: "إضافة قاعدة",
     tribeGovernanceNoRules: "لا توجد قواعد بعد.",
     tribeGovernanceNoLeaders: "لم يُنتخب أي قائد بعد.",
-    tribeGovernanceComingSoon: "قريبًا في وحدة الحوكمة."
+    tribeGovernanceComingSoon: "قريبًا في وحدة الحوكمة.",
+    logsTitle: "السجل",
+    logsDescription: "سجل تجربتك في الشبكة.",
+    logsReadMore: "اقرأ المزيد",
+    logsViewTitle: "السجل",
+    logsColumnType: "النوع",
+    logsView: "عرض",
+    logsManualPrompt: "اكتب سجلك",
+    logsUpdateButton: "تحديث",
+    logsEditTitle: "تعديل الإدخال",
+    logsCreateDescription: "سجل تجربتك...",
+    logsCreateTitle: "إنشاء إدخال",
+    logsWriteButton: "اكتب",
+    logsTextPlaceholder: "صف تجاربك...",
+    logsTextField: "النص",
+    logsLabelPlaceholder: "تسمية...",
+    logsLabelField: "اكتب سجلك (تسمية)",
+    logsAiDisabledWarn: "فعّل وحدة الذكاء الاصطناعي في /modules لاستخدام السجلات المكتوبة بواسطة الذكاء.",
+    logsAiContextValue: "يوم blockchain",
+    logsAiContext: "السياق",
+    logsAiModStatus: "AImod",
+    logsModeApply: "تطبيق!",
+    logsModeAIWritten: "AI-Assistant",
+    logsModeManual: "يدوي",
+    logsModeAI: "ذكاء",
+    logsColumnDelete: "حذف",
+    logsColumnEdit: "تعديل",
+    logsColumnLog: "السجل",
+    logsColumnDate: "التاريخ",
+    logsDelete: "حذف",
+    logsEdit: "تعديل",
+    logsFilterAlways: "دائمًا",
+    logsFilterYear: "السنة الماضية",
+    logsFilterMonth: "الشهر الماضي",
+    logsFilterWeek: "الأسبوع الماضي",
+    logsFilterToday: "اليوم",
+    logsFilterRecent: "الأحدث",
+    logsFilterAll: "الكل",
+    logsCreate: "إنشاء إدخال",
+    logsExport: "تصدير السجل",
+    logsExportOne: "تصدير",
+    logsViewDetails: "عرض التفاصيل",
+    logsGenerateButton: "توليد نص",
+    logsSearchText: "ابحث في السجلات...",
+    logsSearchAnyType: "أي نوع",
+    logsSearchButton: "بحث",
+    logsEmpty: "لا توجد إدخالات بعد.",
+    modulesLogsLabel: "السجل",
+    modulesLogsDescription: "وحدة لتسجيل تجاربك (عبر مساعد ذكاء اصطناعي).",
+    statsLogsTitle: "السجل",
+    statsLogsEntries: "إدخالات",
+    typeLog: "سجل",
+    blockchainCycle: "دورة",
+
     }
 };

+ 56 - 4
src/client/assets/translations/oasis_de.js

@@ -2109,7 +2109,6 @@ module.exports = {
     bankStatusExpired: 'Expired',
     bankPubOnly: 'PUB-only operation',
     bankNoPendingUBI: 'Keine ausstehenden UBI-Zuweisungen für diese Epoche.',
-    bankPubBalance: 'PUB-Kontostand',
     bankEpoch: 'Epoche',
     bankPool: 'Pool (diese Epoche)',
     bankWeightsSum: 'Gewichtssumme',
@@ -2163,9 +2162,9 @@ module.exports = {
     pubIdLabel: "PUB ID",
     pubIdSave: "Save configuration",
     pubIdPlaceholder: "@PUB_ID.ed25519",
-    bankUbiAvailability: "UBI-Verfügbarkeit",
-    bankUbiAvailableOk: "OK",
     bankUbiAvailableNo: "KEIN GUTHABEN!",
+    bankUbiAvailableOk: "VERFÜGBAR!",
+    bankUbiAvailability: "UBI-Verfügbarkeit",
     bankAlreadyClaimedThisMonth: "Diesen Monat bereits beansprucht",
     bankUbiThisMonth: "UBI (diesen Monat)",
     bankUbiLastClaimed: "UBI (zuletzt beansprucht)",
@@ -3133,6 +3132,59 @@ module.exports = {
     tribeGovernanceAddRule: "Regel hinzufügen",
     tribeGovernanceNoRules: "Noch keine Regeln.",
     tribeGovernanceNoLeaders: "Noch keine Führungskräfte gewählt.",
-    tribeGovernanceComingSoon: "Bald im Governance-Modul."
+    tribeGovernanceComingSoon: "Bald im Governance-Modul.",
+    logsTitle: "Logbuch",
+    logsDescription: "Erfasse deine Erfahrung im Netzwerk.",
+    logsReadMore: "Mehr lesen",
+    logsViewTitle: "Logbuch",
+    logsColumnType: "Typ",
+    logsView: "Ansehen",
+    logsManualPrompt: "Schreibe dein Logbuch",
+    logsUpdateButton: "Aktualisieren",
+    logsEditTitle: "Eintrag bearbeiten",
+    logsCreateDescription: "Erfasse deine Erfahrung...",
+    logsCreateTitle: "Eintrag erstellen",
+    logsWriteButton: "Schreiben",
+    logsTextPlaceholder: "Beschreibe deine Erfahrungen...",
+    logsTextField: "Text",
+    logsLabelPlaceholder: "Bezeichnung...",
+    logsLabelField: "Schreibe dein Logbuch (Bezeichnung)",
+    logsAiDisabledWarn: "Aktiviere das KI-Modul in /modules, um KI-Einträge zu verwenden.",
+    logsAiContextValue: "Blockchain-Tag",
+    logsAiContext: "Kontext",
+    logsAiModStatus: "AImod",
+    logsModeApply: "Anwenden!",
+    logsModeAIWritten: "AI-Assistant",
+    logsModeManual: "Manuell",
+    logsModeAI: "KI",
+    logsColumnDelete: "Löschen",
+    logsColumnEdit: "Bearbeiten",
+    logsColumnLog: "Logbuch",
+    logsColumnDate: "Datum",
+    logsDelete: "Löschen",
+    logsEdit: "Bearbeiten",
+    logsFilterAlways: "IMMER",
+    logsFilterYear: "LETZTES JAHR",
+    logsFilterMonth: "LETZTER MONAT",
+    logsFilterWeek: "LETZTE WOCHE",
+    logsFilterToday: "HEUTE",
+    logsFilterRecent: "AKTUELL",
+    logsFilterAll: "ALLE",
+    logsCreate: "Eintrag erstellen",
+    logsExport: "Logbuch exportieren",
+    logsExportOne: "Exportieren",
+    logsViewDetails: "Details ansehen",
+    logsGenerateButton: "Text generieren",
+    logsSearchText: "In Logbüchern suchen...",
+    logsSearchAnyType: "Beliebiger Typ",
+    logsSearchButton: "Suchen",
+    logsEmpty: "Noch keine Einträge.",
+    modulesLogsLabel: "Logbuch",
+    modulesLogsDescription: "Modul, um (über einen KI-Assistenten) deine Erfahrungen aufzuzeichnen.",
+    statsLogsTitle: "Logbuch",
+    statsLogsEntries: "Einträge",
+    typeLog: "LOGBUCH",
+    blockchainCycle: "Zyklus",
+
     }
 }

+ 56 - 4
src/client/assets/translations/oasis_en.js

@@ -2115,7 +2115,6 @@ module.exports = {
     bankStatusExpired: 'Expired',
     bankPubOnly: 'PUB-only operation',
     bankNoPendingUBI: 'No pending UBI allocations for this epoch.',
-    bankPubBalance: 'PUB Balance',
     bankEpoch: 'Epoch',
     bankPool: 'Pool (this epoch)',
     bankWeightsSum: 'Sum of weights',
@@ -2169,9 +2168,9 @@ module.exports = {
     pubIdLabel: "PUB ID",
     pubIdSave: "Save configuration",
     pubIdPlaceholder: "@PUB_ID.ed25519",
-    bankUbiAvailability: "UBI Availability",
-    bankUbiAvailableOk: "OK",
     bankUbiAvailableNo: "NO FUNDS!",
+    bankUbiAvailableOk: "AVAILABLE!",
+    bankUbiAvailability: "UBI availability",
     bankAlreadyClaimedThisMonth: "Already claimed this month",
     bankUbiThisMonth: "UBI (this month)",
     bankUbiLastClaimed: "UBI (last claimed)",
@@ -3156,7 +3155,60 @@ module.exports = {
     tribeGovernanceAddRule: "Add rule",
     tribeGovernanceNoRules: "No rules yet.",
     tribeGovernanceNoLeaders: "No leaders elected yet.",
-    tribeGovernanceComingSoon: "Coming soon in this tribe's governance module."
+    tribeGovernanceComingSoon: "Coming soon in this tribe's governance module.",
+    logsTitle: "Logs",
+    logsDescription: "Record your experience in the network.",
+    logsReadMore: "Read more",
+    logsViewTitle: "Log",
+    logsColumnType: "Type",
+    logsView: "View",
+    logsManualPrompt: "Write your log",
+    logsUpdateButton: "Update",
+    logsEditTitle: "Edit Log",
+    logsCreateDescription: "Record your experience...",
+    logsCreateTitle: "Create Log",
+    logsWriteButton: "Write",
+    logsTextPlaceholder: "Describe your experiences...",
+    logsTextField: "Text",
+    logsLabelPlaceholder: "Label...",
+    logsLabelField: "Write your log (label)",
+    logsAiDisabledWarn: "Enable the AI module in /modules to use AI-written logs.",
+    logsAiContextValue: "Blockchain day",
+    logsAiContext: "Context",
+    logsAiModStatus: "AImod",
+    logsModeApply: "Apply!",
+    logsModeAIWritten: "AI-Assistant",
+    logsModeManual: "Manual",
+    logsModeAI: "AI",
+    logsColumnDelete: "Delete",
+    logsColumnEdit: "Edit",
+    logsColumnLog: "Log",
+    logsColumnDate: "Date",
+    logsDelete: "Delete",
+    logsEdit: "Edit",
+    logsFilterAlways: "ALWAYS",
+    logsFilterYear: "LAST YEAR",
+    logsFilterMonth: "LAST MONTH",
+    logsFilterWeek: "LAST WEEK",
+    logsFilterToday: "TODAY",
+    logsFilterRecent: "RECENT",
+    logsFilterAll: "ALL",
+    logsCreate: "Create Log",
+    logsExport: "Export Logs",
+    logsExportOne: "Export",
+    logsViewDetails: "View Details",
+    logsGenerateButton: "Generate Text",
+    logsSearchText: "Search in logs...",
+    logsSearchAnyType: "Any type",
+    logsSearchButton: "Search",
+    logsEmpty: "No logs yet.",
+    modulesLogsLabel: "Logs",
+    modulesLogsDescription: "Module to record (via AI assistant) your experiences.",
+    statsLogsTitle: "Logs",
+    statsLogsEntries: "Entries",
+    typeLog: "LOGS",
+    blockchainCycle: "Cycle",
+
 
     }
 };

+ 56 - 4
src/client/assets/translations/oasis_es.js

@@ -2113,7 +2113,6 @@ module.exports = {
     bankStatusExpired: 'Expired',
     bankPubOnly: 'PUB-only operation',
     bankNoPendingUBI: 'No hay asignaciones RBU pendientes para esta época.',
-    bankPubBalance: 'Saldo del PUB',
     bankEpoch: 'Época',
     bankPool: 'Fondo (esta época)',
     bankWeightsSum: 'Suma de pesos',
@@ -2167,9 +2166,9 @@ module.exports = {
     pubIdLabel: "PUB ID",
     pubIdSave: "Save configuration",
     pubIdPlaceholder: "@PUB_ID.ed25519",
-    bankUbiAvailability: "Disponibilidad UBI",
-    bankUbiAvailableOk: "OK",
     bankUbiAvailableNo: "SIN FONDOS!",
+    bankUbiAvailableOk: "¡DISPONIBLE!",
+    bankUbiAvailability: "Disponibilidad de UBI",
     bankAlreadyClaimedThisMonth: "Ya reclamado este mes",
     bankUbiThisMonth: "UBI (este mes)",
     bankUbiLastClaimed: "UBI (última reclamada)",
@@ -3147,6 +3146,59 @@ module.exports = {
     tribeGovernanceAddRule: "Añadir regla",
     tribeGovernanceNoRules: "Sin reglas todavía.",
     tribeGovernanceNoLeaders: "Sin líderes elegidos todavía.",
-    tribeGovernanceComingSoon: "Próximamente en el módulo de gobierno de esta tribu."
+    tribeGovernanceComingSoon: "Próximamente en el módulo de gobierno de esta tribu.",
+    logsTitle: "Bitácora",
+    logsDescription: "Registra tu experiencia en la red.",
+    logsReadMore: "Leer más",
+    logsViewTitle: "Bitácora",
+    logsColumnType: "Tipo",
+    logsView: "Ver",
+    logsManualPrompt: "Escribe tu bitácora",
+    logsUpdateButton: "Actualizar",
+    logsEditTitle: "Editar entrada",
+    logsCreateDescription: "Registra tu experiencia...",
+    logsCreateTitle: "Crear entrada",
+    logsWriteButton: "Escribir",
+    logsTextPlaceholder: "Describe tus experiencias...",
+    logsTextField: "Texto",
+    logsLabelPlaceholder: "Etiqueta...",
+    logsLabelField: "Escribe tu bitácora (etiqueta)",
+    logsAiDisabledWarn: "Activa el módulo de IA en /modules para usar entradas escritas por IA.",
+    logsAiContextValue: "Día de blockchain",
+    logsAiContext: "Contexto",
+    logsAiModStatus: "AImod",
+    logsModeApply: "¡Aplicar!",
+    logsModeAIWritten: "AI-Assistant",
+    logsModeManual: "Manual",
+    logsModeAI: "IA",
+    logsColumnDelete: "Eliminar",
+    logsColumnEdit: "Editar",
+    logsColumnLog: "Bitácora",
+    logsColumnDate: "Fecha",
+    logsDelete: "Eliminar",
+    logsEdit: "Editar",
+    logsFilterAlways: "SIEMPRE",
+    logsFilterYear: "ÚLTIMO AÑO",
+    logsFilterMonth: "ÚLTIMO MES",
+    logsFilterWeek: "ÚLTIMA SEMANA",
+    logsFilterToday: "HOY",
+    logsFilterRecent: "RECIENTES",
+    logsFilterAll: "TODOS",
+    logsCreate: "Crear entrada",
+    logsExport: "Exportar bitácora",
+    logsExportOne: "Exportar",
+    logsViewDetails: "Ver detalles",
+    logsGenerateButton: "Generar texto",
+    logsSearchText: "Buscar en bitácoras...",
+    logsSearchAnyType: "Cualquier tipo",
+    logsSearchButton: "Buscar",
+    logsEmpty: "Aún no hay entradas.",
+    modulesLogsLabel: "Bitácora",
+    modulesLogsDescription: "Módulo para registrar (vía asistente de IA) tus experiencias.",
+    statsLogsTitle: "Bitácora",
+    statsLogsEntries: "Entradas",
+    typeLog: "BITÁCORA",
+    blockchainCycle: "Ciclo",
+
     }
 };

+ 56 - 4
src/client/assets/translations/oasis_eu.js

@@ -2080,7 +2080,6 @@ module.exports = {
     bankStatusExpired: 'Expired',
     bankPubOnly: 'PUB-only operation',
     bankNoPendingUBI: 'Ez dago RBU esleipen zain garai honetan.',
-    bankPubBalance: 'PUB saldoa',
     bankEpoch: 'Epea',
     bankPool: 'Funtsa (epe honetan)',
     bankWeightsSum: 'Pisuen batura',
@@ -2135,9 +2134,9 @@ module.exports = {
     pubIdLabel: "PUB ID",
     pubIdSave: "Save configuration",
     pubIdPlaceholder: "@PUB_ID.ed25519",
-    bankUbiAvailability: "UBI Erabilgarritasuna",
-    bankUbiAvailableOk: "OK",
     bankUbiAvailableNo: "FUNTSIK EZ!",
+    bankUbiAvailableOk: "ESKURAGARRI!",
+    bankUbiAvailability: "UBI eskuragarritasuna",
     bankAlreadyClaimedThisMonth: "Hilabete honetan jada aldarrikatu da",
     bankUbiThisMonth: "UBI (hilabete honetan)",
     bankUbiLastClaimed: "UBI (azken aldarrikapena)",
@@ -3107,6 +3106,59 @@ module.exports = {
     tribeGovernanceAddRule: "Gehitu araua",
     tribeGovernanceNoRules: "Arauerik ez oraindik.",
     tribeGovernanceNoLeaders: "Liderrik ez oraindik aukeratu.",
-    tribeGovernanceComingSoon: "Laster gobernantza moduluan."
+    tribeGovernanceComingSoon: "Laster gobernantza moduluan.",
+    logsTitle: "Egunkaria",
+    logsDescription: "Erregistratu zure esperientzia sarean.",
+    logsReadMore: "Gehiago irakurri",
+    logsViewTitle: "Egunkaria",
+    logsColumnType: "Mota",
+    logsView: "Ikusi",
+    logsManualPrompt: "Idatzi zure egunkaria",
+    logsUpdateButton: "Eguneratu",
+    logsEditTitle: "Sarrera editatu",
+    logsCreateDescription: "Erregistratu zure esperientzia...",
+    logsCreateTitle: "Sarrera sortu",
+    logsWriteButton: "Idatzi",
+    logsTextPlaceholder: "Deskribatu zure esperientziak...",
+    logsTextField: "Testua",
+    logsLabelPlaceholder: "Etiketa...",
+    logsLabelField: "Idatzi zure egunkaria (etiketa)",
+    logsAiDisabledWarn: "Gaitu AA modulua /modules-en AA-idatzitako sarrerak erabiltzeko.",
+    logsAiContextValue: "Blockchain-eguna",
+    logsAiContext: "Testuingurua",
+    logsAiModStatus: "AImod",
+    logsModeApply: "Aplikatu!",
+    logsModeAIWritten: "AI-Assistant",
+    logsModeManual: "Eskuz",
+    logsModeAI: "AA",
+    logsColumnDelete: "Ezabatu",
+    logsColumnEdit: "Editatu",
+    logsColumnLog: "Egunkaria",
+    logsColumnDate: "Data",
+    logsDelete: "Ezabatu",
+    logsEdit: "Editatu",
+    logsFilterAlways: "BETI",
+    logsFilterYear: "AZKEN URTEA",
+    logsFilterMonth: "AZKEN HILABETEA",
+    logsFilterWeek: "AZKEN ASTEA",
+    logsFilterToday: "GAUR",
+    logsFilterRecent: "BERRIAK",
+    logsFilterAll: "DENAK",
+    logsCreate: "Sarrera sortu",
+    logsExport: "Egunkaria esportatu",
+    logsExportOne: "Esportatu",
+    logsViewDetails: "Ikusi xehetasunak",
+    logsGenerateButton: "Sortu testua",
+    logsSearchText: "Bilatu egunkarietan...",
+    logsSearchAnyType: "Edozein mota",
+    logsSearchButton: "Bilatu",
+    logsEmpty: "Oraindik sarrerarik ez.",
+    modulesLogsLabel: "Egunkaria",
+    modulesLogsDescription: "Modulua zure esperientziak (AA laguntzailearen bidez) erregistratzeko.",
+    statsLogsTitle: "Egunkaria",
+    statsLogsEntries: "Sarrerak",
+    typeLog: "EGUNKARIA",
+    blockchainCycle: "Zikloa",
+
   }
 };

+ 56 - 4
src/client/assets/translations/oasis_fr.js

@@ -2105,7 +2105,6 @@ module.exports = {
     bankStatusExpired: 'Expired',
     bankPubOnly: 'PUB-only operation',
     bankNoPendingUBI: 'Aucune allocation RBU en attente pour cette époque.',
-    bankPubBalance: 'Solde du PUB',
     bankEpoch: 'Époque',
     bankPool: 'Fonds (cette époque)',
     bankWeightsSum: 'Somme des poids',
@@ -2160,9 +2159,9 @@ module.exports = {
     pubIdLabel: "PUB ID",
     pubIdSave: "Save configuration",
     pubIdPlaceholder: "@PUB_ID.ed25519",
-    bankUbiAvailability: "Disponibilité RBU",
-    bankUbiAvailableOk: "OK",
     bankUbiAvailableNo: "PAS DE FONDS!",
+    bankUbiAvailableOk: "DISPONIBLE!",
+    bankUbiAvailability: "Disponibilité UBI",
     bankAlreadyClaimedThisMonth: "Déjà réclamé ce mois-ci",
     bankUbiThisMonth: "RBU (ce mois)",
     bankUbiLastClaimed: "RBU (dernière réclamation)",
@@ -3135,6 +3134,59 @@ module.exports = {
     tribeGovernanceAddRule: "Ajouter une règle",
     tribeGovernanceNoRules: "Aucune règle pour le moment.",
     tribeGovernanceNoLeaders: "Aucun leader élu.",
-    tribeGovernanceComingSoon: "Bientôt dans le module de gouvernance."
+    tribeGovernanceComingSoon: "Bientôt dans le module de gouvernance.",
+    logsTitle: "Journal",
+    logsDescription: "Enregistrez votre expérience sur le réseau.",
+    logsReadMore: "Lire la suite",
+    logsViewTitle: "Journal",
+    logsColumnType: "Type",
+    logsView: "Voir",
+    logsManualPrompt: "Écrivez votre journal",
+    logsUpdateButton: "Mettre à jour",
+    logsEditTitle: "Éditer une entrée",
+    logsCreateDescription: "Enregistrez votre expérience...",
+    logsCreateTitle: "Créer une entrée",
+    logsWriteButton: "Écrire",
+    logsTextPlaceholder: "Décrivez vos expériences...",
+    logsTextField: "Texte",
+    logsLabelPlaceholder: "Libellé...",
+    logsLabelField: "Écrivez votre journal (libellé)",
+    logsAiDisabledWarn: "Activez le module IA dans /modules pour utiliser les entrées écrites par l'IA.",
+    logsAiContextValue: "Jour de blockchain",
+    logsAiContext: "Contexte",
+    logsAiModStatus: "AImod",
+    logsModeApply: "Appliquer !",
+    logsModeAIWritten: "AI-Assistant",
+    logsModeManual: "Manuel",
+    logsModeAI: "IA",
+    logsColumnDelete: "Supprimer",
+    logsColumnEdit: "Éditer",
+    logsColumnLog: "Journal",
+    logsColumnDate: "Date",
+    logsDelete: "Supprimer",
+    logsEdit: "Éditer",
+    logsFilterAlways: "TOUJOURS",
+    logsFilterYear: "ANNÉE",
+    logsFilterMonth: "MOIS",
+    logsFilterWeek: "SEMAINE",
+    logsFilterToday: "AUJOURD'HUI",
+    logsFilterRecent: "RÉCENTS",
+    logsFilterAll: "TOUS",
+    logsCreate: "Créer une entrée",
+    logsExport: "Exporter le journal",
+    logsExportOne: "Exporter",
+    logsViewDetails: "Voir les détails",
+    logsGenerateButton: "Générer le texte",
+    logsSearchText: "Chercher dans les journaux...",
+    logsSearchAnyType: "Tout type",
+    logsSearchButton: "Chercher",
+    logsEmpty: "Aucune entrée pour l'instant.",
+    modulesLogsLabel: "Journal",
+    modulesLogsDescription: "Module pour enregistrer (via un assistant IA) vos expériences.",
+    statsLogsTitle: "Journal",
+    statsLogsEntries: "Entrées",
+    typeLog: "JOURNAL",
+    blockchainCycle: "Cycle",
+
     }
 };

+ 56 - 4
src/client/assets/translations/oasis_hi.js

@@ -2110,7 +2110,6 @@ module.exports = {
     bankStatusExpired: 'Expired',
     bankPubOnly: 'PUB-only operation',
     bankNoPendingUBI: 'इस अवधि के लिए कोई लंबित UBI आवंटन नहीं।',
-    bankPubBalance: 'PUB शेष',
     bankEpoch: 'युग',
     bankPool: 'पूल (यह युग)',
     bankWeightsSum: 'भारों का योग',
@@ -2164,9 +2163,9 @@ module.exports = {
     pubIdLabel: "PUB ID",
     pubIdSave: "Save configuration",
     pubIdPlaceholder: "@PUB_ID.ed25519",
-    bankUbiAvailability: "UBI उपलब्धता",
-    bankUbiAvailableOk: "OK",
     bankUbiAvailableNo: "कोई धन नहीं!",
+    bankUbiAvailableOk: "उपलब्ध!",
+    bankUbiAvailability: "UBI उपलब्धता",
     bankAlreadyClaimedThisMonth: "इस महीने पहले ही दावा किया गया",
     bankUbiThisMonth: "UBI (इस महीने)",
     bankUbiLastClaimed: "UBI (अंतिम दावा)",
@@ -3137,6 +3136,59 @@ module.exports = {
     tribeGovernanceAddRule: "नियम जोड़ें",
     tribeGovernanceNoRules: "अभी कोई नियम नहीं।",
     tribeGovernanceNoLeaders: "कोई नेता निर्वाचित नहीं।",
-    tribeGovernanceComingSoon: "शासन मॉड्यूल में जल्द ही।"
+    tribeGovernanceComingSoon: "शासन मॉड्यूल में जल्द ही।",
+    logsTitle: "लॉग",
+    logsDescription: "नेटवर्क पर अपना अनुभव रिकॉर्ड करें।",
+    logsReadMore: "और पढ़ें",
+    logsViewTitle: "लॉग",
+    logsColumnType: "प्रकार",
+    logsView: "देखें",
+    logsManualPrompt: "अपनी लॉग लिखें",
+    logsUpdateButton: "अपडेट करें",
+    logsEditTitle: "प्रविष्टि संपादित करें",
+    logsCreateDescription: "अपना अनुभव रिकॉर्ड करें...",
+    logsCreateTitle: "प्रविष्टि बनाएँ",
+    logsWriteButton: "लिखें",
+    logsTextPlaceholder: "अपने अनुभव वर्णित करें...",
+    logsTextField: "पाठ",
+    logsLabelPlaceholder: "लेबल...",
+    logsLabelField: "अपनी लॉग लिखें (लेबल)",
+    logsAiDisabledWarn: "एआई लिखित लॉग के लिए /modules में एआई मॉड्यूल सक्षम करें।",
+    logsAiContextValue: "ब्लॉकचेन दिन",
+    logsAiContext: "संदर्भ",
+    logsAiModStatus: "AImod",
+    logsModeApply: "लागू करें!",
+    logsModeAIWritten: "AI-Assistant",
+    logsModeManual: "मैन्युअल",
+    logsModeAI: "एआई",
+    logsColumnDelete: "हटाएँ",
+    logsColumnEdit: "संपादित करें",
+    logsColumnLog: "लॉग",
+    logsColumnDate: "दिनांक",
+    logsDelete: "हटाएँ",
+    logsEdit: "संपादित करें",
+    logsFilterAlways: "हमेशा",
+    logsFilterYear: "पिछला वर्ष",
+    logsFilterMonth: "पिछला महीना",
+    logsFilterWeek: "पिछला सप्ताह",
+    logsFilterToday: "आज",
+    logsFilterRecent: "हाल के",
+    logsFilterAll: "सभी",
+    logsCreate: "प्रविष्टि बनाएँ",
+    logsExport: "लॉग निर्यात करें",
+    logsExportOne: "निर्यात",
+    logsViewDetails: "विवरण देखें",
+    logsGenerateButton: "पाठ जनरेट करें",
+    logsSearchText: "लॉग में खोजें...",
+    logsSearchAnyType: "कोई भी प्रकार",
+    logsSearchButton: "खोजें",
+    logsEmpty: "कोई प्रविष्टियाँ नहीं।",
+    modulesLogsLabel: "लॉग",
+    modulesLogsDescription: "(एआई सहायक के माध्यम से) अपने अनुभव रिकॉर्ड करने के लिए मॉड्यूल।",
+    statsLogsTitle: "लॉग",
+    statsLogsEntries: "प्रविष्टियाँ",
+    typeLog: "लॉग",
+    blockchainCycle: "चक्र",
+
     }
 };

+ 56 - 4
src/client/assets/translations/oasis_it.js

@@ -2110,7 +2110,6 @@ module.exports = {
     bankStatusExpired: 'Expired',
     bankPubOnly: 'PUB-only operation',
     bankNoPendingUBI: 'Nessuna assegnazione RBU in sospeso per questa epoca.',
-    bankPubBalance: 'Saldo PUB',
     bankEpoch: 'Epoca',
     bankPool: 'Pool (questa epoca)',
     bankWeightsSum: 'Somma dei pesi',
@@ -2164,9 +2163,9 @@ module.exports = {
     pubIdLabel: "PUB ID",
     pubIdSave: "Save configuration",
     pubIdPlaceholder: "@PUB_ID.ed25519",
-    bankUbiAvailability: "Disponibilità RBU",
-    bankUbiAvailableOk: "OK",
     bankUbiAvailableNo: "NESSUN FONDO!",
+    bankUbiAvailableOk: "DISPONIBILE!",
+    bankUbiAvailability: "Disponibilità UBI",
     bankAlreadyClaimedThisMonth: "Già richiesto questo mese",
     bankUbiThisMonth: "RBU (questo mese)",
     bankUbiLastClaimed: "RBU (ultima richiesta)",
@@ -3138,6 +3137,59 @@ module.exports = {
     tribeGovernanceAddRule: "Aggiungi regola",
     tribeGovernanceNoRules: "Nessuna regola.",
     tribeGovernanceNoLeaders: "Nessun leader eletto.",
-    tribeGovernanceComingSoon: "Presto nel modulo governance."
+    tribeGovernanceComingSoon: "Presto nel modulo governance.",
+    logsTitle: "Diario",
+    logsDescription: "Registra la tua esperienza nella rete.",
+    logsReadMore: "Leggi di più",
+    logsViewTitle: "Diario",
+    logsColumnType: "Tipo",
+    logsView: "Vedi",
+    logsManualPrompt: "Scrivi il tuo diario",
+    logsUpdateButton: "Aggiorna",
+    logsEditTitle: "Modifica voce",
+    logsCreateDescription: "Registra la tua esperienza...",
+    logsCreateTitle: "Crea voce",
+    logsWriteButton: "Scrivi",
+    logsTextPlaceholder: "Descrivi le tue esperienze...",
+    logsTextField: "Testo",
+    logsLabelPlaceholder: "Etichetta...",
+    logsLabelField: "Scrivi il tuo diario (etichetta)",
+    logsAiDisabledWarn: "Attiva il modulo IA in /modules per usare le voci scritte dall'IA.",
+    logsAiContextValue: "Giorno di blockchain",
+    logsAiContext: "Contesto",
+    logsAiModStatus: "AImod",
+    logsModeApply: "Applica!",
+    logsModeAIWritten: "AI-Assistant",
+    logsModeManual: "Manuale",
+    logsModeAI: "IA",
+    logsColumnDelete: "Elimina",
+    logsColumnEdit: "Modifica",
+    logsColumnLog: "Diario",
+    logsColumnDate: "Data",
+    logsDelete: "Elimina",
+    logsEdit: "Modifica",
+    logsFilterAlways: "SEMPRE",
+    logsFilterYear: "ULTIMO ANNO",
+    logsFilterMonth: "ULTIMO MESE",
+    logsFilterWeek: "ULTIMA SETTIMANA",
+    logsFilterToday: "OGGI",
+    logsFilterRecent: "RECENTI",
+    logsFilterAll: "TUTTI",
+    logsCreate: "Crea voce",
+    logsExport: "Esporta diario",
+    logsExportOne: "Esporta",
+    logsViewDetails: "Vedi dettagli",
+    logsGenerateButton: "Genera testo",
+    logsSearchText: "Cerca nei diari...",
+    logsSearchAnyType: "Qualsiasi tipo",
+    logsSearchButton: "Cerca",
+    logsEmpty: "Nessuna voce ancora.",
+    modulesLogsLabel: "Diario",
+    modulesLogsDescription: "Modulo per registrare (tramite assistente IA) le tue esperienze.",
+    statsLogsTitle: "Diario",
+    statsLogsEntries: "Voci",
+    typeLog: "DIARIO",
+    blockchainCycle: "Ciclo",
+
     }
 };

+ 56 - 4
src/client/assets/translations/oasis_pt.js

@@ -2110,7 +2110,6 @@ module.exports = {
     bankStatusExpired: 'Expired',
     bankPubOnly: 'PUB-only operation',
     bankNoPendingUBI: 'Nenhuma alocação RBU pendente para esta época.',
-    bankPubBalance: 'PUB Balance',
     bankEpoch: 'Epoch',
     bankPool: 'Pool (this epoch)',
     bankWeightsSum: 'Sum of weights',
@@ -2164,9 +2163,9 @@ module.exports = {
     pubIdLabel: "PUB ID",
     pubIdSave: "Save configuration",
     pubIdPlaceholder: "@PUB_ID.ed25519",
-    bankUbiAvailability: "Disponibilidade RBU",
-    bankUbiAvailableOk: "OK",
     bankUbiAvailableNo: "SEM FUNDOS!",
+    bankUbiAvailableOk: "DISPONÍVEL!",
+    bankUbiAvailability: "Disponibilidade UBI",
     bankAlreadyClaimedThisMonth: "Já reclamado este mês",
     bankUbiThisMonth: "RBU (este mês)",
     bankUbiLastClaimed: "RBU (última reclamada)",
@@ -3138,6 +3137,59 @@ module.exports = {
     tribeGovernanceAddRule: "Adicionar regra",
     tribeGovernanceNoRules: "Sem regras.",
     tribeGovernanceNoLeaders: "Sem líderes eleitos.",
-    tribeGovernanceComingSoon: "Em breve no módulo de governança."
+    tribeGovernanceComingSoon: "Em breve no módulo de governança.",
+    logsTitle: "Diário",
+    logsDescription: "Regista a sua experiência na rede.",
+    logsReadMore: "Ler mais",
+    logsViewTitle: "Diário",
+    logsColumnType: "Tipo",
+    logsView: "Ver",
+    logsManualPrompt: "Escreva o seu diário",
+    logsUpdateButton: "Atualizar",
+    logsEditTitle: "Editar entrada",
+    logsCreateDescription: "Regista a sua experiência...",
+    logsCreateTitle: "Criar entrada",
+    logsWriteButton: "Escrever",
+    logsTextPlaceholder: "Descreva as suas experiências...",
+    logsTextField: "Texto",
+    logsLabelPlaceholder: "Rótulo...",
+    logsLabelField: "Escreva o seu diário (rótulo)",
+    logsAiDisabledWarn: "Ative o módulo de IA em /modules para usar entradas escritas pela IA.",
+    logsAiContextValue: "Dia de blockchain",
+    logsAiContext: "Contexto",
+    logsAiModStatus: "AImod",
+    logsModeApply: "Aplicar!",
+    logsModeAIWritten: "AI-Assistant",
+    logsModeManual: "Manual",
+    logsModeAI: "IA",
+    logsColumnDelete: "Eliminar",
+    logsColumnEdit: "Editar",
+    logsColumnLog: "Diário",
+    logsColumnDate: "Data",
+    logsDelete: "Eliminar",
+    logsEdit: "Editar",
+    logsFilterAlways: "SEMPRE",
+    logsFilterYear: "ÚLTIMO ANO",
+    logsFilterMonth: "ÚLTIMO MÊS",
+    logsFilterWeek: "ÚLTIMA SEMANA",
+    logsFilterToday: "HOJE",
+    logsFilterRecent: "RECENTES",
+    logsFilterAll: "TODOS",
+    logsCreate: "Criar entrada",
+    logsExport: "Exportar diário",
+    logsExportOne: "Exportar",
+    logsViewDetails: "Ver detalhes",
+    logsGenerateButton: "Gerar texto",
+    logsSearchText: "Procurar nos diários...",
+    logsSearchAnyType: "Qualquer tipo",
+    logsSearchButton: "Procurar",
+    logsEmpty: "Sem entradas.",
+    modulesLogsLabel: "Diário",
+    modulesLogsDescription: "Módulo para registar (via assistente de IA) as suas experiências.",
+    statsLogsTitle: "Diário",
+    statsLogsEntries: "Entradas",
+    typeLog: "DIÁRIO",
+    blockchainCycle: "Ciclo",
+
     }
 };

+ 56 - 4
src/client/assets/translations/oasis_ru.js

@@ -2076,7 +2076,6 @@ module.exports = {
     bankStatusExpired: 'Expired',
     bankPubOnly: 'PUB-only operation',
     bankNoPendingUBI: "Нет ожидающих распределений UBI для этой эпохи.",
-    bankPubBalance: "Баланс PUB",
     bankEpoch: "Эпоха",
     bankPool: "Пул (эта эпоха)",
     bankWeightsSum: "Сумма весов",
@@ -2128,9 +2127,9 @@ module.exports = {
     pubIdLabel: "PUB ID",
     pubIdSave: "Save configuration",
     pubIdPlaceholder: "@PUB_ID.ed25519",
-    bankUbiAvailability: "Доступность БОД",
-    bankUbiAvailableOk: "OK",
     bankUbiAvailableNo: "НЕТ СРЕДСТВ!",
+    bankUbiAvailableOk: "ДОСТУПНО!",
+    bankUbiAvailability: "Доступность UBI",
     bankAlreadyClaimedThisMonth: "Уже получено в этом месяце",
     bankUbiThisMonth: "БОД (этот месяц)",
     bankUbiLastClaimed: "БОД (последнее получение)",
@@ -3100,6 +3099,59 @@ module.exports = {
     tribeGovernanceAddRule: "Добавить правило",
     tribeGovernanceNoRules: "Правил пока нет.",
     tribeGovernanceNoLeaders: "Лидеры не избраны.",
-    tribeGovernanceComingSoon: "Скоро в модуле управления."
+    tribeGovernanceComingSoon: "Скоро в модуле управления.",
+    logsTitle: "Журнал",
+    logsDescription: "Записывайте свой опыт в сети.",
+    logsReadMore: "Читать далее",
+    logsViewTitle: "Журнал",
+    logsColumnType: "Тип",
+    logsView: "Смотреть",
+    logsManualPrompt: "Напишите ваш журнал",
+    logsUpdateButton: "Обновить",
+    logsEditTitle: "Редактировать запись",
+    logsCreateDescription: "Записывайте свой опыт...",
+    logsCreateTitle: "Создать запись",
+    logsWriteButton: "Написать",
+    logsTextPlaceholder: "Опишите ваши впечатления...",
+    logsTextField: "Текст",
+    logsLabelPlaceholder: "Метка...",
+    logsLabelField: "Напишите ваш журнал (метка)",
+    logsAiDisabledWarn: "Включите модуль ИИ в /modules для записей от ИИ.",
+    logsAiContextValue: "День блокчейна",
+    logsAiContext: "Контекст",
+    logsAiModStatus: "AImod",
+    logsModeApply: "Применить!",
+    logsModeAIWritten: "AI-Assistant",
+    logsModeManual: "Вручную",
+    logsModeAI: "ИИ",
+    logsColumnDelete: "Удалить",
+    logsColumnEdit: "Редактировать",
+    logsColumnLog: "Журнал",
+    logsColumnDate: "Дата",
+    logsDelete: "Удалить",
+    logsEdit: "Редактировать",
+    logsFilterAlways: "ВСЕГДА",
+    logsFilterYear: "ГОД",
+    logsFilterMonth: "МЕСЯЦ",
+    logsFilterWeek: "НЕДЕЛЯ",
+    logsFilterToday: "СЕГОДНЯ",
+    logsFilterRecent: "НЕДАВНИЕ",
+    logsFilterAll: "ВСЕ",
+    logsCreate: "Создать запись",
+    logsExport: "Экспорт журнала",
+    logsExportOne: "Экспорт",
+    logsViewDetails: "Подробнее",
+    logsGenerateButton: "Сгенерировать текст",
+    logsSearchText: "Искать в журналах...",
+    logsSearchAnyType: "Любой тип",
+    logsSearchButton: "Искать",
+    logsEmpty: "Записей пока нет.",
+    modulesLogsLabel: "Журнал",
+    modulesLogsDescription: "Модуль для записи (с помощью ИИ-ассистента) ваших впечатлений.",
+    statsLogsTitle: "Журнал",
+    statsLogsEntries: "Записи",
+    typeLog: "ЖУРНАЛ",
+    blockchainCycle: "Цикл",
+
     }
 };

+ 56 - 4
src/client/assets/translations/oasis_zh.js

@@ -2111,7 +2111,6 @@ module.exports = {
     bankStatusExpired: 'Expired',
     bankPubOnly: 'PUB-only operation',
     bankNoPendingUBI: '本期无待领取的 UBI 分配。',
-    bankPubBalance: 'PUB 余额',
     bankEpoch: '纪元',
     bankPool: '资金池(本纪元)',
     bankWeightsSum: '权重总和',
@@ -2165,9 +2164,9 @@ module.exports = {
     pubIdLabel: "PUB ID",
     pubIdSave: "Save configuration",
     pubIdPlaceholder: "@PUB_ID.ed25519",
-    bankUbiAvailability: "UBI 可用性",
-    bankUbiAvailableOk: "可用",
     bankUbiAvailableNo: "资金不足!",
+    bankUbiAvailableOk: "可用!",
+    bankUbiAvailability: "UBI 可用性",
     bankAlreadyClaimedThisMonth: "本月已领取",
     bankUbiThisMonth: "UBI(本月)",
     bankUbiLastClaimed: "UBI(上次领取)",
@@ -3138,6 +3137,59 @@ module.exports = {
     tribeGovernanceAddRule: "添加规则",
     tribeGovernanceNoRules: "暂无规则。",
     tribeGovernanceNoLeaders: "尚无领导人被选出。",
-    tribeGovernanceComingSoon: "治理模块即将推出。"
+    tribeGovernanceComingSoon: "治理模块即将推出。",
+    logsTitle: "日志",
+    logsDescription: "记录您在网络中的体验。",
+    logsReadMore: "阅读更多",
+    logsViewTitle: "日志",
+    logsColumnType: "类型",
+    logsView: "查看",
+    logsManualPrompt: "撰写您的日志",
+    logsUpdateButton: "更新",
+    logsEditTitle: "编辑条目",
+    logsCreateDescription: "记录您的体验...",
+    logsCreateTitle: "创建条目",
+    logsWriteButton: "撰写",
+    logsTextPlaceholder: "描述您的体验...",
+    logsTextField: "文本",
+    logsLabelPlaceholder: "标签...",
+    logsLabelField: "撰写您的日志(标签)",
+    logsAiDisabledWarn: "在 /modules 中启用 AI 模块以使用 AI 撰写的日志。",
+    logsAiContextValue: "区块链日",
+    logsAiContext: "上下文",
+    logsAiModStatus: "AImod",
+    logsModeApply: "应用!",
+    logsModeAIWritten: "AI-Assistant",
+    logsModeManual: "手动",
+    logsModeAI: "AI",
+    logsColumnDelete: "删除",
+    logsColumnEdit: "编辑",
+    logsColumnLog: "日志",
+    logsColumnDate: "日期",
+    logsDelete: "删除",
+    logsEdit: "编辑",
+    logsFilterAlways: "始终",
+    logsFilterYear: "去年",
+    logsFilterMonth: "上月",
+    logsFilterWeek: "上周",
+    logsFilterToday: "今天",
+    logsFilterRecent: "最近",
+    logsFilterAll: "全部",
+    logsCreate: "创建条目",
+    logsExport: "导出日志",
+    logsExportOne: "导出",
+    logsViewDetails: "查看详情",
+    logsGenerateButton: "生成文本",
+    logsSearchText: "在日志中搜索...",
+    logsSearchAnyType: "任意类型",
+    logsSearchButton: "搜索",
+    logsEmpty: "暂无条目。",
+    modulesLogsLabel: "日志",
+    modulesLogsDescription: "用于(通过 AI 助手)记录体验的模块。",
+    statsLogsTitle: "日志",
+    statsLogsEntries: "条目",
+    typeLog: "日志",
+    blockchainCycle: "周期",
+
     }
 };

+ 3 - 0
src/configs/config-manager.js

@@ -41,6 +41,7 @@ if (!fs.existsSync(configFilePath)) {
       "agendaMod": "on",
       "aiMod": "on",
       "forumMod": "on",
+      "gamesMod": "on",
       "jobsMod": "on",
       "shopsMod": "on",
       "projectsMod": "on",
@@ -48,7 +49,9 @@ if (!fs.existsSync(configFilePath)) {
       "parliamentMod": "on",
       "courtsMod": "on",
       "favoritesMod": "on",
+      "logsMod": "on",
       "mapsMod": "on",
+      "chatsMod": "on",
       "torrentsMod": "on"
     },
     "wallet": {

+ 1 - 0
src/configs/oasis-config.json

@@ -43,6 +43,7 @@
     "parliamentMod": "on",
     "courtsMod": "on",
     "favoritesMod": "on",
+    "logsMod": "on",
     "mapsMod": "on",
     "chatsMod": "on",
     "torrentsMod": "on"

+ 10 - 3
src/games/labyrinth/index.html

@@ -20,12 +20,15 @@ canvas { display: block; flex: 1; align-self: stretch; min-height: 0; width: 100
 #controls { color: #888; font-size: 13px; text-align: center; margin-bottom: 4px; }
 #scoreSubmit { text-align: center; margin: 6px; }
 #scoreSubmit button { background: #1a3a1a; border: 1px solid #FFA500; color: #FFA500; padding: 6px 16px; cursor: pointer; font-family: monospace; font-size: 14px; }
+#btn-giveup { margin-left: auto; background: #3a1a1a; border: 1px solid #f44; color: #f44; padding: 4px 12px; cursor: pointer; font-family: monospace; font-size: 13px; }
+#btn-giveup:disabled { opacity: 0.4; cursor: not-allowed; }
 </style>
 </head>
 <body>
 <div id="topbar">
   <a href="/games" target="_top">&#8592; Back to Games</a>
   <span style="color:#FFA500;font-weight:bold">LABYRINTH</span>
+  <button id="btn-giveup" disabled>Give Up</button>
 </div>
 <div id="ui">
   <span>LEVEL: <b id="levelEl">1</b></span>
@@ -115,6 +118,7 @@ function startLevel() {
   document.getElementById('movesEl').textContent = maxMoves;
   document.getElementById('msg').textContent = `Level ${level} — Reach the exit!`;
   gameState = 'play';
+  document.getElementById('btn-giveup').disabled = false;
 }
 
 function tryMove(dx, dy) {
@@ -136,20 +140,22 @@ function tryMove(dx, dy) {
     gameState = 'transit';
     setTimeout(() => { level++; startLevel(); }, 1000);
   } else if (moves >= maxMoves) {
-    endGame();
+    endGame('moves');
   }
 }
 
-function endGame() {
+function endGame(reason) {
   gameState = 'over';
   if (best < 0 || total > best) {
     best = total;
     localStorage.setItem('labyrinth_best', best);
     document.getElementById('bestEl').textContent = best;
   }
-  document.getElementById('msg').textContent = `Out of moves! Score: ${total}. SPACE = new game`;
+  const head = reason === 'giveup' ? `Gave up at level ${level}.` : 'Out of moves!';
+  document.getElementById('msg').textContent = `${head} Score: ${total}. SPACE = new game`;
   document.getElementById('scoreInput').value = total;
   document.getElementById('scoreSubmit').style.display = 'block';
+  document.getElementById('btn-giveup').disabled = true;
 }
 
 function newGame() {
@@ -173,6 +179,7 @@ document.getElementById('btn-up').addEventListener('click', () => { dpadStart();
 document.getElementById('btn-down').addEventListener('click', () => { dpadStart(); tryMove(0, 1); });
 document.getElementById('btn-left').addEventListener('click', () => { dpadStart(); tryMove(-1, 0); });
 document.getElementById('btn-right').addEventListener('click', () => { dpadStart(); tryMove(1, 0); });
+document.getElementById('btn-giveup').addEventListener('click', () => { if (gameState === 'play') endGame('giveup'); });
 
 if (best >= 0) document.getElementById('bestEl').textContent = best;
 

+ 1 - 1
src/models/activity_model.js

@@ -352,7 +352,7 @@ module.exports = ({ cooler }) => {
       deduped = Array.from(byKey.values()).map(x => { delete x.__effTs; delete x.__hasImage; return x });
 
       const tribeInternalTypes = new Set(['tribe-content', 'tribeParliamentCandidature', 'tribeParliamentTerm', 'tribeParliamentProposal', 'tribeParliamentRule', 'tribeParliamentLaw', 'tribeParliamentRevocation']);
-      const hiddenTypes = new Set(['padEntry', 'chatMessage', 'calendarDate', 'calendarNote', 'calendarReminderSent', 'feed-action', 'pubBalance']);
+      const hiddenTypes = new Set(['padEntry', 'chatMessage', 'calendarDate', 'calendarNote', 'calendarReminderSent', 'feed-action', 'pubBalance', 'pubAvailability', 'log']);
       const isAllowedTribeActivity = (a) => {
         if (tribeInternalTypes.has(a.type)) return false;
         const c = a.content || {};

+ 40 - 39
src/models/banking_model.js

@@ -713,18 +713,12 @@ async function getLastPublishedTimestamp(userId) {
     if (!pubId) throw new Error("no_pub_configured");
     const alreadyClaimed = await hasClaimedThisMonth(uid);
     if (alreadyClaimed) throw new Error("already_claimed");
-    const pubBalance = await getPubBalanceFromSSB();
-    if (pubBalance <= 0) throw new Error("no_funds");
-    const pv = computePoolVars(pubBalance, DEFAULT_RULES);
-    const addresses = await listAddressesMerged();
-    const eligible = addresses.filter(a => a.address && isValidEcoinAddress(a.address));
     const karmaScore = await getUserEngagementScore(uid);
     const wMin = DEFAULT_RULES.caps.w_min;
     const wMax = DEFAULT_RULES.caps.w_max;
     const capUser = DEFAULT_RULES.caps.cap_user_epoch;
     const userW = clamp(1 + karmaScore / 100, wMin, wMax);
-    const totalW = eligible.reduce((acc, a) => acc + clamp(1 + 0 / 100, wMin, wMax), 0) || 1;
-    const amount = Number(Math.max(1, Math.min(pv.pool * userW / totalW, capUser)).toFixed(6));
+    const amount = Number(Math.max(1, Math.min(capUser * (userW / wMax), capUser)).toFixed(6));
     const ssb = await openSsb();
     if (!ssb || !ssb.publish) throw new Error("ssb_unavailable");
     const now = new Date().toISOString();
@@ -765,30 +759,6 @@ async function getLastPublishedTimestamp(userId) {
     }
   }
 
-  async function getPubBalanceFromSSB() {
-    const pubId = getConfiguredPubId();
-    if (!pubId) return 0;
-    const msgs = await scanLogStream();
-    for (const m of msgs) {
-      const v = m.value || {};
-      const c = v.content || {};
-      if (v.author === pubId && c && c.type === "pubBalance" && c.coin === "ECO") {
-        return Number(c.balance) || 0;
-      }
-    }
-    return 0;
-  }
-
-  async function publishPubBalance() {
-    if (!isPubNode()) return;
-    const balance = await safeGetBalance("pub");
-    const ssb = await openSsb();
-    if (!ssb || !ssb.publish) return;
-    const content = { type: "pubBalance", balance, coin: "ECO", timestamp: Date.now() };
-    await new Promise((resolve, reject) => ssb.publish(content, (err, res) => err ? reject(err) : resolve(res)));
-    return balance;
-  }
-
   async function hasClaimedThisMonth(userId) {
     const epochId = epochIdNow();
     const msgs = await scanLogStream();
@@ -843,19 +813,51 @@ async function getLastPublishedTimestamp(userId) {
     return out;
   }
 
+  async function publishPubAvailability() {
+    if (!isPubNode()) return;
+    const balance = await safeGetBalance("pub");
+    const floor = Math.max(1, DEFAULT_RULES?.caps?.floor_user || 1);
+    const available = Number(balance) >= floor;
+    const ssb = await openSsb();
+    if (!ssb || !ssb.publish) return;
+    const content = { type: "pubAvailability", available, coin: "ECO", timestamp: Date.now() };
+    await new Promise((resolve, reject) => ssb.publish(content, (err, res) => err ? reject(err) : resolve(res)));
+    return available;
+  }
+
+  async function getPubAvailabilityFromSSB() {
+    const pubId = getConfiguredPubId();
+    if (!pubId) return false;
+    const msgs = await scanLogStream();
+    let latest = null;
+    for (const m of msgs) {
+      const v = m.value || {};
+      const c = v.content || {};
+      if (v.author === pubId && c && c.type === "pubAvailability" && c.coin === "ECO") {
+        if (!latest || (Number(c.timestamp) || 0) > (Number(latest.timestamp) || 0)) latest = c;
+      }
+    }
+    return !!(latest && latest.available);
+  }
+
   async function listBanking(filter = "overview", userId) {
     const uid = resolveUserId(userId);
     const epochId = epochIdNow();
-    let pubBalance, allocations;
+    let pubBalance = 0;
+    let ubiAvailable = false;
+    let allocations;
     if (isPubNode()) {
       pubBalance = await safeGetBalance("pub");
+      const floor = Math.max(1, DEFAULT_RULES?.caps?.floor_user || 1);
+      ubiAvailable = Number(pubBalance) >= floor;
+      try { await publishPubAvailability(); } catch (_) {}
       const all = await transfersRepo.listByTag("UBI");
       allocations = all.map(t => ({
         id: t.id, concept: t.concept, from: t.from, to: t.to, amount: t.amount, status: t.status,
         createdAt: t.createdAt || t.deadline || new Date().toISOString(), txid: t.txid
       }));
     } else {
-      pubBalance = await getPubBalanceFromSSB();
+      ubiAvailable = await getPubAvailabilityFromSSB();
       allocations = await getUbiAllocationsFromSSB();
     }
     const userBalance = await safeGetBalance("user");
@@ -875,16 +877,15 @@ async function getLastPublishedTimestamp(userId) {
     const hasValidWallet = !!(userAddress && isValidEcoinAddress(userAddress) && userWalletCfg.url);
     const summary = {
       userBalance,
-      pubBalance,
       epochId,
       pool: poolForEpoch,
       weightsSum: computed?.epoch?.weightsSum || 0,
       userEngagementScore: engagementScore,
       futureUBI,
-      ubiAvailability: pubBalance > 0 ? "OK" : "NO_FUNDS",
       alreadyClaimed,
       pubId,
-      hasValidWallet
+      hasValidWallet,
+      ubiAvailability: ubiAvailable ? "OK" : "NO_FUNDS"
     };
     const exchange = await calculateEcoinValue();
     return { summary, allocations, epochs, rules: DEFAULT_RULES, addresses, exchange };
@@ -971,7 +972,7 @@ async function getLastPublishedTimestamp(userId) {
     const karmaScore = await getUserEngagementScore(userId);
     let estimatedUBI = 0;
     try {
-      const pubBal = isPubNode() ? await safeGetBalance("pub") : await getPubBalanceFromSSB();
+      const pubBal = isPubNode() ? await safeGetBalance("pub") : 0;
       const pv = computePoolVars(pubBal, DEFAULT_RULES);
       const pool = pv.pool || 0;
       const addresses = await listAddressesMerged();
@@ -1112,8 +1113,8 @@ async function getLastPublishedTimestamp(userId) {
     publishUbiAllocation,
     publishUbiClaim,
     publishUbiClaimResult,
-    publishPubBalance,
-    getPubBalanceFromSSB,
+    publishPubAvailability,
+    getPubAvailabilityFromSSB,
     hasClaimedThisMonth,
     getUbiClaimHistory,
     claimUBI,

+ 21 - 3
src/models/blockchain_model.js

@@ -117,10 +117,18 @@ module.exports = ({ cooler }) => {
 
       const nameByFeedId = new Map();
 
+      const showLogs = (filter === 'logs' || filter === 'LOGS');
+      const me = userId || config.keys.id;
       for (const msg of results) {
         const k = msg.key;
-        const c = msg.value?.content;
+        let c = msg.value?.content;
         const author = msg.value?.author;
+        if (showLogs && typeof c === 'string' && author === me) {
+          try {
+            const dec = ssbClient.private.unbox({ key: k, value: msg.value, timestamp: msg.timestamp || msg.value?.timestamp || 0 });
+            c = dec?.value?.content;
+          } catch { c = null; }
+        }
         if (!c?.type) continue;
 
         if (c.type === 'about') {
@@ -198,6 +206,9 @@ module.exports = ({ cooler }) => {
         const me = userId || config.keys.id;
         filtered = filtered.filter(b => b && b.author === me);
       }
+      if (filter === 'LOGS' || filter === 'logs') {
+        filtered = filtered.filter(b => b && b.type === 'log' && b.author === me);
+      }
       if (filter === 'PARLIAMENT' || filter === 'parliament') {
         const pset = new Set(['parliamentTerm','parliamentProposal','parliamentLaw','parliamentCandidature','parliamentRevocation']);
         filtered = filtered.filter(b => b && pset.has(b.type));
@@ -241,7 +252,7 @@ module.exports = ({ cooler }) => {
       return filtered.filter(Boolean);
     },
 
-    async getBlockById(id) {
+    async getBlockById(id, userId) {
       const ssbClient = await openSsb();
       const results = await new Promise((resolve, reject) =>
         pull(
@@ -250,14 +261,21 @@ module.exports = ({ cooler }) => {
         )
       );
 
+      const me = userId || config.keys.id;
       const tombstoned = new Set();
       const idToBlock = new Map();
       const referencedAsReplaces = new Set();
 
       for (const msg of results) {
         const k = msg.key;
-        const c = msg.value?.content;
+        let c = msg.value?.content;
         const author = msg.value?.author;
+        if (typeof c === 'string' && author === me) {
+          try {
+            const dec = ssbClient.private.unbox({ key: k, value: msg.value, timestamp: msg.timestamp || msg.value?.timestamp || 0 });
+            c = dec?.value?.content;
+          } catch { c = null; }
+        }
         if (!c?.type) continue;
         if (c.type === 'tombstone' && c.target) {
           tombstoned.add(c.target);

+ 32 - 11
src/models/chats_model.js

@@ -14,10 +14,23 @@ const normalizeTags = (raw) => {
 const INVITE_CODE_BYTES = 16
 const VALID_STATUS = ["OPEN", "INVITE-ONLY", "CLOSED"]
 
-module.exports = ({ cooler, tribeCrypto }) => {
+module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
   let ssb
   const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
 
+  const getTribeKeysFor = async (tribeId) => {
+    if (!tribeCrypto || !tribesModel || !tribeId) return []
+    try {
+      const rootId = await tribesModel.getRootId(tribeId)
+      return tribeCrypto.getKeys(rootId) || []
+    } catch (_) { return [] }
+  }
+
+  const getTribeFirstKeyFor = async (tribeId) => {
+    const ks = await getTribeKeysFor(tribeId)
+    return ks.length ? ks[0] : null
+  }
+
   const readAll = async (ssbClient) =>
     new Promise((resolve, reject) =>
       pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => err ? reject(err) : resolve(msgs)))
@@ -90,14 +103,14 @@ module.exports = ({ cooler, tribeCrypto }) => {
     }
   }
 
-  const buildMessage = (node, chatRootId) => {
+  const buildMessage = (node, chatRootId, tribeKeys = []) => {
     const c = node.c || {}
     if (c.type !== "chatMessage") return null
 
     let text = c.text || ""
     if (tribeCrypto && c.encryptedText) {
-      const keys = tribeCrypto.getKeys(chatRootId)
-      for (const keyHex of keys) {
+      const candidateKeys = [...tribeKeys, ...tribeCrypto.getKeys(chatRootId)]
+      for (const keyHex of candidateKeys) {
         try {
           text = tribeCrypto.decryptWithKey(c.encryptedText, keyHex)
           break
@@ -170,11 +183,10 @@ module.exports = ({ cooler, tribeCrypto }) => {
         ...(tribeId ? { tribeId } : {})
       }
 
-      if (tribeCrypto) {
+      if (tribeCrypto && !tribeId) {
         const chatKey = tribeCrypto.generateTribeKey()
         const result = await new Promise((resolve, reject) => {
-          const plainContent = Object.assign({}, content)
-          ssbClient.publish(plainContent, (err, msg) => err ? reject(err) : resolve(msg))
+          ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
         })
         tribeCrypto.setKey(result.key, chatKey, 1)
         return result
@@ -470,9 +482,12 @@ module.exports = ({ cooler, tribeCrypto }) => {
       if (image) content.image = image
 
       if (tribeCrypto) {
-        const chatKey = tribeCrypto.getKey(chat.rootId)
-        if (chatKey) {
-          content.encryptedText = tribeCrypto.encryptWithKey(safeText(text), chatKey)
+        let encKey = null
+        if (chat.tribeId) encKey = await getTribeFirstKeyFor(chat.tribeId)
+        if (!encKey) encKey = tribeCrypto.getKey(chat.rootId)
+        if (encKey) {
+          content.encryptedText = tribeCrypto.encryptWithKey(safeText(text), encKey)
+          if (chat.tribeId) content.tribeId = chat.tribeId
         } else {
           content.text = safeText(text)
         }
@@ -490,10 +505,16 @@ module.exports = ({ cooler, tribeCrypto }) => {
       const messages = await readAll(ssbClient)
       const idx = buildIndex(messages)
 
+      let tribeId = null
+      const tipId = idx.tipByRoot.get(chatRootId) || chatRootId
+      const chatNode = idx.nodes.get(tipId) || idx.nodes.get(chatRootId)
+      if (chatNode?.c?.tribeId) tribeId = chatNode.c.tribeId
+      const tribeKeys = tribeId ? await getTribeKeysFor(tribeId) : []
+
       const result = []
       for (const [k, node] of idx.msgNodes.entries()) {
         if (node.c.chatId !== chatRootId) continue
-        const msg = buildMessage(node, chatRootId)
+        const msg = buildMessage(node, chatRootId, tribeKeys)
         if (msg) result.push(msg)
       }
 

+ 373 - 0
src/models/logs_model.js

@@ -0,0 +1,373 @@
+const pull = require('../server/node_modules/pull-stream');
+const util = require('../server/node_modules/util');
+const axios = require('../server/node_modules/axios');
+const fs = require('fs');
+const path = require('path');
+const { getConfig } = require('../configs/config-manager.js');
+
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
+const CYCLE_PATH = path.join(__dirname, '..', 'configs', 'blockchain-cycle.json');
+
+const readCycle = () => {
+  try { return JSON.parse(fs.readFileSync(CYCLE_PATH, 'utf8')).cycle || 0; }
+  catch { return 0; }
+};
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+const WEEK_MS = 7 * DAY_MS;
+const MONTH_MS = 30 * DAY_MS;
+const YEAR_MS = 365 * DAY_MS;
+
+const FILTER_WINDOWS = {
+  today: DAY_MS,
+  week: WEEK_MS,
+  month: MONTH_MS,
+  year: YEAR_MS,
+  always: null
+};
+
+const ACTION_TYPES = new Set([
+  'post', 'about', 'contact', 'feed', 'bookmark', 'image', 'audio', 'video',
+  'document', 'torrent', 'event', 'task', 'taskAssignment',
+  'votes', 'vote', 'report', 'tribe', 'chat', 'chatMessage', 'pad', 'padEntry',
+  'forum', 'market', 'job', 'project', 'pixelia', 'map', 'mapMarker',
+  'shop', 'shopProduct', 'curriculum', 'gameScore',
+  'calendar', 'calendarDate', 'calendarNote',
+  'transfer', 'bankClaim', 'ubiClaim',
+  'parliamentCandidature', 'parliamentProposal', 'parliamentLaw',
+  'parliamentTerm', 'parliamentRevocation',
+  'courtsCase', 'courtsEvidence', 'courtsAnswer', 'courtsVerdict',
+  'courtsNomination', 'courtsNominationVote',
+  'courtsSettlementProposal', 'courtsSettlementAccepted',
+  'tribeParliamentCandidature', 'tribeParliamentRule'
+]);
+
+const ACTION_PHRASES = {
+  post: 'published a post',
+  about: 'updated profile information',
+  contact: 'followed or unfollowed someone',
+  feed: 'shared content in the feed',
+  bookmark: 'bookmarked a resource',
+  image: 'uploaded an image',
+  audio: 'uploaded an audio track',
+  video: 'uploaded a video',
+  document: 'uploaded a document',
+  torrent: 'shared a torrent',
+  event: 'created an event',
+  task: 'created a task',
+  taskAssignment: 'updated a task assignment',
+  votes: 'participated in a vote',
+  vote: 'cast a vote',
+  report: 'submitted a report',
+  tribe: 'interacted with a tribe',
+  chat: 'opened a chat room',
+  chatMessage: 'sent a chat message',
+  pad: 'worked on a collaborative pad',
+  padEntry: 'edited a pad entry',
+  market: 'posted in the market',
+  forum: 'posted in the forum',
+  job: 'posted a job opportunity',
+  project: 'advanced a project',
+  pixelia: 'placed a pixel in pixelia',
+  map: 'contributed to a map',
+  mapMarker: 'placed a marker on a map',
+  shop: 'updated a shop',
+  shopProduct: 'managed a shop product',
+  curriculum: 'edited the curriculum',
+  gameScore: 'logged a game score',
+  calendar: 'managed a calendar',
+  calendarDate: 'added a calendar date',
+  calendarNote: 'added a calendar note',
+  transfer: 'sent or confirmed a transfer',
+  bankClaim: 'completed a banking claim',
+  ubiClaim: 'claimed the UBI',
+  parliamentCandidature: 'published a parliamentary candidature',
+  parliamentProposal: 'published a parliamentary proposal',
+  parliamentLaw: 'participated in a parliamentary law',
+  parliamentTerm: 'participated in a parliamentary term',
+  parliamentRevocation: 'submitted a parliamentary revocation',
+  courtsCase: 'opened a courts case',
+  courtsEvidence: 'submitted courts evidence',
+  courtsAnswer: 'replied in a courts case',
+  courtsVerdict: 'reached a courts verdict',
+  courtsNomination: 'nominated a judge',
+  courtsNominationVote: 'voted on a judge nomination',
+  courtsSettlementProposal: 'proposed a courts settlement',
+  courtsSettlementAccepted: 'accepted a courts settlement',
+  tribeParliamentCandidature: 'stood for a tribe parliament',
+  tribeParliamentRule: 'contributed a tribe parliament rule'
+};
+
+const compact = (s, n = 200) => String(s || '').replace(/\s+/g, ' ').trim().slice(0, n);
+
+module.exports = ({ cooler }) => {
+  let ssb;
+  let userId;
+  const openSsb = async () => {
+    if (!ssb) {
+      ssb = await cooler.open();
+      userId = ssb.id;
+    }
+    return ssb;
+  };
+
+  async function listAllUserActions() {
+    const ssbClient = await openSsb();
+    const msgs = await new Promise((resolve, reject) =>
+      pull(
+        ssbClient.createLogStream({ reverse: true, limit: logLimit }),
+        pull.collect((err, arr) => err ? reject(err) : resolve(arr))
+      )
+    );
+    const out = [];
+    for (const m of msgs) {
+      const v = m?.value || {};
+      const c = v?.content;
+      if (!c || typeof c !== 'object' || !c.type) continue;
+      if (v.author !== userId) continue;
+      if (c.type === 'log') continue;
+      if (!ACTION_TYPES.has(c.type)) continue;
+      const ts = v.timestamp || 0;
+      const summary = c.title || c.text || c.question || c.subject || c.name || c.concept || c.description || '';
+      out.push({ key: m.key, ts, type: c.type, summary: compact(summary) });
+    }
+    return out;
+  }
+
+  async function callAI(prompt) {
+    if (!prompt) return '';
+    const tryOnce = async () => {
+      try {
+        const res = await axios.post('http://localhost:4001/ai', { input: prompt, raw: true }, { timeout: 90000 });
+        return String(res?.data?.answer || '').trim();
+      } catch { return ''; }
+    };
+    let out = await tryOnce();
+    if (!out) {
+      await new Promise(r => setTimeout(r, 2000));
+      out = await tryOnce();
+    }
+    return out;
+  }
+
+  function buildActionPrompt(a) {
+    const d = new Date(a.ts).toISOString().slice(0, 16).replace('T', ' ');
+    const ctx = a.summary ? ` Subject: "${compact(a.summary, 120)}".` : '';
+    return `One first-person diary sentence about a "${a.type}" action at ${d}.${ctx} Vary phrasing. No IDs, hashes, quotes, lists or markdown.`;
+  }
+
+  function buildFallbackSentence(a) {
+    const phrase = ACTION_PHRASES[a.type] || `performed a ${a.type} action`;
+    const d = new Date(a.ts).toISOString().slice(0, 16).replace('T', ' ');
+    const ctx = a.summary ? ` — ${compact(a.summary, 120)}` : '';
+    return `At ${d} I ${phrase}${ctx}.`;
+  }
+
+  function isAImodOn() {
+    try { return getConfig().modules?.aiMod === 'on'; } catch { return false; }
+  }
+
+  async function publishLog({ text, label, mode, ref }) {
+    const ssbClient = await openSsb();
+    const content = {
+      type: 'log',
+      text: String(text || '').slice(0, 8000),
+      label: String(label || '').slice(0, 200),
+      mode: mode === 'ai' ? 'ai' : 'manual',
+      cycle: readCycle(),
+      createdAt: new Date().toISOString(),
+      timestamp: Date.now(),
+      private: true
+    };
+    if (ref) content.ref = String(ref);
+    const publishAsync = util.promisify(ssbClient.private.publish);
+    return publishAsync(content, [userId]);
+  }
+
+  async function republishLog({ replaces, text, label, mode, cycle, createdAt }) {
+    const ssbClient = await openSsb();
+    const content = {
+      type: 'log',
+      replaces,
+      text: String(text || '').slice(0, 8000),
+      label: String(label || '').slice(0, 200),
+      mode: mode === 'ai' ? 'ai' : 'manual',
+      cycle: cycle || readCycle(),
+      createdAt: createdAt || new Date().toISOString(),
+      updatedAt: new Date().toISOString(),
+      timestamp: Date.now(),
+      private: true
+    };
+    const publishAsync = util.promisify(ssbClient.private.publish);
+    return publishAsync(content, [userId]);
+  }
+
+  async function publishTombstone(target) {
+    const ssbClient = await openSsb();
+    const content = {
+      type: 'tombstone',
+      target,
+      deletedAt: new Date().toISOString(),
+      author: userId,
+      private: true
+    };
+    const publishAsync = util.promisify(ssbClient.private.publish);
+    return publishAsync(content, [userId]);
+  }
+
+  async function createManual(label, text) {
+    await openSsb();
+    const t = String(text || '').trim();
+    if (!t) return { status: 'empty' };
+    await publishLog({ text: t, label: String(label || '').trim(), mode: 'manual' });
+    return { status: 'ok' };
+  }
+
+  function sigOf(label, text) {
+    return `${String(label || '').trim()}||${String(text || '').trim().slice(0, 120)}`;
+  }
+
+  async function getProcessedState() {
+    const items = await readAllLogMessages();
+    const refs = new Set();
+    const sigs = new Set();
+    for (const it of items) {
+      if (it.ref) refs.add(it.ref);
+      sigs.add(sigOf(it.label, it.text));
+    }
+    return { refs, sigs };
+  }
+
+  async function createAI() {
+    await openSsb();
+    if (!isAImodOn()) return { status: 'ai_disabled' };
+    const actions = await listAllUserActions();
+    if (!actions.length) return { status: 'no_actions' };
+    const state = await getProcessedState();
+    const pending = actions.filter(a => a.key && !state.refs.has(a.key));
+    if (!pending.length) return { status: 'no_new_actions' };
+    const MAX_ACTIONS = 40;
+    const slice = pending.slice(0, MAX_ACTIONS);
+    let published = 0;
+    let aiFails = 0;
+    let aiDown = false;
+    for (const a of slice) {
+      let sentence = '';
+      if (!aiDown) {
+        sentence = await callAI(buildActionPrompt(a));
+        if (!sentence) {
+          aiFails++;
+          if (aiFails >= 3) aiDown = true;
+        } else {
+          aiFails = 0;
+        }
+      }
+      if (!sentence) sentence = buildFallbackSentence(a);
+      if (!sentence) continue;
+      const sig = sigOf(a.type, sentence);
+      if (state.sigs.has(sig)) { state.refs.add(a.key); continue; }
+      await publishLog({ text: sentence, label: a.type, mode: 'ai', ref: a.key });
+      state.refs.add(a.key);
+      state.sigs.add(sig);
+      published++;
+      await new Promise(r => setTimeout(r, 300));
+    }
+    if (!published) return { status: 'no_narrative' };
+    return { status: 'ok', count: published };
+  }
+
+  async function readAllLogMessages() {
+    const ssbClient = await openSsb();
+    const raw = await new Promise((resolve, reject) =>
+      pull(
+        ssbClient.createLogStream({ reverse: false, limit: logLimit }),
+        pull.collect((err, arr) => err ? reject(err) : resolve(arr))
+      )
+    );
+    const items = [];
+    const tombstoned = new Set();
+    const replaced = new Map();
+    for (const m of raw) {
+      if (!m || !m.value) continue;
+      const keyIn = m.key;
+      const valueIn = m.value;
+      const tsIn = m.timestamp || valueIn?.timestamp || Date.now();
+      let dec;
+      try {
+        dec = ssbClient.private.unbox({ key: keyIn, value: valueIn, timestamp: tsIn });
+      } catch { continue; }
+      const v = dec?.value;
+      const c = v?.content;
+      if (!c) continue;
+      if (v.author !== userId) continue;
+      if (c.type === 'tombstone' && c.target) { tombstoned.add(c.target); continue; }
+      if (c.type !== 'log') continue;
+      if (c.replaces) replaced.set(c.replaces, dec.key || keyIn);
+      items.push({
+        key: dec.key || keyIn,
+        author: v.author,
+        ts: v.timestamp || tsIn,
+        cycle: c.cycle || 0,
+        createdAt: c.createdAt || new Date(v.timestamp || tsIn).toISOString(),
+        text: String(c.text || ''),
+        label: String(c.label || ''),
+        mode: c.mode === 'ai' ? 'ai' : 'manual',
+        replaces: c.replaces || null,
+        ref: c.ref || null
+      });
+    }
+    const survivors = items.filter(i => !tombstoned.has(i.key) && !replaced.has(i.key));
+    survivors.sort((a, b) => b.ts - a.ts);
+    return survivors;
+  }
+
+  async function listLogs(filter = 'today') {
+    const items = await readAllLogMessages();
+    const win = FILTER_WINDOWS[filter];
+    if (win === null || win === undefined) return items;
+    const cutoff = Date.now() - win;
+    return items.filter(i => i.ts >= cutoff);
+  }
+
+  async function getLogById(id) {
+    const items = await readAllLogMessages();
+    return items.find(i => i.key === id) || null;
+  }
+
+  async function updateLog(id, { text, label, mode }) {
+    const current = await getLogById(id);
+    if (!current) return { status: 'not_found' };
+    await republishLog({
+      replaces: current.key,
+      text: text !== undefined ? text : current.text,
+      label: label !== undefined ? label : current.label,
+      mode: mode || current.mode,
+      cycle: current.cycle,
+      createdAt: current.createdAt
+    });
+    return { status: 'ok' };
+  }
+
+  async function deleteLog(id) {
+    const current = await getLogById(id);
+    if (!current) return { status: 'not_found' };
+    await publishTombstone(current.key);
+    return { status: 'ok' };
+  }
+
+  async function countLogs() {
+    const items = await readAllLogMessages();
+    return items.length;
+  }
+
+  return {
+    createManual,
+    createAI,
+    updateLog,
+    deleteLog,
+    getLogById,
+    listLogs,
+    countLogs,
+    isAImodOn
+  };
+};

+ 119 - 35
src/models/pads_model.js

@@ -15,7 +15,7 @@ const INVITE_SALT = "SolarNET.HuB-pads"
 const INVITE_BYTES = 16
 const MEMBER_COLORS = ["#e74c3c","#3498db","#2ecc71","#f39c12","#9b59b6","#1abc9c","#e67e22","#e91e63","#00bcd4","#8bc34a"]
 
-module.exports = ({ cooler, cipherModel }) => {
+module.exports = ({ cooler, cipherModel, tribeCrypto, tribesModel }) => {
   let ssb
   const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
 
@@ -52,6 +52,40 @@ module.exports = ({ cooler, cipherModel }) => {
     } catch (_) { return "" }
   }
 
+  const tryDecryptField = (encrypted, keyHex) => {
+    const key = Buffer.from(keyHex, "hex")
+    const iv = Buffer.from(encrypted.slice(0, 24), "hex")
+    const authTag = Buffer.from(encrypted.slice(24, 56), "hex")
+    const ciphertext = Buffer.from(encrypted.slice(56), "hex")
+    const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv)
+    decipher.setAuthTag(authTag)
+    return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8")
+  }
+
+  const getTribeKeysFor = async (tribeId) => {
+    if (!tribeCrypto || !tribesModel || !tribeId) return []
+    try {
+      const rootId = await tribesModel.getRootId(tribeId)
+      const keys = tribeCrypto.getKeys(rootId) || []
+      return keys
+    } catch (_) { return [] }
+  }
+
+  const decryptWithKeys = (c, keys) => {
+    if (!c.title || !keys.length) return null
+    for (const k of keys) {
+      try {
+        const title = tryDecryptField(c.title, k)
+        let deadline = ""
+        let tagsRaw = ""
+        try { deadline = c.deadline ? tryDecryptField(c.deadline, k) : "" } catch (_) {}
+        try { tagsRaw = c.tags ? tryDecryptField(c.tags, k) : "" } catch (_) {}
+        return { title: safeText(title), deadline, tags: normalizeTags(tagsRaw) }
+      } catch (_) {}
+    }
+    return null
+  }
+
   const encryptForInvite = (padKeyHex, code) => {
     const derived = crypto.scryptSync(code, INVITE_SALT, 32)
     return encryptField(padKeyHex, derived.toString("hex"))
@@ -96,7 +130,14 @@ module.exports = ({ cooler, cipherModel }) => {
     return { tomb, nodes, parent, child, rootOf, tipOf, tipByRoot }
   }
 
-  const decryptPadFields = (c, rootId) => {
+  const decryptPadFields = (c, rootId, tribeKeys) => {
+    if (c.encrypted !== true) {
+      return { title: safeText(c.title), deadline: c.deadline ? String(c.deadline) : "", tags: normalizeTags(c.tags) }
+    }
+    if (c.tribeId && Array.isArray(tribeKeys) && tribeKeys.length) {
+      const viaTribe = decryptWithKeys(c, tribeKeys)
+      if (viaTribe) return viaTribe
+    }
     const keyHex = getPadKey(rootId)
     if (!keyHex) return { title: "", deadline: "", tags: [] }
     const title = c.title ? decryptField(c.title, keyHex) : ""
@@ -106,10 +147,10 @@ module.exports = ({ cooler, cipherModel }) => {
     return { title, deadline, tags }
   }
 
-  const buildPad = (node, rootId) => {
+  const buildPad = (node, rootId, tribeKeys) => {
     const c = node.c || {}
     if (c.type !== "pad") return null
-    const { title, deadline, tags } = decryptPadFields(c, rootId)
+    const { title, deadline, tags } = decryptPadFields(c, rootId, tribeKeys)
     return {
       key: node.key,
       rootId,
@@ -135,8 +176,9 @@ module.exports = ({ cooler, cipherModel }) => {
   return {
     type: "pad",
 
-    decryptContent(content, rootId) {
-      return decryptPadFields(content, rootId)
+    async decryptContent(content, rootId) {
+      const tKeys = content && content.tribeId ? await getTribeKeysFor(content.tribeId) : []
+      return decryptPadFields(content, rootId, tKeys)
     },
 
     async resolveRootId(id) {
@@ -165,23 +207,22 @@ module.exports = ({ cooler, cipherModel }) => {
       const ssbClient = await openSsb()
       const now = new Date().toISOString()
       const validStatus = ["OPEN", "INVITE-ONLY"].includes(String(status).toUpperCase()) ? String(status).toUpperCase() : "OPEN"
-      const keyHex = crypto.randomBytes(32).toString("hex")
-
-      const encrypt = (text) => {
-        const key = Buffer.from(keyHex, "hex")
-        const iv = crypto.randomBytes(12)
-        const cipher = crypto.createCipheriv("aes-256-gcm", key, iv)
-        const enc = Buffer.concat([cipher.update(text, "utf8"), cipher.final()])
-        const authTag = cipher.getAuthTag()
-        return iv.toString("hex") + authTag.toString("hex") + enc.toString("hex")
+
+      let keyHex = null
+      let usesTribeKey = false
+      if (tribeId) {
+        const tKeys = await getTribeKeysFor(tribeId)
+        if (tKeys.length) { keyHex = tKeys[0]; usesTribeKey = true }
       }
+      if (!keyHex) keyHex = crypto.randomBytes(32).toString("hex")
+      const enc = (text) => encryptField(text, keyHex)
 
       const content = {
         type: "pad",
-        title: encrypt(safeText(title)),
+        title: enc(safeText(title)),
         status: validStatus,
-        deadline: deadline ? encrypt(String(deadline)) : "",
-        tags: encrypt(normalizeTags(tagsRaw).join(",")),
+        deadline: deadline ? enc(String(deadline)) : "",
+        tags: enc(normalizeTags(tagsRaw).join(",")),
         author: ssbClient.id,
         members: [ssbClient.id],
         invites: [],
@@ -194,7 +235,7 @@ module.exports = ({ cooler, cipherModel }) => {
       return new Promise((resolve, reject) => {
         ssbClient.publish(content, (err, msg) => {
           if (err) return reject(err)
-          setPadKey(msg.key, keyHex)
+          if (!usesTribeKey) setPadKey(msg.key, keyHex)
           resolve(msg)
         })
       })
@@ -205,13 +246,19 @@ module.exports = ({ cooler, cipherModel }) => {
       const ssbClient = await openSsb()
       const userId = ssbClient.id
       const rootId = await this.resolveRootId(id)
-      const keyHex = getPadKey(rootId)
 
-      return new Promise((resolve, reject) => {
-        ssbClient.get(tipId, (err, item) => {
+      return new Promise(async (resolve, reject) => {
+        ssbClient.get(tipId, async (err, item) => {
           if (err || !item?.content) return reject(new Error("Pad not found"))
           if (item.content.author !== userId) return reject(new Error("Not the author"))
           const c = item.content
+          let keyHex = null
+          let usesTribeKey = false
+          if (c.tribeId) {
+            const tKeys = await getTribeKeysFor(c.tribeId)
+            if (tKeys.length) { keyHex = tKeys[0]; usesTribeKey = true }
+          }
+          if (!keyHex) keyHex = getPadKey(rootId)
           const enc = (text) => keyHex ? encryptField(text, keyHex) : text
           const updated = {
             ...c,
@@ -227,7 +274,7 @@ module.exports = ({ cooler, cipherModel }) => {
             if (e1) return reject(e1)
             ssbClient.publish(updated, (e2, res) => {
               if (e2) return reject(e2)
-              if (keyHex) setPadKey(res.key, keyHex)
+              if (keyHex && !usesTribeKey) setPadKey(res.key, keyHex)
               resolve(res)
             })
           })
@@ -240,12 +287,18 @@ module.exports = ({ cooler, cipherModel }) => {
       const ssbClient = await openSsb()
       const userId = ssbClient.id
       const rootId = await this.resolveRootId(id)
-      const keyHex = getPadKey(rootId)
-      return new Promise((resolve, reject) => {
-        ssbClient.get(tipId, (err, item) => {
+      return new Promise(async (resolve, reject) => {
+        ssbClient.get(tipId, async (err, item) => {
           if (err || !item?.content) return reject(new Error("Pad not found"))
           if (item.content.author !== userId) return reject(new Error("Not the author"))
           const c = item.content
+          let keyHex = null
+          let usesTribeKey = false
+          if (c.tribeId) {
+            const tKeys = await getTribeKeysFor(c.tribeId)
+            if (tKeys.length) { keyHex = tKeys[0]; usesTribeKey = true }
+          }
+          if (!keyHex) keyHex = getPadKey(rootId)
           const updated = {
             ...c,
             status: "CLOSED",
@@ -257,7 +310,7 @@ module.exports = ({ cooler, cipherModel }) => {
             if (e1) return reject(e1)
             ssbClient.publish(updated, (e2, res) => {
               if (e2) return reject(e2)
-              if (keyHex) setPadKey(res.key, keyHex)
+              if (keyHex && !usesTribeKey) setPadKey(res.key, keyHex)
               resolve(res)
             })
           })
@@ -282,8 +335,10 @@ module.exports = ({ cooler, cipherModel }) => {
             if (e1) return reject(e1)
             ssbClient.publish(updated, (e2, res) => {
               if (e2) return reject(e2)
-              const keyHex = getPadKey(rootId)
-              if (keyHex) setPadKey(res.key, keyHex)
+              if (!c.tribeId) {
+                const keyHex = getPadKey(rootId)
+                if (keyHex) setPadKey(res.key, keyHex)
+              }
               resolve(res)
             })
           })
@@ -316,7 +371,8 @@ module.exports = ({ cooler, cipherModel }) => {
       if (!node || node.c.type !== "pad") return null
       let root = tip
       while (idx.parent.has(root)) root = idx.parent.get(root)
-      const pad = buildPad(node, root)
+      const tKeys = node.c.tribeId ? await getTribeKeysFor(node.c.tribeId) : []
+      const pad = buildPad(node, root, tKeys)
       if (!pad) return null
       pad.isClosed = isClosed(pad)
       return pad
@@ -327,12 +383,20 @@ module.exports = ({ cooler, cipherModel }) => {
       const uid = viewerId || ssbClient.id
       const messages = await readAll(ssbClient)
       const idx = buildIndex(messages)
+      const tribeKeyCache = new Map()
       const items = []
       for (const [rootId, tipId] of idx.tipByRoot.entries()) {
         if (idx.tomb.has(tipId)) continue
         const node = idx.nodes.get(tipId)
         if (!node || node.c.type !== "pad") continue
-        const pad = buildPad(node, rootId)
+        let tKeys = []
+        if (node.c.tribeId) {
+          if (!tribeKeyCache.has(node.c.tribeId)) {
+            tribeKeyCache.set(node.c.tribeId, await getTribeKeysFor(node.c.tribeId))
+          }
+          tKeys = tribeKeyCache.get(node.c.tribeId)
+        }
+        const pad = buildPad(node, rootId, tKeys)
         if (!pad) continue
         pad.isClosed = isClosed(pad)
         items.push(pad)
@@ -408,7 +472,13 @@ module.exports = ({ cooler, cipherModel }) => {
     async addEntry(padId, text) {
       const ssbClient = await openSsb()
       const rootId = await this.resolveRootId(padId)
-      const keyHex = getPadKey(rootId)
+      const pad = await this.getPadById(rootId)
+      let keyHex = null
+      if (pad && pad.tribeId) {
+        const tKeys = await getTribeKeysFor(pad.tribeId)
+        if (tKeys.length) keyHex = tKeys[0]
+      }
+      if (!keyHex) keyHex = getPadKey(rootId)
       const now = new Date().toISOString()
       const encText = keyHex ? encryptField(safeText(text), keyHex) : safeText(text)
       const content = {
@@ -417,7 +487,8 @@ module.exports = ({ cooler, cipherModel }) => {
         text: encText,
         author: ssbClient.id,
         createdAt: now,
-        encrypted: !!keyHex
+        encrypted: !!keyHex,
+        ...(pad && pad.tribeId ? { tribeId: pad.tribeId } : {})
       }
       return new Promise((resolve, reject) => {
         ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
@@ -427,14 +498,27 @@ module.exports = ({ cooler, cipherModel }) => {
     async getEntries(padRootId) {
       const ssbClient = await openSsb()
       const messages = await readAll(ssbClient)
-      const keyHex = getPadKey(padRootId)
+      const pad = await this.getPadById(padRootId)
+      const padKey = getPadKey(padRootId)
+      let tribeKeys = []
+      if (pad && pad.tribeId) {
+        tribeKeys = await getTribeKeysFor(pad.tribeId)
+      }
       const entries = []
       for (const m of messages) {
         const v = m.value || {}
         const c = v.content
         if (!c || c.type !== "padEntry") continue
         if (c.padId !== padRootId) continue
-        const text = (keyHex && c.encrypted) ? decryptField(c.text, keyHex) : (c.text || "")
+        let text = c.text || ""
+        if (c.encrypted && c.text) {
+          let decoded = ""
+          for (const k of tribeKeys) {
+            try { decoded = tryDecryptField(c.text, k); break } catch (_) {}
+          }
+          if (!decoded && padKey) decoded = decryptField(c.text, padKey)
+          text = decoded
+        }
         entries.push({
           key: m.key,
           author: c.author || v.author,

+ 8 - 4
src/models/search_model.js

@@ -282,10 +282,14 @@ module.exports = ({ cooler, padsModel }) => {
         const c = msg?.value?.content;
         if (c?.type === 'pad') {
           const rootId = c.replaces ? msg.key : msg.key;
-          const decrypted = padsModel.decryptContent(c, rootId);
-          c.title = decrypted.title || c.title;
-          c.deadline = decrypted.deadline || c.deadline;
-          c.tags = decrypted.tags.length ? decrypted.tags : c.tags;
+          try {
+            const decrypted = await padsModel.decryptContent(c, rootId);
+            if (decrypted && typeof decrypted === 'object') {
+              if (decrypted.title) c.title = decrypted.title;
+              if (decrypted.deadline) c.deadline = decrypted.deadline;
+              if (Array.isArray(decrypted.tags) && decrypted.tags.length) c.tags = decrypted.tags;
+            }
+          } catch (_) {}
         }
       }
     }

+ 1 - 1
src/models/stats_model.js

@@ -37,7 +37,7 @@ module.exports = ({ cooler }) => {
     'bookmark','event','task','votes','report','feed','project',
     'image','torrent','audio','video','document','transfer','post','tribe',
     'market','forum','job','aiExchange','map','shop','shopProduct','chat','chatMessage',
-    'pad','padEntry','gameScore','calendar','calendarDate','calendarNote',
+    'pad','padEntry','gameScore','calendar','calendarDate','calendarNote','log',
     'parliamentCandidature','parliamentTerm','parliamentProposal','parliamentRevocation','parliamentLaw',
     'courtsCase','courtsEvidence','courtsAnswer','courtsVerdict','courtsSettlement','courtsSettlementProposal','courtsSettlementAccepted','courtsNomination','courtsNominationVote'
   ];

+ 47 - 0
src/models/tribes_model.js

@@ -187,11 +187,58 @@ module.exports = ({ cooler, tribeCrypto }) => {
       const oldMembers = tribe.members || [];
       await this.updateTribeById(tribeId, { members });
       const removed = oldMembers.filter(m => !members.includes(m));
+      const added = members.filter(m => !oldMembers.includes(m));
       if (removed.length > 0) {
         await this.rotateTribeKey(tribeId, members);
+      } else if (added.length > 0) {
+        await this.distributeTribeKey(tribeId, added);
       }
     },
 
+    async distributeTribeKey(tribeId, toMembers) {
+      if (!tribeCrypto) return;
+      const ssb = await openSsb();
+      const ssbKeys = require('../server/node_modules/ssb-keys');
+      const rootId = await this.getRootId(tribeId);
+      const currentKey = tribeCrypto.getKey(rootId);
+      if (!currentKey) return;
+      const gen = tribeCrypto.getGen(rootId);
+      const memberKeys = {};
+      for (const memberId of toMembers) {
+        try { memberKeys[memberId] = tribeCrypto.boxKeyForMember(currentKey, memberId, ssbKeys); } catch (_) {}
+      }
+      if (!Object.keys(memberKeys).length) return;
+      await new Promise((resolve, reject) => {
+        ssb.publish({ type: 'tribe-keys', tribeId: rootId, generation: gen, memberKeys }, (err, res) => err ? reject(err) : resolve(res));
+      });
+    },
+
+    async ensureTribeKeyDistribution(tribeId) {
+      if (!tribeCrypto) return;
+      const ssb = await openSsb();
+      const userId = ssb.id;
+      const tribe = await this.getTribeById(tribeId).catch(() => null);
+      if (!tribe || tribe.author !== userId) return;
+      const rootId = await this.getRootId(tribeId);
+      const currentKey = tribeCrypto.getKey(rootId);
+      if (!currentKey) return;
+      const gen = tribeCrypto.getGen(rootId);
+      const msgs = await new Promise((resolve, reject) => {
+        pull(ssb.createLogStream({ limit: logLimit }), pull.collect((err, m) => err ? reject(err) : resolve(m)));
+      });
+      const distributed = new Set();
+      for (const m of msgs) {
+        const c = m.value?.content;
+        if (!c || c.type !== 'tribe-keys') continue;
+        if (c.tribeId !== rootId) continue;
+        if ((c.generation || 0) < gen) continue;
+        for (const mid of Object.keys(c.memberKeys || {})) distributed.add(mid);
+      }
+      const members = Array.isArray(tribe.members) ? tribe.members : [];
+      const missing = members.filter(m => m !== userId && !distributed.has(m));
+      if (missing.length > 0) await this.distributeTribeKey(tribeId, missing);
+    },
+
     async publishUpdatedTribe(tribeId, updatedTribe) {
       const ssb = await openSsb();
       const updatedTribeData = {

+ 8 - 1
src/server/SSB_server.js

@@ -91,7 +91,14 @@ if (argv[0] === 'start') {
   }
 
   const { printMetadata, colors } = require('./ssb_metadata');
-  printMetadata('OASIS Server Only', colors.cyan);
+  printMetadata('OASIS Server Only', colors.cyan, null);
+
+  setTimeout(async () => {
+    try {
+      const bankingModel = require('../models/banking_model.js')({});
+      await bankingModel.ensureSelfAddressPublished();
+    } catch (_) {}
+  }, 5000);
 
 }
 

+ 2 - 2
src/server/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "@krakenslab/oasis",
-  "version": "0.7.3",
+  "version": "0.7.4",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "@krakenslab/oasis",
-      "version": "0.7.1",
+      "version": "0.7.4",
       "hasInstallScript": true,
       "license": "AGPL-3.0",
       "dependencies": {

+ 1 - 1
src/server/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@krakenslab/oasis",
-  "version": "0.7.3",
+  "version": "0.7.4",
   "description": "Oasis - Social Networking Utopia",
   "repository": {
     "type": "git",

+ 4 - 3
src/server/ssb_metadata.js

@@ -46,8 +46,9 @@ async function printMetadata(mode, modeColor = colors.cyan, httpPort = 3000, htt
   const name = pkg.name;
   const logLevel = config.logging?.level || 'info';
   const publicKey = config.keys?.public || '';
-  const httpUrl = `http://${httpHost}:${httpPort}`;
-  const oscLink = `\x1b]8;;${httpUrl}\x07${httpUrl}\x1b]8;;\x07`;
+  const hasHttp = httpPort !== null && httpPort !== false;
+  const httpUrl = hasHttp ? `http://${httpHost}:${httpPort}` : '';
+  const oscLink = hasHttp ? `\x1b]8;;${httpUrl}\x07${httpUrl}\x1b]8;;\x07` : '';
   const ssbPort = config.connections?.incoming?.net?.[0]?.port || config.port || 8008;
   const localDiscovery = config.local === true;
   const hops = config.conn?.hops ?? config.friends?.hops ?? 2;
@@ -56,7 +57,7 @@ async function printMetadata(mode, modeColor = colors.cyan, httpPort = 3000, htt
   console.log(`Running mode: ${modeColor}${mode}${colors.reset}`);
   console.log("=========================");
   console.log(`- Package: ${colors.blue}${name} ${colors.yellow}[Version: ${version}]${colors.reset}`);
-  console.log(`- URL: ${colors.cyan}${oscLink}${colors.reset}`);
+  if (hasHttp) console.log(`- URL: ${colors.cyan}${oscLink}${colors.reset}`);
   console.log(`- Oasis ID: [ ${colors.orange}@${publicKey}${colors.reset} ]`);
   console.log("- Logging Level:", logLevel);
   const ifaces = os.networkInterfaces();

+ 3 - 5
src/views/banking_views.js

@@ -92,7 +92,6 @@ const renderOverviewSummaryTable = (s, rules) => {
     table({ class: "bank-info-table" },
       tbody(
         kvRow(i18n.bankUserBalance, `${Number(s.userBalance || 0).toFixed(6)} ECO`),
-        kvRow(i18n.bankPubBalance, `${Number(s.pubBalance || 0).toFixed(6)} ECO`),
         kvRow(i18n.bankUbiAvailability, span({ class: availClass }, availLabel)),
         s.pubId ? kvRow(i18n.pubIdLabel, a({ href: `/author/${encodeURIComponent(s.pubId)}`, class: "user-link" }, s.pubId)) : null,
         kvRow(i18n.bankEpoch, String(s.epochId || "-")),
@@ -105,12 +104,11 @@ const renderOverviewSummaryTable = (s, rules) => {
   );
 };
 
-const renderClaimUBIBlock = (pendingAllocation, isPub, alreadyClaimed, pubId, hasValidWallet, pubBalance, ubiAvailability) => {
+const renderClaimUBIBlock = (pendingAllocation, isPub, alreadyClaimed, pubId, hasValidWallet, ubiAvailability) => {
   if (alreadyClaimed) return "";
   if (!pubId && !isPub) return "";
   if (!isPub && !hasValidWallet) return "";
-  if (Number(pubBalance || 0) <= 0) return "";
-  if (ubiAvailability !== "OK") return "";
+  if (!isPub && ubiAvailability !== "OK") return "";
   if (!pendingAllocation && !isPub) {
     return div({ class: "bank-claim-ubi" },
       div({ class: "bank-claim-card" },
@@ -324,7 +322,7 @@ const renderBankingView = (data, filter, userId, isPub) =>
       filter === "overview"
         ? div(
             renderOverviewSummaryTable(data.summary || {}, data.rules),
-            renderClaimUBIBlock(data.pendingUBI || null, isPub, data.alreadyClaimed, (data.summary || {}).pubId, (data.summary || {}).hasValidWallet, (data.summary || {}).pubBalance, (data.summary || {}).ubiAvailability),
+            renderClaimUBIBlock(data.pendingUBI || null, isPub, data.alreadyClaimed, (data.summary || {}).pubId, (data.summary || {}).hasValidWallet, (data.summary || {}).ubiAvailability),
             allocationsTable((data.allocations || []).sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)), userId)
           )
         : filter === "exchange"

+ 4 - 2
src/views/blockchain_view.js

@@ -4,7 +4,7 @@ const moment = require("../server/node_modules/moment");
 
 const FILTER_LABELS = {
   votes: i18n.typeVotes, vote: i18n.typeVote, recent: i18n.recent, all: i18n.all,
-  mine: i18n.mine, tombstone: i18n.typeTombstone, pixelia: i18n.typePixelia,
+  mine: i18n.mine, tombstone: i18n.typeTombstone, logs: i18n.typeLog || 'LOGS', pixelia: i18n.typePixelia,
   curriculum: i18n.typeCurriculum, document: i18n.typeDocument, bookmark: i18n.typeBookmark,
   feed: i18n.typeFeed, event: i18n.typeEvent, task: i18n.typeTask, report: i18n.typeReport,
   image: i18n.typeImage, audio: i18n.typeAudio, video: i18n.typeVideo, post: i18n.typePost,
@@ -17,7 +17,7 @@ const FILTER_LABELS = {
   calendar: i18n.typeCalendar || 'CALENDAR', torrent: i18n.typeTorrent
 };
 
-const BASE_FILTERS = ['recent', 'all', 'mine', 'tombstone'];
+const BASE_FILTERS = ['recent', 'all', 'mine', 'tombstone', 'logs'];
 const CAT_BLOCK1  = ['votes', 'event', 'task', 'report', 'calendar', 'parliament', 'courts'];
 const CAT_BLOCK2  = ['pub', 'tribe', 'about', 'contact', 'curriculum', 'vote', 'aiExchange'];
 const CAT_BLOCK3  = ['banking', 'job', 'market', 'project', 'transfer', 'feed', 'post', 'pixelia', 'shop', 'gameScore'];
@@ -64,6 +64,7 @@ const filterBlocks = (blocks, filter, userId) => {
     return blocks.filter(b => cset.has(b.type));
   }
   if (filter === 'shop') return blocks.filter(b => b.type === 'shop' || b.type === 'shopProduct');
+  if (filter === 'logs') return blocks.filter(b => b.type === 'log' && b.author === userId);
   return blocks.filter(b => b.type === filter);
 };
 
@@ -130,6 +131,7 @@ const getViewDetailsAction = (type, block) => {
     case 'pad': return `/pads/${encodeURIComponent(block.id)}`;
     case 'chat': return `/chats/${encodeURIComponent(block.id)}`;
     case 'gameScore': return `/games?filter=scoring`;
+    case 'log': return `/logs/view/${encodeURIComponent(block.id)}`;
     default: return null;
   }
 };

+ 246 - 0
src/views/logs_view.js

@@ -0,0 +1,246 @@
+const { div, h2, p, section, button, form, span, table, thead, tbody, tr, th, td, input, textarea, br, option, select } = require("../server/node_modules/hyperaxe");
+const { template, i18n } = require("./main_views");
+const moment = require("../server/node_modules/moment");
+const { renderUrl } = require("../backend/renderUrl");
+
+const safeArr = v => Array.isArray(v) ? v : [];
+
+const FILTERS = ["today", "week", "month", "year", "always"];
+
+const filterLabel = (f) => {
+  const map = {
+    today: i18n.logsFilterToday || 'TODAY',
+    week: i18n.logsFilterWeek || 'LAST WEEK',
+    month: i18n.logsFilterMonth || 'LAST MONTH',
+    year: i18n.logsFilterYear || 'LAST YEAR',
+    always: i18n.logsFilterAlways || 'ALWAYS'
+  };
+  return map[f] || f.toUpperCase();
+};
+
+const renderFilterBar = (current) =>
+  div({ class: "logs-toolbar" },
+    form({ method: "GET", action: "/logs", class: "logs-toolbar-inline" },
+      FILTERS.map(f =>
+        button({
+          type: "submit", name: "filter", value: f,
+          class: current === f ? "filter-btn active" : "filter-btn"
+        }, filterLabel(f))
+      )
+    ),
+    form({ method: "GET", action: "/logs", class: "logs-toolbar-inline" },
+      input({ type: "hidden", name: "view", value: "create" }),
+      button({ type: "submit", class: "create-button" }, i18n.logsCreate || 'Create Log')
+    ),
+    form({ method: "GET", action: "/logs/export", class: "logs-toolbar-inline" },
+      button({ type: "submit", class: "create-button" }, i18n.logsExport || 'Export Logs')
+    )
+  );
+
+const renderSearchBox = (current, search) => {
+  const q = search || {};
+  return div({ class: "logs-search" },
+    form({ method: "GET", action: "/logs", class: "filter-box" },
+      input({ type: "hidden", name: "filter", value: current || 'today' }),
+      input({
+        type: "text", name: "q", class: "filter-box__input",
+        placeholder: i18n.logsSearchText || 'Search in logs...',
+        value: q.q || ''
+      }),
+      div({ class: "filter-box__controls" },
+        input({
+          type: "date", name: "date", class: "filter-box__select",
+          value: q.date || ''
+        }),
+        select({ name: "type", class: "filter-box__select" },
+          option({ value: '', ...(q.type ? {} : { selected: true }) }, i18n.logsSearchAnyType || 'Any type'),
+          option({ value: 'manual', ...(q.type === 'manual' ? { selected: true } : {}) }, i18n.logsModeManual || 'Manual'),
+          option({ value: 'ai', ...(q.type === 'ai' ? { selected: true } : {}) }, i18n.logsModeAI || 'AI')
+        ),
+        button({ type: "submit", class: "filter-box__button" }, i18n.logsSearchButton || 'Search')
+      )
+    )
+  );
+};
+
+const renderToolbar = (current, search) =>
+  div({ class: "logs-toolbar-wrap" },
+    renderFilterBar(current),
+    renderSearchBox(current, search)
+  );
+
+const MAX_PREVIEW = 140;
+
+const truncate = (str) => {
+  const s = String(str || '');
+  if (s.length <= MAX_PREVIEW) return s;
+  return s.slice(0, MAX_PREVIEW).replace(/\s+\S*$/, '') + '…';
+};
+
+const renderLogPreview = (item) => {
+  const text = truncate(item.text);
+  return div({ class: "logs-entry-text" }, ...renderUrl(text));
+};
+
+const renderTable = (items) => {
+  if (!safeArr(items).length) return p({ class: "no-content" }, i18n.logsEmpty || 'No logs yet.');
+  return table({ class: "logs-table" },
+    thead(
+      tr(
+        th(i18n.logsColumnDate || 'Date'),
+        th(i18n.logsColumnType || 'Type'),
+        th(i18n.logsColumnLog || 'Log'),
+        th(''),
+        th('')
+      )
+    ),
+    tbody(
+      items.map(item =>
+        tr(
+          td({ class: "logs-col-date" },
+            span({ class: "logs-date-day" }, moment(item.ts).format("DD/MM/YYYY")),
+            ' ',
+            span({ class: "logs-date-time" }, moment(item.ts).format("HH:mm"))
+          ),
+          td({ class: "logs-col-type" },
+            span({ class: item.mode === 'ai' ? "logs-type-text logs-type-ai" : "logs-type-text logs-type-manual" },
+              item.mode === 'ai' ? (i18n.logsModeAI || 'AI') : (i18n.logsModeManual || 'Manual')
+            )
+          ),
+          td({ class: "logs-col-log" },
+            item.label ? div({ class: "logs-entry-label" }, item.label) : null,
+            renderLogPreview(item)
+          ),
+          td({ class: "logs-col-actions" },
+            form({ method: "GET", action: `/logs/view/${encodeURIComponent(item.key)}` },
+              button({ type: "submit", class: "filter-btn" }, i18n.logsViewDetails || 'View Details')
+            )
+          ),
+          td({ class: "logs-col-actions" },
+            form({ method: "GET", action: `/logs/export/${encodeURIComponent(item.key)}` },
+              button({ type: "submit", class: "filter-btn" }, i18n.logsExportOne || 'Export')
+            )
+          )
+        )
+      )
+    )
+  );
+};
+
+const renderModeToggle = (mode, aiModOn) => {
+  const isAi = mode === 'ai';
+  const isManual = !mode || mode === 'manual';
+  return form({ method: "GET", action: "/logs", class: "logs-mode-form" },
+    input({ type: "hidden", name: "view", value: "create" }),
+    div({ class: "logs-mode-group" },
+      button({
+        type: "submit", name: "mode", value: "manual",
+        class: isManual ? "filter-btn active" : "filter-btn"
+      }, i18n.logsModeManual || 'Manual'),
+      aiModOn
+        ? button({
+            type: "submit", name: "mode", value: "ai",
+            class: isAi ? "filter-btn active" : "filter-btn"
+          }, i18n.logsModeAIWritten || 'AI-Assistant')
+        : null
+    )
+  );
+};
+
+const renderCreateForm = (mode, aiModOn) => {
+  const isAi = mode === 'ai' && aiModOn;
+  const inner = isAi
+    ? div({ class: "div-center audio-form" },
+        form({ method: "POST", action: "/logs/create" },
+          input({ type: "hidden", name: "mode", value: "ai" }),
+          button({ type: "submit", class: "create-button" }, i18n.logsGenerateButton || 'Generate Text')
+        )
+      )
+    : div({ class: "div-center audio-form" },
+        form({ method: "POST", action: "/logs/create" },
+          input({ type: "hidden", name: "mode", value: "manual" }),
+          span(i18n.logsManualPrompt || 'Write your log'), br(),
+          textarea({ name: "text", rows: "8", required: true, placeholder: i18n.logsTextPlaceholder || 'Describe your experiences...' }),
+          br(), br(),
+          button({ type: "submit", class: "create-button" }, i18n.logsWriteButton || 'Write')
+        )
+      );
+  return div(renderModeToggle(mode, aiModOn), inner);
+};
+
+const renderEditForm = (entry) => {
+  return div({ class: "div-center audio-form" },
+    h2(i18n.logsEditTitle || 'Edit Log'),
+    form({ method: "POST", action: `/logs/edit/${encodeURIComponent(entry.key)}` },
+      span(i18n.logsManualPrompt || 'Write your log...'), br(),
+      textarea({ name: "text", rows: "8", required: true }, entry.text || ''),
+      input({ type: "hidden", name: "label", value: entry.label || '' }),
+      br(), br(),
+      button({ type: "submit", class: "create-button" }, i18n.logsUpdateButton || 'Update')
+    )
+  );
+};
+
+const renderDetail = (entry) => {
+  const headerLine = `[${moment(entry.ts).format("DD/MM/YYYY HH:mm:ss")}]:`;
+  return div({ class: "div-center audio-form logs-detail" },
+    h2(headerLine),
+    entry.label ? div({ class: "logs-entry-label" }, entry.label) : null,
+    div({ class: "logs-detail-text" }, ...renderUrl(String(entry.text || ''))),
+    div({ class: "logs-detail-actions" },
+      form({ method: "GET", action: "/logs" },
+        button({ type: "submit", class: "filter-btn" }, i18n.walletBack || 'Back')
+      ),
+      form({ method: "GET", action: `/logs/export/${encodeURIComponent(entry.key)}` },
+        button({ type: "submit", class: "filter-btn" }, i18n.logsExportOne || 'Export')
+      ),
+      form({ method: "GET", action: "/logs" },
+        input({ type: "hidden", name: "view", value: "edit" }),
+        input({ type: "hidden", name: "id", value: entry.key }),
+        button({ type: "submit", class: "filter-btn" }, i18n.logsEdit || 'Edit')
+      ),
+      form({ method: "POST", action: `/logs/delete/${encodeURIComponent(entry.key)}` },
+        button({ type: "submit", class: "filter-btn" }, i18n.logsDelete || 'Delete')
+      )
+    )
+  );
+};
+
+exports.logsView = (items, filter, mode, opts = {}) => {
+  const listTitle = i18n.logsTitle || 'Logs';
+  const description = i18n.logsDescription || 'Record your experience in the network.';
+  const view = opts.view || 'list';
+  const aiModOn = !!opts.aiModOn;
+
+  if (view === 'create') {
+    const h = i18n.logsCreateTitle || 'Create Log';
+    const body = section(
+      div({ class: "tags-header" }, h2(h), p(description)),
+      renderFilterBar(filter),
+      renderCreateForm(mode, aiModOn)
+    );
+    return template(h, body);
+  }
+  if (view === 'edit' && opts.entry) {
+    const h = i18n.logsEditTitle || 'Edit Log';
+    const body = section(
+      div({ class: "tags-header" }, h2(h), p(description)),
+      renderEditForm(opts.entry)
+    );
+    return template(h, body);
+  }
+  if (view === 'detail' && opts.entry) {
+    const h = i18n.logsViewTitle || 'Log';
+    const body = section(
+      div({ class: "tags-header" }, h2(h), p(description)),
+      renderDetail(opts.entry)
+    );
+    return template(h, body);
+  }
+  const body = section(
+    div({ class: "tags-header" }, h2(listTitle), p(description)),
+    renderToolbar(filter, opts.search || {}),
+    div({ class: "logs-list" }, renderTable(items))
+  );
+  return template(listTitle, body);
+};

+ 20 - 4
src/views/main_views.js

@@ -704,6 +704,20 @@ const renderFavoritesLink = () => {
     : "";
 };
 
+const renderLogsLink = () => {
+  const logsMod = getConfig().modules.logsMod === "on";
+  return logsMod
+    ? [
+        navLink({
+          href: "/logs",
+          emoji: "ꗯ",
+          text: i18n.logsTitle || "Logs",
+          class: "logs-link enabled"
+        })
+      ]
+    : "";
+};
+
 const renderAILink = () => {
   const aiMod = getConfig().modules.aiMod === "on";
   return aiMod
@@ -864,6 +878,7 @@ const template = (titlePrefix, ...elements) => {
                 }),
                 renderAgendaLink(),
                 renderFavoritesLink(),
+                renderLogsLink(),
                 renderWalletLink(),
                 navLink({
                   href: "/modules",
@@ -1722,10 +1737,11 @@ exports.authorView = ({
           ),
           span({ class: "ubi-line" }, `${i18n.bankUbiTotalClaimed}: `, strong(`${Number(totalClaimed || 0).toFixed(6)} ECO`))
         ),
-        div({ class: "eco-wallet" },
-          p(`${i18n.statsEcoWalletLabel || 'ECOin Wallet'}: `,
-            a({ href: '/wallet' }, ecoAddress || i18n.statsEcoWalletNotConfigured || 'Not configured!'))
-        )
+        (ecoAddress || relationship.me)
+          ? div({ class: "eco-wallet" },
+              p(`${i18n.statsEcoWalletLabel || 'ECOin Wallet'}: `,
+                a({ href: '/wallet' }, ecoAddress || i18n.statsEcoWalletNotConfigured || 'Not configured!')))
+          : null
       )
     ),
     description !== "" ? article({ innerHTML: sanitizeHtml(markdown(description)) }) : null,

+ 3 - 2
src/views/modules_view.js

@@ -25,6 +25,7 @@ const modulesView = () => {
     { name: 'jobs', label: i18n.modulesJobsLabel, description: i18n.modulesJobsDescription },
     { name: 'legacy', label: i18n.modulesLegacyLabel, description: i18n.modulesLegacyDescription },
     { name: 'latest', label: i18n.modulesLatestLabel, description: i18n.modulesLatestDescription },
+    { name: 'logs', label: i18n.modulesLogsLabel, description: i18n.modulesLogsDescription },
     { name: 'maps', label: i18n.modulesMapLabel, description: i18n.modulesMapDescription },
     { name: 'market', label: i18n.modulesMarketLabel, description: i18n.modulesMarketDescription },
     { name: 'multiverse', label: i18n.modulesMultiverseLabel, description: i18n.modulesMultiverseDescription },
@@ -80,8 +81,8 @@ const modulesView = () => {
 
   const PRESETS = {
     minimal: ['feed', 'forum', 'games', 'images', 'videos', 'audios', 'bookmarks', 'tags', 'trending', 'popular', 'latest', 'threads', 'opinions', 'cipher', 'legacy'],
-    social: ['agenda', 'audios', 'bookmarks', 'calendars', 'chats', 'cipher', 'courts', 'docs', 'events', 'favorites', 'feed', 'forum', 'games', 'images', 'invites', 'legacy', 'maps', 'multiverse', 'opinions', 'pads', 'parliament', 'pixelia', 'projects', 'reports', 'tags', 'tasks', 'threads', 'trending', 'tribes', 'videos', 'votes'],
-    economy: ['agenda', 'audios', 'bookmarks', 'calendars', 'chats', 'cipher', 'courts', 'docs', 'events', 'favorites', 'feed', 'forum', 'games', 'images', 'invites', 'legacy', 'maps', 'multiverse', 'opinions', 'pads', 'parliament', 'pixelia', 'projects', 'reports', 'tags', 'tasks', 'threads', 'trending', 'tribes', 'videos', 'votes', 'banking', 'wallet', 'transfers', 'market', 'jobs', 'shops'],
+    social: ['agenda', 'audios', 'bookmarks', 'calendars', 'chats', 'cipher', 'courts', 'docs', 'events', 'favorites', 'feed', 'forum', 'games', 'images', 'invites', 'legacy', 'logs', 'maps', 'multiverse', 'opinions', 'pads', 'parliament', 'pixelia', 'projects', 'reports', 'tags', 'tasks', 'threads', 'trending', 'tribes', 'videos', 'votes'],
+    economy: ['agenda', 'audios', 'bookmarks', 'calendars', 'chats', 'cipher', 'courts', 'docs', 'events', 'favorites', 'feed', 'forum', 'games', 'images', 'invites', 'legacy', 'logs', 'maps', 'multiverse', 'opinions', 'pads', 'parliament', 'pixelia', 'projects', 'reports', 'tags', 'tasks', 'threads', 'trending', 'tribes', 'videos', 'votes', 'banking', 'wallet', 'transfers', 'market', 'jobs', 'shops'],
     full: modules.map(m => m.name)
   };
 

+ 2 - 1
src/views/search_view.js

@@ -69,7 +69,8 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
       case 'votes': return `/votes/${encodeURIComponent(contentId)}`;
       case 'transfer': return `/transfers/${encodeURIComponent(contentId)}`;
       case 'tribe': return `/tribe/${encodeURIComponent(contentId)}`;
-      case 'curriculum': return `/inhabitant/${encodeURIComponent(contentId)}`;
+      case 'about': return content && (content.about || content.author) ? `/inhabitant/${encodeURIComponent(content.about || content.author)}` : '#';
+      case 'curriculum': return content && content.author ? `/inhabitant/${encodeURIComponent(content.author)}` : '#';
       case 'image': return `/images/${encodeURIComponent(contentId)}`;
       case 'audio': return `/audios/${encodeURIComponent(contentId)}`;
       case 'video': return `/videos/${encodeURIComponent(contentId)}`;

+ 6 - 0
src/views/stats_view.js

@@ -216,6 +216,12 @@ exports.statsView = (stats, filter) => {
             li({ style: 'font-size:18px; color:#555; margin:8px 0;' }, `${i18n.statsTotalEcoAddresses}: `, span({ style: 'color:#888;' }, String(stats?.banking?.totalAddresses || 0)))
           )
         ),
+        div({ style: headerStyle },
+          h3({ style: 'font-size:18px; color:#555; margin:8px 0; font-weight:600;' }, i18n.statsLogsTitle || 'Logs'),
+          ul({ style: 'list-style-type:none; padding:0; margin:0;' },
+            li({ style: 'font-size:18px; color:#555; margin:8px 0;' }, `${i18n.statsLogsEntries || 'Entries'}: `, span({ style: 'color:#888;' }, String(stats?.logsCount || 0)))
+          )
+        ),
         div({ style: headerStyle },
           h3({ style: 'font-size:18px; color:#555; margin:8px 0; font-weight:600;' }, i18n.statsAITraining),
           ul({ style: 'list-style-type:none; padding:0; margin:0;' },

+ 1 - 1
src/views/tribes_view.js

@@ -1497,7 +1497,7 @@ exports.tribeView = async (tribe, userIdParam, query, section, sectionData) => {
         )
       ),
       h2({ class: 'tribe-members-count' }, `${i18n.tribeMembersCount}: ${tribe.members.length}`),
-      !tribe.parentTribeId ? div({ class: 'tribe-side-subtribes' },
+      (!tribe.parentTribeId && ((tribe.inviteMode === 'open' || tribe.author === userId) || subTribes.length > 0)) ? div({ class: 'tribe-side-subtribes' },
         (tribe.inviteMode === 'open' || tribe.author === userId)
           ? form({ method: 'GET', action: `/tribe/${encodeURIComponent(tribe.id)}` },
               input({ type: 'hidden', name: 'section', value: 'subtribes' }),